Kaynağa Gözat

feat(web-admin): wire product pages to scheme A/B confirm dialogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 hafta önce
ebeveyn
işleme
1b7007c773

+ 104 - 69
web-admin/src/views/m/crm/product/categories.vue

@@ -92,7 +92,7 @@
         position="bottom"
         round
         teleport="body"
-        class="mp-customer-vant-popup"
+        class="mp-customer-vant-popup mp-product-form-popup"
         :style="{ height: '82%' }"
         :close-on-click-overlay="!submitLoading"
         @update:show="onFormShow"
@@ -105,78 +105,90 @@
             @click-left="closeForm"
           />
           <div class="mp-customer-pool-vant__body">
-            <van-cell-group inset>
-              <van-field label="上级分类" label-align="top">
-                <template #input>
-                  <el-tree-select
-                    v-model="formData.parentId"
-                    :data="categoryTreeOptions"
-                    node-key="id"
-                    :props="{ label: 'categoryName', children: 'children' }"
-                    value-key="id"
-                    check-strictly
-                    filterable
-                    clearable
-                    placeholder="选择上级分类(留空为顶级)"
-                    style="width: 100%"
-                    teleported
-                  />
-                </template>
-              </van-field>
-              <van-field
-                v-model="formData.categoryName"
-                label="分类名称"
-                required
-                placeholder="请输入分类名称"
-                :error-message="nameError"
-              />
-              <van-field
-                v-model="orderNumText"
-                type="digit"
-                label="排序号"
-                placeholder="数值越小越靠前"
-              />
-              <van-field label="状态" label-align="top">
-                <template #input>
-                  <el-select
-                    v-model="formData.status"
-                    placeholder="请选择状态"
-                    style="width: 100%"
-                    teleported
-                  >
-                    <el-option
-                      v-for="opt in dictStore.getOptions('sys_normal_disable')"
-                      :key="opt.value"
-                      :label="opt.label"
-                      :value="opt.value"
+            <section class="mp-wv-section">
+              <h3>分类信息</h3>
+              <van-cell-group inset class="mp-customer-fields-vant">
+                <van-field label="上级分类">
+                  <template #input>
+                    <el-tree-select
+                      v-model="formData.parentId"
+                      :data="categoryTreeOptions"
+                      node-key="id"
+                      :props="{ label: 'categoryName', children: 'children' }"
+                      value-key="id"
+                      check-strictly
+                      filterable
+                      clearable
+                      placeholder="选择上级分类(留空为顶级)"
+                      style="width: 100%"
+                      teleported
                     />
-                  </el-select>
-                </template>
-              </van-field>
-              <van-field
-                v-model="formData.remark"
-                rows="3"
-                autosize
-                type="textarea"
-                label="备注"
-                placeholder="请输入备注"
-              />
-            </van-cell-group>
+                  </template>
+                </van-field>
+                <van-field
+                  v-model="formData.categoryName"
+                  label="分类名称"
+                  required
+                  clearable
+                  placeholder="请输入分类名称"
+                  :error-message="nameError"
+                />
+                <van-field
+                  v-model="orderNumText"
+                  type="digit"
+                  label="排序号"
+                  placeholder="数值越小越靠前"
+                />
+                <van-field
+                  is-link
+                  readonly
+                  label="状态"
+                  required
+                  :model-value="statusLabel"
+                  placeholder="请选择状态"
+                  @click="statusPickerOpen = true"
+                />
+                <van-field
+                  v-model="formData.remark"
+                  rows="3"
+                  autosize
+                  type="textarea"
+                  label="备注"
+                  placeholder="请输入备注"
+                />
+              </van-cell-group>
+            </section>
           </div>
-          <div class="mp-customer-pool-vant__foot">
+          <div class="mp-customer-pool-vant__foot mp-product-form-popup__foot">
             <van-button round :disabled="submitLoading" @click="closeForm">取消</van-button>
-            <van-button round type="primary" :loading="submitLoading" @click="submitForm"
-              >确定</van-button
+            <van-button
+              round
+              type="primary"
+              :loading="submitLoading"
+              :disabled="submitLoading"
+              @click="submitForm"
             >
+              保存分类
+            </van-button>
           </div>
         </div>
       </van-popup>
+
+      <van-popup v-model:show="statusPickerOpen" position="bottom" round teleport="body">
+        <van-picker
+          :columns="statusPickerColumns"
+          :model-value="[formData.status || '0']"
+          @confirm="onStatusConfirm"
+          @cancel="statusPickerOpen = false"
+        />
+      </van-popup>
     </div>
   </MpWebviewShell>
 </template>
 
 <script setup lang="ts">
-  import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
+  import { showFailToast, showSuccessToast } from 'vant'
+  import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
   import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
   import {
@@ -229,6 +241,7 @@
   const formTitle = ref('')
   const submitLoading = ref(false)
   const nameError = ref('')
+  const statusPickerOpen = ref(false)
   const categoryTreeOptions = ref<CategoryNode[]>([])
   const formData = reactive({
     id: '',
@@ -246,6 +259,17 @@
     }
   })
 
+  const statusPickerColumns = computed(() =>
+    dictStore.getOptions('sys_normal_disable').map((o) => ({
+      text: o.label,
+      value: String(o.value)
+    }))
+  )
+
+  const statusLabel = computed(
+    () => dictStore.getLabel('sys_normal_disable', formData.status) || ''
+  )
+
   const canDeleteActionNode = computed(() =>
     canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
   )
@@ -407,7 +431,11 @@
       return
     }
     try {
-      await showConfirmDialog({ title: '提示', message: '是否确认删除该产品分类?' })
+      await showProductConfirmDialog({
+        kind: 'danger',
+        title: '删除分类',
+        message: '是否确认删除该产品分类?'
+      })
     } catch {
       return
     }
@@ -426,7 +454,14 @@
     if (!submitLoading.value) formOpen.value = show
   }
   function closeForm() {
-    if (!submitLoading.value) formOpen.value = false
+    if (!submitLoading.value) {
+      formOpen.value = false
+      statusPickerOpen.value = false
+    }
+  }
+  function onStatusConfirm({ selectedValues }: { selectedValues: string[] }) {
+    formData.status = selectedValues[0] || '0'
+    statusPickerOpen.value = false
   }
 
   async function submitForm() {
@@ -467,7 +502,7 @@
 
 <style scoped lang="scss">
   .mp-cat-header-btn {
-    height: 32px;
+    height: 30px;
     padding: 0 10px;
     font-size: 12px;
     font-weight: 600;
@@ -567,9 +602,9 @@
   .mp-cat-row__add,
   .mp-cat-row__more {
     flex-shrink: 0;
-    width: 36px;
-    height: 36px;
-    font-size: 18px;
+    width: 30px;
+    height: 30px;
+    font-size: 16px;
     line-height: 1;
     color: #087f57;
     cursor: pointer;
@@ -579,8 +614,8 @@
   }
 
   .mp-cat-row__add {
-    width: 32px;
-    height: 32px;
+    width: 30px;
+    height: 30px;
     font-size: 16px;
     font-weight: 700;
   }

+ 312 - 191
web-admin/src/views/m/crm/product/detail.vue

@@ -1,154 +1,172 @@
 <template>
   <MpWebviewShell @ready="onShellReady">
-    <header class="mp-wv-header">
-      <div class="mp-wv-header__row">
-        <button class="mp-wv-header__back" type="button" aria-label="返回列表" @click="goList">
-          ←
-        </button>
-        <h1 class="mp-wv-header__title">{{ headerTitle }}</h1>
-        <el-tag v-if="detail && !loading" :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
-      </div>
-      <div v-if="detail && !loading" class="mp-wv-header__sub">
-        {{ detail.productNo || '—' }} · {{ detail.model || '—' }}
-      </div>
-    </header>
-
-    <div
-      v-if="loading"
-      class="mp-plan-form-loading mp-plan-form-loading--page"
-      role="status"
-      aria-live="polite"
-      aria-busy="true"
-    >
-      <div class="mp-plan-form-loading__orb" aria-hidden="true">
-        <span class="mp-plan-form-loading__dot" />
-      </div>
-      <p class="mp-plan-form-loading__title">正在加载产品详情</p>
-      <p class="mp-plan-form-loading__desc">请稍候,正在获取产品信息…</p>
-      <div class="mp-plan-form-loading__skel" aria-hidden="true">
-        <div class="mp-wv-skel mp-wv-skel--block" />
-        <div class="mp-wv-skel mp-wv-skel--mid" />
-        <div class="mp-wv-skel mp-wv-skel--line" />
-        <div class="mp-wv-skel mp-wv-skel--short" />
+    <div class="mp-product-page">
+      <MpBusinessHeader title="产品详情" back-label="返回产品列表" @back="goList" />
+
+      <div v-if="loading" class="mp-product-loading">
+        <van-loading vertical>正在加载产品详情</van-loading>
       </div>
-    </div>
-    <div v-else-if="errorText" class="mp-wv-empty">{{ errorText }}</div>
-    <template v-else-if="detail">
-      <div class="mp-wv-detail-body">
-        <section class="mp-wv-section">
-          <h3>基础信息</h3>
-          <div class="mp-wv-kv"><span>产品编号</span><span>{{ detail.productNo || '—' }}</span></div>
-          <div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div>
-          <div class="mp-wv-kv">
-            <span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span>
-          </div>
-          <div class="mp-wv-kv"><span>产品分类</span><span>{{ categoryPathText }}</span></div>
-          <div class="mp-wv-kv"><span>规格型号</span><span>{{ detail.model || '—' }}</span></div>
-          <div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div>
-          <div class="mp-wv-kv"><span>税率</span><span>{{ taxRateText }}</span></div>
-          <div class="mp-wv-kv"><span>库存</span><span>{{ stockText }}</span></div>
-          <div class="mp-wv-kv"><span>低库存阈值</span><span>{{ lowStockText }}</span></div>
-          <div class="mp-wv-kv">
-            <span>状态</span>
-            <span>
-              <el-tag :type="statusTagType" size="small">{{ statusLabel }}</el-tag>
-            </span>
+      <van-empty v-else-if="errorText" :description="errorText">
+        <van-button round type="primary" @click="loadDetail">重试</van-button>
+      </van-empty>
+
+      <template v-else-if="detail">
+        <nav
+          class="mp-product-detail-tabs"
+          :style="{ top: `${stickyOffset}px` }"
+          aria-label="产品详情分类"
+        >
+          <button
+            v-for="tab in detailTabs"
+            :key="tab.key"
+            class="mp-product-detail-tab mp-wv-touch-target"
+            :class="{ 'is-active': activeTab === tab.key }"
+            type="button"
+            :aria-pressed="activeTab === tab.key"
+            @click="scrollToSection(tab.key)"
+          >
+            {{ tab.label }}
+          </button>
+        </nav>
+
+        <div class="mp-product-detail-body">
+          <div
+            :ref="(el) => setSectionRef('base', el)"
+            class="mp-product-anchor"
+            :style="{ scrollMarginTop: `${scrollMargin}px` }"
+          >
+            <section class="mp-wv-section">
+              <h3>{{ detail.productName || '产品详情' }}</h3>
+              <div class="mp-wv-kv"><span>产品编号</span><span>{{ detail.productNo || '—' }}</span></div>
+              <div class="mp-wv-kv"><span>产品名称</span><span>{{ detail.productName || '—' }}</span></div>
+              <div class="mp-wv-kv"
+                ><span>拼音首字母</span><span>{{ detail.namePinyinAbbr || '—' }}</span></div
+              >
+              <div class="mp-wv-kv"><span>产品分类</span><span>{{ categoryPathText }}</span></div>
+              <div class="mp-wv-kv"><span>规格型号</span><span>{{ detail.model || '—' }}</span></div>
+              <div class="mp-wv-kv"><span>条码</span><span>{{ detail.barcode || '—' }}</span></div>
+              <div class="mp-wv-kv"><span>税率</span><span>{{ taxRateText }}</span></div>
+              <div class="mp-wv-kv"><span>库存</span><span>{{ stockText }}</span></div>
+              <div class="mp-wv-kv"><span>低库存阈值</span><span>{{ lowStockText }}</span></div>
+              <div class="mp-wv-kv">
+                <span>状态</span>
+                <span>
+                  <span class="status-tag" :class="`status-tag--${statusTagType}`">{{
+                    statusLabel
+                  }}</span>
+                </span>
+              </div>
+              <div class="mp-wv-kv">
+                <span>危化标识</span>
+                <span class="mp-product-tag-row">
+                  <span v-if="showToxic" class="status-tag status-tag--danger">易制毒</span>
+                  <span v-if="showExplosive" class="status-tag status-tag--warning">易制爆</span>
+                  <template v-if="!showToxic && !showExplosive">—</template>
+                </span>
+              </div>
+            </section>
+            <section class="mp-wv-section">
+              <h3>描述 / 备注</h3>
+              <div class="mp-wv-kv"
+                ><span>产品描述</span><span class="mp-wv-pre">{{ descriptionText }}</span></div
+              >
+              <div class="mp-wv-kv"
+                ><span>备注</span><span class="mp-wv-pre">{{ remarkText }}</span></div
+              >
+            </section>
           </div>
-          <div class="mp-wv-kv">
-            <span>危化标识</span>
-            <span class="mp-wv-tag-row">
-              <el-tag v-if="showToxic" type="danger" size="small">易制毒</el-tag>
-              <el-tag v-if="showExplosive" type="warning" size="small">易制爆</el-tag>
-              <template v-if="!showToxic && !showExplosive">—</template>
-            </span>
+
+          <div
+            :ref="(el) => setSectionRef('relations', el)"
+            class="mp-product-anchor"
+            :style="{ scrollMarginTop: `${scrollMargin}px` }"
+          >
+            <section class="mp-wv-section">
+              <h3>品牌列表</h3>
+              <template v-if="brands.length">
+                <div
+                  v-for="(b, idx) in brands"
+                  :key="b.id || `${b.brandName}-${idx}`"
+                  class="mp-wv-kv"
+                >
+                  <span>{{ b.brandName || '—' }}</span>
+                  <span>
+                    <span
+                      class="status-tag"
+                      :class="`status-tag--${productStatusTagType(b.status)}`"
+                      >{{ productStatusLabel(b.status) }}</span
+                    >
+                  </span>
+                </div>
+              </template>
+              <van-empty v-else description="暂无品牌" image-size="64" />
+            </section>
+            <section class="mp-wv-section">
+              <h3>别名列表</h3>
+              <template v-if="aliases.length">
+                <div
+                  v-for="(a, idx) in aliases"
+                  :key="a.id || `${a.aliasName}-${idx}`"
+                  class="mp-wv-kv"
+                >
+                  <span>别名</span>
+                  <span>{{ a.aliasName || '—' }}</span>
+                </div>
+              </template>
+              <van-empty v-else description="暂无别名" image-size="64" />
+            </section>
           </div>
-        </section>
-
-        <section class="mp-wv-section">
-          <h3>描述 / 备注</h3>
-          <div class="mp-wv-kv"><span>产品描述</span><span class="mp-wv-pre">{{ descriptionText }}</span></div>
-          <div class="mp-wv-kv"><span>备注</span><span class="mp-wv-pre">{{ remarkText }}</span></div>
-        </section>
-
-        <section class="mp-wv-section">
-          <h3>图片</h3>
-          <div v-if="imageList.length" class="mp-wv-img-grid">
-            <el-image
-              v-for="(url, idx) in imageList"
-              :key="`${url}-${idx}`"
-              class="mp-wv-img-grid__item"
-              :src="url"
-              :preview-src-list="imageList"
-              :initial-index="idx"
-              fit="cover"
-              preview-teleported
-            />
+
+          <div
+            :ref="(el) => setSectionRef('media', el)"
+            class="mp-product-anchor"
+            :style="{ scrollMarginTop: `${scrollMargin}px` }"
+          >
+            <section class="mp-wv-section">
+              <h3>图片</h3>
+              <div v-if="imageList.length" class="mp-wv-img-grid">
+                <van-image
+                  v-for="(url, idx) in imageList"
+                  :key="`${url}-${idx}`"
+                  class="mp-wv-img-grid__item"
+                  width="72"
+                  height="72"
+                  fit="cover"
+                  :src="url"
+                  @click="previewImages(idx)"
+                />
+              </div>
+              <van-empty v-else description="暂无图片" image-size="64" />
+            </section>
           </div>
-          <p v-else class="mp-wv-card__meta">暂无图片</p>
-        </section>
-
-        <section class="mp-wv-section">
-          <h3>品牌列表</h3>
-          <template v-if="brands.length">
-            <div v-for="(b, idx) in brands" :key="b.id || `${b.brandName}-${idx}`" class="mp-wv-kv">
-              <span>{{ b.brandName || '—' }}</span>
-              <span>
-                <el-tag :type="productStatusTagType(b.status)" size="small">
-                  {{ productStatusLabel(b.status) }}
-                </el-tag>
-              </span>
-            </div>
-          </template>
-          <p v-else class="mp-wv-card__meta">暂无品牌</p>
-        </section>
-
-        <section class="mp-wv-section">
-          <h3>别名列表</h3>
-          <template v-if="aliases.length">
-            <div
-              v-for="(a, idx) in aliases"
-              :key="a.id || `${a.aliasName}-${idx}`"
-              class="mp-wv-kv"
-            >
-              <span>别名</span>
-              <span>{{ a.aliasName || '—' }}</span>
-            </div>
-          </template>
-          <p v-else class="mp-wv-card__meta">暂无别名</p>
-        </section>
-      </div>
+        </div>
 
-      <div class="mp-wv-fab-bar">
-        <button
-          class="mp-wv-btn mp-wv-btn--primary"
-          type="button"
-          :disabled="acting"
-          @click="goEdit"
-        >
-          编辑
-        </button>
-        <button class="mp-wv-btn" type="button" :disabled="acting" @click="toggleStatus">
-          {{ statusActionLabel }}
-        </button>
-        <button
-          class="mp-wv-btn is-danger"
-          type="button"
-          :disabled="acting || !canDelete"
-          :title="canDelete ? '' : CREATOR_DELETE_TIP"
-          @click="onDelete"
-        >
-          删除
-        </button>
-      </div>
-    </template>
+        <div class="mp-wv-fab-bar">
+          <van-button round type="primary" :disabled="acting" @click="goEdit">编辑</van-button>
+          <van-button round :disabled="acting" @click="toggleStatus">{{
+            statusActionLabel
+          }}</van-button>
+          <van-button
+            round
+            type="danger"
+            plain
+            :disabled="acting || !canDelete"
+            :title="canDelete ? '' : CREATOR_DELETE_TIP"
+            @click="onDelete"
+          >
+            删除
+          </van-button>
+        </div>
+      </template>
+    </div>
   </MpWebviewShell>
 </template>
 
 <script setup lang="ts">
-  import { computed, ref } from 'vue'
-  import { useRoute, useRouter } from 'vue-router'
-  import { ElMessage, ElMessageBox } from 'element-plus'
+  import { showFailToast, showImagePreview, showSuccessToast } from 'vant'
+  import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
   import {
     fetchProductDetail,
     fetchUpdateProductStatus,
@@ -163,6 +181,16 @@
   import { productStatusLabel, productStatusTagType } from '@/utils/mp-crm-product-card'
   import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
   import '@/styles/mp-webview.scss'
+  import './product.scss'
+
+  type DetailTabKey = 'base' | 'relations' | 'media'
+
+  const TAB_BAR_HEIGHT = 44
+  const detailTabs = [
+    { key: 'base' as const, label: '基础信息' },
+    { key: 'relations' as const, label: '品牌与别名' },
+    { key: 'media' as const, label: '产品图片' }
+  ]
 
   const route = useRoute()
   const router = useRouter()
@@ -170,18 +198,21 @@
   const acting = ref(false)
   const errorText = ref('')
   const detail = ref<ProductDetail | null>(null)
+  const activeTab = ref<DetailTabKey>('base')
+  const stickyOffset = ref(0)
   const categoryPathMap = ref<Map<string, string>>(new Map())
+  const sectionEls: Partial<Record<DetailTabKey, HTMLElement>> = {}
   let categoryMapLoaded = false
+  let categoryRequestSeq = 0
+  let requestSeq = 0
+  let scrollLockUntil = 0
+  let scrollRaf = 0
+
+  const scrollMargin = computed(() => stickyOffset.value + TAB_BAR_HEIGHT)
 
-  const headerTitle = computed(() => {
-    if (loading.value) return '产品详情'
-    return detail.value?.productName || '产品详情'
-  })
   const statusLabel = computed(() => productStatusLabel(detail.value?.status))
   const statusTagType = computed(() => productStatusTagType(detail.value?.status))
-  const statusActionLabel = computed(() =>
-    detail.value?.status === 'on' ? '下架' : '上架'
-  )
+  const statusActionLabel = computed(() => (detail.value?.status === 'on' ? '下架' : '上架'))
   const showToxic = computed(() => Number(detail.value?.isToxic) === 1)
   const showExplosive = computed(() => Number(detail.value?.isExplosive) === 1)
   const canDelete = computed(() => canDeleteByCreator(detail.value as Record<string, unknown>))
@@ -222,26 +253,73 @@
   const brands = computed<ProductBrandItem[]>(() => detail.value?.brands || [])
   const aliases = computed<ProductAliasItem[]>(() => detail.value?.aliases || [])
 
+  function setSectionRef(key: DetailTabKey, el: Element | null) {
+    if (el instanceof HTMLElement) sectionEls[key] = el
+    else delete sectionEls[key]
+  }
+
+  function syncStickyOffset() {
+    if (typeof document === 'undefined') return
+    const header = document.querySelector('.mp-product-page > .mp-wv-header') as HTMLElement | null
+    stickyOffset.value = Math.ceil(header?.getBoundingClientRect().height || 0)
+  }
+
+  function scrollToSection(key: DetailTabKey) {
+    activeTab.value = key
+    scrollLockUntil = Date.now() + 600
+    const el = sectionEls[key]
+    if (!el) return
+    el.scrollIntoView({ behavior: 'smooth', block: 'start' })
+  }
+
+  function syncActiveTabFromScroll() {
+    if (Date.now() < scrollLockUntil) return
+    const probe = stickyOffset.value + TAB_BAR_HEIGHT + 8
+    let current: DetailTabKey = 'base'
+    for (const tab of detailTabs) {
+      const el = sectionEls[tab.key]
+      if (!el) continue
+      if (el.getBoundingClientRect().top <= probe) current = tab.key
+    }
+    if (activeTab.value !== current) activeTab.value = current
+  }
+
+  function onScroll() {
+    if (scrollRaf) return
+    scrollRaf = window.requestAnimationFrame(() => {
+      scrollRaf = 0
+      syncActiveTabFromScroll()
+    })
+  }
+
+  function previewImages(startPosition: number) {
+    showImagePreview({ images: imageList.value, startPosition })
+  }
+
   async function ensureCategoryPathMap() {
     if (categoryMapLoaded) return
+    const categoryRequestId = ++categoryRequestSeq
     try {
       const res = await fetchCategoryTree()
       const roots = (res as { data?: unknown })?.data || res || []
+      if (categoryRequestId !== categoryRequestSeq) return
       categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
         idKey: 'id',
         nameKey: 'categoryName',
         childrenKey: 'children'
       })
+      categoryMapLoaded = true
     } catch {
+      if (categoryRequestId !== categoryRequestSeq) return
       categoryPathMap.value = new Map()
-    } finally {
-      categoryMapLoaded = true
+      categoryMapLoaded = false
     }
   }
 
   async function loadDetail() {
-    const id = String(route.params.id || '')
-    if (!id) {
+    const requestId = ++requestSeq
+    const requestedId = String(route.params.id || '')
+    if (!requestedId) {
       errorText.value = '缺少产品 ID'
       loading.value = false
       return
@@ -249,14 +327,20 @@
     loading.value = true
     try {
       await ensureCategoryPathMap()
-      const res = await fetchProductDetail({ id })
+      const res = await fetchProductDetail({ id: requestedId })
+      if (requestId !== requestSeq || requestedId !== String(route.params.id || '')) return
       detail.value = ((res as { data?: ProductDetail }).data ?? res) as ProductDetail
       errorText.value = ''
+      activeTab.value = 'base'
+      await nextTick()
+      syncStickyOffset()
+      if (typeof window !== 'undefined') window.scrollTo({ top: 0 })
     } catch (error) {
+      if (requestId !== requestSeq || requestedId !== String(route.params.id || '')) return
       detail.value = null
       errorText.value = error instanceof Error ? error.message : '加载失败'
     } finally {
-      loading.value = false
+      if (requestId === requestSeq) loading.value = false
     }
   }
 
@@ -276,10 +360,10 @@
     const nextStatus = d.status === 'on' ? 'off' : 'on'
     const label = nextStatus === 'on' ? '上架' : '下架'
     try {
-      await ElMessageBox.confirm(`确认${label}该产品?`, '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
+      await showProductConfirmDialog({
+        kind: 'default',
+        title: `${label}产品`,
+        message: `确认${label}该产品?`
       })
     } catch {
       return
@@ -287,10 +371,10 @@
     acting.value = true
     try {
       await fetchUpdateProductStatus({ ids: [d.id], status: nextStatus })
-      ElMessage.success(`${label}成功`)
+      showSuccessToast(`${label}成功`)
       await loadDetail()
     } catch (error) {
-      ElMessage.error(error instanceof Error ? error.message : `${label}失败`)
+      showFailToast(error instanceof Error ? error.message : `${label}失败`)
     } finally {
       acting.value = false
     }
@@ -300,14 +384,14 @@
     const d = detail.value
     if (!d?.id) return
     if (!canDeleteByCreator(d as Record<string, unknown>)) {
-      ElMessage.warning(CREATOR_DELETE_TIP)
+      showFailToast(CREATOR_DELETE_TIP)
       return
     }
     try {
-      await ElMessageBox.confirm('确认删除该产品?删除后不可恢复。', '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
+      await showProductConfirmDialog({
+        kind: 'danger',
+        title: '删除产品',
+        message: '确认删除该产品?删除后不可恢复。'
       })
     } catch {
       return
@@ -315,62 +399,99 @@
     acting.value = true
     try {
       await fetchDeleteProduct({ id: d.id })
-      ElMessage.success('删除成功')
+      showSuccessToast('删除成功')
       void router.push(MP_CRM_PRODUCT_LIST_PATH)
     } catch (error) {
-      ElMessage.error(error instanceof Error ? error.message : '删除失败')
+      showFailToast(error instanceof Error ? error.message : '删除失败')
     } finally {
       acting.value = false
     }
   }
 
   function onShellReady() {
+    categoryRequestSeq += 1
+    categoryMapLoaded = false
+    activeTab.value = 'base'
     void loadDetail()
   }
+
+  onMounted(() => {
+    syncStickyOffset()
+    if (typeof window !== 'undefined') {
+      window.addEventListener('resize', syncStickyOffset)
+      window.addEventListener('scroll', onScroll, { passive: true })
+    }
+  })
+  onUnmounted(() => {
+    if (typeof window !== 'undefined') {
+      window.removeEventListener('resize', syncStickyOffset)
+      window.removeEventListener('scroll', onScroll)
+      if (scrollRaf) window.cancelAnimationFrame(scrollRaf)
+    }
+  })
+
+  watch(
+    () => route.params.id,
+    (id, previousId) => {
+      if (previousId !== undefined && id !== previousId) {
+        activeTab.value = 'base'
+        void loadDetail()
+      }
+    }
+  )
 </script>
 
 <style scoped lang="scss">
-  .mp-wv-header__sub {
-    margin-top: 4px;
-    font-size: 12px;
-    color: rgba(255, 255, 255, 0.9);
+  .status-tag {
+    display: inline-flex;
+    align-items: center;
+    min-height: 24px;
+    padding: 2px 8px;
+    font-size: 11px;
+    font-weight: 600;
+    border: 1px solid transparent;
+    border-radius: 999px;
   }
 
-  .mp-wv-detail-body {
-    padding-bottom: 12px;
-    width: 100%;
-    max-width: 100%;
-    overflow-x: hidden;
-    box-sizing: border-box;
+  .status-tag--success,
+  .status-tag--primary {
+    color: #087f57;
+    background: #e9f9f2;
+    border-color: #b7ebce;
   }
 
-  .mp-wv-tag-row {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 6px;
-    align-items: center;
+  .status-tag--warning {
+    color: #a16207;
+    background: #fffbeb;
+    border-color: #fde68a;
+  }
+
+  .status-tag--danger {
+    color: #b91c1c;
+    background: #fef2f2;
+    border-color: #fecaca;
+  }
+
+  .status-tag--info {
+    color: #475569;
+    background: #f1f5f9;
+    border-color: #dbe4ee;
   }
 
   .mp-wv-pre {
-    white-space: pre-wrap;
     word-break: break-word;
+    white-space: pre-wrap;
   }
 
   .mp-wv-img-grid {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
+    display: flex;
+    flex-wrap: wrap;
     gap: 8px;
   }
 
   .mp-wv-img-grid__item {
-    width: 100%;
-    aspect-ratio: 1;
-    border-radius: 8px;
+    flex: 0 0 72px;
     overflow: hidden;
-    background: #f7faf8;
-  }
-
-  .is-danger {
-    color: #d14343 !important;
+    border-radius: 8px;
   }
 </style>

+ 458 - 174
web-admin/src/views/m/crm/product/index.vue

@@ -1,140 +1,266 @@
 <template>
   <MpWebviewShell @ready="onShellReady">
-    <div v-if="forbidden" class="mp-wv-empty">无权限</div>
-    <template v-else>
-      <header class="mp-wv-header">
-        <div class="mp-wv-header__row">
-          <button class="mp-wv-header__back" type="button" aria-label="返回工作台" @click="goBack">
-            ←
-          </button>
-          <h1 class="mp-wv-header__title">产品管理</h1>
-        </div>
-        <input
-          v-model="keyword"
-          class="mp-wv-search"
-          type="search"
-          placeholder="产品名称 / 编号 / 拼音"
-          @search="reload"
-          @keyup.enter="reload"
-        />
-      </header>
+    <div class="mp-product-page mp-product-list">
+      <MpBusinessHeader
+        :keyword="keyword"
+        title="产品管理"
+        searchable
+        search-placeholder="产品名称 / 编号 / 拼音"
+        search-label="搜索产品"
+        back-label="返回工作台"
+        @back="goBack"
+        @search="reload"
+        @update:keyword="onKeywordUpdate"
+      >
+        <template v-if="!forbidden" #after-search>
+          <van-dropdown-menu class="mp-product-dd" :z-index="3000">
+            <van-dropdown-item
+              v-model="categoryId"
+              teleport="body"
+              :options="categoryDropdownOptions"
+              @change="reload"
+            />
+            <van-dropdown-item
+              v-model="model"
+              teleport="body"
+              :options="modelDropdownOptions"
+              @change="reload"
+            />
+            <van-dropdown-item
+              ref="moreDropdown"
+              teleport="body"
+              :title="moreActive ? '更多·已筛' : '更多'"
+              @open="onMoreOpen"
+            />
+          </van-dropdown-menu>
+        </template>
+      </MpBusinessHeader>
 
-      <div class="mp-wv-filter-wrap">
-        <div class="mp-wv-filter-bar">
-          <button
-            class="mp-wv-filter-trig"
-            :class="{ open: openFilter === 'status' }"
-            type="button"
-            @click="toggleFilter"
-          >
-            <span class="mp-wv-filter-trig__val">{{ statusLabel }}</span>
-            <span class="mp-wv-filter-trig__arrow" aria-hidden="true">▼</span>
-          </button>
-        </div>
-        <div class="mp-wv-filter-panel" :class="{ open: openFilter === 'status' }">
-          <button
-            v-for="item in MP_PRODUCT_STATUS_FILTERS"
-            :key="item.value"
-            class="mp-wv-filter-opt"
-            :class="{ active: statusFilter === item.value }"
-            type="button"
-            @click="pickStatus(item.value)"
-          >
-            <span>{{ item.label }}</span>
-            <span class="mp-wv-filter-opt__check" aria-hidden="true">✓</span>
-          </button>
-        </div>
-      </div>
-      <div
-        class="mp-wv-filter-mask"
-        :class="{ open: !!openFilter }"
-        @click="closeFilter"
-      />
+      <van-empty v-if="forbidden" description="无权限">
+        <van-button round type="primary" @click="goBack">返回工作台</van-button>
+      </van-empty>
 
-      <div class="mp-wv-pad-speed">
-        <div
-          v-if="loading && !cards.length"
-          class="mp-plan-form-loading mp-plan-form-loading--page"
-          role="status"
-          aria-live="polite"
-          aria-busy="true"
-        >
-          <div class="mp-plan-form-loading__orb" aria-hidden="true">
-            <span class="mp-plan-form-loading__dot" />
-          </div>
-          <p class="mp-plan-form-loading__title">正在加载产品</p>
-          <p class="mp-plan-form-loading__desc">请稍候,正在获取产品列表…</p>
-          <div class="mp-plan-form-loading__skel" aria-hidden="true">
-            <div class="mp-wv-skel mp-wv-skel--block" />
-            <div class="mp-wv-skel mp-wv-skel--mid" />
-            <div class="mp-wv-skel mp-wv-skel--line" />
-            <div class="mp-wv-skel mp-wv-skel--short" />
+      <template v-else>
+        <p v-if="errorText && products.length" class="mp-product-error" role="alert">
+          {{ errorText }}
+          <van-button size="small" type="primary" plain @click="loadMore">重试</van-button>
+        </p>
+
+        <div class="mp-product-content mp-wv-pad-speed">
+          <div v-if="loading && !products.length" class="mp-product-loading">
+            <van-loading vertical>正在加载产品列表</van-loading>
           </div>
+          <template v-else>
+            <van-empty v-if="errorText && !products.length" :description="errorText">
+              <van-button round type="primary" @click="reload">重试</van-button>
+            </van-empty>
+
+            <article
+              v-for="card in cards"
+              :key="card.id"
+              class="mp-wv-card mp-product-list-card"
+              :aria-label="`查看产品:${card.productName}`"
+              @click="goDetail(card.id)"
+            >
+              <div class="mp-wv-card__head">
+                <div class="mp-product-list-card__head-main">
+                  <div class="mp-product-list-card__titles">
+                    <div class="mp-wv-card__no">
+                      {{ card.productName }}
+                      <span
+                        v-if="card.showToxic"
+                        class="status-tag status-tag--danger mp-product-hazard"
+                        >毒</span
+                      >
+                      <span
+                        v-if="card.showExplosive"
+                        class="status-tag status-tag--warning mp-product-hazard"
+                        >爆</span
+                      >
+                    </div>
+                    <div class="mp-product-list-card__meta">{{ card.metaLine }}</div>
+                  </div>
+                </div>
+                <span class="status-tag" :class="`status-tag--${card.statusTagType}`">{{
+                  card.statusLabel
+                }}</span>
+              </div>
+
+              <div class="mp-product-list-card__preview">
+                <div
+                  v-for="field in card.previewFields"
+                  :key="field.key"
+                  class="mp-product-list-card__line"
+                >
+                  <span>{{ field.label }}</span>
+                  <span>{{ field.value }}</span>
+                </div>
+              </div>
+
+              <button
+                class="mp-product-list-card__toggle"
+                type="button"
+                :aria-expanded="isExpanded(card.id)"
+                @click="toggleExpand(card.id, $event)"
+              >
+                {{ isExpanded(card.id) ? '收起' : '更多字段' }}
+                <span class="mp-product-list-card__toggle-icon" aria-hidden="true">{{
+                  isExpanded(card.id) ? '▴' : '▾'
+                }}</span>
+              </button>
+
+              <div v-show="isExpanded(card.id)" class="mp-product-list-card__extra">
+                <div
+                  v-for="field in card.extraFields"
+                  :key="field.key"
+                  class="mp-product-list-card__row"
+                >
+                  <span>{{ field.label }}</span>
+                  <span>{{ field.value }}</span>
+                </div>
+              </div>
+
+              <div class="mp-wv-card-actions" @click.stop>
+                <button class="mp-wv-btn" type="button" @click="goEdit(card.id)">编辑</button>
+                <button
+                  class="mp-wv-btn"
+                  type="button"
+                  :disabled="actingId === card.id"
+                  @click="toggleStatus(card)"
+                >
+                  {{ card.status === 'on' ? '下架' : '上架' }}
+                </button>
+                <button
+                  class="mp-wv-btn is-danger"
+                  type="button"
+                  :disabled="actingId === card.id || !canDeleteCard(card)"
+                  :title="canDeleteCard(card) ? '' : CREATOR_DELETE_TIP"
+                  @click="deleteCard(card)"
+                >
+                  删除
+                </button>
+                <button
+                  class="mp-wv-btn mp-wv-btn--primary"
+                  type="button"
+                  @click="goDetail(card.id)"
+                >
+                  查看
+                </button>
+              </div>
+            </article>
+
+            <van-empty v-if="!products.length && !errorText" description="暂无产品,可调整筛选后重试" />
+            <p v-if="products.length" class="mp-product-load-hint" role="status">
+              <van-loading v-if="loading" size="16px" />
+              <span>{{
+                loading
+                  ? '加载中…'
+                  : hasMore
+                    ? `已加载 ${products.length}/${total},上拉或点此加载更多`
+                    : `共 ${total} 条`
+              }}</span>
+            </p>
+            <van-button
+              v-if="hasMore"
+              block
+              round
+              :disabled="loading"
+              style="margin-bottom: 16px"
+              @click="loadMore"
+            >
+              {{ loading ? '加载中…' : '加载更多' }}
+            </van-button>
+          </template>
         </div>
-        <div v-else-if="!cards.length" class="mp-wv-empty">
-          <div class="mp-wv-empty__icon" aria-hidden="true">📦</div>
-          <div class="mp-wv-empty__title">暂无产品</div>
-          <div class="mp-wv-empty__desc">可调整状态筛选或搜索后再试</div>
-        </div>
-        <article
-          v-for="card in cards"
-          :key="card.id"
-          class="mp-wv-card"
-          @click="goDetail(card.id)"
+
+        <van-popup
+          :show="moreOpen"
+          position="bottom"
+          round
+          teleport="body"
+          class="mp-customer-vant-popup"
+          :style="{ height: '70%' }"
+          @update:show="moreOpen = $event"
         >
-          <div class="mp-wv-card__title">{{ card.productName }}</div>
-          <div class="mp-wv-card__meta">{{ card.productNo }} · {{ card.model }}</div>
-          <div class="mp-wv-card__meta">{{ card.categoryPath }}</div>
-          <div class="mp-wv-card__tags">
-            <el-tag :type="card.statusTagType" size="small">{{ card.statusLabel }}</el-tag>
-            <el-tag v-if="card.showToxic" type="danger" size="small">易制毒</el-tag>
-            <el-tag v-if="card.showExplosive" type="warning" size="small">易制爆</el-tag>
+          <div class="mp-customer-filter-vant">
+            <van-nav-bar title="更多筛选" left-text="关闭" @click-left="moreOpen = false" />
+            <div class="mp-product-filter-vant__body">
+              <van-cell-group inset>
+                <van-field
+                  v-model="moreDraft.keyword"
+                  label="模糊检索"
+                  placeholder="产品名称 / 编号 / 拼音"
+                  clearable
+                />
+                <van-field
+                  v-model="moreDraft.model"
+                  label="规格型号"
+                  placeholder="输入规格型号"
+                  clearable
+                />
+                <van-field
+                  v-model="moreDraft.priceMin"
+                  type="number"
+                  label="标准价最低"
+                  placeholder="最低价"
+                  clearable
+                />
+                <van-field
+                  v-model="moreDraft.priceMax"
+                  type="number"
+                  label="标准价最高"
+                  placeholder="最高价"
+                  clearable
+                />
+              </van-cell-group>
+            </div>
+            <div class="mp-product-filter-vant__foot">
+              <van-button round @click="resetMoreFilters">重置</van-button>
+              <van-button round type="primary" @click="applyMoreFilters">确定</van-button>
+            </div>
           </div>
-        </article>
-
-        <div v-if="hasMore" class="mp-wv-toolbar" style="padding: 0 0 12px">
-          <button
-            class="mp-wv-btn mp-wv-btn--block"
-            type="button"
-            :disabled="loading"
-            @click="loadMore"
-          >
-            <span v-if="loading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
-            <span>{{ loading ? '加载中…' : `加载更多(${products.length}/${total})` }}</span>
-          </button>
-        </div>
-      </div>
+        </van-popup>
 
-      <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
-      <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
-        <div class="mp-wv-speed-menu">
-          <button class="mp-wv-speed-item" type="button" @click="goCategories">产品分类</button>
-          <button class="mp-wv-speed-item primary" type="button" @click="goNew">新增产品</button>
+        <div class="mp-wv-speed-mask" :class="{ open: speedOpen }" @click="speedOpen = false" />
+        <div class="mp-wv-speed-dial" :class="{ open: speedOpen }">
+          <div class="mp-wv-speed-menu">
+            <van-button round size="small" @click="goCategories">产品分类</van-button>
+            <van-button round size="small" type="primary" @click="goNew">新增产品</van-button>
+          </div>
+          <van-button
+            class="mp-wv-speed-main"
+            type="primary"
+            round
+            icon="plus"
+            aria-label="快捷操作"
+            @click="toggleSpeed"
+          />
         </div>
-        <button class="mp-wv-speed-main" type="button" aria-label="快捷操作" @click="toggleSpeed">
-          +
-        </button>
-      </div>
-    </template>
+      </template>
+    </div>
   </MpWebviewShell>
 </template>
 
 <script setup lang="ts">
-  import { computed, ref } from 'vue'
-  import { useRouter } from 'vue-router'
+  import { showFailToast, showSuccessToast } from 'vant'
+  import { showProductConfirmDialog } from '@/utils/mp-crm-product-confirm'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import { fetchProductList, fetchCategoryTree } from '@/api/crm/product'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
   import {
-    MP_PRODUCT_STATUS_FILTERS,
-    buildProductListQuery,
-    buildProductCard,
-    type MpProductStatusFilter
-  } from '@/utils/mp-crm-product-card'
+    fetchProductList,
+    fetchCategoryTree,
+    fetchProductColumnFacetValues,
+    fetchUpdateProductStatus,
+    fetchDeleteProduct
+  } from '@/api/crm/product'
+  import { buildProductListQuery, buildProductCard } from '@/utils/mp-crm-product-card'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
   import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
   import { goToMiniWorkbench } from '@/utils/mp-webview'
   import { HttpError } from '@/utils/http/error'
   import { ApiStatus } from '@/utils/http/status'
   import '@/styles/mp-webview.scss'
+  import './product.scss'
 
   type ProductRow = {
     id: string
@@ -146,155 +272,313 @@
     status?: string
     isToxic?: number
     isExplosive?: number
+    createTime?: string
+    createBy?: string
+    images?: string
+    brands?: Array<{ brandName?: string }>
+    aliases?: Array<{ aliasName?: string }>
+    price?: number | string
   }
+  type CategoryNode = { id?: string; categoryName?: string; children?: CategoryNode[] }
+  type ProductCard = ReturnType<typeof buildProductCard>
 
   const router = useRouter()
   const keyword = ref('')
-  const statusFilter = ref<MpProductStatusFilter>('on')
-  const openFilter = ref<'' | 'status'>('')
+  const categoryId = ref('')
+  const model = ref('')
+  const priceMin = ref('')
+  const priceMax = ref('')
+  const moreOpen = ref(false)
   const speedOpen = ref(false)
   const loading = ref(false)
   const forbidden = ref(false)
-  const page = ref(1)
+  const errorText = ref('')
+  const actingId = ref('')
+  const pageNum = ref(1)
   const pageSize = 20
   const total = ref(0)
   const products = ref<ProductRow[]>([])
+  const expandedIds = ref<Set<string>>(new Set())
+  const categoryRoots = ref<CategoryNode[]>([])
   const categoryPathMap = ref<Map<string, string>>(new Map())
   let categoryMapLoaded = false
+  let categoryRequestSeq = 0
+  const modelOptions = ref<Array<{ text: string; value: string }>>([
+    { text: '全部型号', value: '' }
+  ])
+  let modelOptionsLoaded = false
+  let modelRequestSeq = 0
+  let requestSeq = 0
+  const moreDropdown = ref<{ toggle?: (show?: boolean) => void } | null>(null)
 
-  const statusLabel = computed(
-    () =>
-      MP_PRODUCT_STATUS_FILTERS.find((item) => item.value === statusFilter.value)?.label || '上架'
-  )
+  const moreDraft = reactive({
+    keyword: '',
+    model: '',
+    priceMin: '',
+    priceMax: ''
+  })
 
+  function flattenCategories(nodes: CategoryNode[], parents: string[] = []) {
+    return nodes.flatMap((node) => {
+      const path = [...parents, String(node.categoryName || '')].filter(Boolean)
+      const current = node.id ? [{ text: path.join(' / '), value: String(node.id) }] : []
+      return current.concat(flattenCategories(node.children || [], path))
+    })
+  }
+
+  const categoryDropdownOptions = computed(() => [
+    { text: '全部分类', value: '' },
+    ...flattenCategories(categoryRoots.value)
+  ])
+  const modelDropdownOptions = computed(() => modelOptions.value)
+  const moreActive = computed(() => Boolean(priceMin.value || priceMax.value))
   const cards = computed(() =>
     products.value.map((row) => {
       const cid = String(row.categoryId || '').trim()
       const path = cid ? categoryPathMap.value.get(cid) : ''
-      return buildProductCard({
-        ...row,
-        categoryPath: path || row.categoryName || '—'
-      })
+      return buildProductCard({ ...row, categoryPath: path || row.categoryName || '—' })
     })
   )
-
   const hasMore = computed(() => products.value.length < total.value)
 
   async function ensureCategoryPathMap() {
     if (categoryMapLoaded) return
+    const categoryRequestId = ++categoryRequestSeq
     try {
       const res = await fetchCategoryTree()
       const roots = (res as any)?.data || res || []
-      categoryPathMap.value = buildPathLabelMapFromTreeRoots(Array.isArray(roots) ? roots : [], {
+      if (categoryRequestId !== categoryRequestSeq) return
+      categoryRoots.value = Array.isArray(roots) ? roots : []
+      categoryPathMap.value = buildPathLabelMapFromTreeRoots(categoryRoots.value, {
         idKey: 'id',
         nameKey: 'categoryName',
         childrenKey: 'children'
       })
+      categoryMapLoaded = true
     } catch {
+      if (categoryRequestId !== categoryRequestSeq) return
+      categoryRoots.value = []
       categoryPathMap.value = new Map()
-    } finally {
-      categoryMapLoaded = true
+      categoryMapLoaded = false
     }
   }
 
+  async function ensureModelOptions() {
+    if (modelOptionsLoaded) return
+    const modelRequestId = ++modelRequestSeq
+    try {
+      const res = await fetchProductColumnFacetValues({ field: 'model', limit: 500 })
+      const data = (res as any)?.data || res || {}
+      const list = (Array.isArray(data) ? data : data.list || []) as Array<{
+        value?: string
+        label?: string
+      }>
+      if (modelRequestId !== modelRequestSeq) return
+      modelOptions.value = [
+        { text: '全部型号', value: '' },
+        ...list
+          .map((item) => ({
+            value: String(item.value || '').trim(),
+            text: String(item.label || item.value || '').trim()
+          }))
+          .filter((item) => item.value)
+      ]
+      modelOptionsLoaded = true
+    } catch {
+      if (modelRequestId !== modelRequestSeq) return
+      modelOptions.value = [{ text: '全部型号', value: '' }]
+      modelOptionsLoaded = false
+    }
+  }
+
+  function requestSnapshot() {
+    return JSON.stringify({
+      keyword: keyword.value.trim(),
+      categoryId: categoryId.value,
+      model: model.value.trim(),
+      priceMin: priceMin.value,
+      priceMax: priceMax.value
+    })
+  }
+
+  function isExpanded(id: string) {
+    return expandedIds.value.has(String(id))
+  }
+  function toggleExpand(id: string, e?: Event) {
+    e?.stopPropagation()
+    const key = String(id)
+    const next = new Set(expandedIds.value)
+    if (next.has(key)) next.delete(key)
+    else next.add(key)
+    expandedIds.value = next
+  }
+
   async function fetchPage(reset: boolean) {
+    if (!reset && loading.value) return
+    const requestId = ++requestSeq
+    const snapshot = requestSnapshot()
+    const requestedPage = reset ? 1 : pageNum.value + 1
     if (reset) {
-      page.value = 1
       products.value = []
+      total.value = 0
+      expandedIds.value = new Set()
     }
     loading.value = true
+    errorText.value = ''
     try {
-      await ensureCategoryPathMap()
+      await Promise.all([ensureCategoryPathMap(), ensureModelOptions()])
       const res = await fetchProductList(
         buildProductListQuery({
-          page: page.value,
+          page: requestedPage,
           pageSize,
-          statusFilter: statusFilter.value,
-          keyword: keyword.value
+          keyword: keyword.value,
+          categoryId: categoryId.value,
+          model: model.value,
+          priceMin: priceMin.value === '' ? undefined : Number(priceMin.value),
+          priceMax: priceMax.value === '' ? undefined : Number(priceMax.value)
         })
       )
       const data = (res as any)?.data || res || {}
       const list = (data.list || data.rows || []) as ProductRow[]
+      if (requestId !== requestSeq || snapshot !== requestSnapshot()) return
       total.value = data.total ?? 0
       products.value = reset ? list : products.value.concat(list)
+      pageNum.value = requestedPage
       forbidden.value = false
     } catch (error) {
+      if (requestId !== requestSeq) return
       if (error instanceof HttpError && error.code === ApiStatus.forbidden) {
         forbidden.value = true
         products.value = []
         return
       }
-      throw error
+      errorText.value =
+        error instanceof Error
+          ? error.message
+          : reset
+            ? '产品加载失败,请重试'
+            : '分页加载失败,请重试'
     } finally {
-      loading.value = false
+      if (requestId === requestSeq) loading.value = false
     }
   }
 
   function reload() {
     void fetchPage(true)
   }
-
-  function closeFilter() {
-    openFilter.value = ''
+  function onKeywordUpdate(value: string) {
+    const cleared = Boolean(keyword.value.trim()) && !value.trim()
+    keyword.value = value
+    if (cleared) reload()
   }
-
-  function toggleFilter() {
-    speedOpen.value = false
-    openFilter.value = openFilter.value === 'status' ? '' : 'status'
+  function onMoreOpen() {
+    Object.assign(moreDraft, {
+      keyword: keyword.value,
+      model: model.value,
+      priceMin: priceMin.value,
+      priceMax: priceMax.value
+    })
+    moreOpen.value = true
+    nextTick(() => moreDropdown.value?.toggle?.(false))
   }
-
-  function pickStatus(value: MpProductStatusFilter) {
-    statusFilter.value = value
-    closeFilter()
+  function resetMoreFilters() {
+    Object.assign(moreDraft, { keyword: '', model: '', priceMin: '', priceMax: '' })
+  }
+  function applyMoreFilters() {
+    keyword.value = moreDraft.keyword.trim()
+    model.value = moreDraft.model.trim()
+    priceMin.value = moreDraft.priceMin
+    priceMax.value = moreDraft.priceMax
+    moreOpen.value = false
     reload()
   }
-
   function loadMore() {
-    page.value += 1
     void fetchPage(false)
   }
-
+  function canDeleteCard(card: ProductCard) {
+    return canDeleteByCreator(card as Record<string, unknown>)
+  }
+  async function toggleStatus(card: ProductCard) {
+    const nextStatus = card.status === 'on' ? 'off' : 'on'
+    const label = nextStatus === 'on' ? '上架' : '下架'
+    try {
+      await showProductConfirmDialog({
+        kind: 'default',
+        title: `${label}产品`,
+        message: `确认${label}该产品?`
+      })
+    } catch {
+      return
+    }
+    actingId.value = card.id
+    try {
+      await fetchUpdateProductStatus({ ids: [card.id], status: nextStatus })
+      showSuccessToast(`${label}成功`)
+      reload()
+    } catch (error) {
+      showFailToast(error instanceof Error ? error.message : `${label}失败`)
+    } finally {
+      actingId.value = ''
+    }
+  }
+  async function deleteCard(card: ProductCard) {
+    if (!canDeleteCard(card)) {
+      showFailToast(CREATOR_DELETE_TIP)
+      return
+    }
+    try {
+      await showProductConfirmDialog({
+        kind: 'danger',
+        title: '删除产品',
+        message: '确认删除该产品?删除后不可恢复。'
+      })
+    } catch {
+      return
+    }
+    actingId.value = card.id
+    try {
+      await fetchDeleteProduct({ id: card.id })
+      showSuccessToast('删除成功')
+      reload()
+    } catch (error) {
+      showFailToast(error instanceof Error ? error.message : '删除失败')
+    } finally {
+      actingId.value = ''
+    }
+  }
   function toggleSpeed() {
-    closeFilter()
+    moreOpen.value = false
     speedOpen.value = !speedOpen.value
   }
-
   function goBack() {
     goToMiniWorkbench()
   }
-
   function goNew() {
     speedOpen.value = false
     void router.push('/m/crm/product/new')
   }
-
   function goCategories() {
     speedOpen.value = false
     void router.push('/m/crm/product/categories')
   }
-
+  function goEdit(id: string) {
+    void router.push(`/m/crm/product/${id}/edit`)
+  }
   function goDetail(id: string) {
     void router.push(`/m/crm/product/${id}`)
   }
-
   function onShellReady() {
+    categoryRequestSeq += 1
     categoryMapLoaded = false
-    statusFilter.value = 'on'
+    modelRequestSeq += 1
+    modelOptionsLoaded = false
+    keyword.value = ''
+    categoryId.value = ''
+    model.value = ''
+    priceMin.value = ''
+    priceMax.value = ''
+    speedOpen.value = false
+    expandedIds.value = new Set()
     reload()
   }
 </script>
-
-<style scoped lang="scss">
-  .mp-wv-card__title {
-    font-size: 15px;
-    font-weight: 700;
-    color: #087f57;
-  }
-
-  .mp-wv-card__tags {
-    display: flex;
-    flex-wrap: wrap;
-    gap: 6px;
-    margin-top: 8px;
-  }
-</style>