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

114 lines
9.0 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 用户体验增强(第一批:浏览器反馈包)· 设计 spec
日期:2026-10-01 | 决策:owner UX 摸底三包(A 浏览器反馈 / B 效率可访问 / C 暗色模式)选 **A**;B、C 留后续批次。「继续增强该项目,从用户体验方面」为总纲。
## 0. 一句话
让浏览器标签页「说人话」:每个路由有自己的 `document.title`(异步数据到达后精化),作品详情页带 `meta description`;列表加载骨架与实际网格对齐、不再误导非网格页。
## 1. 现状勘查(grounded,2026-10-01)
- `document.title` 全站无人设置:任何页面浏览器标签恒为 `index.html:10` 的 `crearte 创艺`(`grep -rn "document.title" app/` 零命中)。
- `index.html` 无 `meta name=description`。
- `e2e/landing.spec.ts:6` 钉住 home 标题**恰为** `crearte 创艺` —— home 无后缀既是现状也是本设计的兼容约束。
- `StatePanel.vue` 骨架 `v-for="n in 3"`,网格消费页实际 `sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4`(CatalogView:70、LandingView:62、AuthorView:36);骨架的游戏卡形状还被 7 个非网格页复用(Docs、Admin、AdminUsers、AdminAudit、SubmitList、CreatorCenter、Game 详情),加载瞬间显示的是「不存在的卡片」。
- 数据面锚点:`GameView` 有 `game.name/description`(`data/types.ts` Game extends GameSummary,description 可选);`CatalogView` 经 `useFilterState` 持 `state.q`(route.query 派生,天然响应);`DocsView` 有 `doc?.title`;`AuthorView` 有 works 列表→`authorDisplayName()`(labels.ts:21)与 `props.user` 兜底。
- 路由 19 个 name(router/index.ts),其中 8 个纯静态(login/register/account/creator/outbound/admin*/submit*)无数据精化需求。
## 2. 决策记录
| # | 岔路 | 决定 | 理由 / 放弃项 |
|---|------|------|--------------|
| D-A | title 所有权 | **两段式**:router `afterEach` 按路由名设静态基线;4 个数据页(game/catalog/docs/author)watch 数据后精化覆盖 | 游戏名/文档名是异步数据,纯 `meta.title` 方案拿不到;纯 watch 方案则导航瞬间标签页还是旧题。放弃全站 meta 表驱动。 |
| D-B | home 标题 | 保持恰为 `crearte 创艺`(无后缀);其余页 `段 · crearte 创艺` | 不破坏 `landing.spec.ts:6` 既有钉桩与用户书签;也是品牌页的合理特例。 |
| D-C | description 范围 | **仅作品详情页**写 `game.description`(截 120 码点;缺省回落默认文案),离开该页恢复 index.html 默认 | SEO 增益主要落在可分享的作品页;全站 per-page description 属 YAGNI(SPA、无抓取压力)。 |
| D-D | 骨架策略 | cards 变体 **6 张**(3→6,覆盖 2/3/4 列断点各≥1.5 行);新增 `variant="lines"`(3 行横线、无封面纵横比结构)给 7 个非网格消费页 | 行数固定 6 而非按视口动态生成:列数已由 CSS 断点处理,JS 测视口属过度工程。放弃「所有页维持游戏卡」。 |
| D-E | 仓库面 | 纯 crearte 前端;server/deploy 零改动;版本 0.22.0 | 全部为客户端 DOM 写操作。 |
## 3. 设计
### 3.1 新文件 `app/lib/pageTitle.ts`(纯 builder + 两个 DOM setter)
```ts
export const SITE = 'crearte 创艺'
export const DEFAULT_DESCRIPTION =
'crearte 创艺——互动小说与浏览器小游戏托管社区,收录可直接游玩的作品目录。'
// joinTitle('作品') === '作品 · crearte 创艺';joinTitle() === 'crearte 创艺'(home/D-B)
export function joinTitle(...parts: Array<string | '' | undefined | null>): string
export function gameTitle(name: string): string // `${name} · SITE`
export function gameNotFoundTitle(): string // `未找到的作品 · SITE`
export function catalogTitle(q: string): string // q ? `搜索「${clip(q, 40)}」 · 作品 · SITE` : `作品 · SITE`
export function docsTitle(docTitle?: string): string // docTitle ? `${docTitle} · 文档 · SITE` : `文档 · SITE`
export function authorTitle(displayName: string): string // `${displayName} · 创作者 · SITE`
export function setPageTitle(t: string): void // document.title = t
export function setPageDescription(d: string): void // 找 meta[name=description](无则建入 head),content = clip(d || DEFAULT_DESCRIPTION, 120 码点)
```
`clip` 用 `Array.from` 码点截断加 `…`,防长名炸标签页。纯函数无 DOM 依赖,setter 只在 happy-dom/浏览器可达。
### 3.2 路由基线(同文件内 `sectionTitleOf`)
19 个 name 全表:home→`''`(经 joinTitle 得 D-B 整串);catalog/game→作品;docs/doc→文档;author→创作者;creator→创作者中心;login→登录;register→注册;account→我的账号;submit→我的投稿;submit-new→提交作品;submit-edit→编辑投稿;outbound→离开本站;admin→审核;admin-submission→投稿审核;admin-users→用户管理;admin-audit→审计日志;not-found→页面不存在。**未名中路由回退 `SITE`**(新增路由忘配时优雅降级)。
`router/index.ts` 挂:
```ts
router.afterEach((to) => {
setPageTitle(joinTitle(sectionTitleOf(to.name)))
})
```
### 3.3 数据页精化(watch,进路由基线之后覆盖)
- **GameView**:`watch(game)`:notFound 计算态→`gameNotFoundTitle()`;有数据→`gameTitle(name)` + `setPageDescription(description ?? '')`。loading/网络 error 期保持基线不闪。
- **CatalogView**:`watch(() => state.value.q)`→`catalogTitle(q)`。筛选变化即时反映(update() 走 router.replace,state 天然响应)。
- **DocsView**:`watch(doc)`→`docsTitle(doc?.title)`;`watch(docs)` 仅列表就绪且无 slug 时保持 `文档` 基线(路由 replace 到首篇后走 doc 精化)。
- **AuthorView**:`watch(games)`:取首个作品 `authorDisplayName()`,列表空/缺 author→`props.user` 兜底。
其余 15 页零改动,基线即终态。
### 3.4 StatePanel 骨架
```ts
defineProps<{ loading: boolean; error: Error | null; variant?: 'cards' | 'lines' }>()
```
- `cards`(默认,Catalog/Landing/Author 不改即得):`v-for="n in 6"`(3→6),其余结构原样。
- `variant="lines"`:border-2 surface 面板 ×3 行(h-4 w-2/3 + h-3 w-full + h-3 w-1/2 线组,`animate-skeleton`,无 aspect-video 封面块);GameView/DocsView/AdminView/AdminUsersView/AdminAuditView/SubmitListView 六页显式传 `variant="lines"`(StatePanel 消费页共九个:三个网格页保持 cards,此六页换 lines)。
- error/重试段零改动。
### 3.5 index.html
head 加 `<meta name="description" content="…默认文案…">`(`setPageDescription` 的回落锚,也让首屏无 JS 时爬虫可读)。
## 4. 错误处理与边界
- `game.description` 缺省/空串→DEFAULT;`q` 为纯空白→按空处理(parseFilterState 已 trim)。
- 路由 `to.name` 为 undefined(catch-all 之外的怪值)→ sectionTitleOf 返回 `''` → 恰 home 同款整串,可接受。
- afterEach 在守卫 redirect 后对最终路由触发(afterEach 收 to=重定向后目标),登录回跳 `?next=` 不影响标题正确性。
- 不设 `beforeunload` 还原——SPA 内 afterEach 天然逐页重置。
## 5. 测试计划
**vitest(happy-dom)**
- 新 `app/lib/pageTitle.test.ts`:joinTitle 空段/去重拼接、各 builder、clip(中英混/emoji 码点)、setter 写 document.title 与 meta(自动建 head 节点)、DEFAULT 回落。
- 扩 `app/router/index.test.ts`:`/`→`crearte 创艺` 整串;`/games`→`作品 · crearte 创艺`;`/account`→`我的账号 · crearte 创艺`;`/no-such`→`页面不存在 · crearte 创艺`。
- 扩 `GameView.test.ts`:加载后 `document.title === '最小作品 · crearte 创艺'` 且 meta description=作品描述(截断腿);notFound 腿标题为未找到款。
- 扩/新 `CatalogView` 腿:mount 后 push `/games?q=2048`→标题含 `搜索「2048」`。
- 新增 `StatePanel.test.ts`:默认渲染 6 个 aspect-video 卡;`variant="lines"` 零 aspect-video、3 个行块。
- 扩 `AuthorView.test.ts` 标题精化断言 1 条;新建 `CatalogView.test.ts` 与 `DocsView.test.ts`(两者现无测试文件,mock 惯例照抄 AuthorView.test.ts 的 vi.hoisted+vi.mock 模式),各钉标题精化 1 条(Catalog 另钉骨架 6 卡)。
**e2e(Playwright)**
- `landing.spec.ts` 首条**不动**(D-B 兼容回归即它的现有断言)。
- 新增 1 条到 `landing.spec.ts`:`/games/fixture/2048` → `toHaveTitle(/^2048 · crearte 创艺$/)`;`/games?q=2048` → `toHaveTitle(/搜索「2048」/)`。
- 实施时核对 `author-page.noauth.spec.ts` 与主套件有无标题假设(预期无)。
**验收腿**:vitest 全绿(基线 512+新增)、vue-tsc 零错、主 e2e(71+新增 1 条+1 skip)、noauth 4。
## 6. 版本与记账
crearte `0.22.0`:CHANGELOG 双语(Added:路由级标题/描述、骨架对齐;P9 第一批标注)。wrapper:本 spec + ROADMAP 加 P9 行(第一批完成、B/C 批次挂账)。server/deploy 零改动。