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:
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 |
|
||||
Reference in new issue
Block a user