feat(web-admin): add mp webview CRM product category page.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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>
|
||||
Reference in new issue
Block a user