Просмотр исходного кода

feat(web-admin): add mp webview CRM product category page.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 недель назад
Родитель
Сommit
9de0459edc
1 измененных файлов с 508 добавлено и 4 удалено
  1. 508 4
      web-admin/src/views/m/crm/product/categories.vue

+ 508 - 4
web-admin/src/views/m/crm/product/categories.vue

@@ -1,16 +1,520 @@
 <template>
-  <MpWebviewShell>
+  <MpWebviewShell @ready="onShellReady">
     <header class="mp-wv-header">
       <div class="mp-wv-header__row">
-        <h1 class="mp-wv-header__title">{{ title }}</h1>
+        <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 class="mp-wv-empty">页面建设中</div>
+
+    <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` }"
+      >
+        <button
+          class="mp-cat-row__toggle"
+          type="button"
+          :class="{ 'is-leaf': !item.hasChildren }"
+          :aria-label="item.expanded ? '折叠' : '展开'"
+          :disabled="!item.hasChildren"
+          @click="toggleExpand(item.node.id)"
+        >
+          <span v-if="item.hasChildren">{{ item.expanded ? '▼' : '▶' }}</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">
+            <el-tag
+              :type="dictStore.getTagType('sys_normal_disable', item.node.status)"
+              size="small"
+            >
+              {{ dictStore.getLabel('sys_normal_disable', item.node.status) }}
+            </el-tag>
+          </div>
+        </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' }"
+      >
+        <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%"
+          />
+        </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>
   </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 MpWebviewShell from '@/layouts/MpWebviewShell.vue'
+  import MpBottomSheet from '@/components/mp/MpBottomSheet.vue'
+  import {
+    fetchCategoryTree,
+    fetchCreateCategory,
+    fetchUpdateCategory,
+    fetchDeleteCategory
+  } from '@/api/crm/product'
+  import { useDictStore } from '@/store/modules/dict'
+  import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
+  import { MP_CRM_PRODUCT_LIST_PATH } from '@/utils/mp-webview'
   import '@/styles/mp-webview.scss'
 
-  const title = '产品分类'
+  type CategoryNode = {
+    id: string
+    parentId?: string
+    categoryName?: string
+    orderNum?: number
+    status?: string
+    remark?: string
+    children?: CategoryNode[]
+    createBy?: string
+    [key: string]: unknown
+  }
+
+  type FlatItem = {
+    node: CategoryNode
+    depth: number
+    hasChildren: boolean
+    expanded: boolean
+  }
+
+  const router = useRouter()
+  const dictStore = useDictStore()
+
+  const loading = ref(false)
+  const tree = ref<CategoryNode[]>([])
+  const expandedIds = ref<Record<string, boolean>>({})
+
+  const actionOpen = ref(false)
+  const actionNode = ref<CategoryNode | null>(null)
+
+  const formOpen = ref(false)
+  const formTitle = ref('')
+  const submitLoading = ref(false)
+  const formRef = ref<FormInstance>()
+  const categoryTreeOptions = ref<CategoryNode[]>([])
+  const formData = reactive({
+    id: '',
+    parentId: '' as string,
+    categoryName: '',
+    orderNum: 0,
+    status: '0',
+    remark: ''
+  })
+  const formRules = {
+    categoryName: [{ required: true, message: '分类名称不能为空', trigger: 'blur' }]
+  }
+
+  const canDeleteActionNode = computed(() =>
+    canDeleteByCreator(actionNode.value as Record<string, unknown> | null)
+  )
+
+  const flatNodes = computed((): FlatItem[] => {
+    const out: FlatItem[] = []
+    const walk = (nodes: CategoryNode[], depth: number) => {
+      for (const node of nodes) {
+        const children = Array.isArray(node.children) ? node.children : []
+        const hasChildren = children.length > 0
+        const expanded = !!expandedIds.value[node.id]
+        out.push({ node, depth, hasChildren, expanded })
+        if (hasChildren && expanded) walk(children, depth + 1)
+      }
+    }
+    walk(tree.value, 0)
+    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 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
+      }
+    }
+    return null
+  }
+
+  async function loadTree() {
+    loading.value = true
+    try {
+      const res = await fetchCategoryTree()
+      const roots = ((res as { data?: CategoryNode[] }).data || res || []) as CategoryNode[]
+      tree.value = Array.isArray(roots) ? roots : []
+      expandedIds.value = collectExpandIds(tree.value)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function getCategoryTreeSelect() {
+    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 : []
+      }
+    ]
+  }
+
+  function toggleExpand(id: string) {
+    expandedIds.value = {
+      ...expandedIds.value,
+      [id]: !expandedIds.value[id]
+    }
+  }
+
+  function goBack() {
+    if (typeof window !== 'undefined' && window.history.length > 1) {
+      router.back()
+      return
+    }
+    void router.replace(MP_CRM_PRODUCT_LIST_PATH)
+  }
+
+  function resetForm() {
+    formData.id = ''
+    formData.parentId = ''
+    formData.categoryName = ''
+    formData.orderNum = 0
+    formData.status = '0'
+    formData.remark = ''
+    formRef.value?.resetFields()
+  }
+
+  async function openAdd(parentRow?: CategoryNode) {
+    actionOpen.value = false
+    resetForm()
+    await getCategoryTreeSelect()
+    if (parentRow?.id) {
+      formData.parentId = parentRow.id
+    }
+    formTitle.value = '新增产品分类'
+    formOpen.value = true
+  }
+
+  async function openEdit(node: CategoryNode) {
+    actionOpen.value = false
+    resetForm()
+    await getCategoryTreeSelect()
+    formData.id = node.id
+    formData.parentId = node.parentId || ''
+    formData.categoryName = node.categoryName || ''
+    formData.orderNum = node.orderNum ?? 0
+    formData.status = node.status ?? '0'
+    formData.remark = (node.remark as string) || ''
+    formTitle.value = '修改产品分类'
+    formOpen.value = true
+  }
+
+  function openNodeActions(node: CategoryNode) {
+    actionNode.value = node
+    actionOpen.value = true
+  }
+
+  function onActionAddChild() {
+    const node = actionNode.value
+    if (!node) return
+    void openAdd(node)
+  }
+
+  function onActionEdit() {
+    const node = actionNode.value
+    if (!node) return
+    void openEdit(node)
+  }
+
+  async function onActionDelete() {
+    const node = actionNode.value
+    if (!node?.id) return
+    if (!canDeleteByCreator(node as Record<string, unknown>)) {
+      ElMessage.warning(CREATOR_DELETE_TIP)
+      return
+    }
+    try {
+      await ElMessageBox.confirm('是否确认删除该产品分类?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+    } catch {
+      return
+    }
+    actionOpen.value = false
+    try {
+      await fetchDeleteCategory({ id: node.id })
+      ElMessage.success('删除成功')
+      actionNode.value = null
+      await loadTree()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '删除失败')
+    }
+  }
+
+  async function submitForm() {
+    const valid = await formRef.value?.validate().catch(() => false)
+    if (!valid) return
+    submitLoading.value = true
+    try {
+      const payload = {
+        parentId: formData.parentId || '0',
+        categoryName: formData.categoryName,
+        orderNum: formData.orderNum,
+        status: formData.status,
+        remark: formData.remark
+      }
+      if (formData.id) {
+        await fetchUpdateCategory({ id: formData.id, ...payload })
+        ElMessage.success('修改成功')
+      } else {
+        await fetchCreateCategory(payload)
+        ElMessage.success('新增成功')
+      }
+      formOpen.value = false
+      await loadTree()
+    } catch (error) {
+      ElMessage.error(error instanceof Error ? error.message : '保存失败')
+    } finally {
+      submitLoading.value = false
+    }
+  }
+
+  function onShellReady() {
+    void loadTree()
+  }
 </script>
+
+<style scoped lang="scss">
+  .mp-cat-header-add {
+    flex-shrink: 0;
+    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;
+    cursor: pointer;
+  }
+
+  .mp-cat-header-add:active {
+    opacity: 0.8;
+  }
+
+  .mp-cat-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    padding-bottom: 24px;
+  }
+
+  .mp-cat-row {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    min-height: 52px;
+    padding: 8px 10px 8px 0;
+    background: #fff;
+    border: 1px solid #dcebe4;
+    border-radius: 12px;
+  }
+
+  .mp-cat-row__toggle {
+    flex-shrink: 0;
+    width: 28px;
+    height: 28px;
+    padding: 0;
+    border: none;
+    background: transparent;
+    color: #0a9f6d;
+    font-size: 11px;
+    line-height: 28px;
+    text-align: center;
+    cursor: pointer;
+  }
+
+  .mp-cat-row__toggle.is-leaf {
+    visibility: hidden;
+    pointer-events: none;
+  }
+
+  .mp-cat-row__main {
+    flex: 1;
+    min-width: 0;
+    cursor: pointer;
+  }
+
+  .mp-cat-row__name {
+    font-size: 15px;
+    font-weight: 600;
+    color: #1f2a26;
+    word-break: break-all;
+  }
+
+  .mp-cat-row__meta {
+    margin-top: 4px;
+  }
+
+  .mp-cat-row__more {
+    flex-shrink: 0;
+    width: 36px;
+    height: 36px;
+    border: none;
+    border-radius: 8px;
+    background: #e9f9f2;
+    color: #087f57;
+    font-size: 18px;
+    line-height: 1;
+    cursor: pointer;
+  }
+
+  .mp-cat-form {
+    padding: 4px 8px 0;
+  }
+
+  .mp-cat-form :deep(.el-form-item) {
+    margin-bottom: 12px;
+  }
+
+  .mp-cat-foot-btn {
+    flex: 1;
+  }
+
+  .is-danger {
+    color: #dc2626 !important;
+  }
+</style>