Procházet zdrojové kódy

feat(web-admin): wire category tree list markup for scheme A.

Add expand-all control, row meta, and inline add-child on the mp category page.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong před 2 týdny
rodič
revize
5d943cf76b
1 změnil soubory, kde provedl 306 přidání a 235 odebrání
  1. 306 235
      web-admin/src/views/m/crm/product/categories.vue

+ 306 - 235
web-admin/src/views/m/crm/product/categories.vue

@@ -1,171 +1,179 @@
 <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="goBack">
-          ←
-        </button>
-        <h1 class="mp-wv-header__title">产品分类</h1>
-        <button class="mp-cat-header-add" type="button" @click="openAdd()">新增</button>
-      </div>
-    </header>
-
-    <div
-      v-if="loading && !tree.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" />
-      </div>
-    </div>
-    <div v-else-if="!flatNodes.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>
-    <div v-else class="mp-cat-list">
-      <div
-        v-for="item in flatNodes"
-        :key="item.node.id"
-        class="mp-cat-row"
-        :style="{ paddingLeft: `${12 + item.depth * 16}px` }"
-      >
+    <div class="mp-product-page">
+      <MpBusinessHeader title="产品分类" back-label="返回产品列表" @back="goBack" />
+      <div class="mp-cat-header-tools">
         <button
-          class="mp-cat-row__toggle"
+          v-if="flatNodes.length"
+          class="mp-cat-header-btn"
           type="button"
-          :class="{ 'is-leaf': !item.hasChildren }"
-          :aria-label="item.expanded ? '折叠' : '展开'"
-          :disabled="!item.hasChildren"
-          @click="toggleExpand(item.node.id)"
+          @click="toggleExpandAll"
         >
-          <span v-if="item.hasChildren">{{ item.expanded ? '▼' : '▶' }}</span>
+          {{ expandAllLabel }}
         </button>
-        <div class="mp-cat-row__main" @click="openNodeActions(item.node)">
-          <div class="mp-cat-row__name">{{ item.node.categoryName || '—' }}</div>
-          <div class="mp-cat-row__meta">
-            <el-tag
-              :type="dictStore.getTagType('sys_normal_disable', item.node.status)"
-              size="small"
-            >
-              {{ dictStore.getLabel('sys_normal_disable', item.node.status) }}
-            </el-tag>
+        <button class="mp-cat-header-btn" type="button" @click="openAdd()">新增</button>
+      </div>
+
+      <div v-if="loading && !tree.length" class="mp-product-loading">
+        <van-loading vertical>正在加载分类</van-loading>
+      </div>
+      <van-empty v-else-if="!flatNodes.length" description="暂无分类,点击右上角新增">
+        <van-button round type="primary" @click="openAdd()">新增分类</van-button>
+      </van-empty>
+      <div v-else class="mp-cat-tree">
+        <div
+          v-for="item in flatNodes"
+          :key="item.node.id"
+          class="mp-cat-row"
+          :class="{
+            'is-l2': item.depth === 1,
+            'is-l3': item.depth >= 2
+          }"
+          :style="{ paddingLeft: `${12 + item.depth * 16}px` }"
+        >
+          <button
+            class="mp-cat-row__toggle"
+            type="button"
+            :class="{ 'is-leaf': !item.hasChildren, 'is-open': item.expanded }"
+            :aria-label="item.expanded ? '折叠' : '展开'"
+            :disabled="!item.hasChildren"
+            @click="toggleExpand(item.node.id)"
+          >
+            <span v-if="item.hasChildren">▶</span>
+          </button>
+          <div class="mp-cat-row__main" @click="openNodeActions(item.node)">
+            <div class="mp-cat-row__name">{{ item.node.categoryName || '—' }}</div>
+            <div class="mp-cat-row__meta">
+              <span
+                class="status-tag"
+                :class="`status-tag--${statusTone(item.node.status)}`"
+              >
+                {{ dictStore.getLabel('sys_normal_disable', item.node.status) || '—' }}
+              </span>
+              <span>排序 {{ item.node.orderNum ?? 0 }}</span>
+              <span>{{ categoryChildMetaLabel(item.node) }}</span>
+            </div>
           </div>
+          <button
+            class="mp-cat-row__add"
+            type="button"
+            aria-label="新增子级"
+            @click="openAdd(item.node)"
+          >
+            +
+          </button>
+          <button
+            class="mp-cat-row__more"
+            type="button"
+            aria-label="操作"
+            @click="openNodeActions(item.node)"
+          >
+            ⋯
+          </button>
         </div>
-        <button
-          class="mp-cat-row__more"
-          type="button"
-          aria-label="操作"
-          @click="openNodeActions(item.node)"
-        >
-          ⋯
-        </button>
       </div>
-    </div>
 
-    <MpBottomSheet v-model="actionOpen" title="分类操作" half>
-      <button class="mp-wv-filter-opt" type="button" @click="onActionAddChild">新增子级</button>
-      <button class="mp-wv-filter-opt" type="button" @click="onActionEdit">编辑</button>
-      <button
-        class="mp-wv-filter-opt is-danger"
-        type="button"
-        :disabled="!canDeleteActionNode"
-        :title="canDeleteActionNode ? '' : CREATOR_DELETE_TIP"
-        @click="onActionDelete"
-      >
-        删除
-      </button>
-    </MpBottomSheet>
-
-    <MpBottomSheet v-model="formOpen" :title="formTitle">
-      <el-form
-        ref="formRef"
-        class="mp-cat-form"
-        :model="formData"
-        :rules="formRules"
-        label-position="top"
-        scroll-to-error
-        :scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
+      <van-action-sheet
+        v-model:show="actionOpen"
+        teleport="body"
+        :actions="actionSheetActions"
+        cancel-text="取消"
+        close-on-click-action
+        @select="onActionSelect"
+      />
+
+      <van-popup
+        :show="formOpen"
+        position="bottom"
+        round
+        teleport="body"
+        class="mp-customer-vant-popup"
+        :style="{ height: '82%' }"
+        :close-on-click-overlay="!submitLoading"
+        @update:show="onFormShow"
       >
-        <el-form-item label="上级分类">
-          <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%"
+        <div class="mp-customer-pool-vant">
+          <van-nav-bar
+            :title="formTitle"
+            left-text="关闭"
+            :left-disabled="submitLoading"
+            @click-left="closeForm"
           />
-        </el-form-item>
-        <el-form-item label="分类名称" prop="categoryName">
-          <el-input v-model="formData.categoryName" placeholder="请输入分类名称" />
-        </el-form-item>
-        <el-form-item label="排序号">
-          <el-input-number
-            v-model="formData.orderNum"
-            controls-position="right"
-            :min="0"
-            style="width: 100%"
-          />
-        </el-form-item>
-        <el-form-item label="状态">
-          <el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
-            <el-option
-              v-for="opt in dictStore.getOptions('sys_normal_disable')"
-              :key="opt.value"
-              :label="opt.label"
-              :value="opt.value"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="备注">
-          <el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" :rows="3" />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <button
-          class="mp-wv-btn mp-cat-foot-btn"
-          type="button"
-          :disabled="submitLoading"
-          @click="formOpen = false"
-        >
-          取消
-        </button>
-        <button
-          class="mp-wv-btn mp-wv-btn--primary mp-cat-foot-btn"
-          type="button"
-          :disabled="submitLoading"
-          @click="submitForm"
-        >
-          <span v-if="submitLoading" class="mp-wv-spinner mp-wv-spinner--sm" aria-hidden="true" />
-          <span>{{ submitLoading ? '提交中…' : '确定' }}</span>
-        </button>
-      </template>
-    </MpBottomSheet>
+          <div class="mp-customer-pool-vant__body">
+            <van-cell-group inset>
+              <van-field
+                is-link
+                readonly
+                label="上级分类"
+                :model-value="parentLabel"
+                placeholder="选择上级分类(留空为顶级)"
+                @click="parentPickerOpen = true"
+              />
+              <van-field
+                v-model="formData.categoryName"
+                label="分类名称"
+                required
+                placeholder="请输入分类名称"
+                :error-message="nameError"
+              />
+              <van-field
+                v-model="orderNumText"
+                type="digit"
+                label="排序号"
+                placeholder="数值越小越靠前"
+              />
+              <van-field
+                is-link
+                readonly
+                label="状态"
+                :model-value="statusLabel"
+                placeholder="请选择状态"
+                @click="statusPickerOpen = true"
+              />
+              <van-field
+                v-model="formData.remark"
+                rows="3"
+                autosize
+                type="textarea"
+                label="备注"
+                placeholder="请输入备注"
+              />
+            </van-cell-group>
+          </div>
+          <div class="mp-customer-pool-vant__foot">
+            <van-button round :disabled="submitLoading" @click="closeForm">取消</van-button>
+            <van-button round type="primary" :loading="submitLoading" @click="submitForm"
+              >确定</van-button
+            >
+          </div>
+        </div>
+      </van-popup>
+
+      <van-popup v-model:show="parentPickerOpen" position="bottom" round teleport="body">
+        <van-picker
+          :columns="parentPickerColumns"
+          :model-value="[formData.parentId || '0']"
+          @confirm="onParentConfirm"
+          @cancel="parentPickerOpen = false"
+        />
+      </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 { computed, reactive, ref } from 'vue'
-  import { useRouter } from 'vue-router'
-  import { ElMessage, ElMessageBox } from 'element-plus'
-  import type { FormInstance } from 'element-plus'
+  import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
   import MpWebviewShell from '@/layouts/MpWebviewShell.vue'
-  import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
+  import MpBusinessHeader from '@/components/mp/MpBusinessHeader.vue'
   import {
     fetchCategoryTree,
     fetchCreateCategory,
@@ -174,8 +182,14 @@
   } from '@/api/crm/product'
   import { useDictStore } from '@/store/modules/dict'
   import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import {
+    areAllExpandableExpanded,
+    categoryChildMetaLabel,
+    collectExpandIds
+  } from '@/utils/mp-crm-product-category-tree'
   import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
   import '@/styles/mp-webview.scss'
+  import './product.scss'
 
   type CategoryNode = {
     id: string
@@ -209,8 +223,10 @@
   const formOpen = ref(false)
   const formTitle = ref('')
   const submitLoading = ref(false)
-  const formRef = ref<FormInstance>()
-  const categoryTreeOptions = ref<CategoryNode[]>([])
+  const nameError = ref('')
+  const parentPickerOpen = ref(false)
+  const statusPickerOpen = ref(false)
+  const flatParentOptions = ref<Array<{ text: string; value: string }>>([])
   const formData = reactive({
     id: '',
     parentId: '' as string,
@@ -219,14 +235,51 @@
     status: '0',
     remark: ''
   })
-  const formRules = {
-    categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }]
-  }
+
+  const orderNumText = computed({
+    get: () => String(formData.orderNum ?? 0),
+    set: (v: string) => {
+      formData.orderNum = Number(v || 0)
+    }
+  })
 
   const canDeleteActionNode = computed(() =>
     canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
   )
 
+  const actionSheetActions = computed(() => [
+    { name: '新增子级', value: 'add' },
+    { name: '编辑', value: 'edit' },
+    {
+      name: '删除',
+      value: 'delete',
+      color: '#dc2626',
+      disabled: !canDeleteActionNode.value
+    }
+  ])
+
+  const parentPickerColumns = computed(() => [
+    { text: '顶级分类', value: '0' },
+    ...flatParentOptions.value
+  ])
+
+  const statusPickerColumns = computed(() =>
+    dictStore.getOptions('sys_normal_disable').map((o) => ({
+      text: o.label,
+      value: String(o.value)
+    }))
+  )
+
+  const parentLabel = computed(() => {
+    const id = formData.parentId || '0'
+    if (id === '0' || !id) return '顶级分类'
+    return parentPickerColumns.value.find((c) => c.value === id)?.text || id
+  })
+
+  const statusLabel = computed(() => {
+    return dictStore.getLabel('sys_normal_disable', formData.status) || ''
+  })
+
   const flatNodes = computed((): FlatItem[] => {
     const out: FlatItem[] = []
     const walk = (nodes: CategoryNode[], depth: number) => {
@@ -242,27 +295,29 @@
     return out
   })
 
-  function collectExpandIds(nodes: CategoryNode[], acc: Record<string, boolean> = {}) {
-    for (const node of nodes) {
-      const children = Array.isArray(node.children) ? node.children : []
-      if (children.length) {
-        acc[node.id] = true
-        collectExpandIds(children, acc)
-      }
-    }
-    return acc
+  function statusTone(status?: string) {
+    if (String(status) === '1') return 'danger'
+    return 'success'
   }
 
-  function findNodeById(nodes: CategoryNode[], id: string): CategoryNode | null {
-    for (const node of nodes) {
-      if (node.id === id) return node
-      const children = Array.isArray(node.children) ? node.children : []
-      if (children.length) {
-        const found = findNodeById(children, id)
-        if (found) return found
-      }
+  function flattenParents(nodes: CategoryNode[], parents: string[] = []): Array<{ text: string; value: 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(flattenParents(node.children || [], path))
+    })
+  }
+
+  const expandAllLabel = computed(() =>
+    areAllExpandableExpanded(tree.value, expandedIds.value) ? '全部折叠' : '全部展开'
+  )
+
+  function toggleExpandAll() {
+    if (areAllExpandableExpanded(tree.value, expandedIds.value)) {
+      expandedIds.value = {}
+      return
     }
-    return null
+    expandedIds.value = collectExpandIds(tree.value)
   }
 
   async function loadTree() {
@@ -277,17 +332,10 @@
     }
   }
 
-  async function getCategoryTreeSelect() {
+  async function refreshParentOptions() {
     const res = await fetchCategoryTree({})
     const data = ((res as { data?: CategoryNode[] }).data || res || []) as CategoryNode[]
-    categoryTreeOptions.value = [
-      {
-        id: '0',
-        categoryName: '顶级分类',
-        parentId: '0',
-        children: Array.isArray(data) ? data : []
-      }
-    ]
+    flatParentOptions.value = flattenParents(Array.isArray(data) ? data : [])
   }
 
   function toggleExpand(id: string) {
@@ -312,16 +360,14 @@
     formData.orderNum = 0
     formData.status = '0'
     formData.remark = ''
-    formRef.value?.resetFields()
+    nameError.value = ''
   }
 
   async function openAdd(parentRow?: CategoryNode) {
     actionOpen.value = false
     resetForm()
-    await getCategoryTreeSelect()
-    if (parentRow?.id) {
-      formData.parentId = parentRow.id
-    }
+    await refreshParentOptions()
+    if (parentRow?.id) formData.parentId = parentRow.id
     formTitle.value = '新增产品分类'
     formOpen.value = true
   }
@@ -329,7 +375,7 @@
   async function openEdit(node: CategoryNode) {
     actionOpen.value = false
     resetForm()
-    await getCategoryTreeSelect()
+    await refreshParentOptions()
     formData.id = node.id
     formData.parentId = node.parentId || ''
     formData.categoryName = node.categoryName || ''
@@ -345,68 +391,79 @@
     actionOpen.value = true
   }
 
-  function onActionAddChild() {
+  function onActionSelect(action: { value?: string }) {
     const node = actionNode.value
     if (!node) return
-    void openAdd(node)
-  }
-
-  function onActionEdit() {
-    const node = actionNode.value
-    if (!node) return
-    void openEdit(node)
+    if (action.value === 'add') void openAdd(node)
+    else if (action.value === 'edit') void openEdit(node)
+    else if (action.value === 'delete') void onActionDelete()
   }
 
   async function onActionDelete() {
     const node = actionNode.value
     if (!node?.id) return
     if (!canDeleteByCreator(node as Record<string, unknown>)) {
-      ElMessage.warning(CREATOR_DELETE_TIP)
+      showFailToast(CREATOR_DELETE_TIP)
       return
     }
     try {
-      await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
-        confirmButtonText: '确定',
-        cancelButtonText: '取消',
-        type: 'warning'
-      })
+      await showConfirmDialog({ title: '提示', message: '是否确认删除该产品分类?' })
     } catch {
       return
     }
     actionOpen.value = false
     try {
       await fetchDeleteCategory({ id: node.id })
-      ElMessage.success('删除成功')
+      showSuccessToast('删除成功')
       actionNode.value = null
       await loadTree()
     } catch (error) {
-      ElMessage.error(error instanceof Error ? error.message : '删除失败')
+      showFailToast(error instanceof Error ? error.message : '删除失败')
     }
   }
 
+  function onFormShow(show: boolean) {
+    if (!submitLoading.value) formOpen.value = show
+  }
+  function closeForm() {
+    if (!submitLoading.value) formOpen.value = false
+  }
+  function onParentConfirm({ selectedValues }: { selectedValues: string[] }) {
+    const v = selectedValues[0] || '0'
+    formData.parentId = v === '0' ? '' : v
+    parentPickerOpen.value = false
+  }
+  function onStatusConfirm({ selectedValues }: { selectedValues: string[] }) {
+    formData.status = selectedValues[0] || '0'
+    statusPickerOpen.value = false
+  }
+
   async function submitForm() {
-    const valid = await formRef.value?.validate().catch(() => false)
-    if (!valid) return
+    if (!formData.categoryName.trim()) {
+      nameError.value = '分类名称不能为空'
+      return
+    }
+    nameError.value = ''
     submitLoading.value = true
     try {
       const payload = {
         parentId: formData.parentId || '0',
-        categoryName: formData.categoryName,
+        categoryName: formData.categoryName.trim(),
         orderNum: formData.orderNum,
         status: formData.status,
         remark: formData.remark
       }
       if (formData.id) {
         await fetchUpdateCategory({ id: formData.id, ...payload })
-        ElMessage.success('修改成功')
+        showSuccessToast('修改成功')
       } else {
         await fetchCreateCategory(payload)
-        ElMessage.success('新增成功')
+        showSuccessToast('新增成功')
       }
       formOpen.value = false
       await loadTree()
     } catch (error) {
-      ElMessage.error(error instanceof Error ? error.message : '保存失败')
+      showFailToast(error instanceof Error ? error.message : '保存失败')
     } finally {
       submitLoading.value = false
     }
@@ -418,17 +475,24 @@
 </script>
 
 <style scoped lang="scss">
+  .mp-product-page {
+    position: relative;
+  }
+
   .mp-cat-header-add {
-    flex-shrink: 0;
+    position: absolute;
+    top: calc(8px + env(safe-area-inset-top, 0px));
+    right: 16px;
+    z-index: 60;
     height: 32px;
     padding: 0 12px;
-    border: 1px solid rgba(255, 255, 255, 0.75);
-    border-radius: 8px;
-    background: rgba(255, 255, 255, 0.15);
-    color: #fff;
     font-size: 13px;
     font-weight: 600;
+    color: #fff;
     cursor: pointer;
+    background: rgb(255 255 255 / 15%);
+    border: 1px solid rgb(255 255 255 / 75%);
+    border-radius: 8px;
   }
 
   .mp-cat-header-add:active {
@@ -439,13 +503,13 @@
     display: flex;
     flex-direction: column;
     gap: 8px;
-    padding-bottom: 24px;
+    padding: 12px 10px 24px;
   }
 
   .mp-cat-row {
     display: flex;
-    align-items: center;
     gap: 6px;
+    align-items: center;
     min-height: 52px;
     padding: 8px 10px 8px 0;
     background: #fff;
@@ -458,13 +522,13 @@
     width: 28px;
     height: 28px;
     padding: 0;
-    border: none;
-    background: transparent;
-    color: #0a9f6d;
     font-size: 11px;
     line-height: 28px;
+    color: #0a9f6d;
     text-align: center;
     cursor: pointer;
+    background: transparent;
+    border: none;
   }
 
   .mp-cat-row__toggle.is-leaf {
@@ -493,28 +557,35 @@
     flex-shrink: 0;
     width: 36px;
     height: 36px;
-    border: none;
-    border-radius: 8px;
-    background: #e9f9f2;
-    color: #087f57;
     font-size: 18px;
     line-height: 1;
+    color: #087f57;
     cursor: pointer;
+    background: #e9f9f2;
+    border: none;
+    border-radius: 8px;
   }
 
-  .mp-cat-form {
-    padding: 4px 8px 0;
-  }
-
-  .mp-cat-form :deep(.el-form-item) {
-    margin-bottom: 12px;
+  .status-tag {
+    display: inline-flex;
+    align-items: center;
+    min-height: 22px;
+    padding: 2px 8px;
+    font-size: 11px;
+    font-weight: 600;
+    border: 1px solid transparent;
+    border-radius: 999px;
   }
 
-  .mp-cat-foot-btn {
-    flex: 1;
+  .status-tag--success {
+    color: #087f57;
+    background: #e9f9f2;
+    border-color: #b7ebce;
   }
 
-  .is-danger {
-    color: #dc2626 !important;
+  .status-tag--danger {
+    color: #b91c1c;
+    background: #fef2f2;
+    border-color: #fecaca;
   }
 </style>