19 KiB
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,26parseFilterState/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:6home 标题恰为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.tsSESSION_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.jsonversion 恒 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:
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)
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:
<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 之前插一行:
<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>
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)
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 读写):
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:
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"> 之间:
<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 不可用(隐私模式/配额满):
recordPlaytry/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行为断言:mockwindow.confirm返 false → 导航被拒;返 true → 放行)②loadExisting回填不 dirty ③save 成功 push 不触发 confirm(suppressLeave)④dirty 时 beforeunload 监听已挂、干净时未挂(window.addEventListenerspy 或 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 后删分支。