7.4 KiB
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)
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 = tsetPageDescription(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)) })(importauthorDisplayNamefrom@/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 首条测试一字不动(兼容回归钉)。新增一条:
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。