Files
crearte-monorepo/docs/specs/2026-10-01-p10-ux-daily-interaction-design.md
T

207 lines
19 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.
# 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` 后删分支。