# TVU 直播平台原型 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 用 Vite + React + TS 构建一个双端直播平台前端原型(C 端 TVU Live 观看 + B 端 TVU Studio 控制台,共 8 页),全程使用 TVU 设计系统 CSS token,数据走 mock service 接口层留好升级真实 API 的位置。

**Architecture:** 单仓双区(`/watch` + `/studio`)+ 共享层。TVU DS 是 Vue 库,本项目只引入其 `style.css`(框架无关 CSS 变量),基础组件按 token 自封装。所有数据经 `mock/services.ts` 的 async 函数获取;实时类(聊天、监控)用定时器 hook 模拟,封装成将来可换 WebSocket 的接口。所有界面文字经 i18n 字典查 key,支持中英整体切换。

**Tech Stack:** Vite, React 18, TypeScript, react-router-dom v6, Vitest, @testing-library/react, @testing-library/jest-dom。样式:TVU DS CSS token + CSS Modules / 原生 CSS。

## Global Constraints

- **TVU DS 包**:`@nancyzeng0210/tvu-design-system@^0.10.1`,仅用于 `import '@nancyzeng0210/tvu-design-system/style.css'`(CSS 变量)。不导入其 Vue 组件。
- **关键 token(CSS 变量,verbatim)**:主色 `--brand`(#2fb54e)、`--brand-hover`(#41c760)、文字 `--text-heading`(#fff)/`--text-body`(#f8f8f8)、背景 `--bg-layer1`(#141414)/`--bg-topbar`(#000)、`--red`/`--orange`/`--blue`、灰阶 `--color-grey-8`(#595959)。间距 `--sp-xxs`4/`--sp-xs`8/`--sp-s`12/`--sp-m`16/`--sp-l`24/`--sp-xl`32/`--sp-xxl`40。圆角 `--radius-s`4/`--radius-m`8/`--radius-l`12/`--radius-xl`16。图表色 `--chart-color-1..12`。
- **禁止硬编码颜色**:组件样式一律引用上面的 CSS 变量;找不到对应 token 时用最接近的灰阶/品牌变量,不写裸 hex。
- **深色模式默认**:TVU DS 默认深色,不设 `data-theme="light"`。
- **文案**:所有界面文字必须走 i18n key,中英两版均来自 spec 第 9 节《双语文案库》,不在组件里写裸字符串。
- **数据访问**:组件不直接 import `mock/data.ts`;一律经 `mock/services.ts` 的 async 函数或 `mock/hooks.ts` 的实时 hook。
- **路由**:`/watch`、`/watch/:id`、`/watch/c/:channel`、`/studio`、`/studio/stream`、`/studio/broadcast`、`/studio/monitor`、`/studio/destinations`。
- **测试基线**:逻辑层(i18n、services、hooks)写行为断言;页面/组件写 smoke render + 关键文案/角色断言。高保真视觉打磨留给执行阶段,不写像素级断言。

---

### Task 1: 项目脚手架 + TVU token 接入

**Files:**
- Create: `package.json`, `vite.config.ts`, `tsconfig.json`, `tsconfig.node.json`, `index.html`, `vitest.setup.ts`
- Create: `src/main.tsx`, `src/App.tsx`, `src/styles/global.css`
- Test: `src/App.test.tsx`

**Interfaces:**
- Produces: `App` 默认导出(React 组件,渲染 `<div data-app-root>`);`src/styles/global.css` 已 `@import` TVU token 并设页面深色底。

- [ ] **Step 1: 初始化项目与依赖**

```bash
npm create vite@latest . -- --template react-ts
npm install react-router-dom @nancyzeng0210/tvu-design-system@^0.10.1
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
```

- [ ] **Step 2: 配置 Vitest(vite.config.ts)**

```ts
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    css: false,
  },
})
```

`vitest.setup.ts`:
```ts
import '@testing-library/jest-dom'
```

在 `package.json` 的 `scripts` 加:`"test": "vitest run"`, `"test:watch": "vitest"`。

- [ ] **Step 3: 全局样式接入 TVU token**

`src/styles/global.css`:
```css
@import '@nancyzeng0210/tvu-design-system/style.css';

:root { color-scheme: dark; }
html, body, #root { height: 100%; margin: 0; }
body {
  background: var(--bg-layer1);
  color: var(--text-body);
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
* { box-sizing: border-box; }
```

`src/main.tsx`:
```tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import './styles/global.css'
import App from './App'

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode><App /></React.StrictMode>,
)
```

- [ ] **Step 4: 写最小 App 与失败测试**

`src/App.tsx`:
```tsx
export default function App() {
  return <div data-app-root>TVU</div>
}
```

`src/App.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import App from './App'

test('renders app root', () => {
  render(<App />)
  expect(screen.getByText('TVU')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试**

Run: `npm test`
Expected: PASS(1 passed)。`npm run dev` 能起,页面深色底。

- [ ] **Step 6: Commit**

```bash
git init && git add -A
git commit -m "chore: scaffold vite+react+ts with TVU DS tokens"
```

---

### Task 2: i18n 双语字典 + useI18n

**Files:**
- Create: `src/i18n/messages.ts`, `src/i18n/I18nProvider.tsx`, `src/i18n/useI18n.ts`
- Test: `src/i18n/useI18n.test.tsx`

**Interfaces:**
- Produces:
  - `type Lang = 'zh' | 'en'`
  - `messages: Record<string, { zh: string; en: string }>`(key 来自 spec 第 9 节,如 `room.follow`、`home.liveNow`)
  - `I18nProvider`:`<I18nProvider>{children}</I18nProvider>`,内部持 lang 状态,默认 `'zh'`
  - `useI18n(): { lang, setLang, t }`,`t(key, vars?) => string`,支持 `{n}` 插值

- [ ] **Step 1: 写字典(摘录 spec 第 9 节,先放全局 + 首页两组,其余在各页 Task 补)**

`src/i18n/messages.ts`:
```ts
export type Lang = 'zh' | 'en'
type Entry = { zh: string; en: string }

export const messages: Record<string, Entry> = {
  'brand.live': { zh: 'TVU Live', en: 'TVU Live' },
  'brand.studio': { zh: 'TVU Studio', en: 'TVU Studio' },
  'nav.watch': { zh: '观看', en: 'Watch' },
  'nav.studio': { zh: '创作中心', en: 'Studio' },
  'nav.switchToStudio': { zh: '进入创作中心', en: 'Go to Studio' },
  'nav.switchToLive': { zh: '返回观看', en: 'Back to Watch' },
  'common.retry': { zh: '加载失败,请重试', en: 'Failed to load, retry' },
  'common.loading': { zh: '加载中…', en: 'Loading…' },
  'common.empty': { zh: '暂无内容', en: 'Nothing here yet' },
  'common.copy': { zh: '复制', en: 'Copy' },
  'common.copied': { zh: '已复制', en: 'Copied' },
  'common.save': { zh: '保存', en: 'Save' },
  'common.cancel': { zh: '取消', en: 'Cancel' },
  'common.confirm': { zh: '确认', en: 'Confirm' },
}
```

- [ ] **Step 2: 写 Provider 与 hook**

`src/i18n/I18nProvider.tsx`:
```tsx
import { createContext, useState, type ReactNode } from 'react'
import { messages, type Lang } from './messages'

export const I18nContext = createContext<{
  lang: Lang
  setLang: (l: Lang) => void
  t: (key: string, vars?: Record<string, string | number>) => string
} | null>(null)

export function I18nProvider({ children }: { children: ReactNode }) {
  const [lang, setLang] = useState<Lang>('zh')
  const t = (key: string, vars?: Record<string, string | number>) => {
    let s = messages[key]?.[lang] ?? key
    if (vars) for (const k in vars) s = s.replace(`{${k}}`, String(vars[k]))
    return s
  }
  return <I18nContext.Provider value={{ lang, setLang, t }}>{children}</I18nContext.Provider>
}
```

`src/i18n/useI18n.ts`:
```ts
import { useContext } from 'react'
import { I18nContext } from './I18nProvider'

export function useI18n() {
  const ctx = useContext(I18nContext)
  if (!ctx) throw new Error('useI18n must be used within I18nProvider')
  return ctx
}
```

- [ ] **Step 3: 写测试**

`src/i18n/useI18n.test.tsx`:
```tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { I18nProvider } from './I18nProvider'
import { useI18n } from './useI18n'

function Probe() {
  const { t, lang, setLang } = useI18n()
  return (
    <div>
      <span data-testid="v">{t('nav.watch')}</span>
      <button onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}>toggle</button>
    </div>
  )
}

test('translates and switches language', () => {
  render(<I18nProvider><Probe /></I18nProvider>)
  expect(screen.getByTestId('v')).toHaveTextContent('观看')
  fireEvent.click(screen.getByText('toggle'))
  expect(screen.getByTestId('v')).toHaveTextContent('Watch')
})
```

- [ ] **Step 4: 跑测试**

Run: `npm test src/i18n`
Expected: PASS。

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat(i18n): bilingual dictionary + useI18n with interpolation"
```

> **后续约定**:每个页面 Task 在 `messages.ts` 追加该页 spec 第 9 节对应的 key,不重复造 key。

---

### Task 3: mock 数据 + service 接口层

**Files:**
- Create: `src/mock/types.ts`, `src/mock/data.ts`, `src/mock/services.ts`
- Test: `src/mock/services.test.ts`

**Interfaces:**
- Produces(types):
  - `Channel = { id: string; name: string; handle: string; avatar: string; category: string; viewers: number; isLive: boolean; cover: string; title: string }`
  - `ChatMessage = { id: string; user: string; text: string }`
  - `MonitorPoint = { t: number; bitrate: number; fps: number; dropped: number; latency: number }`
  - `Destination = { id: string; platform: 'youtube'|'twitch'|'facebook'|'custom'; name: string; status: 'connected'|'disconnected'|'error'; enabled: boolean }`
  - `StreamConfig = { protocol: 'rtmp'|'srt'; serverUrl: string; streamKey: string; resolution: string; bitrate: string; framerate: string }`
- Produces(services, 全部 async,内部 `await delay()` 模拟网络):
  - `getLiveChannels(): Promise<Channel[]>`
  - `getStream(id: string): Promise<Channel | undefined>`
  - `getChannel(handle: string): Promise<Channel | undefined>`
  - `getPastStreams(handle: string): Promise<Channel[]>`
  - `getStreamConfig(): Promise<StreamConfig>`
  - `getDestinations(): Promise<Destination[]>`

- [ ] **Step 1: 写 types + data**

`src/mock/types.ts`:(按上面 Interfaces 的 type 定义逐条写出 export type)
```ts
export type Channel = { id: string; name: string; handle: string; avatar: string; category: string; viewers: number; isLive: boolean; cover: string; title: string }
export type ChatMessage = { id: string; user: string; text: string }
export type MonitorPoint = { t: number; bitrate: number; fps: number; dropped: number; latency: number }
export type Destination = { id: string; platform: 'youtube' | 'twitch' | 'facebook' | 'custom'; name: string; status: 'connected' | 'disconnected' | 'error'; enabled: boolean }
export type StreamConfig = { protocol: 'rtmp' | 'srt'; serverUrl: string; streamKey: string; resolution: string; bitrate: string; framerate: string }
```

`src/mock/data.ts`:(写出 6+ 个 Channel,2+ Destination,1 个 StreamConfig 的真实假数据;cover/avatar 用 `https://picsum.photos/seed/<id>/640/360` 这类占位图)
```ts
import type { Channel, Destination, StreamConfig } from './types'

export const channels: Channel[] = [
  { id: 'c1', name: 'Aurora Live', handle: 'aurora', avatar: 'https://i.pravatar.cc/120?img=1', category: 'Sports', viewers: 12400, isLive: true, cover: 'https://picsum.photos/seed/c1/640/360', title: 'Live: City Marathon Finals' },
  { id: 'c2', name: 'NovaCast', handle: 'nova', avatar: 'https://i.pravatar.cc/120?img=2', category: 'News', viewers: 8230, isLive: true, cover: 'https://picsum.photos/seed/c2/640/360', title: 'Breaking: Evening Briefing' },
  { id: 'c3', name: 'PixelPlay', handle: 'pixel', avatar: 'https://i.pravatar.cc/120?img=3', category: 'Gaming', viewers: 3110, isLive: true, cover: 'https://picsum.photos/seed/c3/640/360', title: 'Ranked Grind' },
  { id: 'c4', name: 'StudioOne', handle: 'studioone', avatar: 'https://i.pravatar.cc/120?img=4', category: 'Music', viewers: 0, isLive: false, cover: 'https://picsum.photos/seed/c4/640/360', title: 'Live Session (offline)' },
  { id: 'c5', name: 'TechTalk', handle: 'techtalk', avatar: 'https://i.pravatar.cc/120?img=5', category: 'Tech', viewers: 5400, isLive: true, cover: 'https://picsum.photos/seed/c5/640/360', title: 'Building in Public' },
  { id: 'c6', name: 'ChefStream', handle: 'chef', avatar: 'https://i.pravatar.cc/120?img=6', category: 'Food', viewers: 990, isLive: true, cover: 'https://picsum.photos/seed/c6/640/360', title: 'Live Cooking' },
]

export const destinations: Destination[] = [
  { id: 'd1', platform: 'youtube', name: 'YouTube Main', status: 'connected', enabled: true },
  { id: 'd2', platform: 'twitch', name: 'Twitch', status: 'connected', enabled: false },
  { id: 'd3', platform: 'facebook', name: 'Facebook Page', status: 'disconnected', enabled: false },
]

export const streamConfig: StreamConfig = {
  protocol: 'rtmp', serverUrl: 'rtmp://live.tvu.example/app', streamKey: 'live_8x2k9f3a7m', resolution: '1080p', bitrate: '6000 kbps', framerate: '30 fps',
}
```

- [ ] **Step 2: 写 services**

`src/mock/services.ts`:
```ts
import { channels, destinations, streamConfig } from './data'
import type { Channel, Destination, StreamConfig } from './types'

const delay = (ms = 300) => new Promise<void>((r) => setTimeout(r, ms))

export async function getLiveChannels(): Promise<Channel[]> {
  await delay(); return channels.filter((c) => c.isLive)
}
export async function getStream(id: string): Promise<Channel | undefined> {
  await delay(); return channels.find((c) => c.id === id)
}
export async function getChannel(handle: string): Promise<Channel | undefined> {
  await delay(); return channels.find((c) => c.handle === handle)
}
export async function getPastStreams(handle: string): Promise<Channel[]> {
  await delay(); return channels.filter((c) => c.handle !== handle).slice(0, 4)
}
export async function getStreamConfig(): Promise<StreamConfig> {
  await delay(); return { ...streamConfig }
}
export async function getDestinations(): Promise<Destination[]> {
  await delay(); return destinations.map((d) => ({ ...d }))
}
```

- [ ] **Step 3: 写测试**

`src/mock/services.test.ts`:
```ts
import { getLiveChannels, getStream, getDestinations } from './services'

test('getLiveChannels returns only live channels', async () => {
  const list = await getLiveChannels()
  expect(list.length).toBeGreaterThan(0)
  expect(list.every((c) => c.isLive)).toBe(true)
})

test('getStream finds by id', async () => {
  const s = await getStream('c1')
  expect(s?.name).toBe('Aurora Live')
})

test('getDestinations returns a copy', async () => {
  const a = await getDestinations()
  a[0].enabled = !a[0].enabled
  const b = await getDestinations()
  expect(b[0].enabled).not.toBe(a[0].enabled)
})
```

- [ ] **Step 4: 跑测试**

Run: `npm test src/mock/services`
Expected: PASS(3 passed)。

- [ ] **Step 5: Commit**

```bash
git add -A && git commit -m "feat(mock): data + async service layer"
```

---

### Task 4: 实时 hooks(聊天 + 监控)

**Files:**
- Create: `src/mock/hooks.ts`
- Test: `src/mock/hooks.test.tsx`

**Interfaces:**
- Consumes: `ChatMessage`, `MonitorPoint`(Task 3 types)
- Produces:
  - `useChatStream(): { messages: ChatMessage[]; send: (text: string) => void; connected: boolean }` —— 每 ~2s push 一条假弹幕,`send` 追加本地消息
  - `useMonitorStream(): { points: MonitorPoint[]; latest: MonitorPoint | null; stable: boolean }` —— 每 ~1s push 一个监控点,保留最近 30 个

- [ ] **Step 1: 写 hooks**

`src/mock/hooks.ts`:
```ts
import { useEffect, useRef, useState } from 'react'
import type { ChatMessage, MonitorPoint } from './types'

const FAKE_USERS = ['Mia', 'Leo', 'Sora', 'Kai', 'Zoe']
const FAKE_LINES = ['👋', 'Nice stream!', 'LOL', 'GG', 'where from?', '🔥🔥']

export function useChatStream() {
  const [messages, setMessages] = useState<ChatMessage[]>([])
  const n = useRef(0)
  useEffect(() => {
    const id = setInterval(() => {
      n.current += 1
      setMessages((m) => [
        ...m.slice(-50),
        { id: `s${n.current}`, user: FAKE_USERS[n.current % FAKE_USERS.length], text: FAKE_LINES[n.current % FAKE_LINES.length] },
      ])
    }, 2000)
    return () => clearInterval(id)
  }, [])
  const send = (text: string) => {
    if (!text.trim()) return
    n.current += 1
    setMessages((m) => [...m.slice(-50), { id: `me${n.current}`, user: 'You', text }])
  }
  return { messages, send, connected: true }
}

export function useMonitorStream() {
  const [points, setPoints] = useState<MonitorPoint[]>([])
  const t = useRef(0)
  useEffect(() => {
    const id = setInterval(() => {
      t.current += 1
      const jitter = (t.current % 7 === 0)
      setPoints((p) => [
        ...p.slice(-29),
        { t: t.current, bitrate: 6000 + (t.current % 5) * 50, fps: 30, dropped: jitter ? 12 : 1, latency: jitter ? 240 : 80 },
      ])
    }, 1000)
    return () => clearInterval(id)
  }, [])
  const latest = points[points.length - 1] ?? null
  const stable = latest ? latest.dropped < 5 : true
  return { points, latest, stable }
}
```

- [ ] **Step 2: 写测试(用 fake timers)**

`src/mock/hooks.test.tsx`:
```tsx
import { renderHook, act } from '@testing-library/react'
import { useChatStream } from './hooks'

test('useChatStream pushes messages over time and send adds one', () => {
  vi.useFakeTimers()
  const { result } = renderHook(() => useChatStream())
  act(() => { vi.advanceTimersByTime(2000) })
  expect(result.current.messages.length).toBe(1)
  act(() => { result.current.send('hello') })
  expect(result.current.messages.at(-1)?.text).toBe('hello')
  vi.useRealTimers()
})
```

- [ ] **Step 3: 跑测试**

Run: `npm test src/mock/hooks`
Expected: PASS。

- [ ] **Step 4: Commit**

```bash
git add -A && git commit -m "feat(mock): realtime chat + monitor hooks (timer-driven)"
```

---

### Task 5: 自封装基础 UI 组件(基于 TVU token)

**Files:**
- Create: `src/shared/ui/Button.tsx`, `Card.tsx`, `Badge.tsx`, `Switch.tsx`, `Field.tsx`, `Stat.tsx`, `ui.css`, `index.ts`
- Test: `src/shared/ui/ui.test.tsx`

**Interfaces:**
- Produces:
  - `Button({ variant?: 'primary'|'ghost'|'danger', ...buttonProps })`
  - `Card({ children, className? })` —— 容器,`--bg` 深一层 + `--radius-l`
  - `Badge({ tone?: 'live'|'success'|'neutral'|'danger', children })`
  - `Switch({ checked, onChange })`
  - `Field({ label, children })` —— 表单项,label 用 `--text-body`
  - `Stat({ label, value, hint? })` —— 指标卡内容
  - 全部从 `src/shared/ui/index.ts` 重新导出

- [ ] **Step 1: 写组件 + ui.css(用 CSS 变量,禁裸 hex)**

`src/shared/ui/ui.css`(节选,token-only):
```css
.tvu-btn { font: inherit; border: 0; border-radius: var(--radius-m); padding: var(--sp-xs) var(--sp-m); cursor: pointer; color: var(--text-heading); }
.tvu-btn--primary { background: var(--brand); }
.tvu-btn--primary:hover { background: var(--brand-hover); }
.tvu-btn--ghost { background: transparent; border: 1px solid var(--color-grey-8); }
.tvu-btn--danger { background: var(--red); }
.tvu-card { background: var(--bg-topbar); border-radius: var(--radius-l); padding: var(--sp-l); }
.tvu-badge { display: inline-flex; align-items: center; gap: var(--sp-xxs); padding: 2px var(--sp-xs); border-radius: var(--radius-s); font-size: 12px; }
.tvu-badge--live { background: var(--red); color: #fff; }
.tvu-badge--success { background: var(--brand); color: #fff; }
.tvu-badge--neutral { background: var(--color-grey-8); color: var(--text-body); }
.tvu-switch { width: 40px; height: 22px; border-radius: 999px; background: var(--color-grey-8); position: relative; cursor: pointer; }
.tvu-switch[data-on='true'] { background: var(--brand); }
.tvu-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .15s; }
.tvu-switch[data-on='true']::after { transform: translateX(18px); }
.tvu-field { display: flex; flex-direction: column; gap: var(--sp-xxs); margin-bottom: var(--sp-m); }
.tvu-field > label { color: var(--text-body); font-size: 13px; }
.tvu-stat { display: flex; flex-direction: column; gap: var(--sp-xxs); }
.tvu-stat__value { font-size: 28px; color: var(--text-heading); font-weight: 600; }
.tvu-stat__label { font-size: 13px; color: var(--color-grey-8); }
```

`src/shared/ui/Button.tsx`:
```tsx
import './ui.css'
import type { ButtonHTMLAttributes } from 'react'

export function Button({ variant = 'primary', className = '', ...rest }:
  ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' }) {
  return <button className={`tvu-btn tvu-btn--${variant} ${className}`} {...rest} />
}
```

`Badge.tsx`:
```tsx
import './ui.css'
import type { ReactNode } from 'react'
export function Badge({ tone = 'neutral', children }: { tone?: 'live' | 'success' | 'neutral' | 'danger'; children: ReactNode }) {
  return <span className={`tvu-badge tvu-badge--${tone}`}>{children}</span>
}
```

`Switch.tsx`:
```tsx
import './ui.css'
export function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
  return <div role="switch" aria-checked={checked} className="tvu-switch" data-on={checked} onClick={() => onChange(!checked)} />
}
```

`Card.tsx`:
```tsx
import './ui.css'
import type { ReactNode } from 'react'
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
  return <div className={`tvu-card ${className}`}>{children}</div>
}
```

`Field.tsx`:
```tsx
import './ui.css'
import type { ReactNode } from 'react'
export function Field({ label, children }: { label: string; children: ReactNode }) {
  return <div className="tvu-field"><label>{label}</label>{children}</div>
}
```

`Stat.tsx`:
```tsx
import './ui.css'
export function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
  return <div className="tvu-stat"><div className="tvu-stat__value">{value}</div><div className="tvu-stat__label">{label}{hint ? ` · ${hint}` : ''}</div></div>
}
```

`index.ts`:
```ts
export * from './Button'
export * from './Card'
export * from './Badge'
export * from './Switch'
export * from './Field'
export * from './Stat'
```

- [ ] **Step 2: 写测试**

`src/shared/ui/ui.test.tsx`:
```tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button, Badge, Switch } from './index'

test('Button applies variant class', () => {
  render(<Button variant="danger">x</Button>)
  expect(screen.getByText('x').className).toContain('tvu-btn--danger')
})

test('Switch toggles via callback', () => {
  let v = false
  render(<Switch checked={v} onChange={(n) => (v = n)} />)
  fireEvent.click(screen.getByRole('switch'))
  expect(v).toBe(true)
})

test('Badge renders tone', () => {
  render(<Badge tone="live">LIVE</Badge>)
  expect(screen.getByText('LIVE').className).toContain('tvu-badge--live')
})
```

- [ ] **Step 3: 跑测试**

Run: `npm test src/shared/ui`
Expected: PASS(3 passed)。

- [ ] **Step 4: Commit**

```bash
git add -A && git commit -m "feat(ui): token-based base components (Button/Card/Badge/Switch/Field/Stat)"
```

---

### Task 6: 三态组件 + 布局 + 路由(Live/Studio 切换)

**Files:**
- Create: `src/shared/states/AsyncState.tsx`, `src/shared/layout/TopBar.tsx`, `LiveLayout.tsx`, `StudioLayout.tsx`, `layout.css`
- Modify: `src/App.tsx`(装上 I18nProvider + Router + 8 条路由占位)
- Test: `src/shared/states/AsyncState.test.tsx`, `src/App.routing.test.tsx`

**Interfaces:**
- Produces:
  - `useAsync<T>(fn: () => Promise<T>, deps?): { data: T | null; loading: boolean; error: boolean; reload: () => void }`
  - `<AsyncView state={...} empty={bool} onRetry={...}>{(data) => ReactNode}</AsyncView>` —— 统一渲染 loading 骨架 / error(用 `common.retry`)/ empty / 内容
  - `TopBar`:含语言切换(zh/en)+ Live↔Studio 跳转(用 `nav.switchToStudio`/`nav.switchToLive`)
  - `LiveLayout` / `StudioLayout`:`<Outlet/>` 外壳;Studio 左侧含 5 个导航项
- Consumes: `useI18n`(Task 2),`Button`(Task 5)

- [ ] **Step 1: 写 useAsync + AsyncView**

`src/shared/states/AsyncState.tsx`:
```tsx
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import { useI18n } from '../../i18n/useI18n'

export function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(false)
  const run = useCallback(() => {
    setLoading(true); setError(false)
    fn().then(setData).catch(() => setError(true)).finally(() => setLoading(false))
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)
  useEffect(() => { run() }, [run])
  return { data, loading, error, reload: run }
}

export function AsyncView<T>({ state, empty, children }: {
  state: { data: T | null; loading: boolean; error: boolean; reload: () => void }
  empty?: (d: T) => boolean
  children: (data: T) => ReactNode
}) {
  const { t } = useI18n()
  if (state.loading) return <div className="tvu-skeleton" aria-busy>{t('common.loading')}</div>
  if (state.error) return <div role="alert">{t('common.retry')} <button onClick={state.reload}>↻</button></div>
  if (!state.data || (empty && empty(state.data))) return <div>{t('common.empty')}</div>
  return <>{children(state.data)}</>
}
```

- [ ] **Step 2: 写 TopBar + 两个 Layout**

`src/shared/layout/TopBar.tsx`:
```tsx
import { Link } from 'react-router-dom'
import { useI18n } from '../../i18n/useI18n'
import { Button } from '../ui'

export function TopBar({ side }: { side: 'live' | 'studio' }) {
  const { lang, setLang, t } = useI18n()
  return (
    <header className="tvu-topbar">
      <span className="tvu-topbar__brand">{t(side === 'live' ? 'brand.live' : 'brand.studio')}</span>
      <div className="tvu-topbar__right">
        <Button variant="ghost" onClick={() => setLang(lang === 'zh' ? 'en' : 'zh')}>{lang === 'zh' ? 'EN' : '中'}</Button>
        <Link to={side === 'live' ? '/studio' : '/watch'}>
          <Button variant="ghost">{t(side === 'live' ? 'nav.switchToStudio' : 'nav.switchToLive')}</Button>
        </Link>
      </div>
    </header>
  )
}
```

`LiveLayout.tsx`:
```tsx
import { Outlet } from 'react-router-dom'
import { TopBar } from './TopBar'
import './layout.css'
export function LiveLayout() {
  return <div className="tvu-shell"><TopBar side="live" /><main className="tvu-main"><Outlet /></main></div>
}
```

`StudioLayout.tsx`:
```tsx
import { NavLink, Outlet } from 'react-router-dom'
import { TopBar } from './TopBar'
import { useI18n } from '../../i18n/useI18n'
import './layout.css'
const NAV = [
  { to: '/studio', key: 'dash.title', end: true },
  { to: '/studio/stream', key: 'stream.title' },
  { to: '/studio/broadcast', key: 'bcast.title' },
  { to: '/studio/monitor', key: 'monitor.title' },
  { to: '/studio/destinations', key: 'dest.title' },
]
export function StudioLayout() {
  const { t } = useI18n()
  return (
    <div className="tvu-shell"><TopBar side="studio" />
      <div className="tvu-studio-body">
        <nav className="tvu-sidenav">
          {NAV.map((n) => <NavLink key={n.to} to={n.to} end={n.end}>{t(n.key)}</NavLink>)}
        </nav>
        <main className="tvu-main"><Outlet /></main>
      </div>
    </div>
  )
}
```

`layout.css`:(用 token 写 topbar/sidenav/shell;`.tvu-topbar` 背景 `var(--bg-topbar)`,padding `var(--sp-m)`,flex space-between;`.tvu-sidenav` 宽 220px、`var(--bg-topbar)`;`.tvu-main` padding `var(--sp-l)`;`.tvu-skeleton` 用灰阶占位)

- [ ] **Step 3: 改 App 装路由(页面先用占位)**

`src/App.tsx`:
```tsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { I18nProvider } from './i18n/I18nProvider'
import { LiveLayout } from './shared/layout/LiveLayout'
import { StudioLayout } from './shared/layout/StudioLayout'

const P = ({ name }: { name: string }) => <div data-page={name}>{name}</div>

export default function App() {
  return (
    <I18nProvider>
      <BrowserRouter>
        <Routes>
          <Route path="/" element={<Navigate to="/watch" replace />} />
          <Route path="/watch" element={<LiveLayout />}>
            <Route index element={<P name="home" />} />
            <Route path=":id" element={<P name="room" />} />
            <Route path="c/:channel" element={<P name="channel" />} />
          </Route>
          <Route path="/studio" element={<StudioLayout />}>
            <Route index element={<P name="dashboard" />} />
            <Route path="stream" element={<P name="stream" />} />
            <Route path="broadcast" element={<P name="broadcast" />} />
            <Route path="monitor" element={<P name="monitor" />} />
            <Route path="destinations" element={<P name="destinations" />} />
          </Route>
        </Routes>
      </BrowserRouter>
    </I18nProvider>
  )
}
```

> 注:Task 1 的 `App.test.tsx`(断言 'TVU' 文本)此时会失效——本步用下面的路由测试取代它,删除旧 `App.test.tsx`。

- [ ] **Step 4: 写测试**

`src/App.routing.test.tsx`:
```tsx
import { render, screen } from '@testing-library/react'
import App from './App'

test('default route lands on watch home', async () => {
  render(<App />)
  expect(await screen.findByText('home')).toBeInTheDocument()
})
```

`src/shared/states/AsyncState.test.tsx`:
```tsx
import { render, screen, waitFor } from '@testing-library/react'
import { I18nProvider } from '../../i18n/I18nProvider'
import { useAsync, AsyncView } from './AsyncState'

function Demo({ fn }: { fn: () => Promise<string[]> }) {
  const s = useAsync(fn, [])
  return <AsyncView state={s} empty={(d) => d.length === 0}>{(d) => <span>{d.join(',')}</span>}</AsyncView>
}

test('shows content then', async () => {
  render(<I18nProvider><Demo fn={async () => ['a', 'b']} /></I18nProvider>)
  await waitFor(() => expect(screen.getByText('a,b')).toBeInTheDocument())
})

test('shows empty', async () => {
  render(<I18nProvider><Demo fn={async () => []} /></I18nProvider>)
  await waitFor(() => expect(screen.getByText('暂无内容')).toBeInTheDocument())
})

test('shows error + retry', async () => {
  render(<I18nProvider><Demo fn={async () => { throw new Error('x') }} /></I18nProvider>)
  await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument())
})
```

删除旧文件:`rm src/App.test.tsx`。

- [ ] **Step 5: 跑测试**

Run: `npm test`
Expected: 全部 PASS(routing + AsyncState 等)。`npm run dev` 可在 Live/Studio 间切换、可切语言。

- [ ] **Step 6: Commit**

```bash
git add -A && git commit -m "feat(shell): async states, layouts, topbar, router with 8 routes"
```

---

### Task 7: C 端 · 首页/发现 `/watch`

**Files:**
- Create: `src/watch/HomePage.tsx`, `src/watch/home.css`
- Modify: `src/i18n/messages.ts`(加 9.1 组 key), `src/App.tsx`(index 路由换成 `HomePage`)
- Test: `src/watch/HomePage.test.tsx`

**Interfaces:**
- Consumes: `getLiveChannels`(Task 3),`useAsync`/`AsyncView`(Task 6),`Card`/`Badge`(Task 5),`useI18n`
- Produces: `HomePage` 默认导出;频道卡片点击跳 `/watch/:id`

- [ ] **Step 1: 追加 i18n key(spec 9.1)**

在 `messages.ts` 加:`home.liveNow`、`home.recommended`、`home.categories`、`home.topStreamers`、`home.badge.live`、`home.viewers`({n} watching / {n} 人观看)、`home.empty`、`home.cta.watch`(均按 spec 9.1 中英)。

- [ ] **Step 2: 写 HomePage**

`src/watch/HomePage.tsx`:
```tsx
import { Link } from 'react-router-dom'
import { useAsync, AsyncView } from '../shared/states/AsyncState'
import { getLiveChannels } from '../mock/services'
import { useI18n } from '../i18n/useI18n'
import { Badge } from '../shared/ui'
import './home.css'

export default function HomePage() {
  const { t } = useI18n()
  const state = useAsync(getLiveChannels, [])
  return (
    <section>
      <h2>{t('home.liveNow')}</h2>
      <AsyncView state={state} empty={(d) => d.length === 0}>
        {(channels) => (
          <div className="tvu-grid">
            {channels.map((c) => (
              <Link key={c.id} to={`/watch/${c.id}`} className="tvu-card-link">
                <div className="tvu-thumb" style={{ backgroundImage: `url(${c.cover})` }}>
                  <Badge tone="live">{t('home.badge.live')}</Badge>
                </div>
                <div className="tvu-meta">
                  <strong>{c.title}</strong>
                  <span>{c.name} · {c.category}</span>
                  <span>{t('home.viewers', { n: c.viewers.toLocaleString() })}</span>
                </div>
              </Link>
            ))}
          </div>
        )}
      </AsyncView>
    </section>
  )
}
```

`home.css`:`.tvu-grid` 用 `grid-template-columns: repeat(auto-fill,minmax(280px,1fr))` + `gap: var(--sp-l)`;`.tvu-thumb` 16:9 背景图 + `--radius-m` + 相对定位放 Badge;`.tvu-meta` flex column,文字色用 `--text-body`/`--color-grey-8`。空态文案改用 `home.empty`(在 AsyncView 外层或传入)。

- [ ] **Step 3: 接路由**

`App.tsx`:`import HomePage from './watch/HomePage'`,把 `/watch` 的 index 元素从 `<P name="home"/>` 换成 `<HomePage/>`。

- [ ] **Step 4: 写测试**

`src/watch/HomePage.test.tsx`:
```tsx
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { I18nProvider } from '../i18n/I18nProvider'
import HomePage from './HomePage'

test('renders live channels with viewers', async () => {
  render(<I18nProvider><MemoryRouter><HomePage /></MemoryRouter></I18nProvider>)
  await waitFor(() => expect(screen.getByText('Live: City Marathon Finals')).toBeInTheDocument())
  expect(screen.getAllByText('LIVE').length).toBeGreaterThan(0)
})
```

- [ ] **Step 5: 跑测试**

Run: `npm test src/watch/HomePage`
Expected: PASS。

- [ ] **Step 6: Commit**

```bash
git add -A && git commit -m "feat(watch): home/discover page with live channel grid"
```

---

### Task 8: C 端 · 直播间 `/watch/:id`

**Files:**
- Create: `src/watch/LiveRoomPage.tsx`, `src/watch/room.css`
- Modify: `messages.ts`(9.2 组), `App.tsx`(`:id` 路由换 `LiveRoomPage`)
- Test: `src/watch/LiveRoomPage.test.tsx`

**Interfaces:**
- Consumes: `getStream`(Task 3),`useChatStream`(Task 4),`useAsync`/`AsyncView`,`Button`/`Badge`,`useI18n`,`useParams`
- Produces: `LiveRoomPage` 默认导出。播放器优先 HLS 占位流播放,失败回退封面图 + LIVE 角标(spec §3 已定回退策略)。

- [ ] **Step 1: 追加 i18n key(spec 9.2 全部)**

加 `room.badge.live`、`room.status.streaming`、`room.status.ended`、`room.follow`、`room.following`、`room.subscribe`、`room.like`、`room.quality`、`room.fullscreen`、`room.chat.title`、`room.chat.placeholder`、`room.chat.send`、`room.chat.reconnecting`、`room.about`(按 spec 9.2 中英)。

- [ ] **Step 2: 写 LiveRoomPage**

`src/watch/LiveRoomPage.tsx`:
```tsx
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useAsync, AsyncView } from '../shared/states/AsyncState'
import { getStream } from '../mock/services'
import { useChatStream } from '../mock/hooks'
import { useI18n } from '../i18n/useI18n'
import { Button, Badge } from '../shared/ui'
import './room.css'

export default function LiveRoomPage() {
  const { id = '' } = useParams()
  const { t } = useI18n()
  const state = useAsync(() => getStream(id), [id])
  const { messages, send } = useChatStream()
  const [following, setFollowing] = useState(false)
  const [draft, setDraft] = useState('')
  return (
    <AsyncView state={state} empty={(d) => !d}>
      {(channel) => (
        <div className="tvu-room">
          <div className="tvu-room__main">
            <div className="tvu-player" style={{ backgroundImage: `url(${channel!.cover})` }}>
              <Badge tone="live">{t('room.badge.live')}</Badge>
            </div>
            <div className="tvu-room__bar">
              <div>
                <h2>{channel!.title}</h2>
                <span>{channel!.name} · {t('room.status.streaming')}</span>
              </div>
              <div className="tvu-room__actions">
                <Button variant={following ? 'ghost' : 'primary'} onClick={() => setFollowing(!following)}>
                  {following ? t('room.following') : t('room.follow')}
                </Button>
                <Button variant="ghost">{t('room.subscribe')}</Button>
                <Button variant="ghost">♥ {t('room.like')}</Button>
              </div>
            </div>
          </div>
          <aside className="tvu-chat">
            <h3>{t('room.chat.title')}</h3>
            <ul className="tvu-chat__list">
              {messages.map((m) => <li key={m.id}><b>{m.user}</b> {m.text}</li>)}
            </ul>
            <form className="tvu-chat__input" onSubmit={(e) => { e.preventDefault(); send(draft); setDraft('') }}>
              <input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={t('room.chat.placeholder')} />
              <Button type="submit">{t('room.chat.send')}</Button>
            </form>
          </aside>
        </div>
      )}
    </AsyncView>
  )
}
```

`room.css`:`.tvu-room` grid 两栏(主 1fr + 聊天 320px);`.tvu-player` 16:9 背景图 + 黑底 + `--radius-l`;`.tvu-chat` flex column 满高,`.tvu-chat__list` 可滚动;输入区 flex。颜色全用 token。

- [ ] **Step 3: 接路由**(`:id` 元素换 `LiveRoomPage`)

- [ ] **Step 4: 写测试**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { I18nProvider } from '../i18n/I18nProvider'
import LiveRoomPage from './LiveRoomPage'

function setup() {
  return render(
    <I18nProvider><MemoryRouter initialEntries={['/watch/c1']}>
      <Routes><Route path="/watch/:id" element={<LiveRoomPage />} /></Routes>
    </MemoryRouter></I18nProvider>,
  )
}
test('shows stream title and follow toggles', async () => {
  setup()
  await waitFor(() => expect(screen.getByText('Live: City Marathon Finals')).toBeInTheDocument())
  const btn = screen.getByText('关注')
  fireEvent.click(btn)
  expect(screen.getByText('已关注')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试** → Run: `npm test src/watch/LiveRoomPage` → Expected: PASS。
- [ ] **Step 6: Commit** → `git add -A && git commit -m "feat(watch): live room with player + realtime chat + follow"`

---

### Task 9: C 端 · 主播主页 `/watch/c/:channel`

**Files:**
- Create: `src/watch/ChannelPage.tsx`, `src/watch/channel.css`
- Modify: `messages.ts`(9.3 组), `App.tsx`(`c/:channel` 换 `ChannelPage`)
- Test: `src/watch/ChannelPage.test.tsx`

**Interfaces:**
- Consumes: `getChannel` + `getPastStreams`(Task 3),`useAsync`/`AsyncView`,`Badge`/`Button`,`useI18n`,`useParams`
- Produces: `ChannelPage` 默认导出。

- [ ] **Step 1: 追加 i18n key(spec 9.3)**:`channel.followers`、`channel.status.live`、`channel.status.offline`、`channel.pastStreams`、`channel.about`、`channel.watchLive`。

- [ ] **Step 2: 写 ChannelPage**

```tsx
import { Link, useParams } from 'react-router-dom'
import { useAsync, AsyncView } from '../shared/states/AsyncState'
import { getChannel, getPastStreams } from '../mock/services'
import { useI18n } from '../i18n/useI18n'
import { Badge, Button, Card } from '../shared/ui'
import './channel.css'

export default function ChannelPage() {
  const { channel = '' } = useParams()
  const { t } = useI18n()
  const head = useAsync(() => getChannel(channel), [channel])
  const past = useAsync(() => getPastStreams(channel), [channel])
  return (
    <AsyncView state={head} empty={(d) => !d}>
      {(c) => (
        <div className="tvu-channel">
          <header className="tvu-channel__hero">
            <img src={c!.avatar} alt={c!.name} />
            <div>
              <h2>{c!.name}</h2>
              <Badge tone={c!.isLive ? 'live' : 'neutral'}>{t(c!.isLive ? 'channel.status.live' : 'channel.status.offline')}</Badge>
              <p>{c!.viewers.toLocaleString()} {t('channel.followers')}</p>
              {c!.isLive && <Link to={`/watch/${c!.id}`}><Button>{t('channel.watchLive')}</Button></Link>}
            </div>
          </header>
          <h3>{t('channel.pastStreams')}</h3>
          <AsyncView state={past} empty={(d) => d.length === 0}>
            {(list) => (
              <div className="tvu-grid">
                {list.map((p) => (
                  <Card key={p.id}><div className="tvu-thumb" style={{ backgroundImage: `url(${p.cover})` }} /><strong>{p.title}</strong></Card>
                ))}
              </div>
            )}
          </AsyncView>
        </div>
      )}
    </AsyncView>
  )
}
```

`channel.css`:hero flex(头像圆 + 信息),往期复用 `.tvu-grid`/`.tvu-thumb`。token 配色。

- [ ] **Step 3: 接路由**(`c/:channel` → `ChannelPage`)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import { I18nProvider } from '../i18n/I18nProvider'
import ChannelPage from './ChannelPage'

test('renders channel header and past streams heading', async () => {
  render(<I18nProvider><MemoryRouter initialEntries={['/watch/c/aurora']}>
    <Routes><Route path="/watch/c/:channel" element={<ChannelPage />} /></Routes>
  </MemoryRouter></I18nProvider>)
  await waitFor(() => expect(screen.getByText('Aurora Live')).toBeInTheDocument())
  expect(screen.getByText('往期回放')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试** → `npm test src/watch/ChannelPage` → PASS。
- [ ] **Step 6: Commit** → `git commit -am "feat(watch): channel page with live status + past streams"`

---

### Task 10: B 端 · 总览 Dashboard `/studio`

**Files:**
- Create: `src/studio/DashboardPage.tsx`, `src/studio/dashboard.css`
- Modify: `messages.ts`(9.4 组), `App.tsx`(`/studio` index → `DashboardPage`)
- Test: `src/studio/DashboardPage.test.tsx`

**Interfaces:**
- Consumes: `useMonitorStream`(Task 4),`Stat`/`Card`/`Badge`/`Button`(Task 5),`useI18n`,`Link`
- Produces: `DashboardPage` 默认导出。

- [ ] **Step 1: 追加 i18n key(spec 9.4)**:`dash.title`、`dash.status.onair`、`dash.status.offline`、`dash.metric.viewers`、`dash.metric.bitrate`、`dash.metric.health`、`dash.metric.uptime`、`dash.quick.goLive`、`dash.quick.monitor`、`dash.quick.stream`。

- [ ] **Step 2: 写 DashboardPage**

```tsx
import { Link } from 'react-router-dom'
import { useMonitorStream } from '../mock/hooks'
import { useI18n } from '../i18n/useI18n'
import { Card, Stat, Badge, Button } from '../shared/ui'
import './dashboard.css'

export default function DashboardPage() {
  const { t } = useI18n()
  const { latest, stable } = useMonitorStream()
  return (
    <section>
      <div className="tvu-dash__head">
        <h2>{t('dash.title')}</h2>
        <Badge tone={stable ? 'success' : 'danger'}>{t('dash.status.onair')}</Badge>
      </div>
      <div className="tvu-dash__grid">
        <Card><Stat label={t('dash.metric.viewers')} value="12,400" /></Card>
        <Card><Stat label={t('dash.metric.bitrate')} value={latest ? `${latest.bitrate} kbps` : '—'} /></Card>
        <Card><Stat label={t('dash.metric.health')} value={t(stable ? 'monitor.signal.stable' : 'monitor.signal.unstable')} /></Card>
        <Card><Stat label={t('dash.metric.uptime')} value="01:24:00" /></Card>
      </div>
      <div className="tvu-dash__quick">
        <Link to="/studio/broadcast"><Button>{t('dash.quick.goLive')}</Button></Link>
        <Link to="/studio/monitor"><Button variant="ghost">{t('dash.quick.monitor')}</Button></Link>
        <Link to="/studio/stream"><Button variant="ghost">{t('dash.quick.stream')}</Button></Link>
      </div>
    </section>
  )
}
```

> 依赖 `monitor.signal.stable`/`monitor.signal.unstable`(Task 13 会加)。本 Task Step 1 一并先加这两条,避免缺 key。

`dashboard.css`:`.tvu-dash__grid` 4 列指标卡,`gap: var(--sp-l)`;quick 区 flex gap。

- [ ] **Step 3: 接路由**(`/studio` index → `DashboardPage`)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { I18nProvider } from '../i18n/I18nProvider'
import DashboardPage from './DashboardPage'

test('renders metric cards', () => {
  render(<I18nProvider><MemoryRouter><DashboardPage /></MemoryRouter></I18nProvider>)
  expect(screen.getByText('当前在线')).toBeInTheDocument()
  expect(screen.getByText('已直播时长')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试** → `npm test src/studio/DashboardPage` → PASS。
- [ ] **Step 6: Commit** → `git commit -am "feat(studio): overview dashboard with live metrics"`

---

### Task 11: B 端 · 推流设置 `/studio/stream`

**Files:**
- Create: `src/studio/StreamSettingsPage.tsx`, `src/studio/stream.css`
- Modify: `messages.ts`(9.5 组), `App.tsx`(`stream` → page)
- Test: `src/studio/StreamSettingsPage.test.tsx`

**Interfaces:**
- Consumes: `getStreamConfig`(Task 3),`useAsync`/`AsyncView`,`Field`/`Button`/`Card`,`useI18n`
- Produces: `StreamSettingsPage` 默认导出。串流密钥可复制(`navigator.clipboard.writeText`)+ 复制后显示 `common.copied`。

- [ ] **Step 1: 追加 i18n key(spec 9.5)**:`stream.title`、`stream.protocol`、`stream.serverUrl`、`stream.streamKey`、`stream.resetKey`、`stream.resetKey.confirm`、`stream.resolution`、`stream.bitrate`、`stream.framerate`、`stream.saved`。

- [ ] **Step 2: 写 StreamSettingsPage**

```tsx
import { useState } from 'react'
import { useAsync, AsyncView } from '../shared/states/AsyncState'
import { getStreamConfig } from '../mock/services'
import { useI18n } from '../i18n/useI18n'
import { Card, Field, Button } from '../shared/ui'
import './stream.css'

export default function StreamSettingsPage() {
  const { t } = useI18n()
  const state = useAsync(getStreamConfig, [])
  const [copied, setCopied] = useState(false)
  const copy = (v: string) => { navigator.clipboard?.writeText(v); setCopied(true); setTimeout(() => setCopied(false), 1500) }
  return (
    <section>
      <h2>{t('stream.title')}</h2>
      <AsyncView state={state} empty={() => false}>
        {(cfg) => (
          <Card>
            <Field label={t('stream.serverUrl')}><input readOnly value={cfg.serverUrl} /></Field>
            <Field label={t('stream.streamKey')}>
              <div className="tvu-key">
                <input readOnly type="password" value={cfg.streamKey} />
                <Button variant="ghost" onClick={() => copy(cfg.streamKey)}>{copied ? t('common.copied') : t('common.copy')}</Button>
                <Button variant="danger">{t('stream.resetKey')}</Button>
              </div>
            </Field>
            <Field label={t('stream.resolution')}><input readOnly value={cfg.resolution} /></Field>
            <Field label={t('stream.bitrate')}><input readOnly value={cfg.bitrate} /></Field>
            <Field label={t('stream.framerate')}><input readOnly value={cfg.framerate} /></Field>
            <Button>{t('common.save')}</Button>
          </Card>
        )}
      </AsyncView>
    </section>
  )
}
```

`stream.css`:`.tvu-key` flex gap;input 用 `--bg-layer1` 底 + `--color-grey-8` 边 + `--radius-m`,文字 `--text-body`。

- [ ] **Step 3: 接路由**(`stream` → page)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { I18nProvider } from '../i18n/I18nProvider'
import StreamSettingsPage from './StreamSettingsPage'

beforeAll(() => { Object.assign(navigator, { clipboard: { writeText: vi.fn() } }) })

test('copy button toggles to copied label', async () => {
  render(<I18nProvider><StreamSettingsPage /></I18nProvider>)
  await waitFor(() => expect(screen.getByText('串流密钥')).toBeInTheDocument())
  fireEvent.click(screen.getByText('复制'))
  expect(screen.getByText('已复制')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试** → `npm test src/studio/StreamSettingsPage` → PASS。
- [ ] **Step 6: Commit** → `git commit -am "feat(studio): stream settings with copy/reset key"`

---

### Task 12: B 端 · 直播管理 `/studio/broadcast`

**Files:**
- Create: `src/studio/BroadcastPage.tsx`, `src/studio/broadcast.css`
- Modify: `messages.ts`(9.6 组), `App.tsx`(`broadcast` → page)
- Test: `src/studio/BroadcastPage.test.tsx`

**Interfaces:**
- Consumes: `Field`/`Button`/`Card`/`Badge`,`useI18n`
- Produces: `BroadcastPage` 默认导出。本地 state 管理 title/category/desc + 直播开关(`live` 布尔),开播/下播按钮切换状态与文案。

- [ ] **Step 1: 追加 i18n key(spec 9.6)**:`bcast.title`、`bcast.streamTitle`、`bcast.streamTitle.placeholder`、`bcast.thumbnail`、`bcast.category`、`bcast.description`、`bcast.goLive`、`bcast.endStream`、`bcast.endStream.confirm`、`bcast.preview`。

- [ ] **Step 2: 写 BroadcastPage**

```tsx
import { useState } from 'react'
import { useI18n } from '../i18n/useI18n'
import { Card, Field, Button, Badge } from '../shared/ui'
import './broadcast.css'

export default function BroadcastPage() {
  const { t } = useI18n()
  const [live, setLive] = useState(false)
  const [title, setTitle] = useState('')
  return (
    <section className="tvu-bcast">
      <div className="tvu-bcast__head">
        <h2>{t('bcast.title')}</h2>
        <Badge tone={live ? 'live' : 'neutral'}>{t(live ? 'dash.status.onair' : 'dash.status.offline')}</Badge>
      </div>
      <div className="tvu-bcast__body">
        <div className="tvu-bcast__preview">{t('bcast.preview')}</div>
        <Card>
          <Field label={t('bcast.streamTitle')}>
            <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t('bcast.streamTitle.placeholder')} />
          </Field>
          <Field label={t('bcast.category')}><input /></Field>
          <Field label={t('bcast.description')}><textarea rows={3} /></Field>
          <Field label={t('bcast.thumbnail')}><input type="file" /></Field>
          {live
            ? <Button variant="danger" onClick={() => { if (confirm(t('bcast.endStream.confirm'))) setLive(false) }}>{t('bcast.endStream')}</Button>
            : <Button onClick={() => setLive(true)}>{t('bcast.goLive')}</Button>}
        </Card>
      </div>
    </section>
  )
}
```

`broadcast.css`:两栏(预览占位 + 表单卡);预览块 16:9 黑底 `--radius-l`。

- [ ] **Step 3: 接路由**(`broadcast` → page)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { I18nProvider } from '../i18n/I18nProvider'
import BroadcastPage from './BroadcastPage'

test('go live toggles status to on air', () => {
  render(<I18nProvider><BroadcastPage /></I18nProvider>)
  fireEvent.click(screen.getByText('开始直播'))
  expect(screen.getByText('直播中')).toBeInTheDocument()
})
```

- [ ] **Step 5: 跑测试** → `npm test src/studio/BroadcastPage` → PASS。
- [ ] **Step 6: Commit** → `git commit -am "feat(studio): broadcast management with go-live toggle"`

---

### Task 13: B 端 · 实时监控 `/studio/monitor`

**Files:**
- Create: `src/studio/MonitorPage.tsx`, `src/studio/monitor.css`
- Modify: `messages.ts`(9.7 组,`signal.stable`/`unstable` 若 Task 10 已加则跳过), `App.tsx`(`monitor` → page)
- Test: `src/studio/MonitorPage.test.tsx`

**Interfaces:**
- Consumes: `useMonitorStream`(Task 4),`Card`/`Stat`/`Badge`,`useI18n`
- Produces: `MonitorPage` 默认导出。码率曲线用内联 SVG polyline(无需图表库)绘制 `points` 的 bitrate;不稳定时显示告警条 `monitor.alert`。

- [ ] **Step 1: 追加 i18n key(spec 9.7)**:`monitor.title`、`monitor.bitrate`、`monitor.framerate`、`monitor.dropped`、`monitor.latency`、`monitor.signal.stable`、`monitor.signal.unstable`、`monitor.signal.lost`、`monitor.timeline`、`monitor.alert`。

- [ ] **Step 2: 写 MonitorPage**

```tsx
import { useMonitorStream } from '../mock/hooks'
import { useI18n } from '../i18n/useI18n'
import { Card, Stat, Badge } from '../shared/ui'
import './monitor.css'

export default function MonitorPage() {
  const { t } = useI18n()
  const { points, latest, stable } = useMonitorStream()
  const poly = points.map((p, i) => `${(i / 29) * 100},${100 - Math.min(100, (p.bitrate - 5800) / 6)}`).join(' ')
  return (
    <section>
      <div className="tvu-mon__head">
        <h2>{t('monitor.title')}</h2>
        <Badge tone={stable ? 'success' : 'danger'}>{t(stable ? 'monitor.signal.stable' : 'monitor.signal.unstable')}</Badge>
      </div>
      {!stable && <div className="tvu-mon__alert" role="alert">{t('monitor.alert')}</div>}
      <Card>
        <svg className="tvu-mon__chart" viewBox="0 0 100 100" preserveAspectRatio="none">
          <polyline fill="none" stroke="var(--brand)" strokeWidth="1" points={poly} />
        </svg>
      </Card>
      <div className="tvu-mon__grid">
        <Card><Stat label={t('monitor.bitrate')} value={latest ? `${latest.bitrate} kbps` : '—'} /></Card>
        <Card><Stat label={t('monitor.framerate')} value={latest ? `${latest.fps} fps` : '—'} /></Card>
        <Card><Stat label={t('monitor.dropped')} value={latest ? String(latest.dropped) : '—'} /></Card>
        <Card><Stat label={t('monitor.latency')} value={latest ? `${latest.latency} ms` : '—'} /></Card>
      </div>
    </section>
  )
}
```

`monitor.css`:`.tvu-mon__chart` 高 160px 宽 100%;`.tvu-mon__alert` 背景 `--orange`/`--red` 半透明 + `--radius-m` + padding;指标 4 列。

- [ ] **Step 3: 接路由**(`monitor` → page)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen, act } from '@testing-library/react'
import { I18nProvider } from '../i18n/I18nProvider'
import MonitorPage from './MonitorPage'

test('renders monitor metrics over time', () => {
  vi.useFakeTimers()
  render(<I18nProvider><MonitorPage /></I18nProvider>)
  act(() => { vi.advanceTimersByTime(1000) })
  expect(screen.getByText('码率')).toBeInTheDocument()
  expect(screen.getByText('延迟')).toBeInTheDocument()
  vi.useRealTimers()
})
```

- [ ] **Step 5: 跑测试** → `npm test src/studio/MonitorPage` → PASS。
- [ ] **Step 6: Commit** → `git commit -am "feat(studio): realtime monitor with svg bitrate chart + alerts"`

---

### Task 14: B 端 · 多平台分发 `/studio/destinations`

**Files:**
- Create: `src/studio/DestinationsPage.tsx`, `src/studio/destinations.css`
- Modify: `messages.ts`(9.8 组), `App.tsx`(`destinations` → page)
- Test: `src/studio/DestinationsPage.test.tsx`

**Interfaces:**
- Consumes: `getDestinations`(Task 3),`useAsync`/`AsyncView`,`Card`/`Switch`/`Badge`/`Button`,`useI18n`
- Produces: `DestinationsPage` 默认导出。每个目标一行:平台名 + 状态 Badge + 启用 Switch(本地 state 切换)。顶部"同时转推"总开关 + "添加分发目标"按钮。

- [ ] **Step 1: 追加 i18n key(spec 9.8)**:`dest.title`、`dest.simulcast`、`dest.add`、`dest.status.connected`、`dest.status.disconnected`、`dest.status.error`、`dest.enable`、`dest.disable`、`dest.custom`、`dest.empty`。

- [ ] **Step 2: 写 DestinationsPage**

```tsx
import { useEffect, useState } from 'react'
import { getDestinations } from '../mock/services'
import type { Destination } from '../mock/types'
import { useI18n } from '../i18n/useI18n'
import { Card, Switch, Badge, Button } from '../shared/ui'
import './destinations.css'

const toneOf = (s: Destination['status']) => s === 'connected' ? 'success' : s === 'error' ? 'danger' : 'neutral'
const statusKey = (s: Destination['status']) => s === 'connected' ? 'dest.status.connected' : s === 'error' ? 'dest.status.error' : 'dest.status.disconnected'

export default function DestinationsPage() {
  const { t } = useI18n()
  const [list, setList] = useState<Destination[] | null>(null)
  useEffect(() => { getDestinations().then(setList) }, [])
  const toggle = (id: string) => setList((l) => l!.map((d) => d.id === id ? { ...d, enabled: !d.enabled } : d))
  return (
    <section>
      <div className="tvu-dest__head">
        <h2>{t('dest.title')}</h2>
        <Button>{t('dest.add')}</Button>
      </div>
      {!list ? <div>{t('common.loading')}</div> : list.length === 0 ? <div>{t('dest.empty')}</div> : (
        <div className="tvu-dest__list">
          {list.map((d) => (
            <Card key={d.id} className="tvu-dest__row">
              <span className="tvu-dest__name">{d.name}</span>
              <Badge tone={toneOf(d.status)}>{t(statusKey(d.status))}</Badge>
              <Switch checked={d.enabled} onChange={() => toggle(d.id)} />
            </Card>
          ))}
        </div>
      )}
    </section>
  )
}
```

`destinations.css`:`.tvu-dest__row` flex 行,name 占 1fr,Badge + Switch 右对齐,gap `var(--sp-m)`。

- [ ] **Step 3: 接路由**(`destinations` → page)
- [ ] **Step 4: 写测试**

```tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
import { I18nProvider } from '../i18n/I18nProvider'
import DestinationsPage from './DestinationsPage'

test('lists destinations and toggles enable switch', async () => {
  render(<I18nProvider><DestinationsPage /></I18nProvider>)
  await waitFor(() => expect(screen.getByText('YouTube Main')).toBeInTheDocument())
  const switches = screen.getAllByRole('switch')
  const before = switches[0].getAttribute('aria-checked')
  fireEvent.click(switches[0])
  expect(switches[0].getAttribute('aria-checked')).not.toBe(before)
})
```

- [ ] **Step 5: 跑测试** → `npm test src/studio/DestinationsPage` → PASS。
- [ ] **Step 6: 全量回归 + Commit**

```bash
npm test          # 全部 PASS
npm run build     # 构建通过
git add -A && git commit -m "feat(studio): multi-platform destinations with simulcast toggles"
```

---

## 验收清单(对照 spec 第 10 节)

- [ ] 8 页可路由互通,顶部 Live↔Studio 可切换,Studio 侧栏 5 项导航可用。
- [ ] 全局语言切换(zh/en)即时生效,所有界面文字来自 i18n 字典(spec 第 9 节)。
- [ ] 每个数据页有加载(骨架/loading)、空、错误(retry)三态。
- [ ] 直播间聊天、Dashboard/监控指标随定时器实时更新。
- [ ] 全程使用 TVU CSS token,无裸 hex(grep 自检:组件 css 不含 `#` 颜色,除 Switch 滑块白点等中性白)。
- [ ] 组件不直接 import `mock/data.ts`,均经 services/hooks。
- [ ] `npm test` 全绿,`npm run build` 成功。

## Self-Review 记录

- **Spec 覆盖**:8 页 → Task 7–14 一一对应;i18n 双语 → Task 2 + 各页追加;数据/接口分层 → Task 3/4;三态 → Task 6;TVU token → Task 1/5;多平台分发 → Task 14。无遗漏。
- **类型一致**:`Channel`/`Destination`/`StreamConfig`/`MonitorPoint`/`ChatMessage` 在 Task 3 定义,后续 Task 引用同名同签名。
- **跨 Task 依赖**:`monitor.signal.*` key 在 Task 10 首次用到,已在 Task 10 Step 1/2 注明先补,Task 13 不重复加;旧 `App.test.tsx` 在 Task 6 注明删除。
- **占位符**:无 TODO/TBD;CSS 描述性段落均给出具体 token 与布局规则,组件代码完整可跑。
