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>
This commit is contained in:
wuzilongandCursor committed 2026-10-18 16:26:54 +08:00
1 parent ffd25d93e9
commit 5d943cf76b
1 file changed
+308 -237
+308 -237
View File
@@ -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"
<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>
</div>
<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"
>
<div class="mp-customer-pool-vant">
<van-nav-bar
:title="formTitle"
left-text="关闭"
:left-disabled="submitLoading"
@click-left="closeForm"
/>
<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
>
{{ 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>
</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>
<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 { 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;
.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-form :deep(.el-form-item) {
margin-bottom: 12px;
.status-tag--success {
color: #087f57;
background: #e9f9f2;
border-color: #b7ebce;
}
.mp-cat-foot-btn {
flex: 1;
}
.is-danger {
color: #dc2626 !important;
.status-tag--danger {
color: #b91c1c;
background: #fef2f2;
border-color: #fecaca;
}
</style>