docs: P10 UX batch-1 booked — ROADMAP P10 row + spec/plan index entry, CHANGELOG 0.3.2 (crearte ee4edf7 / 0.23.0: toast system, dirty-form guard, clickable tags, copy-link, recently-played strip, header dropdown; five legs green vitest 601 / e2e 77+1skip / noauth 4; four review pins)

This commit is contained in:
2026-10-01 20:18:27 +08:00
parent 3eec67513c
commit 9226926ebb
4 changed files with 591 additions and 0 deletions
@@ -0,0 +1,206 @@
# P10 用户体验增强(第一批:日常交互包)· 设计 spec
日期:2026-10-01 | 决策链:owner 在 UX 摸底清单(第一/二/三梯队)后指令「按序实现」。第一批 = 第一梯队六项:脏表单守卫、标签可点、全局 toast、分享/复制链接、页头下拉外点关闭、最近玩过。后续批次(P9-B 可访问、OG/播放计数、暗色/创作者数据/目录分页)另行立项。
## 0. 一句话
把用户每天都会撞上的六个交互缺口一次补齐:投稿表单不再静默丢输入、标签变成可点的发现入口、全站有统一的操作反馈面(toast)、作品页能一键复制链接分享、页头菜单响应外点/Esc、落地页出现「继续游玩」。
## 1. 现状勘查(grounded,2026-10-01)
- **无脏表单守卫**:`SubmitFormView.vue`(502 行)grep 不到 `beforeunload` / `onBeforeRouteLeave` / dirty 跟踪。表单含 bundle/cover 上传、七键 CSP、hosted 三档;`save()` 成功路径 `await router.push('/submit')`(:295 附近)。已有回填抑制惯例:`suppressAadWatch`(:114-121)+ `loadExisting` 的 try/finally(:186-227)。
- **标签是死的**:`GameView.vue:88-95` 与 `GameCard.vue:70-79` 的 tags 均为纯 `<span>`;而目录过滤已支持 `?tag=` 逗号多值 AND(`filter.ts:20,26` `parseFilterState` / `toQuery`)。
- **无 toast 机制**:`components/ui/` 只有 Base 全家桶(Button/Checkbox/Input/Pagination/Select/Tabs/Textarea/FileInput);各页反馈是自带内联 `role="alert"` 段(AccountView:216、AdminUsersView:111、AdminView:137 等各写一套)。全站无 clipboard 使用。
- **页头下拉不响应外点/Esc**:`AppHeader.vue:79` 用 `<details ref="detailsRef">`,仅 `watch(route.fullPath)` 收起(:11-13);点页面其他地方或按 Esc 不关。
- **无「最近玩过」**:前端除 `lib/reactions` 外零 localStorage 消费;`GameHost.vue` 的 phase 机(booting/ready/degraded/error)是天然的「真玩过」信号点(`useGameFrame.ts:30`)。落地页结构:hero(:38-56)→ 精选(:58-72)→ 投稿与文档(:74-88),`FEATURED_LIMIT = 6`。
- **兼容钉桩**(一字不许动):
- `landing.spec.ts:6` home 标题恰为 `crearte 创艺`;`:60-68` 精选区 `a[href^="/games/"]` **toHaveCount(6)** 且逐 href 相等;`:148-156` 标题大纲层级(h1 crearte / h2 精选 / h3 2048 / h2 投稿与文档 / h3 ×2)。
- `merge-repo.spec.ts:42` 目录页 `a[href^="/games/"]` toHaveCount(20)。
- tag 链接 href 渲染为 `/games?tag=…`——`?` 不匹配 `^="/games/"` 前缀选择器,两处计数天然免疫(已核实)。
- e2e 对话框惯例:Playwright 无监听时自动 dismiss;`submit-flow.spec.ts:172`、`full-loop.spec.ts:64` 等已用 `page.once('dialog', d => d.accept())` 接 `save()` 里既有的「确认提交审核?」confirm。
- **localStorage key 惯例**:`crearte.auth.session.v1`(helpers.ts `SESSION_KEY`)。
- **样式 tokens**:`shadow-hard(-sm/-lg)`、`bg-paper/surface/ink/highlight/accent/success`、`btn-ink/btn-surface/lift`(main.css:4-25,63-77);header z-40、下拉 z-50、GameHost 全屏 z-50(GameHost.vue:79)。
- **版本记账惯例**:`package.json` version 恒 0.1.0 不动,版本以 CHANGELOG 为准(P9 = 0.22.0 → 本批 0.23.0)。
## 2. 决策记录
| # | 岔路 | 决定 | 理由 / 放弃项 |
|---|------|------|--------------|
| D-A | 脏守卫交互形态 | SPA 内离开用 `window.confirm`(`onBeforeRouteLeave` 返回其布尔值);浏览器级离开用 `beforeunload`(仅 dirty 时挂监听) | 与 `save()` 既有「确认提交审核?」confirm 同一形态,零新组件。放弃自绘弹层(B 批可访问时再统一升级不迟)。 |
| D-B | dirty 判定 | 深度 watch `[form, kind, bundle.upload_id, cover.upload_id]`;`loadExisting` 回填期抑制(与 `suppressAadWatch` 同窗);`prefill` **不**抑制(其触发前提——手输 workId/切 kind——本身已是用户修改);上传成功计入 dirty;`save()` 成功后 `dirty=false` + `suppressLeave=true` 再 push | 回填≠用户修改(P6 既有语义)。suppressLeave 不置位则 save-draft 的 `router.push('/submit')` 会被守卫拦下、Playwright 自动 dismiss 把 e2e 卡死——已核实 submit-flow/full-loop 全部依赖该 push。 |
| D-C | toast 架构 | 模块级单例 store(`useToast.ts` 导出 `toasts` ref + `push/success/error/info/dismiss`),`ToastHost.vue` 挂在 `App.vue`;不用 provide/inject | 任意组件(含非 setup 上下文的 lib)可直接 import 调用;测试可直接断言 store。放弃 mitt 等事件总线(零新依赖红线)。 |
| D-D | toast 行为参数 | 默认 3000ms、error 5000ms 自动消失;同屏最多 3 条(超限移除最旧);容器 `role="status"` `aria-live="polite"`,右下角 `fixed bottom-4 right-4 z-[70]` | z-[70] 压过 GameHost 全屏 z-50 与下拉 z-50。条数上限防刷屏。 |
| D-E | 复制失败兜底 | `navigator.clipboard.writeText` 失败 → error toast「复制失败,请手动复制地址栏链接」;不做 `execCommand` 降级 | localhost 与 prod https 都是 secure context,clipboard API 必可用;execCommand 已废弃。 |
| D-F | 复制链接的目标 | `location.origin + router.resolve({ name:'game', params:{user,slug} }).href`(规范站内路径),非 `location.href`(可能带 query/hash) | 分享出去的是干净规范链接。 |
| D-G | 标签链接目标 | `RouterLink :to="{ path:'/games', query:{ tag } }"` 单 tag;GameCard 内加 `relative z-10`(卡面有拉伸链接 `after:absolute inset-0`,GameCard.vue:49) | 点击=「看这个 tag 的所有作品」,从当前过滤态整体切换而非叠加(叠加语义留给目录侧栏)。「+N」折叠角标维持 span 不做链接。 |
| D-H | 最近玩过记录点 | `GameHost.vue` watch phase 到 `ready` 时 `recordPlay(game.id)`,**`{ flush: 'sync' }`**(T6 审查 re-pin:hosted 目标 restart 内 booting→ready 同 tick 折叠,默认 pre-flush 看不见变化,「重开重新冒泡」对 hosted 不成立;sync 下每步相位变化可见,`if (p==='ready')` 守卫保语义不变,GameHost.test.ts 专项钉桩);**external 作品不记**(不经 GameHost,在站外玩) | 「玩过」以站内运行真就绪为准,booting 误触不算。放弃 OutboundView 记录(拿不到可靠 game id)。 |
| D-I | 最近玩过存储 | key `crearte.recent.v1`,值 `[{id, at}]` JSON,cap 12、按 id 去重前插;落地页读取后将 id 对 `repo.listGames()` 结果解析成卡(解析不到的静默丢弃),展示上限 6(对齐 FEATURED_LIMIT 网格) | 只存 id+时间戳不存快照:作品改名/换封面/下架自动跟随或消失,无陈旧数据。解析失败(下架)不显示即诚实。 |
| D-J | 落地页条带位置与钉桩 | hero 与精选之间新增 `<section v-if="recent.length" data-testid="recent-strip">`,h2「继续游玩 · RECENTLY PLAYED」沿用精选 h2 的 font-mono 样式;卡 `heading-level=3` | 无记录时整段不渲染 → landing.spec 的 6 卡计数、标题大纲、精选 href 列表全部零触碰(Playwright 每测试全新 context,localStorage 天然干净)。 |
| D-K | 下拉关闭机制 | `<details>` 保留,加 `@toggle` 同步 `menuOpen` ref;document 级 `pointerdown`(外点关)+ `keydown`(Esc 关并把焦点还给 summary)监听,`onMounted` 挂 / `onBeforeUnmount` 卸 | 不换组件库、不动既有 route.fullPath 收起逻辑。Esc 还焦是可访问性基线(B 批深化)。 |
| D-L | 仓库面 | 纯 crearte 前端;server/deploy 零改动;CHANGELOG 0.23.0(package.json version 不动) | 全部为客户端行为。 |
## 3. 设计
### 3.1 toast 基座(新文件)
`app/composables/useToast.ts`:
```ts
export type ToastKind = 'success' | 'error' | 'info'
export interface ToastItem { id: number; kind: ToastKind; text: string }
export const MAX_VISIBLE = 3
export const DURATIONS: Record<ToastKind, number> = { success: 3000, error: 5000, info: 3000 }
// 模块级 store:toasts ref + push(kind, text, duration?) / success(text) / error(text) / info(text) / dismiss(id)
// push 超限移除最旧;setTimeout 自动 dismiss;返回 toast id
export function useToast(): { toasts: Ref<ToastItem[]>; push; success; error; info; dismiss }
```
`app/components/ToastHost.vue`:`<Teleport to="body">` 可选(App 内直挂即可,不 Teleport——测试友好);容器 `data-testid="toast-host"` `role="status"` `aria-live="polite"` `class="pointer-events-none fixed bottom-4 right-4 z-[70] flex w-72 flex-col gap-2"`;每条 `data-testid="toast"` `pointer-events-auto border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard`,kind 配色:success `bg-success text-paper`、error `bg-accent text-paper`、info `bg-highlight text-ink`;右侧关闭按钮 `aria-label="关闭提示"`(PhX 图标)。进出场过渡用 Vue `<TransitionGroup>`(name="toast",CSS 位移+透明,尊重既有 `prefers-reduced-motion` 全局分支)。
`App.vue`:`<AppFooter />` 之后挂 `<ToastHost />`。
### 3.2 脏表单守卫(SubmitFormView.vue)
```ts
const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'
const dirty = ref(false)
let suppressLeave = false
onBeforeRouteLeave(() => {
if (suppressLeave || !dirty.value) return true
return window.confirm(GUARD_COPY)
})
watch([form, kind, () => bundle.value?.upload_id, () => cover.value?.upload_id],
() => { if (!suppressDirty) dirty.value = true }, { deep: true })
// loadExisting:suppressAadWatch 置位处同时置 suppressDirty=true;finally 同窗释放;
// 释放后 dirty.value=false(回填完成=干净基线)
// bundle/cover 上传成功路径(onBundleFile/onCoverFile try 尾)无需额外代码——upload_id watch 自动触发
// save() 成功:dirty.value=false; suppressLeave=true; await router.push('/submit')
// beforeunload:watch(dirty) 挂/卸 window 'beforeunload'(handler: e.preventDefault(); e.returnValue='');
// onBeforeUnmount 兜底卸载
```
模板层无可见变化(原生对话框)。
### 3.3 标签可点(GameView.vue + GameCard.vue)
GameView tags span → RouterLink:
```html
<RouterLink v-for="tag in game.tags" :key="tag" data-testid="tag-link"
:to="{ path: '/games', query: { tag } }"
class="border-[1.5px] border-ink bg-surface px-2 py-0.5 font-mono text-[0.6875rem] hover:bg-highlight"
>{{ tag }}</RouterLink>
```
GameCard visibleTags span → RouterLink,同款样式 + `relative z-10`(拉伸链接之上,与作者链接 :54-58 同法);`+N` 角标维持 span。
### 3.4 分享/复制链接(GameView.vue)
meta 段(作者/时长/收录行)之后、description 之前插一行:
```html
<button type="button" data-testid="copy-link" class="btn-surface lift inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold"
@click="copyLink"><PhLinkSimple :size="14" weight="bold" aria-hidden="true" />复制链接</button>
```
```ts
async function copyLink(): Promise<void> {
const url = location.origin + router.resolve({ name: 'game', params: { user: props.user, slug: props.slug } }).href
try { await navigator.clipboard.writeText(url); toast.success('链接已复制') }
catch { toast.error('复制失败,请手动复制地址栏链接') }
}
```
external/virtual/hosted 三档都显示(分享的是站内作品页,与 runtime 无关)。notFound 分支不渲染。
### 3.5 页头下拉(AppHeader.vue)
```ts
const menuOpen = ref(false)
const summaryRef = ref<HTMLElement | null>(null)
function onPointerDown(e: PointerEvent): void {
if (menuOpen.value && detailsRef.value && !detailsRef.value.contains(e.target as Node)) detailsRef.value.open = false
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape' && menuOpen.value && detailsRef.value) { detailsRef.value.open = false; summaryRef.value?.focus() }
}
// onMounted 挂 document 两监听,onBeforeUnmount 卸;<details @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">;
// <summary ref="summaryRef">;既有 route.fullPath watch 保留
```
### 3.6 最近玩过(lib/recent.ts + GameHost.vue + LandingView.vue)
`app/lib/recent.ts`(纯函数 + try/catch 包裹的 storage 读写):
```ts
const KEY = 'crearte.recent.v1'
const MAX_RECENT = 12
export interface RecentEntry { id: string; at: string } // at = new Date().toISOString()
export function listRecent(): string[] // 新→旧 id 列;解析失败/无存储 → []
export function recordPlay(id: string): void // 去重前插、截断 MAX_RECENT、写失败静默
```
GameHost.vue:`watch(() => frame.state.value.phase, (p) => { if (p === 'ready') recordPlay(props.game.id) })`(import 走 `../../app/lib/recent`,与 GameView import GameHost 的跨层惯例对称)。
LandingView.vue:
```ts
const RECENT_LIMIT = 6
const recentIds = listRecent() // setup 时读一次;路由重挂载天然刷新
const recent = computed(() => {
const byId = new Map((games.value ?? []).map((g) => [g.id, g]))
return recentIds.map((id) => byId.get(id)).filter((g): g is GameSummary => Boolean(g)).slice(0, RECENT_LIMIT)
})
```
模板:hero `</section>` 与精选 `<section class="mt-8">` 之间:
```html
<section v-if="recent.length" class="mt-8" data-testid="recent-strip">
<h2 class="font-mono text-[0.6875rem] font-bold tracking-[0.08em]">继续游玩 · RECENTLY PLAYED</h2>
<div class="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<GameCard v-for="game in recent" :key="game.id" :game="game" :heading-level="3" />
</div>
</section>
```
## 4. 错误处理与边界
- localStorage 不可用(隐私模式/配额满):`recordPlay` try/catch 静默;`listRecent` 损坏 JSON → `[]`。条带随之不渲染,无用户可见错误。
- clipboard 拒绝权限/非 secure context:走 catch → error toast(D-E)。
- toast 计时器:组件卸载不悬空——dismiss 时 clearTimeout;store 模块级长存(App 生命周期=页面生命周期,可接受)。
- 守卫与 `readOnly`(pending/approved 详情态):readOnly 表单不可编辑 → dirty 永不置位 → 守卫自然静默;`suppressLeave` 亦覆盖 save 成功路径。
- 下拉:菜单项点击→路由变化→既有 watch 收起,pointerdown 先行关闭也不冲突(close 幂等)。
- external 作品无 GameHost → 不进最近玩过(D-H 有意为之)。
- 最近玩过 id 指向已下架作品 → 解析丢弃(D-I)。
## 5. 测试计划
**vitest(happy-dom,基线 551 全绿不回退)**
- 新 `useToast.test.ts`:push/success/error/info 形状、自动消失(`vi.useFakeTimers` 按 kind 时长)、超 3 条移最旧、dismiss 清计时器、id 递增。
- 新 `ToastHost.test.ts`:三条 kind 渲染与配色类、`aria-live="polite"` `role="status"`、关闭按钮触发 dismiss、空 store 零渲染。
- 扩 `SubmitFormView.test.ts`:①用户改 name → dirty(经 `onBeforeRouteLeave` 行为断言:mock `window.confirm` 返 false → 导航被拒;返 true → 放行)②`loadExisting` 回填不 dirty ③save 成功 push 不触发 confirm(suppressLeave)④dirty 时 beforeunload 监听已挂、干净时未挂(`window.addEventListener` spy 或 dispatchEvent 验 preventDefault)⑤上传成功置 dirty。
- 扩 `GameView.test.ts`:tag 链接 href=`/games?tag=<t>`、copy-link 按钮存在且点击后 clipboard.writeText 收到 `http://localhost/games/fixture/minimal` 形规范 URL(mock navigator.clipboard)、成功→toast store 有「链接已复制」、reject→error toast;notFound 分支无 copy-link。
- 扩 `GameCard.test.ts`:visibleTags 渲染为 `a[href^="/games?tag="]` 且带 `relative z-10`;`+N` 仍为 span;无 tag 零链接。
- 新 `AppHeader.test.ts`:open 后 document 外 pointerdown → closed;Esc → closed 且焦点回 summary;菜单内点击不关(contains 分支);路由变化收起(既有行为回归)。
- 新 `recent.test.ts`:recordPlay 前插去重截 12、listRecent 损坏 JSON → []、空存储 → []、写异常静默(mock localStorage throw)。
- 扩 `GameHost.test.ts`:phase → ready 触发 recordPlay(mock lib/recent),booting/error 不触发,restart 再 ready 再记录。
- 扩/新 `LandingView.test.ts`(如无则新建,mock 惯例照抄 AuthorView.test.ts):有记录且可解析 → `data-testid="recent-strip"` 渲染、上限 6、顺序新→旧;记录指向不存在 id → 不渲染该卡;零记录 → 整段不渲染。
**e2e(Playwright)**
- `landing.spec.ts` **既有全部一字不动**(D-J 保证:新 context 无 localStorage → 条带不渲染)。
- 扩 `submit-flow.spec.ts` 一条「脏表单守卫」:seedSession + installSubmissionApi → goto /submit/new → fill 展示名称 → 点页头「作品」链接 →(无监听自动 dismiss)URL 仍 /submit/new → `page.once('dialog', accept)` 再点 → URL /games → 返回 /submit/new(此时干净,goto 无守卫)确认填表后 save-draft 仍直通 /submit(suppressLeave 回归)。
- 新 `e2e/ux.spec.ts`(主套件,4173)四条:
① tag 点击:goto /games/fixture/2048 → 记录首个 tag 文本 → 点击 → URL `/games?tag=<t>` 且结果计数 ≥1;
② 复制链接:`context.grantPermissions(['clipboard-read','clipboard-write'])` → goto 作品页 → click `[data-testid=copy-link]` → `[data-testid=toast]` 含「链接已复制」→ `navigator.clipboard.readText()` 等于 `http://localhost:4173/games/fixture/2048`;
③ 下拉:seedSession → click summary(菜单开)→ Escape → 菜单关;再开 → 点 main 区域 → 菜单关;
④ 最近玩过:`openGame(page, '2048')`(helpers 既有,data-ready 即 phase ready)→ goto / → `[data-testid=recent-strip]` 可见且含 href `/games/fixture/2048` 的卡;再 openGame 'a-dark-room' → goto / → 2048 卡序在 a-dark-room 之后。
- noauth 套件:零新增零改动(条带纯本地、无 auth 依赖;noauth 4 条基线不动)。
**验收腿(控制者跑,全绿才合并)**:`npm test`(vitest ≥551+新增全过)、`npm run typecheck` 零错、`npm run build` OK、`npm run e2e`(72+1skip 基线 + 新增全过)、`npm run e2e:noauth` 4。
## 6. 版本与记账
crearte CHANGELOG `0.23.0`(Added:toast 基座/标签可点/复制链接/最近玩过;Fixed 或 Added:脏表单守卫/下拉外点关闭——按 Keep a Changelog 归类,守卫与下拉归 Added「交互护栏」口径亦可,实施时统一为 Added + Fixed 两段);`package.json` version 不动(既有惯例)。wrapper:本 spec + `docs/plans/2026-10-01-p10-ux-daily-interaction.md` + ROADMAP 新增 P10 行与索引登记。server/deploy 零改动。分支 `feat/p10-ux-daily`,合并 `--no-ff` 后删分支。