Review changes batch 5 of 6

This commit is contained in:
2026-07-20 06:52:26 +08:00
parent 0d0645a112
commit db97d3da27
41 changed files with 6672 additions and 1124 deletions
@@ -0,0 +1,494 @@
# T07 模块基准页实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. 本项目明确禁止多代理与 worktree,因此只允许当前会话内联执行。
**Goal:** 把 T07“成员目录”完善为 T 模块常规页面的视觉基准,并在用户明确通过前停留在 T07,不修改 T01、F01 或其他页面。
**Architecture:** T07 保留现有搜索与成员列表业务结构,补齐当前家谱上下文、失败恢复入口、可访问操作语义和小屏自然滚动。页面继续消费 `ModulePageBackground``PageHeader``AppLoading``AppButton` 及现有真实位图资产,不抽取新全局组件;新增聚焦静态合同与 Chrome CDP 运行检查作为后续 T 页面推广的基准合同。
**Tech Stack:** uni-app、Vue 3 `<script setup>`、SCSS、PowerShell 合同测试、Node.js Chrome DevTools Protocol 9222、现有 Chrome 5173 H5。
## Global Constraints
- 全程只处理 T07,用户指出问题时不得跨页修改。
- 不对接接口,不把 Mock 或 H5 截图描述为功能通过。
- 只复用 9222 上唯一的 `localhost:5173` Chrome 标签页,不新开浏览器或第二个项目标签页。
- 不使用多代理,不使用 worktree。
- 不执行 `git add``commit``push``reset``checkout`
- 保留全部现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。
- 不为测试通过而放宽阈值。
- 所有弹窗、Toast、Loading 和 ActionSheet 使用项目自定义组件。
- T07 只有全部适用状态、四档尺寸和用户明确结论均通过后,才能成为 T 模块冻结基准。
- Android/HBuilderX 真机或模拟器仍未验证,最终报告必须保留此限制。
---
### Task 1: 固化 T07 基准页视觉与状态合同
**Files:**
- Create: `tests/t07-module-baseline-contract.ps1`
- Read: `pages/tree/t07-member-directory.vue`
- Read: `docs/superpowers/specs/2026-07-19-module-baseline-accelerated-visual-review-design.md`
**Interfaces:**
- Consumes: T07 当前 `directoryState``filteredMembers``searchMembers()``openMember(item)`
- Produces: T07 唯一的聚焦静态基准合同,约束 `.directory-context`、搜索语义、四个状态、恢复动作和滚动规则。
- [ ] **Step 1: 写入失败合同**
创建 `tests/t07-module-baseline-contract.ps1`,完整内容如下:
```powershell
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$page = Get-Content -LiteralPath (Join-Path $root 'pages/tree/t07-member-directory.vue') -Raw -Encoding utf8
function Assert-Match([string]$Pattern, [string]$Message) {
if ($page -notmatch $Pattern) { throw $Message }
}
Assert-Match 'class="directory-context"' 'T07 must show the current genealogy context.'
Assert-Match '汤氏家谱' 'T07 baseline must identify the current genealogy in the H5 mock state.'
Assert-Match '''directory-state--loading'': directoryState === ''loading''' 'T07 loading state must have an explicit root class.'
Assert-Match 'v-if="directoryState === ''list'' \|\| directoryState === ''empty''"' 'T07 search must remain available for list and empty states only.'
Assert-Match 'class="directory-search__action"[^>]*role="button"[^>]*aria-label="查找成员"' 'T07 search action must expose button semantics and an accessible label.'
Assert-Match '<AppButton\s+v-if="directoryState === ''error''"[^>]+type="secondary"[^>]+label="重新查看"[^>]+@click="retryDirectory"' 'T07 error state must provide a custom recovery action.'
Assert-Match 'const retryDirectory = \(\) => \{ directoryState\.value = ''list'' \}' 'T07 retry action must return to the list state.'
Assert-Match '(?s)\.directory-page\s*\{[^}]*overflow-x:\s*hidden;[^}]*overflow-y:\s*auto;' 'T07 must allow natural vertical scrolling without horizontal overflow.'
Assert-Match '(?s)\.directory-search__action\s*\{[^}]*min-width:\s*88rpx;[^}]*min-height:\s*72rpx;' 'T07 search action must preserve a usable touch target.'
foreach ($state in @('loading', 'list', 'empty', 'error')) {
Assert-Match $state "T07 must preserve the $state state."
}
Write-Output 'T07-MODULE-BASELINE-CONTRACT PASS'
```
- [ ] **Step 2: 运行合同确认旧实现失败**
Run:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File tests/t07-module-baseline-contract.ps1
```
Expected: FAIL,首个失败信息为 `T07 must show the current genealogy context.`;不得先修改断言。
---
### Task 2: 最小化完善 T07 模板、状态恢复与视觉层级
**Files:**
- Modify: `pages/tree/t07-member-directory.vue`
- Test: `tests/t07-module-baseline-contract.ps1`
- Test: `tests/t07-t08-all-states-visual-contract.ps1`
- Test: `tests/module-app-loading-contract.ps1`
- Test: `tests/t03-t08-member-flow-contract.ps1`
**Interfaces:**
- Consumes: Task 1 的静态合同;现有 `AppLoading``AppButton``PageHeader``ModulePageBackground`
- Produces: T07 常规基准结构:当前谱上下文、列表/空态搜索、错误恢复、自然纵向滚动。
- [ ] **Step 1: 调整模板但保留原有业务结构**
`PageHeader` 后增加当前谱上下文;搜索只在列表和空态显示;把搜索文字改为具有按钮语义的命名元素;在失败状态卡后增加自定义恢复按钮:
同时在页面根节点的状态 class 对象中补入:
```vue
'directory-state--loading': directoryState === 'loading'
```
```vue
<view class="directory-context">
<text class="directory-context__name">汤氏家谱</text>
<text class="directory-context__meta">主支 · {{ members.length }} 位成员</text>
</view>
<view v-if="directoryState === 'list' || directoryState === 'empty'" class="directory-search">
<image src="/static/assets/modules/genealogy/opaque/g06-search-input-wide.png" mode="scaleToFill" />
<input v-model="keyword" aria-label="成员搜索关键词" placeholder="按姓名、字辈或支系查找" placeholder-class="directory-placeholder" @confirm="searchMembers" />
<view class="directory-search__action" role="button" aria-label="查找成员" hover-class="action-hover" @click="searchMembers"><text>查找</text></view>
</view>
```
失败卡片后加入:
```vue
<AppButton
v-if="directoryState === 'error'"
block
type="secondary"
label="重新查看"
@click="retryDirectory"
/>
```
- [ ] **Step 2: 增加最小脚本逻辑**
只增加现有组件导入和恢复函数,不新增接口、计时器或抽象:
```js
import AppButton from '@/components/AppButton.vue'
const retryDirectory = () => { directoryState.value = 'list' }
```
- [ ] **Step 3: 收敛 T07 基准样式**
保留现有位图和卡片尺寸,只增加上下文、触控区和自然滚动样式;把旧 `.directory-search > text` 规则替换为 `.directory-search__action`
```scss
.directory-page { position: relative; min-height: 100vh; overflow-x: hidden; overflow-y: auto; background: $paper; }
.directory-page__header, .directory-context, .directory-search, .directory-content { position: relative; z-index: 2; }
.directory-context { display: flex; align-items: baseline; justify-content: space-between; box-sizing: border-box; padding: 22rpx 32rpx 0; }
.directory-context__name { color: $ink; font-family: 'STKaiti', 'KaiTi', serif; font-size: 32rpx; font-weight: 700; }
.directory-context__meta { color: #62584c; font-size: 24rpx; font-weight: 500; }
.directory-search__action { position: absolute; top: 6rpx; right: 8rpx; z-index: 1; display: flex; min-width: 88rpx; min-height: 72rpx; align-items: center; justify-content: center; color: $brand-red; font-size: 24rpx; font-weight: 700; }
.directory-content > .app-button { margin: 20rpx auto 0; }
```
不得修改共享组件、T08 或其他页面。
- [ ] **Step 4: 运行聚焦与既有合同**
Run:
```powershell
$tests = @(
'tests/t07-module-baseline-contract.ps1',
'tests/t07-t08-all-states-visual-contract.ps1',
'tests/module-app-loading-contract.ps1',
'tests/t03-t08-member-flow-contract.ps1'
)
foreach ($test in $tests) {
powershell -NoProfile -ExecutionPolicy Bypass -File $test
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
```
Expected: 四个合同均输出 `PASS`,退出码为 0。
---
### Task 3: 增加唯一 Chrome 标签页的 T07 响应式与交互检查
**Files:**
- Create: `tests/t07-module-baseline-runtime-smoke.js`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/01-list-412x915.png`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/02-loading-412x915.png`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/03-empty-412x915.png`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/04-error-412x915.png`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/05-responsive-contact-sheet.png`
**Interfaces:**
- Consumes: 9222 上唯一 `localhost:5173` 页面;T07 查询状态 `state=loading|empty|error`
- Produces: 四状态、四尺寸、搜索和错误恢复的运行证据;最终把当前标签页停在 `412×915` T07 列表态。
- [ ] **Step 1: 编写 CDP 运行检查**
创建 `tests/t07-module-baseline-runtime-smoke.js`,完整内容如下。运行脚本不得打开新标签页、不得调用 Playwright、不得清除 Chrome 用户数据。
```js
const fs = require('fs')
const path = require('path')
const origin = 'http://localhost:5173'
const outputDirectory = path.resolve('docs/design/screens/runtime/2026-07-19/t07-baseline')
const sizes = [
{ width: 320, height: 568 },
{ width: 360, height: 640 },
{ width: 360, height: 800 },
{ width: 412, height: 915 }
]
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))
const connect = async () => {
const pages = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const projectPages = pages.filter((page) => page.type === 'page' && page.url.startsWith(origin))
if (projectPages.length !== 1) throw new Error(`Expected one project page, found ${projectPages.length}`)
const socket = new WebSocket(projectPages[0].webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
socket.addEventListener('open', resolve, { once: true })
socket.addEventListener('error', reject, { once: true })
})
let id = 0
const pending = new Map()
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
const request = pending.get(message.id)
if (!request) return
pending.delete(message.id)
message.error ? request.reject(new Error(message.error.message)) : request.resolve(message.result)
})
const send = (method, params = {}) => new Promise((resolve, reject) => {
id += 1
pending.set(id, { resolve, reject })
socket.send(JSON.stringify({ id, method, params }))
})
return { projectPageCount: projectPages.length, socket, send }
}
const valueOf = async (send, expression) => {
const result = await send('Runtime.evaluate', { expression, returnByValue: true })
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed')
return result.result?.value
}
const waitFor = async (send, expression, message) => {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
if (await valueOf(send, expression)) return
} catch (error) {
if (!String(error.message).includes('Inspected target navigated or closed')) throw error
}
await sleep(100)
}
throw new Error(message)
}
const setSize = (send, size) => send('Emulation.setDeviceMetricsOverride', {
...size,
deviceScaleFactor: 1,
mobile: true,
screenWidth: size.width,
screenHeight: size.height
})
let navigationId = 0
const openState = async (send, query = '') => {
navigationId += 1
const url = `${origin}/?t07Baseline=${navigationId}#/pages/tree/t07-member-directory${query}`
await send('Page.navigate', { url })
await waitFor(send, `location.href === ${JSON.stringify(url)} && Boolean(document.querySelector('.directory-page'))`, `T07 did not render ${query || 'list'}`)
await sleep(250)
}
const capture = async (send, filename) => {
const screenshot = await send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false })
fs.mkdirSync(outputDirectory, { recursive: true })
fs.writeFileSync(path.join(outputDirectory, filename), Buffer.from(screenshot.data, 'base64'))
}
const metrics = (send) => valueOf(send, `(() => {
const rect = (selector) => {
const node = document.querySelector(selector)
if (!node) return null
const box = node.getBoundingClientRect()
return { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height }
}
return {
viewport: { width: innerWidth, height: innerHeight },
context: rect('.directory-context'),
search: rect('.directory-search'),
firstCard: rect('.directory-card'),
lastCard: rect('.directory-card:last-child'),
cardCount: document.querySelectorAll('.directory-card').length,
state: document.querySelector('.directory-state--loading') ? 'loading' : document.querySelector('.directory-state--empty') ? 'empty' : document.querySelector('.directory-state--error') ? 'error' : 'list',
horizontalOverflow: document.documentElement.scrollWidth > innerWidth || document.body.scrollWidth > innerWidth,
documentScrollHeight: document.documentElement.scrollHeight
}
})()`)
const assert = (condition, message) => { if (!condition) throw new Error(message) }
const run = async () => {
const { projectPageCount, socket, send } = await connect()
const runtimeErrors = []
const resourceErrors = []
socket.addEventListener('message', (event) => {
const message = JSON.parse(event.data)
if (message.method === 'Runtime.exceptionThrown') runtimeErrors.push(message.params.exceptionDetails?.text || 'runtime exception')
if (message.method === 'Network.responseReceived' && message.params.response.status >= 400) resourceErrors.push(`${message.params.response.status} ${message.params.response.url}`)
})
try {
await send('Page.enable')
await send('Runtime.enable')
await send('Network.enable')
const responsive = []
for (const size of sizes) {
await setSize(send, size)
await openState(send, '?genealogyId=1001')
const current = await metrics(send)
assert(!current.horizontalOverflow, `${size.width}x${size.height}: horizontal overflow`)
assert(current.context && current.search && current.firstCard, `${size.width}x${size.height}: missing baseline content`)
assert(current.cardCount === 3, `${size.width}x${size.height}: expected three members`)
await valueOf(send, 'scrollTo(0, document.documentElement.scrollHeight)')
await sleep(50)
const lastBottom = await valueOf(send, "document.querySelector('.directory-card:last-child').getBoundingClientRect().bottom")
assert(lastBottom <= size.height + 1, `${size.width}x${size.height}: last member is not reachable`)
await valueOf(send, 'scrollTo(0, 0)')
responsive.push(current)
await capture(send, `responsive-${size.width}x${size.height}.png`)
}
await setSize(send, { width: 412, height: 915 })
await openState(send, '?state=loading&genealogyId=1001')
assert((await metrics(send)).state === 'loading', 'Loading state did not render')
await capture(send, '02-loading-412x915.png')
await openState(send, '?state=empty&genealogyId=1001')
assert((await metrics(send)).state === 'empty', 'Empty state did not render')
await capture(send, '03-empty-412x915.png')
await openState(send, '?state=error&genealogyId=1001')
assert((await metrics(send)).state === 'error', 'Error state did not render')
assert(!await valueOf(send, "Boolean(document.querySelector('.directory-search'))"), 'Error state must not expose search')
await capture(send, '04-error-412x915.png')
await valueOf(send, "document.querySelector('.directory-content .app-button').click()")
await waitFor(send, "document.querySelectorAll('.directory-card').length === 3 && Boolean(document.querySelector('.directory-search'))", 'Error retry did not restore the list')
const inputSearch = async (value) => {
await valueOf(send, `(() => {
const input = document.querySelector('.directory-search input')
input.value = ${JSON.stringify(value)}
input.dispatchEvent(new Event('input', { bubbles: true }))
return true
})()`)
await sleep(50)
await valueOf(send, "document.querySelector('.directory-search__action').click()")
}
await inputSearch('不存在')
await waitFor(send, "Boolean(document.querySelector('.directory-state--empty'))", 'No-result search did not render the empty state')
await inputSearch('')
await waitFor(send, "document.querySelectorAll('.directory-card').length === 3", 'Cleared search did not restore all members')
await openState(send, '?genealogyId=1001')
await capture(send, '01-list-412x915.png')
const final = await metrics(send)
assert(final.viewport.width === 412 && final.viewport.height === 915 && final.state === 'list', 'Final approval state is invalid')
assert(runtimeErrors.length === 0, `Runtime errors: ${runtimeErrors.join(' | ')}`)
assert(resourceErrors.length === 0, `Resource errors: ${resourceErrors.join(' | ')}`)
process.stdout.write(`${JSON.stringify({ projectPageCount, responsive, final, runtimeErrors, resourceErrors }, null, 2)}\n`)
process.stdout.write('PASS T07 module baseline runtime smoke\n')
} finally {
socket.close()
}
}
run().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`)
process.exit(1)
})
```
- [ ] **Step 2: 运行 CDP 检查**
Run:
```powershell
node tests/t07-module-baseline-runtime-smoke.js
```
Expected: 输出四档几何数据、四状态结果、`projectPageCount: 1``runtimeErrors: []``resourceErrors: []`,最后输出 `PASS T07 module baseline runtime smoke`
- [ ] **Step 3: 打开并检查全部截图**
先生成四档联系表:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -Command "& '.\scripts\create-horizontal-contact-sheet.ps1' -Output '.\docs\design\screens\runtime\2026-07-19\t07-baseline\05-responsive-contact-sheet.png' -InputPaths @('.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-320x568.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-360x640.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-360x800.png','.\docs\design\screens\runtime\2026-07-19\t07-baseline\responsive-412x915.png')"
```
使用本地图片查看工具逐张打开四状态和四尺寸证据。拒绝以下问题:
- 当前谱名缺失或与搜索框挤压。
- 320×568 出现横向溢出、卡片文字裁切或末项不可达。
- 空态仍显示旧列表,失败态仍显示可误操作搜索框。
- 恢复按钮不是项目卷轴按钮。
- 背景、标题栏、文字层级与 T 模块候选不一致。
发现问题时只返工 T07,并重新执行 Task 1–3 的相关步骤。
---
### Task 4: 完成 T07 内部设计 QA 并交给用户逐态审批
**Files:**
- Modify: `design-qa.md`
- Create: `docs/design/screens/runtime/2026-07-19/t07-baseline/06-before-vs-after.png`
**Interfaces:**
- Consumes: 审查前 `module-baseline-audit/04-t07.png`、Task 3 新鲜运行截图。
- Produces: T07 基准页内部 QA 记录和唯一 Chrome 审批现场。
- [ ] **Step 1: 生成同尺寸前后对比**
使用 `scripts/create-horizontal-contact-sheet.ps1` 合并:
```text
docs/design/screens/runtime/2026-07-19/module-baseline-audit/04-t07.png
docs/design/screens/runtime/2026-07-19/t07-baseline/01-list-412x915.png
```
输出 `06-before-vs-after.png`,必须打开原图检查,而不是只确认文件存在。
- [ ] **Step 2: 更新 `design-qa.md`**
新增 T07 基准页小节,明确记录:
- source visual truth:已批准的模块基准规范与审查前截图。
- implementation screenshotT07 新鲜 412×915 列表态。
- 字体、间距、颜色、图片资产、文案五项检查。
- 四状态、四尺寸、搜索交互、失败恢复、控制台和资源错误检查。
- 仍未验证 Android/HBuilderX、系统字体、软键盘和读屏。
- 没有 P0/P1/P2 时写入精确的 `final result: passed`;有问题则写 `final result: blocked` 并继续返工。
- [ ] **Step 3: 在同一标签页按顺序交付用户审批**
依次只展示一个状态并等待用户结论:
1. 正常列表态。
2. 搜索无结果空态。
3. 加载态。
4. 失败态与“重新查看”。
5. 320×568、360×640、360×800、412×915 四档。
没有用户明确“通过”,不得把 T07 标记为模块基准或冻结。
---
### Task 5: 用户通过后记录 T07 基准状态并准备 F01
**Files:**
- Modify: `docs/验收规划.md`
- Modify: `docs/交接记录.md`
- Modify: `docs/新会话审批交接_2026-07-19.md`
- Modify: `docs/superpowers/specs/2026-07-19-module-baseline-accelerated-visual-review-design.md`
**Interfaces:**
- Consumes: 用户对 T07 全部适用状态与四档尺寸的明确通过结论。
- Produces: T07 成为 T 模块基准的权威记录;下一审核页为 F01。
- [ ] **Step 1: 只在用户整页通过后更新文档**
记录以下事实,不扩大结论:
- G01 已是 G 模块 H5 视觉基准。
- T07 已通过 H5 基准页审核,成为 T 模块常规页面基准。
- 下一基准页为 F01T01 必须等 T07、F01、R01、N01、M01 全部通过后处理。
- Android/HBuilderX、真实接口和真机性能仍未完成。
若用户指出问题,保持 T07 当前审核状态,只返工 T07,不执行本任务。
- [ ] **Step 2: 最终新鲜验证**
Run:
```powershell
$tests = @(
'tests/t07-module-baseline-contract.ps1',
'tests/t07-t08-all-states-visual-contract.ps1',
'tests/module-app-loading-contract.ps1',
'tests/t03-t08-member-flow-contract.ps1'
)
foreach ($test in $tests) {
powershell -NoProfile -ExecutionPolicy Bypass -File $test
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
node tests/t07-module-baseline-runtime-smoke.js
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
git diff --check
```
Expected: 所有合同和运行检查通过;`git diff --check` 退出码为 0,仅允许工作区既有 LF/CRLF 警告;Chrome 最终保持唯一项目标签页。