Просмотр исходного кода

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>
wuzilong 1 неделя назад
Родитель
Сommit
9b405a9227

+ 828 - 0
bak/mobile-ui/shanxia-mp-crm-product-category-tree-compare-demo.html

@@ -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>

+ 101 - 0
docs/superpowers/specs/2026-10-18-mp-crm-product-category-tree-style-design.md

@@ -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 |