Przeglądaj źródła

docs: add implementation plan for mp product category tree style.

Break scheme A restyle into helper tests, list markup, and scoped CSS tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 tygodni temu
rodzic
commit
4529a18a8d

+ 565 - 0
docs/superpowers/plans/2026-10-18-mp-crm-product-category-tree-style.md

@@ -0,0 +1,565 @@
+# 产品分类列表样式(方案 A 缩进展开树)Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 将移动端产品分类列表改为 Demo 方案 A:一体缩进展开树表,补齐行元信息、行内 `+` 与「全部展开/折叠」。
+
+**Architecture:** 抽出纯函数工具 `mp-crm-product-category-tree.ts`(展开集合、是否全展开、子类文案)供页面与单测复用;`categories.vue` 改模板为整卡一体树表并调整 Header 双按钮与 scoped 样式。不改后端契约与表单弹窗字段。
+
+**Tech Stack:** Vue 3 + Vant(WebView)+ TypeScript;node:test 单测;三墙 Token。
+
+**Spec:** `docs/superpowers/specs/2026-10-18-mp-crm-product-category-tree-style-design.md`
+
+**Demo:** `bak/mobile-ui/shanxia-mp-crm-product-category-tree-compare-demo.html`(方案 A)
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|----------------|
+| `web-admin/src/utils/mp-crm-product-category-tree.ts` | 展开 ID 收集、是否全展开、子节点数与行元信息文案 |
+| `web-admin/src/utils/mp-crm-product-category-tree.test.ts` | 上述纯函数单测 |
+| `web-admin/src/views/m/crm/product/categories.vue` | 列表 DOM、Header 工具、调用工具函数、scoped 样式 |
+
+---
+
+### Task 1: 单测驱动 — 树展开与行元信息纯函数
+
+**Files:**
+- Create: `web-admin/src/utils/mp-crm-product-category-tree.ts`
+- Create: `web-admin/src/utils/mp-crm-product-category-tree.test.ts`
+
+- [ ] **Step 1: 写失败测试**
+
+创建 `web-admin/src/utils/mp-crm-product-category-tree.test.ts`:
+
+```ts
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import {
+  collectExpandIds,
+  areAllExpandableExpanded,
+  categoryChildCount,
+  categoryChildMetaLabel
+} from './mp-crm-product-category-tree'
+
+type Node = {
+  id: string
+  children?: Node[]
+}
+
+const sample: Node[] = [
+  {
+    id: '1',
+    children: [
+      { id: '11', children: [{ id: '111' }] },
+      { id: '12' }
+    ]
+  },
+  { id: '2' }
+]
+
+describe('collectExpandIds', () => {
+  it('marks every node that has children', () => {
+    assert.deepEqual(collectExpandIds(sample), { '1': true, '11': true })
+  })
+})
+
+describe('areAllExpandableExpanded', () => {
+  it('returns true when all expandable ids are true', () => {
+    assert.equal(areAllExpandableExpanded(sample, { '1': true, '11': true }), true)
+  })
+
+  it('returns false when any expandable id is missing or false', () => {
+    assert.equal(areAllExpandableExpanded(sample, { '1': true }), false)
+    assert.equal(areAllExpandableExpanded(sample, { '1': true, '11': false }), false)
+  })
+
+  it('returns true for empty tree', () => {
+    assert.equal(areAllExpandableExpanded([], {}), true)
+  })
+})
+
+describe('categoryChildCount / categoryChildMetaLabel', () => {
+  it('counts direct children only', () => {
+    assert.equal(categoryChildCount(sample[0]), 2)
+    assert.equal(categoryChildCount(sample[1]), 0)
+  })
+
+  it('labels leaf vs parent', () => {
+    assert.equal(categoryChildMetaLabel(sample[0]), '2 个子类')
+    assert.equal(categoryChildMetaLabel(sample[1]), '末级')
+  })
+})
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+Run:
+
+```bash
+cd web-admin && node --import tsx --test src/utils/mp-crm-product-category-tree.test.ts
+```
+
+Expected: FAIL(模块不存在或导出缺失)
+
+- [ ] **Step 3: 最小实现**
+
+创建 `web-admin/src/utils/mp-crm-product-category-tree.ts`:
+
+```ts
+export type CategoryTreeNodeLike = {
+  id: string
+  children?: CategoryTreeNodeLike[]
+}
+
+export function categoryChildCount(node: CategoryTreeNodeLike | null | undefined): number {
+  const children = Array.isArray(node?.children) ? node!.children! : []
+  return children.length
+}
+
+export function categoryChildMetaLabel(node: CategoryTreeNodeLike | null | undefined): string {
+  const n = categoryChildCount(node)
+  return n > 0 ? `${n} 个子类` : '末级'
+}
+
+export function collectExpandIds(
+  nodes: CategoryTreeNodeLike[],
+  acc: Record<string, boolean> = {}
+): Record<string, boolean> {
+  for (const node of nodes) {
+    if (categoryChildCount(node) > 0) {
+      acc[node.id] = true
+      collectExpandIds(node.children || [], acc)
+    }
+  }
+  return acc
+}
+
+export function areAllExpandableExpanded(
+  nodes: CategoryTreeNodeLike[],
+  expandedIds: Record<string, boolean>
+): boolean {
+  const required = collectExpandIds(nodes, {})
+  const ids = Object.keys(required)
+  if (!ids.length) return true
+  return ids.every((id) => !!expandedIds[id])
+}
+```
+
+- [ ] **Step 4: 跑测确认通过**
+
+Run:
+
+```bash
+cd web-admin && node --import tsx --test src/utils/mp-crm-product-category-tree.test.ts
+```
+
+Expected: PASS(全部用例通过)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/utils/mp-crm-product-category-tree.ts web-admin/src/utils/mp-crm-product-category-tree.test.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add product category tree expand helpers.
+
+Centralize expand-id collection and child meta labels for the mp category list.
+EOF
+)"
+```
+
+---
+
+### Task 2: 页面模板 — 一体树表 + Header 双按钮 + 行元信息与 `+`
+
+**Files:**
+- Modify: `web-admin/src/views/m/crm/product/categories.vue`
+
+- [ ] **Step 1: 替换 Header 与列表模板**
+
+将模板中 Header 与列表区域(约第 3–50 行)改为:
+
+```vue
+      <MpBusinessHeader title="产品分类" back-label="返回产品列表" @back="goBack" />
+      <div class="mp-cat-header-tools">
+        <button
+          v-if="flatNodes.length"
+          class="mp-cat-header-btn"
+          type="button"
+          @click="toggleExpandAll"
+        >
+          {{ expandAllLabel }}
+        </button>
+        <button class="mp-cat-header-btn" type="button" @click="openAdd()">新增</button>
+      </div>
+
+      <div v-if="loading && !tree.length" class="mp-product-loading">
+        <van-loading vertical>正在加载分类</van-loading>
+      </div>
+      <van-empty v-else-if="!flatNodes.length" description="暂无分类,点击右上角新增">
+        <van-button round type="primary" @click="openAdd()">新增分类</van-button>
+      </van-empty>
+      <div v-else class="mp-cat-tree">
+        <div
+          v-for="item in flatNodes"
+          :key="item.node.id"
+          class="mp-cat-row"
+          :class="{
+            'is-l2': item.depth === 1,
+            'is-l3': item.depth >= 2
+          }"
+          :style="{ paddingLeft: `${12 + item.depth * 16}px` }"
+        >
+          <button
+            class="mp-cat-row__toggle"
+            type="button"
+            :class="{ 'is-leaf': !item.hasChildren, 'is-open': item.expanded }"
+            :aria-label="item.expanded ? '折叠' : '展开'"
+            :disabled="!item.hasChildren"
+            @click="toggleExpand(item.node.id)"
+          >
+            <span v-if="item.hasChildren">▶</span>
+          </button>
+          <div class="mp-cat-row__main" @click="openNodeActions(item.node)">
+            <div class="mp-cat-row__name">{{ item.node.categoryName || '—' }}</div>
+            <div class="mp-cat-row__meta">
+              <span
+                class="status-tag"
+                :class="`status-tag--${statusTone(item.node.status)}`"
+              >
+                {{ dictStore.getLabel('sys_normal_disable', item.node.status) || '—' }}
+              </span>
+              <span>排序 {{ item.node.orderNum ?? 0 }}</span>
+              <span>{{ categoryChildMetaLabel(item.node) }}</span>
+            </div>
+          </div>
+          <button
+            class="mp-cat-row__add"
+            type="button"
+            aria-label="新增子级"
+            @click="openAdd(item.node)"
+          >
+            +
+          </button>
+          <button
+            class="mp-cat-row__more"
+            type="button"
+            aria-label="操作"
+            @click="openNodeActions(item.node)"
+          >
+            ⋯
+          </button>
+        </div>
+      </div>
+```
+
+保留其后的 `van-action-sheet`、表单 popup 等不变。
+
+- [ ] **Step 2: 接入工具函数与全部展开逻辑**
+
+在 `<script setup>` 中:
+
+1. 删除本地 `collectExpandIds` 函数。
+2. 增加 import:
+
+```ts
+  import {
+    areAllExpandableExpanded,
+    categoryChildMetaLabel,
+    collectExpandIds
+  } from '@/utils/mp-crm-product-category-tree'
+```
+
+3. 增加 computed 与 toggle:
+
+```ts
+  const expandAllLabel = computed(() =>
+    areAllExpandableExpanded(tree.value, expandedIds.value) ? '全部折叠' : '全部展开'
+  )
+
+  function toggleExpandAll() {
+    if (areAllExpandableExpanded(tree.value, expandedIds.value)) {
+      expandedIds.value = {}
+      return
+    }
+    expandedIds.value = collectExpandIds(tree.value)
+  }
+```
+
+4. `loadTree` 内继续使用导入的 `collectExpandIds`(替换原本地函数调用,行为不变)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/m/crm/product/categories.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): wire category tree list markup for scheme A.
+
+Add expand-all control, row meta, and inline add-child on the mp category page.
+EOF
+)"
+```
+
+---
+
+### Task 3: Scoped 样式对齐 Demo A 一体树表
+
+**Files:**
+- Modify: `web-admin/src/views/m/crm/product/categories.vue`(`<style scoped>`)
+
+- [ ] **Step 1: 替换样式块**
+
+将 `<style scoped lang="scss">` 整段替换为:
+
+```scss
+  .mp-product-page {
+    position: relative;
+  }
+
+  .mp-cat-header-tools {
+    position: absolute;
+    top: calc(8px + env(safe-area-inset-top, 0px));
+    right: 12px;
+    z-index: 60;
+    display: flex;
+    gap: 8px;
+    align-items: center;
+  }
+
+  .mp-cat-header-btn {
+    height: 32px;
+    padding: 0 10px;
+    font-size: 12px;
+    font-weight: 600;
+    color: #fff;
+    cursor: pointer;
+    background: rgb(255 255 255 / 15%);
+    border: 1px solid rgb(255 255 255 / 75%);
+    border-radius: 8px;
+    white-space: nowrap;
+  }
+
+  .mp-cat-header-btn:active {
+    opacity: 0.8;
+  }
+
+  .mp-cat-tree {
+    margin: 12px 10px 24px;
+    overflow: hidden;
+    background: #fff;
+    border: 1px solid #dcebe4;
+    border-radius: 16px;
+  }
+
+  .mp-cat-row {
+    display: flex;
+    gap: 6px;
+    align-items: center;
+    min-height: 54px;
+    padding: 10px 10px 10px 0;
+    background: #fff;
+    border-bottom: 1px solid #edf5f1;
+  }
+
+  .mp-cat-row:last-child {
+    border-bottom: none;
+  }
+
+  .mp-cat-row.is-l2 {
+    background: #fbfdff;
+  }
+
+  .mp-cat-row.is-l3 {
+    background: #fafcfb;
+  }
+
+  .mp-cat-row:active {
+    background: #f7fbf9;
+  }
+
+  .mp-cat-row__toggle {
+    flex-shrink: 0;
+    width: 28px;
+    height: 28px;
+    padding: 0;
+    font-size: 11px;
+    line-height: 28px;
+    color: #0a9f6d;
+    text-align: center;
+    cursor: pointer;
+    background: transparent;
+    border: none;
+    transition: transform 0.15s ease;
+  }
+
+  .mp-cat-row__toggle.is-open {
+    transform: rotate(90deg);
+  }
+
+  .mp-cat-row__toggle.is-leaf {
+    visibility: hidden;
+    pointer-events: none;
+  }
+
+  .mp-cat-row__main {
+    flex: 1;
+    min-width: 0;
+    cursor: pointer;
+  }
+
+  .mp-cat-row__name {
+    font-size: 15px;
+    font-weight: 600;
+    color: #1f2a26;
+    word-break: break-all;
+  }
+
+  .mp-cat-row__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    align-items: center;
+    margin-top: 3px;
+    font-size: 11px;
+    color: #6b7b74;
+  }
+
+  .mp-cat-row__add,
+  .mp-cat-row__more {
+    flex-shrink: 0;
+    width: 36px;
+    height: 36px;
+    font-size: 18px;
+    line-height: 1;
+    color: #087f57;
+    cursor: pointer;
+    background: #e9f9f2;
+    border: none;
+    border-radius: 8px;
+  }
+
+  .mp-cat-row__add {
+    width: 32px;
+    height: 32px;
+    font-size: 16px;
+    font-weight: 700;
+  }
+
+  .status-tag {
+    display: inline-flex;
+    align-items: center;
+    min-height: 20px;
+    padding: 1px 7px;
+    font-size: 11px;
+    font-weight: 600;
+    border: 1px solid transparent;
+    border-radius: 999px;
+  }
+
+  .status-tag--success {
+    color: #087f57;
+    background: #e9f9f2;
+    border-color: #b7ebce;
+  }
+
+  .status-tag--danger {
+    color: #b91c1c;
+    background: #fef2f2;
+    border-color: #fecaca;
+  }
+```
+
+删除已无用的 `.mp-cat-header-add`、`.mp-cat-list` 旧规则(已被上面替换)。
+
+- [ ] **Step 2: 本地目视对照**
+
+Run(若已在跑开发服可跳过 install):
+
+```bash
+cd web-admin && pnpm dev
+```
+
+打开 webview 产品分类页(或路由 `/#/m/crm/product/categories`),对照 Demo A:
+
+1. 一体树表(非整行散卡)  
+2. 缩进 + 箭头旋转  
+3. 行内状态/排序/子类数、`+`、`⋯`  
+4. 「全部展开/全部折叠」文案切换  
+
+Expected: 与 Demo A 一致,表单/删除流程无回归。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/m/crm/product/categories.vue
+git commit -m "$(cat <<'EOF'
+style(web-admin): restyle mp product category list as unified tree.
+
+Match scheme A indent-expand demo with sanqiang tokens.
+EOF
+)"
+```
+
+---
+
+### Task 4: Spec 状态回写 + 图谱更新
+
+**Files:**
+- Modify: `docs/superpowers/specs/2026-10-18-mp-crm-product-category-tree-style-design.md`
+- Run: `./scripts/update-graph.sh`(或 `graphify update .` 于仓库根)
+
+- [ ] **Step 1: 更新 Spec 状态**
+
+将文件头 `状态:待审阅` 改为 `状态:已实现`。
+
+- [ ] **Step 2: 再跑单测**
+
+```bash
+cd web-admin && node --import tsx --test src/utils/mp-crm-product-category-tree.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 3: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh
+```
+
+若脚本不可用,则在仓库根执行:`graphify update .`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/superpowers/specs/2026-10-18-mp-crm-product-category-tree-style-design.md graphify-out backend/graphify-out web-admin/graphify-out 2>/dev/null
+git commit -m "$(cat <<'EOF'
+docs: mark product category tree style design as implemented.
+
+Refresh graphify after the mp category list style change.
+EOF
+)"
+```
+
+(若图谱无变更,仅提交 spec 状态即可。)
+
+---
+
+## Spec coverage checklist
+
+| Spec 要求 | Task |
+|-----------|------|
+| 一体树表 + 缩进展开 | Task 2 + 3 |
+| 行元信息:状态 · 排序 · 子类数 | Task 1 + 2 |
+| 行内 `+` 新增子级 | Task 2 |
+| Header「全部展开/折叠」 | Task 1 + 2 |
+| Token 三墙色、行高触控 | Task 3 |
+| 不改 API / 表单 / 删除权限 | 全任务未触碰这些逻辑 |
+| 验收清单 1–6 | Task 2–3 目视 + Task 1 单测覆盖展开辅助逻辑 |
+
+## Placeholder / 一致性自检
+
+- 无 TBD/TODO;工具函数名在 Task 1–2 一致(`collectExpandIds` / `areAllExpandableExpanded` / `categoryChildMetaLabel`)。  
+- 缩进公式与现页一致:`12 + depth * 16`。  
+- Header 用绝对定位双按钮组,兼容现有无右侧 slot 的 `MpBusinessHeader`。