Files
shanxia2.0/bak/mobile-ui/shanxia-mp-crm-product-category-tree-compare-demo.html
T
wuzilongandCursor 9b405a9227 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>
2026-10-18 16:21:17 +08:00

829 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>