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
+7
View File
@@ -6,6 +6,13 @@ All notable changes to this repository should be documented in this file.
The format loosely follows Keep a Changelog and can be adapted to the team's habits. The format loosely follows Keep a Changelog and can be adapted to the team's habits.
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。 本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
## [0.3.2] - 2026-10-01
### Done / 完成
- P10 UX batch-1 (daily-interaction pack) delivered: crearte `ee4edf7` (0.23.0, merge-base `26cd625`) — six user-facing capabilities across six tasks, each through implement → task-level review → adjudication, plus a whole-branch final review cross-checking spec §3/§4/§5 in full. **Toast system** (`composables/useToast.ts` module singleton: success/info 3s, error 5s, max 3 visible oldest-dropped, silent quota/private-mode failures; `ToastHost.vue` mounted in `App.vue`, bottom-right `role=status`/`aria-live=polite`, per-kind hard-shadow styling, TransitionGroup folded into the global reduced-motion branch). **Dirty-form leave guard** on `/submit/new` and `/submit/:id` (`onBeforeRouteLeave` confirm + `beforeunload`; deep-watch of form/kind/both upload ids; edit-mode prefill suppressed in the same window as the existing AAD watcher, manual prefill deliberately counts as an edit; save-success disarms before its `router.push`). **Clickable tags** (game-page + game-card tags → `RouterLink` to `/games?tag=`, card tags keep `relative z-10` above the stretched link, `+N` fold badge stays a span). **Copy-link share** (game detail page writes the canonical in-site URL — `location.origin + router.resolve(...)`, never `location.href` — to clipboard, confirmed via toast, single try/catch also absorbing a missing `navigator.clipboard`). **Recently played** (`lib/recent.ts` local `crearte.recent.v1`: dedup-to-front, cap 12, corrupt JSON reads empty; `GameHost` records on frame phase `ready` via a sync-flush watch so a hosted-target restart that folds booting→ready in one tick still re-bubbles; external works never recorded; landing page renders a 继续游玩 strip between hero and featured, resolving ids against the live catalog, capping at 6, and rendering nothing at all when history is empty so the landing DOM is unchanged for first-time visitors). **Header dropdown** now closes on outside-click and Escape (returning focus to the summary toggle), preserving the existing route-change auto-close. Zero server/deploy changes. Acceptance (five legs, green on merge tip): vitest 601 (baseline 551) / vue-tsc clean / build + build-runtime OK / main e2e 77+1skip (baseline 72+1skip) / noauth 4. Four review pins landed on the branch: `fc0270e` (AppHeader unmount cleanup asserted via removeEventListener spy), `dbf4bf0` (hosted restart re-records via sync-flush phase watch; spec D-H re-pinned), `bb9bb35` (e2e acceptance fixes — the spec's own test plan had picked external-runtime fixtures 2048/a-dark-room that never reach GameHost ready; switched to abs-paths/storage, plus a latent optional-fields pre-navigation race pinned by h1 level), `836b2fb` (AppHeader route-change collapse regression, a spec §5 listed item). Final review verdict 「需修复后合并」 — both closing conditions met before merge. B (keyboard/skip-link/mobile header), C (dark mode), OG social cards, play-count and later batches parked pending owner go-ahead.
- P10 UX 第一批(日常交互包)交付:crearte `ee4edf7`(0.23.0,merge-base `26cd625`)——六任务落地六个用户可感能力,每任务经实现→任务级审查→裁定,另加全分支终审对 spec §3/§4/§5 全量对照。**Toast 体系**(`composables/useToast.ts` 模块单例:success/info 3s、error 5s、同屏上限 3 条超限移最旧、配额/隐私模式写失败静默;`ToastHost.vue` 挂入 `App.vue`,右下角 `role=status`/`aria-live=polite`、按 kind 配色硬阴影、TransitionGroup 折入全局 reduced-motion 分支)。**投稿表单脏数据离开守卫**(`/submit/new` 与 `/submit/:id`:`onBeforeRouteLeave` confirm + `beforeunload`;深度观察 form/kind/两个 upload id;编辑回填与既有 AAD watcher 同窗抑制、手动 prefill 有意计为修改;save 成功先解除守卫再 `router.push`)。**标签可点**(作品页+作品卡 tags → `/games?tag=` 的 `RouterLink`,卡内标签保留 `relative z-10` 浮于拉伸链接之上,`+N` 折叠角标维持 span)。**复制链接分享**(作品详情页写入规范站内 URL——`location.origin + router.resolve(...)`,绝不用 `location.href`——经 toast 确认,单一 try/catch 同时兜住 `navigator.clipboard` 缺失)。**最近玩过**(`lib/recent.ts` 本地 `crearte.recent.v1`:去重前插、上限 12、损坏 JSON 读回空;`GameHost` 在 frame phase `ready` 时经 sync-flush watch 记录,hosted 目标重开同 tick 折叠的 booting→ready 仍会重新冒泡;external 作品不记;落地页在 hero 与精选之间渲染「继续游玩」条带,id 对实时目录解析、上限 6、无记录时整段不渲染使首访者落地页 DOM 零变化)。**页头下拉**现支持外点与 Esc 关闭(焦点回 summary),保留既有路由变化自动收起。零后端/部署改动。验收(五腿,合并 tip 全绿):vitest 601(基线 551)/ vue-tsc 零错 / build+build-runtime OK / 主 e2e 77+1skip(基线 72+1skip)/ noauth 4。分支上落四处审查补钉:`fc0270e`(AppHeader 卸载清理经 removeEventListener spy 钉桩)、`dbf4bf0`(hosted 重开经 sync-flush phase watch 再记;spec D-H re-pin)、`bb9bb35`(e2e 验收修正——spec 测试计划自身误选 external-runtime 夹具 2048/a-dark-room 永达不到 GameHost ready,换 abs-paths/storage,另钉住 optional-fields 一处潜伏的导航前竞态)、`836b2fb`(AppHeader 路由变化收起回归,spec §5 明列项)。终审裁定「需修复后合并」——两个闭合条件均在合并前满足。B(键盘/skip-link/移动端页头)、C(暗色模式)、OG 社交卡片、播放计数及后续批挂账待 owner 启动。
## [0.3.0] - 2026-10-01 ## [0.3.0] - 2026-10-01
### Done / 完成 ### Done / 完成
+2
View File
@@ -35,6 +35,7 @@
| P7 | 管理后台增强 | 用户列表 / 角色管理 UI(替代 CLI `user set-role`)+ 审计日志 | **完成**(server `ca24805` 0.12.0 / crearte `900f13a` 0.18.0,2026-09-30 合并推送。audit_log 表 0010 + 组中间件统一记录(owner 定稿:admin 路由收敛为组,Full-Path 模板入史,GET 也记,失败尝试照记,未遂 401/403 不记);用户列表/PATCH 角色/审计查询三端点;唯一 admin 降级 409 护栏 CLI/API 双覆盖;前端 /admin/users + /admin/audit 两页,降级确认双要点文案;本机六腿验收全绿:容器四连+fresh-DB `-count=1` 10 ok 零 FAIL 零 skip、废 token 端到端 401、护栏矩阵(非唯一降 OK/唯一降 CLI 拒+API 409)、审计流水含 409 失败尝试、真栈 full-loop 分支后端 1 passed×2、vitest 488/typecheck/admin-flow 6/主套件 68) | | P7 | 管理后台增强 | 用户列表 / 角色管理 UI(替代 CLI `user set-role`)+ 审计日志 | **完成**(server `ca24805` 0.12.0 / crearte `900f13a` 0.18.0,2026-09-30 合并推送。audit_log 表 0010 + 组中间件统一记录(owner 定稿:admin 路由收敛为组,Full-Path 模板入史,GET 也记,失败尝试照记,未遂 401/403 不记);用户列表/PATCH 角色/审计查询三端点;唯一 admin 降级 409 护栏 CLI/API 双覆盖;前端 /admin/users + /admin/audit 两页,降级确认双要点文案;本机六腿验收全绿:容器四连+fresh-DB `-count=1` 10 ok 零 FAIL 零 skip、废 token 端到端 401、护栏矩阵(非唯一降 OK/唯一降 CLI 拒+API 409)、审计流水含 409 失败尝试、真栈 full-loop 分支后端 1 passed×2、vitest 488/typecheck/admin-flow 6/主套件 68) |
| P8 | 长尾打包 | 权限开关 UI 全量(`inlineStyle/wasm/coop/fullscreen/gamepad`)、后端 triage 小项(`games.Detail` 400 细分、admin 路由 slug 校验、approve 同名竞态测试)、账号注销、静态兜底目录、CHANGELOG 模板文案 | **第一批完成**(②⑤①,2026-10-01 合并推送:server `921443a` 0.14.0 / crearte `70ba10c` 0.20.0。容器四连+空库 `-count=1` 集成全绿(新竞态测试含,skip 守卫 0)+`-race` 竞态腿 PASS;curl 冒烟全中:detail 400 细分 user:/slug: 且双非法 user 优先、admin 四路非法 400 `invalid work id`、合法格式不存在仍 404;FE vitest 499/typecheck/admin-flow 7/主套件 70+1skip;双审 PASS(server PASS with notes—唯一 note 双非法优先级缺钉桩,已以 `ee5b960` 测试断言补上);deploy 零改动。**第二批完成(③账号注销+④静态兜底目录)**(2026-10-01 合并推送:server `12b8ffb` 0.15.0(迁移 0011 墓碑化删行、DELETE /api/auth/account 对密码注销、唯一 admin 409、user delete CLI、catalog export 出 schemaVersion=2 目录)+ crearte `7d5f338` 0.21.0(/account 危险区+deleteAccount 客户端+AdminUsersView 三码中文化钉桩)+。真库注销链集成+分支真产物 curl 冒烟 14 项全 PASS(墓碑行/邮箱复用/409/旧 token 作废/CLI delete/export schema);FE vitest 512/typecheck/主套件 71+1skip/noauth 4,双审 PASS with notes(前端 2 条次要已钉桩 `7a54152`,server 裁决项:LOG_LEVEL 大小写不一致—deferred)。余 ④的 render 消费端接入按需(feed 已交付)**,CHANGELOG 头部去模板腔两仓已随第一批完成、wrapper 头部不在 spec 范围 | | P8 | 长尾打包 | 权限开关 UI 全量(`inlineStyle/wasm/coop/fullscreen/gamepad`)、后端 triage 小项(`games.Detail` 400 细分、admin 路由 slug 校验、approve 同名竞态测试)、账号注销、静态兜底目录、CHANGELOG 模板文案 | **第一批完成**(②⑤①,2026-10-01 合并推送:server `921443a` 0.14.0 / crearte `70ba10c` 0.20.0。容器四连+空库 `-count=1` 集成全绿(新竞态测试含,skip 守卫 0)+`-race` 竞态腿 PASS;curl 冒烟全中:detail 400 细分 user:/slug: 且双非法 user 优先、admin 四路非法 400 `invalid work id`、合法格式不存在仍 404;FE vitest 499/typecheck/admin-flow 7/主套件 70+1skip;双审 PASS(server PASS with notes—唯一 note 双非法优先级缺钉桩,已以 `ee5b960` 测试断言补上);deploy 零改动。**第二批完成(③账号注销+④静态兜底目录)**(2026-10-01 合并推送:server `12b8ffb` 0.15.0(迁移 0011 墓碑化删行、DELETE /api/auth/account 对密码注销、唯一 admin 409、user delete CLI、catalog export 出 schemaVersion=2 目录)+ crearte `7d5f338` 0.21.0(/account 危险区+deleteAccount 客户端+AdminUsersView 三码中文化钉桩)+。真库注销链集成+分支真产物 curl 冒烟 14 项全 PASS(墓碑行/邮箱复用/409/旧 token 作废/CLI delete/export schema);FE vitest 512/typecheck/主套件 71+1skip/noauth 4,双审 PASS with notes(前端 2 条次要已钉桩 `7a54152`,server 裁决项:LOG_LEVEL 大小写不一致—deferred)。余 ④的 render 消费端接入按需(feed 已交付)**,CHANGELOG 头部去模板腔两仓已随第一批完成、wrapper 头部不在 spec 范围 |
| P9 | UX 增强(第一批:浏览器反馈包) | 路由级 `document.title` 两段式(router `afterEach` 按 19 个 name 全表设静态基线 + 四个数据页 watch 数据后精化)+ 作品页 `meta description`(截 120 码点、默认回落)+ `StatePanel` 骨架对齐(cards 3→6 张、新增 `lines` 变体六页八实例)。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `26cd625` 0.22.0(`a196603`+`3f1de38`+审查补钉 `e9e4c56`)。五腿全绿:vitest **551**(基线 512+新增 39)/ vue-tsc 零错 / build OK / 主套件 72+1skip(含新增标题 e2e 腿,`landing.spec.ts` 首条兼容钉一字未动)/ noauth 4。独立审查 PASS with notes:ISSUE-1(离开作品页 description 未回落默认,spec D-C 子项)与 ISSUE-2(「七页」实为六页文案)均已在合主前补钉+补测。B 效率可访问 / C 暗色模式两批挂账待启动) | | P9 | UX 增强(第一批:浏览器反馈包) | 路由级 `document.title` 两段式(router `afterEach` 按 19 个 name 全表设静态基线 + 四个数据页 watch 数据后精化)+ 作品页 `meta description`(截 120 码点、默认回落)+ `StatePanel` 骨架对齐(cards 3→6 张、新增 `lines` 变体六页八实例)。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `26cd625` 0.22.0(`a196603`+`3f1de38`+审查补钉 `e9e4c56`)。五腿全绿:vitest **551**(基线 512+新增 39)/ vue-tsc 零错 / build OK / 主套件 72+1skip(含新增标题 e2e 腿,`landing.spec.ts` 首条兼容钉一字未动)/ noauth 4。独立审查 PASS with notes:ISSUE-1(离开作品页 description 未回落默认,spec D-C 子项)与 ISSUE-2(「七页」实为六页文案)均已在合主前补钉+补测。B 效率可访问 / C 暗色模式两批挂账待启动) |
| P10 | UX 增强(第一批:日常交互包) | toast 体系(useToast 单例 store + ToastHost 挂 App.vue)+ 投稿表单脏数据离开守卫(onBeforeRouteLeave confirm + beforeunload)+ 作品标签可点(GameView/GameCard tags → /games?tag= RouterLink)+ 作品页复制链接分享(clipboard + toast)+ 最近玩过条带(recent.ts localStorage + GameHost ready 记录 + LandingView 条带)+ 页头下拉外点/Esc 关闭。零后端/部署改动 | **第一批完成**(2026-10-01 合并推送:crearte `ee4edf7` 0.23.0,merge-base `26cd625`。六任务各经实现→任务级审查→裁定,另加全分支终审(spec §3/§4/§5 全量对照)。五腿全绿:vitest **601**(基线 551+新增 50)/ vue-tsc 零错 / build OK / 主套件 **77+1skip**(基线 72+1skip)/ noauth 4。审查补钉四处:`fc0270e`(AppHeader 卸载清理 spy)、`dbf4bf0`(hosted 重开 sync-flush 再记,spec D-H re-pin)、`bb9bb35`(e2e 夹具修正——spec 测试计划自身误选 external-runtime 夹具 2048/a-dark-room,换 abs-paths/storage)、`836b2fb`(AppHeader 路由收起回归,spec §5 明列项)。终审裁定「需修复后合并」两条件均已闭合。B 效率可访问 / C 暗色模式 / OG 卡片 / 播放计数等后续批挂账待启动) |
## 排序理由 ## 排序理由
@@ -62,3 +63,4 @@
| P8 长尾打包(第一批:②⑤①) | `docs/specs/2026-10-01-p8-long-tail-design.md` | `docs/plans/2026-10-01-p8-long-tail.md`(第一批已执行,2026-10-01) | | P8 长尾打包(第一批:②⑤①) | `docs/specs/2026-10-01-p8-long-tail-design.md` | `docs/plans/2026-10-01-p8-long-tail.md`(第一批已执行,2026-10-01) |
| P8 长尾打包(第二批:③④) | `docs/specs/2026-10-01-p8b2-deletion-catalog-design.md` | `docs/plans/2026-10-01-p8b2-account-catalog.md`(已执行,2026-10-01:server 0.15.0 / crearte 0.21.0) | | P8 长尾打包(第二批:③④) | `docs/specs/2026-10-01-p8b2-deletion-catalog-design.md` | `docs/plans/2026-10-01-p8b2-account-catalog.md`(已执行,2026-10-01:server 0.15.0 / crearte 0.21.0) |
| P9 UX 第一批(浏览器反馈包) | `docs/specs/2026-10-01-p9-ux-browser-feedback-design.md` | `docs/plans/2026-10-01-p9-ux-browser-feedback.md`(第一批已执行,2026-10-01:crearte 0.22.0) | | P9 UX 第一批(浏览器反馈包) | `docs/specs/2026-10-01-p9-ux-browser-feedback-design.md` | `docs/plans/2026-10-01-p9-ux-browser-feedback.md`(第一批已执行,2026-10-01:crearte 0.22.0) |
| P10 UX 第一批(日常交互包) | `docs/specs/2026-10-01-p10-ux-daily-interaction-design.md` | `docs/plans/2026-10-01-p10-ux-daily-interaction.md`(第一批已执行,2026-10-01:crearte 0.23.0) |
@@ -0,0 +1,376 @@
# P10 UX 第一批(日常交互包)· 实现计划
spec:`docs/specs/2026-10-01-p10-ux-daily-interaction-design.md`(决策 D-A…D-L 以其为准;本计划与 spec 冲突时 spec 赢)。
仓库:**仅 crearte**(`crearte-monorepo/crearte`),分支 `feat/p10-ux-daily`。server/deploy 零改动。
npm 根:`crearte/src`(package.json 在此)。命令一律 `cd crearte/src` 后跑。
## 全局约束(逐字,进每个任务简报)
1. 零新依赖:不得改 `package.json` dependencies/devDependencies;version 字段恒 `0.1.0` 不动。
2. 禁触文件/行:`e2e/landing.spec.ts` 既有全部用例、`e2e/merge-repo.spec.ts`、`e2e/noauth.spec.ts`、`playwright.config.ts`、`vitest.config.ts`、`vite.config.ts`、`runtime/` 下除 `GameHost.vue` 外一切文件(D-H 唯一例外)、`scripts/`。
3. 样式语言:新粗野主义既有 tokens(`border-2 border-ink`、`shadow-hard*`、`bg-paper/surface/ink/highlight/accent/success`、`btn-ink/btn-surface/lift`、`font-mono text-[0.6875rem]`);注释中文、标识符英文(仓内惯例)。
4. TDD:先写/改 vitest 再实现;e2e 用例随所属任务同 commit。
5. 每任务独立 commit(message 用 `feat(ux):`/`fix(ux):` 前缀 + 任务号);**CHANGELOG 不由任务写**——控制者在全部任务后统一补 0.23.0 条目。
6. 验收命令(每任务收尾自跑并贴输出摘要):`npm test -- <相关测试文件>`(改动面)+ `npm run typecheck`。全量 `npm test`/`npm run build`/`npm run e2e` 由控制者在波次末跑。
7. 分支纪律:commit 前 `git branch --show-current` 必须是 `feat/p10-ux-daily`;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。
8. vitest 基线 551 全绿不回退;e2e 主套件基线 72+1skip 不回退。
9. `data-testid` 命名精确使用 spec 给定值:`toast-host`、`toast`、`copy-link`、`tag-link`、`recent-strip`。
10. 文案精确逐字(spec §3):守卫 `表单尚未保存,确定离开吗?未保存的修改将丢失。`;复制成功 `链接已复制`;复制失败 `复制失败,请手动复制地址栏链接`;toast 关闭按钮 `aria-label="关闭提示"`;条带标题 `继续游玩 · RECENTLY PLAYED`;toast 参数 success/info 3000ms、error 5000ms、上限 3 条。
---
### Task 1: toast 基座(useToast + ToastHost + App.vue)
**新文件 `app/composables/useToast.ts`**(模块级单例 store,spec §3.1 + D-C/D-D):
```ts
import { ref, type Ref } from 'vue'
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 }
const toasts: Ref<ToastItem[]> = ref([])
let nextId = 1
const timers = new Map<number, ReturnType<typeof setTimeout>>()
function dismiss(id: number): void {
const t = timers.get(id)
if (t) { clearTimeout(t); timers.delete(id) }
toasts.value = toasts.value.filter((x) => x.id !== id)
}
function push(kind: ToastKind, text: string, duration: number = DURATIONS[kind]): number {
const id = nextId++
toasts.value = [...toasts.value.slice(-(MAX_VISIBLE - 1)), { id, kind, text }]
timers.set(id, setTimeout(() => dismiss(id), duration))
return id
}
export function useToast() {
return {
toasts,
push,
success: (text: string) => push('success', text),
error: (text: string) => push('error', text),
info: (text: string) => push('info', text),
dismiss
}
}
// 测试隔离用:清空全部 toast 与计时器(仅测试导入)
export function __resetToasts(): void {
for (const t of timers.values()) clearTimeout(t)
timers.clear()
toasts.value = []
nextId = 1
}
```
**新文件 `app/components/ToastHost.vue`**:不 Teleport(D-C 测试友好);容器 `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"`;`<TransitionGroup name="toast">` 包每条;每条 `data-testid="toast"` + kind 配色(success `bg-success text-paper`、error `bg-accent text-paper`、info `bg-highlight text-ink`)+ `pointer-events-auto border-2 border-ink px-3 py-2 text-xs font-bold shadow-hard flex items-start justify-between gap-2`;关闭按钮 `aria-label="关闭提示"`(PhX from `@phosphor-icons/vue`,size 12 weight bold,`aria-hidden="true"`)。`main.css` 追加 `.toast-enter-active/.toast-leave-active`(transform+opacity 150ms)与 `.toast-enter-from/.toast-leave-to`,放进既有 `@media (prefers-reduced-motion: reduce)` 归零分支可及的范围(reduce 时 transition 时长归零即可,写法对齐 main.css:95 既有分支)。store 空时容器仍渲染(`role="status"` 常驻,屏幕阅读器语义稳定)但零子条。
**改 `app/App.vue`**:`<AppFooter />` 之后挂 `<ToastHost />`(import 补上)。
**测试 新 `app/composables/useToast.test.ts`**:`beforeEach(__resetToasts)` + `vi.useFakeTimers`;钉:push 返回递增 id;success/info 3000ms、error 5000ms 后自动消失(`vi.advanceTimersByTime`);连 push 4 条只剩最末 3 条(最旧被移除);`dismiss` 提前清掉且计时器不泄漏(dismiss 后 advance 不抛);自定义 duration 生效。
**测试 新 `app/components/ToastHost.test.ts`**:mount 后 store 空→容器在、零 `[data-testid=toast]`;push 三种 kind→各配色类命中;点关闭按钮→store 少一条;容器 `role="status"` `aria-live="polite"` 钉死。
**验收**:`npm test -- app/composables/useToast.test.ts app/components/ToastHost.test.ts` 全绿 + `npm run typecheck` 零错。
**commit**:`feat(ux): P10-T1 toast store + ToastHost (D-C/D-D)`
---
### Task 2: 脏表单守卫(SubmitFormView)
**改 `app/views/SubmitFormView.vue`**(spec §3.2 + D-A/D-B)。锚点:`suppressAadWatch` 声明在 :114 附近;`loadExisting` :186-227(try 前 `suppressAadWatch = true`,finally `= false`);`save()` 成功路径 `await router.push('/submit')` :295 附近;既有 `onBeforeUnmount` :64-67。
实现要点:
1. `import { onBeforeRouteLeave } from 'vue-router'`(该文件已从 vue-router import `useRoute, useRouter`,同行追加)。
2. 常量 `const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'`(放 KIND_LABELS 附近)。
3. `const dirty = ref(false)`、`let suppressDirty = false`、`let suppressLeave = false`。
4. 深度 watch:`watch([form, kind, () => bundle.value?.upload_id, () => cover.value?.upload_id], () => { if (!suppressDirty) dirty.value = true }, { deep: true })`。放既有 AAD watch 之后。注意:`form` 是 `reactive` 对象,watch 数组元素里直接放 `form`(reactive 对象自身可深度观察);`kind` 是 ref。
5. `loadExisting`:进入 try 前 `suppressDirty = true`(与 `suppressAadWatch = true` 同点);finally 里 `suppressAadWatch = false` 后 `await nextTick()`(既有)再 `suppressDirty = false; dirty.value = false`。⚠️ 顺序:既有代码 finally 前已有 `await nextTick()` 在 try 内——实现时把 `suppressDirty = false; dirty.value = false` 放 finally 中 `suppressAadWatch = false` 之后、且需再 `await nextTick()` 一次让深度 watch 队列冲完再释放(防止回填触发的 watch 回调在释放后才跑而误置 dirty;深度 watch 默认 flush pre,nextTick 足以)。
6. `prefill()` **不**抑制(D-B:其触发前提本身是用户修改)——不动 prefill。
7. `save()` 成功分支:`await router.push('/submit')` 前插 `dirty.value = false; suppressLeave = true`。失败路径(catch)不动 dirty。
8. `onBeforeRouteLeave(() => { if (suppressLeave || !dirty.value) return true; return window.confirm(GUARD_COPY) })`。
9. beforeunload:`watch(dirty, (d) => { if (d) window.addEventListener('beforeunload', onBeforeUnload); else window.removeEventListener('beforeunload', onBeforeUnload) })` + `function onBeforeUnload(e: BeforeUnloadEvent): void { e.preventDefault(); e.returnValue = '' }` + 既有 `onBeforeUnmount` 里补 `window.removeEventListener('beforeunload', onBeforeUnload)`。
**测试 扩 `app/views/SubmitFormView.test.ts`**(先读既有文件沿用其 mock/router 惯例;该文件已有完整 contentClient mock 体系):
- 用户改 `form.name`(`w.vm` 或直接 input setValue)→ 触发 router 导航被 `window.confirm` mock(返 false)拒 → 停留原路由;confirm 改返 true → 放行。
- `loadExisting` 回填后(既有编辑模式用例路径)→ confirm spy 未被调(导航直通)。
- save-draft 成功流(既有用例已有该流)→ push('/submit') 前 confirm 未被调(suppressLeave)。
- dirty=true 时 `window.dispatchEvent(new Event('beforeunload', { cancelable: true }))` → `defaultPrevented` true;干净时 false。
- 上传成功(既有 bundle 上传用例路径)→ dirty。
**e2e 扩 `e2e/submit-flow.spec.ts` 追加一条**(文件尾,既有用例一字不动):
```ts
test('脏表单守卫:SPA 离开需确认,save 成功直通', async ({ page }) => {
await seedSession(page)
const state = { subs: [] as Sub[] }
installSubmissionApi(page, state)
await page.goto('http://localhost:4173/submit/new')
await page.getByLabel('展示名称').fill('My Game')
// 无监听 → Playwright 自动 dismiss → 导航被拒
await page.getByRole('link', { name: '作品', exact: true }).click()
await expect(page).toHaveURL('http://localhost:4173/submit/new')
// accept → 放行
page.once('dialog', (d) => void d.accept())
await page.getByRole('link', { name: '作品', exact: true }).click()
await expect(page).toHaveURL('http://localhost:4173/games')
// 回到表单填好 → save-draft 成功 push 不被守卫拦(suppressLeave 回归)
await page.goto('http://localhost:4173/submit/new')
await fillNewWorkForm(page) // 注意:fillNewWorkForm 自带 goto /submit/new,直接调用即可
await page.locator('[data-testid=save-draft]').click()
await expect(page).toHaveURL('http://localhost:4173/submit')
})
```
(实施时按文件内既有 helper 命名微调;`fillNewWorkForm` 已含 goto,上面第三段直接调它、不再重复 goto。)
**验收**:`npm test -- app/views/SubmitFormView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T2 dirty-form leave guard on SubmitFormView (D-A/D-B)`
---
### Task 3: 标签可点(GameView + GameCard)
**改 `app/views/GameView.vue` :88-95**(spec §3.3 + D-G):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>
```
**改 `app/components/GameCard.vue` :70-75**:`visibleTags` 的 `<span>` → `<RouterLink>`,同款样式 + `relative z-10`(卡面拉伸链接 `after:absolute inset-0` 在 :49,作者链接 :54-58 已用 `relative z-10` 同法)+ `data-testid="tag-link"`;`:to="{ path: '/games', query: { tag } }"`;`hiddenTags` 的 `+N` 角标(:76-79)**维持 span 不动**(D-G)。
**测试 扩 `app/views/GameView.test.ts`**:带 tags 的作品(既有夹具可扩)→ `[data-testid=tag-link]` 数量=tags 数、首个 href `/games?tag=<encodeURIComponent(t)>`(断言用 `router.resolve` 产物或字面量,中文 tag 注意编码)。
**测试 扩 `app/components/GameCard.test.ts`**:有 tags → `a[data-testid=tag-link]` 带 `relative z-10` 类且 href 以 `/games?tag=` 开头;tags>2 → 第三个是 `+N` span 非链接;tags=[] → 零 tag-link。
**e2e 新文件 `e2e/ux.spec.ts`**(本任务创建,后续任务追加)第一条:
```ts
test('作品页标签点击进入目录过滤', async ({ page }) => {
await page.goto('http://localhost:4173/games/fixture/2048')
const tag = page.locator('[data-testid=tag-link]').first()
const text = (await tag.textContent())!.trim()
await tag.click()
await expect(page).toHaveURL(`http://localhost:4173/games?tag=${encodeURIComponent(text)}`)
await expect(page.locator('[data-testid=game-card]').first()).toBeVisible()
})
```
(前置核实:`fixtures/generated/games/fixture__2048.json` 的 tags 非空;若为空换 `case-files` 等任一有 tag 的夹具,并把 spec 测试计划里的 slug 一并换——实施时以真实夹具为准,不改 spec 结构。)
**验收**:`npm test -- app/views/GameView.test.ts app/components/GameCard.test.ts` + typecheck。
**commit**:`feat(ux): P10-T3 clickable tags on game page and cards (D-G)`
---
### Task 4: 复制链接 + toast 接线(GameView)
**改 `app/views/GameView.vue`**(spec §3.4 + D-E/D-F)。依赖 Task 1 的 `useToast`。
1. import:`PhLinkSimple`(`@phosphor-icons/vue` 同行追加)、`useToast`。
2. setup:`const toast = useToast()`;
```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('复制失败,请手动复制地址栏链接')
}
}
```
3. 模板:meta 段(`预计时长` 那个 `<p>`,:80-90 区域)之后、description `<p>` 之前插:
```html
<div>
<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>
</div>
```
(happy-dom/旧环境 `navigator.clipboard` 可能 undefined——`try` 需覆盖属性访问:实现为 `await navigator.clipboard?.writeText(url)` 且 undefined 时手动 throw,或整段放 try 内直接访问 `navigator.clipboard.writeText`(undefined 属性访问抛 TypeError 也被 catch)。取后者:整段 try/catch 已兜住。)
**测试 扩 `app/views/GameView.test.ts`**:
- mock `navigator.clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }`(happy-dom 下 `Object.defineProperty(navigator, 'clipboard', …)`;beforeEach 还原)+ `__resetToasts()`。
- 点 `[data-testid=copy-link]` → writeText 收到 `http://localhost/games/fixture/minimal`(happy-dom location.origin 以实际环境为准,断言用 `location.origin + '/games/fixture/minimal'` 拼接式)→ toast store 有 kind=success text=`链接已复制`。
- writeText reject → toast kind=error text=`复制失败,请手动复制地址栏链接`。
- notFound 分支 → `[data-testid=copy-link]` 零渲染。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('复制链接:clipboard 写入规范 URL + toast 确认', async ({ context, page }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('http://localhost:4173/games/fixture/2048')
await page.locator('[data-testid=copy-link]').click()
await expect(page.locator('[data-testid=toast]').first()).toContainText('链接已复制')
const text = await page.evaluate(() => navigator.clipboard.readText())
expect(text).toBe('http://localhost:4173/games/fixture/2048')
})
```
**验收**:`npm test -- app/views/GameView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T4 copy-link share button wired to toast (D-E/D-F)`
---
### Task 5: 页头下拉外点/Esc 关闭(AppHeader)
**改 `app/components/AppHeader.vue`**(spec §3.5 + D-K)。锚点:`detailsRef` :9-10、route watch :11-13、`<details>` :79、`<summary>` :80。
1. import 追加 `onBeforeUnmount, onMounted`(vue 同行)。
2. setup 追加:
```ts
const menuOpen = ref(false)
const summaryRef = ref<HTMLElement | null>(null)
function onDocPointerDown(e: PointerEvent): void {
if (menuOpen.value && detailsRef.value && !detailsRef.value.contains(e.target as Node)) {
detailsRef.value.open = false
}
}
function onDocKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape' && menuOpen.value && detailsRef.value) {
detailsRef.value.open = false
summaryRef.value?.focus()
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocPointerDown)
document.addEventListener('keydown', onDocKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocPointerDown)
document.removeEventListener('keydown', onDocKeydown)
})
```
3. 模板:`<details … @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">`;`<summary ref="summaryRef" …>`(其余属性类名一字不动)。既有 route.fullPath watch 保留(可在其中同步 `menuOpen.value = false`,或依赖 toggle 事件——实现取后者:程序性 `open = false` 也触发 toggle,无需重复)。
**测试 新 `app/components/AppHeader.test.ts`**(mock 惯例:`vi.mock('@/data')` 供 repo.listGames/listDocs、`vi.mock('@/auth')` 供 authEnabled=true + session.state.user 已登录;memory router 带 home/catalog 两路由):
- 设 `details.open = true`(dispatchEvent toggle 或 `element.open=true` 后 `await nextTick()` 让 @toggle 同步 menuOpen)→ document 上 `dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`(target 为 document.body 外点)→ open 变 false。
- 菜单内 pointerdown(target=details 子节点,bubbles)→ 不关。
- open 后 `document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))` → 关且 `document.activeElement` 为 summary。
- 卸载后监听移除(unmount → 再 dispatch 不抛)。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('页头菜单:Esc 与外点关闭', async ({ page }) => {
await seedSession(page) // 从 './helpers' import
await page.goto('http://localhost:4173/')
const summary = page.getByRole('button', { name: /tester/ }) // details summary 的可访问名;实施时按真实渲染调整选择器(可用 page.locator('details summary'))
await summary.click()
await expect(page.locator('details[open]')).toHaveCount(1)
await page.keyboard.press('Escape')
await expect(page.locator('details[open]')).toHaveCount(0)
await summary.click()
await expect(page.locator('details[open]')).toHaveCount(1)
await page.locator('main').click({ position: { x: 5, y: 5 } })
await expect(page.locator('details[open]')).toHaveCount(0)
})
```
**验收**:`npm test -- app/components/AppHeader.test.ts` + typecheck。
**commit**:`feat(ux): P10-T5 header dropdown closes on outside-click and Esc (D-K)`
---
### Task 6: 最近玩过(recent.ts + GameHost + LandingView)
**新文件 `app/lib/recent.ts`**(spec §3.6 + D-H/D-I):
```ts
const KEY = 'crearte.recent.v1'
const MAX_RECENT = 12
export interface RecentEntry { id: string; at: string }
export function listRecent(): string[] {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return []
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed
.filter((e): e is RecentEntry => Boolean(e) && typeof (e as RecentEntry).id === 'string')
.map((e) => e.id)
} catch {
return []
}
}
export function recordPlay(id: string): void {
if (!id) return
try {
const entries: RecentEntry[] = [{ id, at: new Date().toISOString() },
...listRecentRaw().filter((e) => e.id !== id)].slice(0, MAX_RECENT)
localStorage.setItem(KEY, JSON.stringify(entries))
} catch {
// 隐私模式/配额满:静默(spec §4)
}
}
// listRecentRaw:同 listRecent 但返回 RecentEntry[](内部复用解析逻辑,导出与否以测试便利定)
```
**改 `runtime/host/GameHost.vue`**(D-H,全局约束 2 的唯一 runtime 例外):
- import `recordPlay`:`import { recordPlay } from '../../app/lib/recent'`(与 GameView import GameHost 的跨层相对路径惯例对称)。
- setup 追加 `watch(() => frame.state.value.phase, (p) => { if (p === 'ready') recordPlay(props.game.id) })`(`watch` 已在 import 列表)。
**改 `app/views/LandingView.vue`**(spec §3.6 + D-J):
- import `listRecent`;`const RECENT_LIMIT = 6`(FEATURED_LIMIT 旁)。
- setup:`const recentIds = listRecent()`(setup 时读一次即可——路由重挂载天然刷新,D-J)。
- `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">` 之间插(**一字不差**按 spec §3.6 的条带模板,`data-testid="recent-strip"`、h2 样式逐字、`:heading-level="3"`)。
**测试 新 `app/lib/recent.test.ts`**:beforeEach `localStorage.clear()`;钉:空→[];recordPlay 前插(两次不同 id → 新者先);重复 id 去重冒泡到最前;13 次 → 截 12;损坏 JSON(手 setItem 'not-json')→ [];非数组 JSON → [];mock `localStorage.setItem` throw → recordPlay 不抛、listRecent 仍可用。
**测试 扩 `runtime/host/GameHost.test.ts`**(先读既有 mock 体系;`vi.mock('../../app/lib/recent')` 或按该文件相对别名):phase → 'ready' 触发 `recordPlay(game.id)`;'booting'/'error' 不触发;restart 后再次 ready 再次触发。若既有 GameHost.test 的 frame mock 不便驱动 phase,允许对该测试文件做最小扩展(不删既有用例)。
**测试 新/扩 `app/views/LandingView.test.ts`**(如不存在则新建,mock 惯例照抄 AuthorView.test.ts 的 vi.hoisted+vi.mock):games 夹具两款 + localStorage 预置 `[{"id":"b","at":"…"},{"id":"a","at":"…"},{"id":"gone","at":"…"}]` → `[data-testid=recent-strip]` 渲染 2 卡(gone 丢弃)且顺序 b,a;无记录 → 零渲染;记录全不可解析 → 零渲染。
**e2e 追加 `e2e/ux.spec.ts`**:
```ts
test('最近玩过:站内运行就绪后落地页出现继续游玩条带', async ({ page }) => {
await openGame(page, '2048') // helpers 既有;data-ready=1 即 phase ready
await openGame(page, 'a-dark-room')
await page.goto('http://localhost:4173/')
const strip = page.locator('[data-testid=recent-strip]')
await expect(strip).toBeVisible()
await expect(strip.getByText('继续游玩 · RECENTLY PLAYED')).toBeVisible()
const hrefs = await strip.locator('a[href^="/games/"]').evaluateAll((els) => els.map((el) => el.getAttribute('href')))
expect(hrefs[0]).toBe('/games/fixture/a-dark-room') // 最近玩的冒泡最前
expect(hrefs).toContain('/games/fixture/2048')
// 精选区计数不受影响(条带在 hero 与精选之间,选择器分开)
await expect(page.locator('main > section:nth-of-type(3) a[href^="/games/"]')).toHaveCount(6)
})
```
(⚠️ 最后一行的选择器以真实 DOM 结构为准——条带渲染后 section 序号会变;实施时改用精选区自己的稳定定位,如给精选 section 的 h2 文本定位后 xpath following,或直接用 `page.locator('section', { hasText: '精选 · SELECTED' })` 圈定后计数。目的只有一个:证明 landing.spec 精选 6 卡钉桩在有条带时仍成立。)
**验收**:`npm test -- app/lib/recent.test.ts runtime/host/GameHost.test.ts app/views/LandingView.test.ts` + typecheck。
**commit**:`feat(ux): P10-T6 recently-played strip (recent.ts + GameHost record + landing, D-H/D-I/D-J)`
---
### 控制者收尾(不派子代理)
1. 全量验收五腿:`npm test`(≥551+新增全绿)、`npm run typecheck`、`npm run build`、`npm run e2e`(72+1skip+新增全绿)、`npm run e2e:noauth`(4)。
2. CHANGELOG 0.23.0 双语条目(Added:toast/标签/复制链接/最近玩过条带;Fixed:脏表单守卫口径归 Added「交互护栏」、下拉关闭归 Fixed)+ commit。
3. 全分支审查包(`scripts/review-package $(git merge-base master HEAD) HEAD`)→ 派最终审查者(强模型)。
4. 审查 notes 逐条裁定后 `--no-ff` 合 master、删分支、push 三仓(crearte + wrapper 记账:ROADMAP P10 行 + 索引登记 + wrapper CHANGELOG 0.3.2)。
5. wrapper 记账 commit 引审查判定形("PASS with notes — note pinned by <sha>"惯例)。
@@ -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` 后删分支。