Files
jiapuapp/docs/superpowers/plans/2026-07-19-t07-module-baseline.md
T
2026-07-20 06:52:26 +08:00

495 lines
22 KiB
Markdown
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.
# 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 最终保持唯一项目标签页。