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
This commit is contained in:
+1
-1
@@ -50,4 +50,4 @@
|
||||
|
||||
| 子项目 | spec | 实现计划 |
|
||||
|--------|------|----------|
|
||||
| P4 作者主页 | `docs/specs/2026-09-29-author-page-design.md` | (待写) |
|
||||
| P4 作者主页 | `docs/specs/2026-09-29-author-page-design.md` | `docs/plans/2026-09-29-author-page.md` |
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# 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: … } })`)
|
||||
|
||||
```ts
|
||||
// 夹具(模块内常量):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)
|
||||
|
||||
```ts
|
||||
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 之前)
|
||||
|
||||
```ts
|
||||
{ path: '/users/:user', name: 'author', component: () => import('@/views/AuthorView.vue'), props: true },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑测试确认通过**:Step 2 同命令 → PASS;再跑全量 `… npm test` → 全绿。
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
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)`):
|
||||
```ts
|
||||
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:
|
||||
```ts
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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'`)
|
||||
|
||||
```ts
|
||||
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: 提交**
|
||||
|
||||
```bash
|
||||
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 状态与文档索引。
|
||||
Reference in New Issue
Block a user