Files
crearte-monorepo/docs/plans/2026-10-01-p9-ux-browser-feedback.md
T

87 lines
7.4 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.
# P9 UX 第一批(浏览器反馈包)· 实现计划
Spec:`docs/specs/2026-10-01-p9-ux-browser-feedback-design.md`(§1–§6 全量为审查依据,不只本计划)。
仓库:仅 crearte(分支 `feat/p9-ux-titles`,BASE `7d5f338`)。零 server/deploy 改动。版本 0.22.0。
## 环境与惯例(全部已验证)
- 测试/run 命令在 `/root/.openclaw/workspace/coder/crearte-monorepo/crearte/src`(package.json 在此)。vitest:happy-dom 文件首行 `// @vitest-environment happy-dom`。mock 惯例见 `app/views/AuthorView.test.ts`(`vi.hoisted` + `vi.mock('@/data')` + 复用真实 `resolveUserSlug`)。
- e2e 主套件 baseURL `localhost:4173`(preview),helpers:`src/e2e/helpers.ts`(`openGame(page, slug)` 已存在)。跑 e2e 前先 `npm run build`(preview 吃 dist)。
- 路由表在 `app/router/index.ts`:`routes: RouteRecordRaw[]`(export 供测试)、`beforeEach(resolveNavigation)`、`scrollBehavior`。文件顶部 import 区加 afterEach 接线。
- 网格页(保持 cards 骨架):CatalogView.vue、LandingView.vue、AuthorView.vue。lines 转换页(六文件、八个实例,AdminView 有 3 处):GameView.vue:37、DocsView.vue:29、AdminView.vue:148/172/219、AdminUsersView.vue:113、AdminAuditView.vue:58、SubmitListView.vue:93。
## 任务 U1 — `app/lib/pageTitle.ts`(纯 builder + setter)
```ts
export const SITE = 'crearte 创艺'
export const DEFAULT_DESCRIPTION =
'crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。'
export function clip(s: string, max: number): string // Array.from 码点截断,超出加 '…'
export function joinTitle(...parts: Array<string | '' | null | undefined>): string
// 过滤空段后 ' · ' 连接;joinTitle() === SITE
export function gameTitle(name: string): string // joinTitle(name, SITE 去前缀逻辑见下)
```
**命名格式硬约束(spec D-A/D-B)**:所有页面标题 = `段1 · 段2 · crearte 创艺`;home = 恰 `crearte 创艺`。实现建议:`sectionTitleOf(name: unknown): string` 返回中文段名(19 个 name 全表见 spec §3.2;未名中→`''`),`setPageTitle(joinTitle(section))` 自动把 SITE 收尾。builder 清单:
- `gameTitle(name)` → `${name} · ${SITE}`
- `gameNotFoundTitle()` → `未找到的作品 · ${SITE}`
- `catalogTitle(q)` → q 非空:`搜索「${clip(q, 40)}」 · 作品 · ${SITE}`;空:`作品 · ${SITE}`
- `docsTitle(docTitle?)` → 有:`${docTitle} · 文档 · ${SITE}`;无:`文档 · ${SITE}`
- `authorTitle(display)` → `${display} · 创作者 · ${SITE}`
- `setPageTitle(t)`:`document.title = t`
- `setPageDescription(d)`:`document.querySelector('meta[name="description"]')` 缺则 create+append head;`content = clip(d || DEFAULT_DESCRIPTION, 120)`
新 `app/lib/pageTitle.test.ts`(happy-dom):joinTitle 空段/全空、clip 中英混+emoji 码点、每 builder 一钉、setter 写 title、meta 自动建+回落 DEFAULT、截断 120。
## 任务 U2 — router afterEach
`app/router/index.ts`:`import { joinTitle, setPageTitle, sectionTitleOf } from '@/lib/pageTitle'`;在 `beforeEach` 之后挂 `router.afterEach((to) => setPageTitle(joinTitle(sectionTitleOf(to.name))))`。
扩 `app/router/index.test.ts`(现文件用 `createMemoryHistory` + `makeRouter()`):push `/`→`document.title==='crearte 创艺'`;`/games`→`作品 · crearte 创艺`;`/account`→`我的账号 · crearte 创艺`;`/no-such-page`→`页面不存在 · crearte 创艺`。**注意**测试 router 是 makeRouter 重建,afterEach 挂在导出的 `router` 单例上——测试若用 `createRouter({routes})` 新实例则钩子不在。**解法**:把 afterEach 注册抽成导出函数 `attachTitleHook(r: Router)` 在 index.ts 底部对 `router` 调用,测试对 makeRouter 实例同样调用后再断言。
## 任务 U3 — 四数据页精化
- **GameView.vue** script 加:`watch(game, (g) => { if (notFound.value) { setPageTitle(gameNotFoundTitle()); setPageDescription('') } else if (g) { setPageTitle(gameTitle(g.name)); setPageDescription(g.description ?? '') } })`。loading/error(非 notFound)期不动(保持基线)。
- **CatalogView.vue**:`watch(() => state.value.q, (q) => setPageTitle(catalogTitle(q)), { immediate: true })`。
- **DocsView.vue**:`watch(doc, (d) => setPageTitle(docsTitle(d?.title)))`。
- **AuthorView.vue**:`watch(games, (list) => { const first = (list ?? [])[0]; setPageTitle(authorTitle(first ? authorDisplayName(first) : props.user)) })`(import `authorDisplayName` from `@/lib/labels`)。
测试:
- 扩 `GameView.test.ts`:现有 `minimalGame` 挂载 flush 后断言 `document.title` 为 `最小作品 · crearte 创艺`;给带 description 的 fixture 断言 meta content;notFound 腿(mock getGame reject NotFoundError)断言 `未找到的作品`。**每个 it 前重置 `document.title=''` 与 meta**(beforeEach 清)。
- 新建 `CatalogView.test.ts`、`DocsView.test.ts`:mock 惯例照 AuthorView.test.ts;Catalog mount+push `/games?q=2048`(makeRouter 需该路由)断言标题含 `搜索「2048」`;Docs mock listDocs/getDoc 断言 `文档` 基线与 doc.title 精化。
- 扩 `AuthorView.test.ts`:列表 [{author:{name:'笔锋'}}]→`笔锋 · 创作者 · crearte 创艺`;空列表→`@fixture · 创作者 · …`(props.user 兜底)。
## 任务 U4 — StatePanel variant + 六页 lines
`StatePanel.vue`:props 加 `variant?: 'cards' | 'lines'`(默认 cards)。cards 骨架 `v-for="n in 6"`(原 3);lines 分支:三个 `border-2 border-ink bg-surface shadow-hard p-4` 块内 `h-4 w-2/3` + `h-3 w-full` + `h-3 w-1/2`(bg-[#EFE9DA]、animate-skeleton),无 aspect-video。error/slot 段零改动。
六页八个实例全部加 `variant="lines"`(行号见头部清单;AdminView 三处都要)。
新 `StatePanel.test.ts`:默认 6 个 aspect-video 节点;lines 时 0 aspect-video、3 面板;error 腿 `role="alert"` 原样。
## 任务 U5 — index.html
head `theme-color` 行后加:`<meta name="description" content="DEFAULT_DESCRIPTION 同文(手抄一致,测试钉)" />`。
## 任务 U6 — e2e
`src/e2e/landing.spec.ts` **首条测试一字不动**(兼容回归钉)。新增一条:
```ts
test('路由级标题:作品页与目录搜索态', async ({ page }) => {
await page.goto('http://localhost:4173/games/fixture/2048')
await expect(page).toHaveTitle(/^2048 · crearte 创艺$/)
await page.goto('http://localhost:4173/games?q=2048')
await expect(page).toHaveTitle(/搜索「2048」 · 作品 · crearte 创艺$/)
})
```
game 页 fixture「2048」名称核对:`public/data/index.json`(e2e 用静态数据)里该条目 name 若不为 `2048`,以实际 name 为准改正则(实施时第一步先 `grep '"name"' src/public/data/games/fixture__2048.json | head -1` 取证)。
## 任务 U7 — CHANGELOG + 提交
`docs/CHANGELOG.md` 顶插 `## [0.22.0] - 2026-10-01`(Added 双语:路由级 document.title 两段式、作品页 meta description、StatePanel variant 骨架对齐;注明 P9 UX 第一批)。commit 拆两枚:`feat(ux): router-level document titles and game-page description` 与 `fix(ux): align loading skeletons with actual grids (StatePanel variants)`。分支 `feat/p9-ux-titles`,**不 merge master、不 push**(控制者验收后合)。
## 验收腿(控制者跑)
vitest 全绿(基线 512+新增 ~20)、`npx vue-tsc --noEmit`、`npm run build`、主 e2e(71 存量含 landing 首条不动 + 新增 1)、noauth 4。