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>
This commit is contained in:
wuzilongandCursor committed 2026-10-18 16:22:11 +08:00
1 parent 9b405a9227
commit 4529a18a8d
1 file changed
+565
@@ -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`。