377 lines
24 KiB
Markdown
377 lines
24 KiB
Markdown
# 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>"惯例)。
|