Files
crearte-monorepo/docs/plans/2026-09-29-author-page.md
T
XingfenD d814914e21 docs(plan): P4 author page implementation plan
- docs/plans/2026-09-29-author-page.md: 3 TDD tasks (AuthorView+route,
  entry links, e2e+changelog) with containerized npm commands per
  HANDOFF toolchain notes
- docs/ROADMAP.md: register plan in doc index, P4 status update
2026-09-29 16:04:20 +08:00

10 KiB
Raw Blame History

Author Page (/users/:user) 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: 新增作者主页 /users/:user:列出该作者全部已上架作品(最新在前),并从作品详情页与卡片提供入口。

Architecture: 纯前端实现 —— 新视图 AuthorView.vue 复用现有 repo.listGames()(API+static 双源合并、ETag 缓存、API 宕机降级),按 resolveUserSlug(game).user 过滤后交给 filterGames 的 DEFAULT_FILTER(sort: 'new')排序渲染;路由 /users/:user(props: true)。crearte-server 零改动。

Tech Stack: Vue 3.5 <script setup> + TypeScript + vue-router 5 + Vitest/@vue/test-utils(happy-dom)+ Playwright(noauth 配置)。

Spec: docs/specs/2026-09-29-author-page-design.md(monorepo 仓)

Global Constraints

  • crearte-server 零改动:本计划任何任务不得在 crearte-server/ 产生 diff。
  • 页面只做纯聚合:无筛选器、无查询参数、无用户存在性探测。
  • 头部展示账号 username(路由参数),不从 author.name 推导;显示名统一用 authorDisplayName(game)(@/lib/labels),两者语义不混用。
  • 排序复用 filterGames(games, DEFAULT_FILTER) 的 sort: 'new'(addedAt 降序、id 决胜),禁止复制比较器代码。
  • TDD:每个任务先写失败测试、看它以正确理由失败,再实现;任务内提交。
  • 工作分支 feat/author-page(crearte 仓);CHANGELOG 条目为双语(英文行 + 中文行连续,条目间空行),版本 0.14.0。
  • 本机无 host node:npm 命令一律容器内执行(见各任务 Run 命令;来源 crearte-deploy/docs/HANDOFF.md §2)。若 node_modules 卷为空,先在容器内 npm ci。
  • 别名 @ → src/app。

Review Focus

  1. 旧式单段 id 作品(无 user/slug 字段)不得错误匹配到任何作者页 —— Task 1 测试钉住 resolveUserSlug 回退(user: '' 不匹配)。
  2. 未知用户名必须渲染空态(非 404、非报错)—— Task 1 测试钉住。
  3. author.name 与 username 语义分离:显示文本可以是 author.name,但链接目标永远是 /users/<user>;无 user 时不得产生内部链接 —— Task 2 测试钉住三种形态(内部链接 / 外链兜底 / 纯文本)。
  4. GameCard 新增作者行不得破坏既有卡片测试与布局断言 —— Task 2 跑全量前端测试。
  5. 加载失败可重试:repo.listGames() reject 时进 StatePanel 错误态,@retry 重新加载 —— Task 1 测试钉住。

Task 1: AuthorView + 路由

Files:

  • Create: src/app/views/AuthorView.vue
  • Modify: src/app/router/index.ts(routes 数组,注册在 /:pathMatch(.*)* 之前)
  • Test: src/app/views/AuthorView.test.ts

Interfaces:

  • Consumes: repo.listGames(): Promise<GameSummary[]>、resolveUserSlug(game): { user, slug }(@/data)、filterGames(games, state) + DEFAULT_FILTER(@/lib/filter)、useAsync<T>(loader, deps)(@/composables/useAsync)、StatePanel(props { loading, error },emit retry)、GameCard(props { game, headingLevel? })、BaseButton。

  • Produces: 路由 { path: '/users/:user', name: 'author', props: true };AuthorView props user: string。

  • Step 1: 写失败测试(src/app/views/AuthorView.test.ts,仿 GameView.test.ts 的 vi.hoisted + vi.mock('@/data', …) + memory-router + RouterLink stub 模式;mount(AuthorView, { props: { user: 'alice' }, global: { plugins: [router], stubs: … } }))

// 夹具(模块内常量):matching(2 个 alice + 1 个 bob,addedAt 分别 2026-09-01/2026-09-03/2026-09-02)
it('只显示该作者作品并按最新上架排序')      // 渲染 2 张 GameCard,顺序为 addedAt 降序(09-03 在前)
it('旧式单段 id 作品不匹配任何作者页')      // { id: 'plain-legacy' /* 无 user/slug */ } 不出现在 alice 页
it('复合 id 但缺 user 字段时按 id 前段解析') // { id: 'alice/legacy' /* 无 user/slug */ } 出现在 alice 页
it('未知作者渲染空态而非报错')              // props user='ghost' → 文案「该作者暂无已上架作品」可见,无 role="alert"
it('头部展示 @username、作品数与面包屑')    // 文本含 '@alice' 与 '2';面包屑「目录」链接 href='/games'
it('加载失败进入错误态且可重试')            // mockRejectedValue 一次 → StatePanel 错误;触发 retry 后 mockResolvedValue → 列表渲染
  • Step 2: 跑测试确认失败

Run: docker run --rm -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-deploy_mock_node_modules:/repo/src/node_modules -w /repo/src crearte:dev npm test -- AuthorView Expected: FAIL(AuthorView.vue 不存在 / 路由名未注册),失败原因正确。

  • Step 3: 实现 AuthorView.vue(结构镜像 CatalogView.vue 的 StatePanel 包裹 + GameCard 网格 + 空态块;无筛选 UI)
const props = defineProps<{ user: string }>()
const { data: games, error, loading, reload } = useAsync<GameSummary[]>(() => repo.listGames())
const visible = computed(() =>
  filterGames((games.value ?? []).filter((g) => resolveUserSlug(g).user === props.user), DEFAULT_FILTER)
)

模板要点:面包屑「目录 / @user」(RouterLink :to="{ name: 'catalog' }");<h1>@{{ props.user }}</h1>;作品数 {{ visible.length }};<GameCard v-for="game in visible" :key="game.id" :game="game" /> 网格(栅格类名抄 CatalogView.vue:70);空态块抄 CatalogView.vue:73-85 形态,文案「该作者暂无已上架作品」+ BaseButton 返回目录。

  • Step 4: 注册路由(src/app/router/index.ts,catch-all 之前)
{ path: '/users/:user', name: 'author', component: () => import('@/views/AuthorView.vue'), props: true },
  • Step 5: 跑测试确认通过:Step 2 同命令 → PASS;再跑全量 … npm test → 全绿。
  • Step 6: 提交
git checkout -b feat/author-page   # 若尚未创建
git add src/app/views/AuthorView.vue src/app/views/AuthorView.test.ts src/app/router/index.ts
git commit -m "feat(views): author page /users/:user aggregating a user's published works"

Task 2: 入口链接(GameView 作者名 + GameCard 作者行)

Files:

  • Modify: src/app/views/GameView.vue:55-66(作者段落)
  • Modify: src/app/components/GameCard.vue:35 之后(描述 </p> 与徽章行之间)
  • Test: src/app/views/GameView.test.ts(追加用例)、src/app/components/GameCard.test.ts(追加用例)

Interfaces:

  • Consumes: resolveUserSlug(@/data)、authorDisplayName(game)(@/lib/labels,返回 author?.name || user || '佚名')、Task 1 的路由 name: 'author'。

  • Produces: 无新 API;链接契约 RouterLink :to="'/users/' + user"。

  • Step 1: 写失败测试

GameView(三形态,显示文本统一 authorDisplayName(game)):

it('有命名空间时作者名链接到 /users/:user')   // game{user:'alice', author:{name:'爱丽丝'}} → <a href="/users/alice">爱丽丝</a>
it('无 user 但有 author.url 时保持外链')      // game{author:{name:'X', url:'https://e.com'}} → href 含 toInterstitialIfExternal 结果(既有行为不回归)
it('无 user 无 url 时纯文本')                 // 无 <a>,文本仍显示作者名

GameCard:

it('卡片渲染作者行并链接到作者页')            // game{user:'alice'} → 文本含 authorDisplayName,href='/users/alice'
it('无 user 的卡片作者行为纯文本')            // 无 href='/users/' 前缀的链接
  • Step 2: 跑测试确认失败

Run: docker run --rm -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-deploy_mock_node_modules:/repo/src/node_modules -w /repo/src crearte:dev npm test -- GameView Run: 同上 … npm test -- GameCard Expected: 两处 FAIL(作者行/内部链接不存在),失败原因正确。

  • Step 3: 实现(优先级:resolveUserSlug(game).user 非空 → 内部 RouterLink;否则 game.author?.url → 既有外链(toInterstitialIfExternal);否则纯文本。GameCard 作者行放在描述与徽章行之间,text-ink-soft 小字,与卡片 headingLevel 无交互)

  • Step 4: 跑测试确认通过:Step 2 两命令 → PASS;全量 … npm test → 全绿(Review Focus #4:既有卡片/详情断言不回归)。

  • Step 5: 提交

git add src/app/views/GameView.vue src/app/views/GameView.test.ts src/app/components/GameCard.vue src/app/components/GameCard.test.ts
git commit -m "feat(views): link author names to the author page from detail and cards"

Task 3: e2e + CHANGELOG + 全量验证

Files:

  • Create: src/e2e/author-page.noauth.spec.ts
  • Modify: crearte/docs/CHANGELOG.md(顶部加 [0.14.0])

Interfaces:

  • Consumes: src/e2e/helpers.ts 的既有辅助(fixtureGame 等);noauth 配置 testMatch: /noauth\.spec\.ts/、baseURL: http://localhost:4174(src/playwright.noauth.config.ts,webServer 自动 build:e2e:noauth)。

  • Produces: 无。

  • Step 1: 写 e2e 用例(夹具 20 作品全部 user: 'fixture')

test('/users/fixture 列出该作者作品并可跳转详情')  // goto '/users/fixture' → '@fixture' 可见、卡片数 > 0、每张卡链接前缀 '/games/fixture/'
test('/users/ghost 显示空态')                     // 「该作者暂无已上架作品」可见
  • Step 2: 跑 e2e

Run: docker run --rm --ipc=host -v /root/workspace/crearte-monorepo/crearte:/repo -v crearte-e2e-nm:/repo/src/node_modules -w /repo/src crearte:e2e npm run e2e:noauth Expected: PASS(新用例 + 既有 noauth 用例全过)。

  • Step 3: 全量验证:… crearte:dev npm test 全绿;… crearte:dev npm run typecheck 无错误。
  • Step 4: CHANGELOG 条目([0.14.0],双语:EN 行 + ZH 行):新增 /users/:user 作者主页与作者名互链。
  • Step 5: 提交
git add src/e2e/author-page.noauth.spec.ts docs/CHANGELOG.md
git commit -m "test(e2e): author page noauth coverage; docs: changelog 0.14.0"

收尾(非任务):三个任务完成后走 superpowers:finishing-a-development-branch —— feat/author-page 以 git merge --no-ff 并回 master、删除分支;随后回 monorepo 更新 docs/ROADMAP.md P4 状态与文档索引。