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>
829 lines
25 KiB
HTML
829 lines
25 KiB
HTML
<!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>
|