24 KiB
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 后跑。
全局约束(逐字,进每个任务简报)
- 零新依赖:不得改
package.jsondependencies/devDependencies;version 字段恒0.1.0不动。 - 禁触文件/行:
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/。 - 样式语言:新粗野主义既有 tokens(
border-2 border-ink、shadow-hard*、bg-paper/surface/ink/highlight/accent/success、btn-ink/btn-surface/lift、font-mono text-[0.6875rem]);注释中文、标识符英文(仓内惯例)。 - TDD:先写/改 vitest 再实现;e2e 用例随所属任务同 commit。
- 每任务独立 commit(message 用
feat(ux):/fix(ux):前缀 + 任务号);CHANGELOG 不由任务写——控制者在全部任务后统一补 0.23.0 条目。 - 验收命令(每任务收尾自跑并贴输出摘要):
npm test -- <相关测试文件>(改动面)+npm run typecheck。全量npm test/npm run build/npm run e2e由控制者在波次末跑。 - 分支纪律:commit 前
git branch --show-current必须是feat/p10-ux-daily;不 merge、不 push、不碰 wrapper 仓(ROADMAP/spec/plan 记账归控制者)。 - vitest 基线 551 全绿不回退;e2e 主套件基线 72+1skip 不回退。
data-testid命名精确使用 spec 给定值:toast-host、toast、copy-link、tag-link、recent-strip。- 文案精确逐字(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):
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。
实现要点:
import { onBeforeRouteLeave } from 'vue-router'(该文件已从 vue-router importuseRoute, useRouter,同行追加)。- 常量
const GUARD_COPY = '表单尚未保存,确定离开吗?未保存的修改将丢失。'(放 KIND_LABELS 附近)。 const dirty = ref(false)、let suppressDirty = false、let suppressLeave = false。- 深度 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。 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 足以)。prefill()不抑制(D-B:其触发前提本身是用户修改)——不动 prefill。save()成功分支:await router.push('/submit')前插dirty.value = false; suppressLeave = true。失败路径(catch)不动 dirty。onBeforeRouteLeave(() => { if (suppressLeave || !dirty.value) return true; return window.confirm(GUARD_COPY) })。- 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.confirmmock(返 false)拒 → 停留原路由;confirm 改返 true → 放行。 loadExisting回填后(既有编辑模式用例路径)→ confirm spy 未被调(导航直通)。- save-draft 成功流(既有用例已有该流)→ push('/submit') 前 confirm 未被调(suppressLeave)。
- dirty=true 时
window.dispatchEvent(new Event('beforeunload', { cancelable: true }))→defaultPreventedtrue;干净时 false。 - 上传成功(既有 bundle 上传用例路径)→ dirty。
e2e 扩 e2e/submit-flow.spec.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>:
<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(本任务创建,后续任务追加)第一条:
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。
- import:
PhLinkSimple(@phosphor-icons/vue同行追加)、useToast。 - setup:
const toast = useToast();
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('复制失败,请手动复制地址栏链接')
}
}
- 模板:meta 段(
预计时长那个<p>,:80-90 区域)之后、description<p>之前插:
<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:
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。
- import 追加
onBeforeUnmount, onMounted(vue 同行)。 - setup 追加:
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)
})
- 模板:
<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:
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):
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:
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)
控制者收尾(不派子代理)
- 全量验收五腿:
npm test(≥551+新增全绿)、npm run typecheck、npm run build、npm run e2e(72+1skip+新增全绿)、npm run e2e:noauth(4)。 - CHANGELOG 0.23.0 双语条目(Added:toast/标签/复制链接/最近玩过条带;Fixed:脏表单守卫口径归 Added「交互护栏」、下拉关闭归 Fixed)+ commit。
- 全分支审查包(
scripts/review-package $(git merge-base master HEAD) HEAD)→ 派最终审查者(强模型)。 - 审查 notes 逐条裁定后
--no-ff合 master、删分支、push 三仓(crearte + wrapper 记账:ROADMAP P10 行 + 索引登记 + wrapper CHANGELOG 0.3.2)。 - wrapper 记账 commit 引审查判定形("PASS with notes — note pinned by "惯例)。