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

9.0 KiB
Raw Blame History

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)

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 挂:

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 骨架

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 零改动。