docs: add mp CRM product category tree style design (scheme A).

Record the chosen indent-expand tree list styling for the mobile product-category webview, with the three-option HTML demo for reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-18 16:21:17 +08:00
1 parent 020fbe1b0e
commit 9b405a9227
2 files changed
+929

No files matched your search

@@ -0,0 +1,828 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>山峡化工 · 产品分类树形列表 · 三方案对比 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--bg: #f5f8f7;
--surface: #fff;
--text: #1f2a26;
--muted: #6b7b74;
--placeholder: #9aa8a1;
--border: #dcebe4;
--border-light: #edf5f1;
--danger: #b91c1c;
--danger-soft: #fef2f2;
--header: linear-gradient(180deg, #087f57 0%, #087f57 28%, #0a9f6d 78%, #0a9f6d 100%);
--shadow-sm: 0 4px 12px rgba(10, 159, 109, 0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
button, input { font: inherit; border: none; background: none; cursor: pointer; }
body {
background: #cfd9d4;
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
font-size: 14px;
line-height: 1.45;
min-height: 100vh;
}
.stage { max-width: 440px; margin: 0 auto; padding: 14px 12px 40px; }
.banner {
background: #1f2a26;
color: #fff;
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 12px;
font-size: 12px;
line-height: 1.55;
}
.banner strong { color: #9fe3c4; }
.banner li { margin: 4px 0 0 1.1em; }
.tabs {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
}
.tab {
padding: 10px 6px;
border-radius: 12px;
background: #fff;
border: 1px solid #b7c9c0;
text-align: center;
font-size: 12px;
font-weight: 700;
color: var(--muted);
line-height: 1.3;
}
.tab small {
display: block;
margin-top: 2px;
font-size: 10px;
font-weight: 500;
opacity: 0.9;
}
.tab.active {
background: var(--brand-soft);
border-color: #b7ebce;
color: var(--brand-dark);
}
.note {
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px 12px;
margin-bottom: 12px;
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
.note strong { color: var(--text); }
.note .rec {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand-dark);
font-size: 10px;
font-weight: 700;
}
.panel { display: none; }
.panel.active { display: block; }
.phone {
background: var(--bg);
border-radius: 28px;
overflow: hidden;
border: 8px solid #24312c;
height: 720px;
box-shadow: 0 18px 40px rgba(0,0,0,.16);
display: flex;
flex-direction: column;
}
.status-bar {
flex: 0 0 auto;
height: 28px;
background: var(--brand-dark);
color: rgba(255,255,255,.85);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
font-size: 11px;
}
.header {
flex: 0 0 auto;
background: var(--header);
color: #fff;
padding: 8px 12px 14px;
border-radius: 0 0 22px 22px;
box-shadow: 0 10px 18px -10px rgba(8, 127, 87, 0.35);
}
.header-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
}
.back {
color: #fff;
font-size: 13px;
opacity: 0.95;
padding: 6px 4px;
white-space: nowrap;
}
.header h1 {
flex: 1;
min-width: 0;
font-size: 17px;
font-weight: 700;
text-align: center;
}
.hdr-btn {
height: 32px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
color: #fff;
background: rgb(255 255 255 / 15%);
border: 1px solid rgb(255 255 255 / 75%);
border-radius: 8px;
white-space: nowrap;
}
.hdr-btn:active { opacity: 0.8; }
.scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 20px;
}
.tag {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 1px 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.tag--ok {
color: var(--brand-dark);
background: var(--brand-soft);
border-color: #b7ebce;
}
.tag--off {
color: var(--danger);
background: var(--danger-soft);
border-color: #fecaca;
}
.tag--lv {
color: var(--muted);
background: #f1f5f3;
border-color: var(--border);
}
.icon-more {
width: 36px;
height: 36px;
border-radius: 8px;
background: var(--brand-soft);
color: var(--brand-dark);
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-add {
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--brand-soft);
color: var(--brand-dark);
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast {
position: fixed;
left: 50%;
bottom: 36px;
transform: translateX(-50%) translateY(16px);
background: rgba(31, 42, 38, 0.92);
color: #fff;
padding: 10px 16px;
border-radius: 999px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
z-index: 50;
white-space: nowrap;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ── A:缩进展开树(整卡一体) ── */
.tree-box {
margin: 12px 10px 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
}
.tree-tools {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 12px 0;
}
.ghost-btn {
height: 30px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--muted);
font-size: 12px;
font-weight: 600;
}
.ghost-btn:active { background: #f7fbf9; }
.tree-row {
display: flex;
align-items: center;
gap: 6px;
min-height: 54px;
padding: 10px 10px 10px 0;
border-bottom: 1px solid var(--border-light);
background: #fff;
}
.tree-row:last-child { border-bottom: none; }
.tree-row:active { background: #f7fbf9; }
.tree-row.is-l2 { background: #fbfdff; }
.tree-row.is-l3 { background: #fafcfb; }
.chevron {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
color: var(--brand);
font-size: 11px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.chevron.is-open { transform: rotate(90deg); }
.chevron.is-leaf { visibility: hidden; }
.tree-main { flex: 1; min-width: 0; }
.tree-name {
font-size: 15px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-meta {
margin-top: 3px;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
font-size: 11px;
color: var(--muted);
}
.guide {
width: 12px;
flex-shrink: 0;
align-self: stretch;
position: relative;
}
.kids { display: none; }
.kids.open { display: block; }
/* ── B:逐级下钻 ── */
.crumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding: 12px 12px 0;
font-size: 12px;
color: var(--muted);
}
.crumb a { color: var(--brand-dark); font-weight: 600; }
.crumb .sep { opacity: 0.45; }
.crumb .cur { color: var(--text); font-weight: 700; }
.drill-list {
margin: 12px 10px 0;
background: #fff;
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
}
.drill-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 58px;
padding: 12px 12px;
border-bottom: 1px solid var(--border-light);
}
.drill-row:last-child { border-bottom: none; }
.drill-row:active { background: #f7fbf9; }
.drill-icon {
width: 38px;
height: 38px;
border-radius: 10px;
background: var(--brand-soft);
color: var(--brand-dark);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.drill-main { flex: 1; min-width: 0; }
.drill-name { font-size: 15px; font-weight: 600; }
.drill-sub {
margin-top: 2px;
font-size: 11px;
color: var(--muted);
}
.drill-arrow { color: var(--placeholder); font-size: 18px; }
/* ── C:层级卡片 + 全路径 ── */
.card-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 10px 0;
}
.cat-card {
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px 12px 10px;
box-shadow: var(--shadow-sm);
}
.cat-card:active { background: #f9fcfa; }
.cat-card__top {
display: flex;
align-items: flex-start;
gap: 8px;
}
.cat-card__body { flex: 1; min-width: 0; }
.cat-card__title-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.cat-card__title {
font-size: 15px;
font-weight: 700;
}
.cat-card__path {
margin-top: 4px;
font-size: 11px;
color: var(--muted);
line-height: 1.4;
word-break: break-all;
}
.cat-card__foot {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.cat-card__meta {
font-size: 11px;
color: var(--muted);
}
.cat-card__actions { display: flex; gap: 6px; }
.mini-btn {
height: 30px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 12px;
font-weight: 600;
}
.mini-btn.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
.indent-mark {
width: 3px;
align-self: stretch;
border-radius: 999px;
flex-shrink: 0;
margin-top: 2px;
margin-bottom: 2px;
}
.indent-mark.l1 { background: var(--brand); }
.indent-mark.l2 { background: #7ec9ad; }
.indent-mark.l3 { background: #c5e6d7; }
.choice-hint {
margin-top: 14px;
padding: 12px;
border-radius: 12px;
background: #fff;
border: 1px dashed #b7c9c0;
font-size: 12px;
color: var(--muted);
line-height: 1.55;
}
.choice-hint strong { color: var(--text); }
</style>
</head>
<body>
<div class="stage">
<div class="banner">
<strong>产品分类 · 三方案对比</strong>(对齐小程序产品管理 webview)
<ul>
<li>数据为父子孙三级示例:化工原料 → 有机溶剂 → 醇类</li>
<li>顶部 Tab 切换方案;手机框内可点展开 / 下钻 / 操作按钮</li>
<li>选好后在对话里回复 A / B / C 即可</li>
</ul>
</div>
<div class="tabs" role="tablist">
<button class="tab active" type="button" data-panel="a">
A 缩进展开
<small>推荐 · 总览强</small>
</button>
<button class="tab" type="button" data-panel="b">
B 逐级下钻
<small>目录式</small>
</button>
<button class="tab" type="button" data-panel="c">
C 层级卡片
<small>对齐产品卡</small>
</button>
</div>
<!-- 方案 A -->
<section class="panel active" id="panel-a">
<p class="note">
<strong>A · 缩进展开树</strong><span class="rec">推荐</span><br />
整卡一体列表:箭头展开/折叠,左侧缩进表达父子孙;「+」加子类、「⋯」编辑/删除。一屏看清整棵树,适合管理 CRUD。当前线上页接近本方案,但行是分散小卡;本 Demo 收成一体树表。
</p>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>▍</span></div>
<header class="header">
<div class="header-row">
<button class="back" type="button" data-toast="返回产品列表">← 返回产品列表</button>
<h1>产品分类</h1>
<button class="hdr-btn" type="button" data-toast="新增顶级分类">新增</button>
</div>
</header>
<div class="scroll">
<div class="tree-tools">
<button class="ghost-btn" type="button" id="btn-expand-all">全部展开</button>
</div>
<div class="tree-box" id="tree-a"></div>
</div>
</div>
</section>
<!-- 方案 B -->
<section class="panel" id="panel-b">
<p class="note">
<strong>B · 逐级下钻</strong><br />
类似文件管理器:每次只显示当前层;面包屑回退。深层分类更清晰,但无法一屏总览整树,新增子类需先进入父级。
</p>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>📶</span></div>
<header class="header">
<div class="header-row">
<button class="back" type="button" data-toast="返回产品列表">← 返回产品列表</button>
<h1>产品分类</h1>
<button class="hdr-btn" type="button" data-toast="在当前层新增">新增</button>
</div>
</header>
<div class="scroll">
<nav class="crumb" id="drill-crumb"></nav>
<div class="drill-list" id="drill-list"></div>
</div>
</div>
</section>
<!-- 方案 C -->
<section class="panel" id="panel-c">
<p class="note">
<strong>C · 层级卡片 + 全路径</strong><br />
与近期产品/客户列表卡视觉对齐:每条独立卡片,左侧色条表示层级,副文案用「父 / 子 / 孙」全路径。搜索友好、风格统一,但树感弱、列表更长。
</p>
<div class="phone">
<div class="status-bar"><span>9:41</span><span>📶</span></div>
<header class="header">
<div class="header-row">
<button class="back" type="button" data-toast="返回产品列表">← 返回产品列表</button>
<h1>产品分类</h1>
<button class="hdr-btn" type="button" data-toast="新增顶级分类">新增</button>
</div>
</header>
<div class="scroll">
<div class="card-list" id="flat-list"></div>
</div>
</div>
</section>
<div class="choice-hint">
<strong>怎么选?</strong><br />
· 分类数量不多、要一眼看层级 → <strong>A</strong><br />
· 层级很深、只关心当前层 → <strong>B</strong><br />
· 希望和产品列表卡同一套视觉语言 → <strong>C</strong>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const TREE = [
{
id: '1', parentId: '0', categoryName: '化工原料', status: '0', orderNum: 1,
children: [
{
id: '11', parentId: '1', categoryName: '有机溶剂', status: '0', orderNum: 1,
children: [
{ id: '111', parentId: '11', categoryName: '醇类', status: '0', orderNum: 1, children: [] },
{ id: '112', parentId: '11', categoryName: '酮类', status: '0', orderNum: 2, children: [] }
]
},
{
id: '12', parentId: '1', categoryName: '无机盐', status: '0', orderNum: 2,
children: [
{ id: '121', parentId: '12', categoryName: '硫酸盐', status: '1', orderNum: 1, children: [] }
]
}
]
},
{
id: '2', parentId: '0', categoryName: '精细化学品', status: '0', orderNum: 2,
children: [
{ id: '21', parentId: '2', categoryName: '催化剂', status: '0', orderNum: 1, children: [] },
{
id: '22', parentId: '2', categoryName: '助剂', status: '0', orderNum: 2,
children: [
{ id: '221', parentId: '22', categoryName: '分散剂', status: '0', orderNum: 1, children: [] },
{ id: '222', parentId: '22', categoryName: '消泡剂', status: '0', orderNum: 2, children: [] }
]
}
]
},
{
id: '3', parentId: '0', categoryName: '包装耗材', status: '1', orderNum: 3,
children: []
}
]
const toastEl = document.getElementById('toast')
function showToast(msg) {
toastEl.textContent = msg
toastEl.classList.add('show')
clearTimeout(showToast._t)
showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1500)
}
document.querySelectorAll('[data-toast]').forEach((el) => {
el.addEventListener('click', () => showToast(el.getAttribute('data-toast')))
})
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'))
document.querySelectorAll('.panel').forEach((p) => p.classList.remove('active'))
tab.classList.add('active')
document.getElementById('panel-' + tab.dataset.panel).classList.add('active')
})
})
const statusTag = (s) =>
String(s) === '0'
? '<span class="tag tag--ok">正常</span>'
: '<span class="tag tag--off">停用</span>'
function childCount(node) {
return (node.children || []).length
}
/* ── A ── */
const expanded = new Set(['1', '11', '2'])
function renderTreeA() {
const root = document.getElementById('tree-a')
root.innerHTML = ''
function renderNodes(nodes, level, container) {
nodes.forEach((node) => {
const hasKids = childCount(node) > 0
const isOpen = expanded.has(node.id)
const pad = 10 + level * 16
const row = document.createElement('div')
row.className = 'tree-row' + (level === 1 ? ' is-l2' : level >= 2 ? ' is-l3' : '')
row.style.paddingLeft = pad + 'px'
row.innerHTML = `
<button class="chevron ${hasKids ? '' : 'is-leaf'} ${isOpen ? 'is-open' : ''}" type="button" data-toggle="${node.id}">▶</button>
<div class="tree-main" data-more="${node.categoryName}">
<div class="tree-name">${node.categoryName}</div>
<div class="tree-meta">
${statusTag(node.status)}
<span>排序 ${node.orderNum}</span>
${hasKids ? `<span>${childCount(node)} 个子类</span>` : '<span>末级</span>'}
</div>
</div>
<button class="icon-add" type="button" data-toast="新增子分类:${node.categoryName}">+</button>
<button class="icon-more" type="button" data-toast="操作:${node.categoryName}">⋯</button>
`
container.appendChild(row)
if (hasKids) {
const wrap = document.createElement('div')
wrap.className = 'kids' + (isOpen ? ' open' : '')
renderNodes(node.children, level + 1, wrap)
container.appendChild(wrap)
}
})
}
renderNodes(TREE, 0, root)
root.querySelectorAll('[data-toggle]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
const id = btn.getAttribute('data-toggle')
if (expanded.has(id)) expanded.delete(id)
else expanded.add(id)
renderTreeA()
})
})
root.querySelectorAll('[data-toast]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
showToast(btn.getAttribute('data-toast'))
})
})
root.querySelectorAll('[data-more]').forEach((el) => {
el.addEventListener('click', () => showToast('操作:' + el.getAttribute('data-more')))
})
}
function collectIds(nodes, set) {
nodes.forEach((n) => {
if (childCount(n)) {
set.add(n.id)
collectIds(n.children, set)
}
})
}
let allExpanded = false
document.getElementById('btn-expand-all').addEventListener('click', () => {
allExpanded = !allExpanded
expanded.clear()
if (allExpanded) collectIds(TREE, expanded)
document.getElementById('btn-expand-all').textContent = allExpanded ? '全部折叠' : '全部展开'
renderTreeA()
})
/* ── B ── */
let drillStack = [{ id: '0', categoryName: '全部', children: TREE }]
function renderDrill() {
const node = drillStack[drillStack.length - 1]
const crumb = document.getElementById('drill-crumb')
crumb.innerHTML = drillStack
.map((n, i) => {
if (i === drillStack.length - 1) return `<span class="cur">${n.categoryName}</span>`
return `<a href="#" data-crumb="${i}">${n.categoryName}</a><span class="sep">/</span>`
})
.join('')
crumb.querySelectorAll('[data-crumb]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault()
drillStack = drillStack.slice(0, Number(a.getAttribute('data-crumb')) + 1)
renderDrill()
})
})
const list = document.getElementById('drill-list')
const kids = node.children || []
if (!kids.length) {
list.innerHTML = `<div class="drill-row"><div class="drill-main"><div class="drill-name">暂无子分类</div><div class="drill-sub">可点击右上角新增</div></div></div>`
return
}
list.innerHTML = kids
.map((n) => {
const hasKids = childCount(n) > 0
return `
<div class="drill-row" data-enter="${n.id}">
<div class="drill-icon">${n.categoryName.slice(0, 1)}</div>
<div class="drill-main">
<div class="drill-name">${n.categoryName}</div>
<div class="drill-sub">${String(n.status) === '0' ? '正常' : '停用'} · 排序 ${n.orderNum}${hasKids ? ' · ' + childCount(n) + ' 个子类' : ' · 末级'}</div>
</div>
<button class="icon-more" type="button" data-toast="操作:${n.categoryName}">⋯</button>
<span class="drill-arrow" style="${hasKids ? '' : 'opacity:0'}">›</span>
</div>
`
})
.join('')
list.querySelectorAll('[data-enter]').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('[data-toast]')) return
const id = row.getAttribute('data-enter')
const next = kids.find((k) => k.id === id)
if (next && childCount(next)) {
drillStack.push(next)
renderDrill()
} else if (next) {
showToast('末级:' + next.categoryName)
}
})
})
list.querySelectorAll('[data-toast]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation()
showToast(btn.getAttribute('data-toast'))
})
})
}
/* ── C ── */
function flattenWithPath(nodes, pathParts, depth, out) {
nodes.forEach((n) => {
const path = [...pathParts, n.categoryName]
out.push({ ...n, pathLabel: path.join(' / '), depth })
if (n.children?.length) flattenWithPath(n.children, path, depth + 1, out)
})
return out
}
function renderFlat() {
const items = flattenWithPath(TREE, [], 1, [])
const list = document.getElementById('flat-list')
list.innerHTML = items
.map((n) => {
const lv = Math.min(n.depth, 3)
return `
<article class="cat-card">
<div class="cat-card__top">
<span class="indent-mark l${lv}"></span>
<div class="cat-card__body">
<div class="cat-card__title-row">
<span class="cat-card__title">${n.categoryName}</span>
<span class="tag tag--lv">L${lv}</span>
${statusTag(n.status)}
</div>
<div class="cat-card__path">${n.pathLabel}</div>
</div>
</div>
<div class="cat-card__foot">
<span class="cat-card__meta">排序 ${n.orderNum}${childCount(n) ? ' · ' + childCount(n) + ' 个子类' : ' · 末级'}</span>
<div class="cat-card__actions">
<button class="mini-btn" type="button" data-toast="新增子分类:${n.categoryName}">子分类</button>
<button class="mini-btn primary" type="button" data-toast="编辑:${n.categoryName}">编辑</button>
</div>
</div>
</article>
`
})
.join('')
list.querySelectorAll('[data-toast]').forEach((btn) => {
btn.addEventListener('click', () => showToast(btn.getAttribute('data-toast')))
})
}
renderTreeA()
renderDrill()
renderFlat()
</script>
</body>
</html>
@@ -0,0 +1,101 @@
# 小程序 · CRM 产品分类列表样式(方案 A 缩进展开树)设计
> 日期:2026-10-18
> 状态:待审阅
> 视觉 Demo:`bak/mobile-ui/shanxia-mp-crm-product-category-tree-compare-demo.html`(方案 A)
> 实现目标页:`web-admin/src/views/m/crm/product/categories.vue`
> 前序设计:`docs/superpowers/specs/2026-07-18-mp-crm-product-category-design.md`(能力基线已落地)
## 1. 背景
- 产品管理移动端 webview 已具备产品分类 CRUD(树加载、展开折叠、新增/编辑弹窗、删除与创建人校验)。
- 当前列表为「每行一张分散小卡」,层级感弱,与对比 Demo 方案 A(一体树表)及近期产品/客户列表视觉节奏不一致。
- 用户在三方案对比中选定 **方案 A · 缩进展开树**。
## 2. 目标
1. 将分类列表视觉与交互对齐 Demo 方案 A:一体白底圆角树表 + 缩进展开。
2. 补齐行元信息(状态 Tag · 排序号 · 子类数量)与行内 `+` 快捷新增子级。
3. Header 增加「全部展开 / 全部折叠」。
4. 不改后端接口、表结构、表单字段与删除权限规则。
## 3. 非目标
- 不做方案 B(逐级下钻)或方案 C(层级卡片 + 全路径)。
- 不做 Excel 导入导出、多选批量删除、搜索/状态 chips(若现页未有,本期不新增)。
- 不改 Web 桌面端产品分类页(`crm/product-category` / maintain dialog)。
- 不改小程序原生页(本能力在 webview `m/crm/product/categories`)。
## 4. 范围与落点
| 项 | 路径 / 说明 |
|----|-------------|
| 主改 | `web-admin/src/views/m/crm/product/categories.vue`(模板结构 + scoped 样式 + 少量交互) |
| 对照 Demo | `bak/mobile-ui/shanxia-mp-crm-product-category-tree-compare-demo.html` 方案 A |
| 不变 | `fetchCategoryTree` / create / update / delete API;弹窗表单字段;`canDeleteByCreator` |
## 5. UI 设计(方案 A)
### 5.1 列表骨架
1. Header:`MpBusinessHeader` 标题「产品分类」· 返回产品列表
2. Header 右侧工具:「全部展开 / 全部折叠」+「新增」(布局须保证触控可达,不遮挡返回)
3. 一体树表:白底、边框 `#dcebe4`、圆角约 16px;行间细分割线 `#edf5f1`
4. 空态 / 加载态:保持现有 `van-loading` / `van-empty`
### 5.2 树行(左 → 右)
| 区域 | 说明 |
|------|------|
| 展开箭头 | 有子才显示;点击仅切换该节点展开态 |
| 主区 | 名称(15px/600);元信息:状态 Tag · `排序 N` · `M 个子类` 或 `末级`;点击打开 ActionSheet |
| `+` | 快捷新增子级(预填 `parentId`) |
| `⋯` | 打开 ActionSheet:新增子级 / 编辑 / 删除 |
- 缩进:约 **16px / 级**(`paddingLeft: 12 + depth * 16`)。
- 默认展开:加载后对「有子节点」的节点全部展开(与现网 `collectExpandIds` 一致)。
- 「全部展开 / 全部折叠」:切换整树展开态,按钮文案随状态切换。
### 5.3 ActionSheet
与现网一致:
- 新增子级
- 编辑
- 删除(`disabled` 当不可删;确认框与 `CREATOR_DELETE_TIP` 不变)
### 5.4 Token
对齐 `docs/miniprogram-ui-spec-sanqiang.md`:
- 主色 `#0a9f6d` / 深 `#087f57` / 浅底 `#e9f9f2`
- 正文 `#1f2a26` / 次要 `#6b7b74`
- 页底 `#f5f8f7`
- 状态 Tag:正常 success 绿系;停用 danger 红系(与现网 product 页一致)
- 行高 ≥ 52px;触控区 ≥ 44×44
禁止混用 web-admin Fresh Mint `#10b981` 作为本页主色。
## 6. 数据与逻辑
- 仍使用全量树接口,前端 `flatNodes` 按 `expandedIds` 展平渲染。
- 表单弹窗、上级选择、状态字典、提交 payload **不改字段契约**。
- 仅新增 UI 状态:如 `allExpanded`(或由「是否所有有子节点均在 expandedIds」推导)驱动 Header 文案。
## 7. 验收清单
1. 父子孙三级缩进清晰,展开/折叠正确。
2. 列表为一体树表(非整行散卡),视觉接近 Demo A。
3. 行内展示状态 / 排序 / 子类数;`+` 与 `⋯` 可用。
4. 「全部展开 / 全部折叠」切换正确。
5. 新增顶级、新增子级、编辑、删除流程与现网一致,无回归。
6. 空态、加载态正常;创建人不可删时删除项禁用或提示不变。
## 8. 已确认决策
| 决策 | 结论 |
|------|------|
| 树形展示方案 | A 缩进展开树(非 B/C) |
| 相对现页变更重心 | 视觉一体树表 + 元信息 + 行内 `+` + 全部展开/折叠 |
| API / 表单 | 不改 |
| Demo 权威 | `shanxia-mp-crm-product-category-tree-compare-demo.html` 方案 A |