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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-15 10:03:50 +08:00
1 parent afad78bf60
commit 9de0459edc
1 file changed
+508 -4
@@ -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>