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

19 KiB
Raw Blame History

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:

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 不可用(隐私模式/配额满):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 后删分支。