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

22 KiB
Raw Blame History

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 保留现有搜索与成员列表业务结构,补齐当前家谱上下文、失败恢复入口、可访问操作语义和小屏自然滚动。页面继续消费 ModulePageBackgroundPageHeaderAppLoadingAppButton 及现有真实位图资产,不抽取新全局组件;新增聚焦静态合同与 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 addcommitpushresetcheckout
  • 保留全部现有修改、未跟踪文件、测试、文档、截图、母版和候选资产。
  • 不为测试通过而放宽阈值。
  • 所有弹窗、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 当前 directoryStatefilteredMemberssearchMembers()openMember(item)

  • Produces: T07 唯一的聚焦静态基准合同,约束 .directory-context、搜索语义、四个状态、恢复动作和滚动规则。

  • Step 1: 写入失败合同

创建 tests/t07-module-baseline-contract.ps1,完整内容如下:

$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 -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 的静态合同;现有 AppLoadingAppButtonPageHeaderModulePageBackground

  • Produces: T07 常规基准结构:当前谱上下文、列表/空态搜索、错误恢复、自然纵向滚动。

  • Step 1: 调整模板但保留原有业务结构

PageHeader 后增加当前谱上下文;搜索只在列表和空态显示;把搜索文字改为具有按钮语义的命名元素;在失败状态卡后增加自定义恢复按钮:

同时在页面根节点的状态 class 对象中补入:

'directory-state--loading': directoryState === 'loading'
<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>

失败卡片后加入:

<AppButton
  v-if="directoryState === 'error'"
  block
  type="secondary"
  label="重新查看"
  @click="retryDirectory"
/>
  • Step 2: 增加最小脚本逻辑

只增加现有组件导入和恢复函数,不新增接口、计时器或抽象:

import AppButton from '@/components/AppButton.vue'

const retryDirectory = () => { directoryState.value = 'list' }
  • Step 3: 收敛 T07 基准样式

保留现有位图和卡片尺寸,只增加上下文、触控区和自然滚动样式;把旧 .directory-search > text 规则替换为 .directory-search__action

.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:

$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 用户数据。

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:

node tests/t07-module-baseline-runtime-smoke.js

Expected: 输出四档几何数据、四状态结果、projectPageCount: 1runtimeErrors: []resourceErrors: [],最后输出 PASS T07 module baseline runtime smoke

  • Step 3: 打开并检查全部截图

先生成四档联系表:

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 合并:

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:

$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 最终保持唯一项目标签页。