|
|
@@ -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>
|