video-shuoshan/web/test/theme-screenshots.mjs
seaislee1209 f0f47e8368 feat(theme): 亮色主题切换完整实现 — dark/light 双套 var + Sidebar 切换 + 浅色色板
Stage 1 (var 化, 350 处): 425 处硬编码颜色 → CSS var, 涉及 49 个 tsx/css module 文件,
   按 hot files (DashboardPage/TeamDashboardPage/RecordDetailModal/ReferenceList) →
   Modal/Asset/Profile/Login → 生成页家族/管理后台/公共 UI 三波 8 个 sub-agent 并行处理。
   index.css :root 加 ~70 个新 var (modal/text 层级/状态色 bg 变体/chart/mention pill 等)。
Stage 2 (双套 var): :root 保留 DARK 默认值, [data-theme="light"] 覆盖 ~95 个 token。
   浅色色板按 Vercel Geist (#fafafa / #171717 / shadow-border) + Linear Light surface 分层规范,
   主色 #6c63ff → #5048cc 加深 18% 满足 WCAG AA。aurora 极光在 light 下 display:none。
Stage 3 (切换机制): 新建 store/theme.ts (Zustand + localStorage 持久化),
   Sidebar 加月亮/太阳 SVG 切换按钮 (位于头像上方),
   DashboardPage/TeamDashboardPage/ProfilePage 的 ECharts 配 key={theme} 强制重渲染。
Stage 4 (微调): LandingPage 强制 data-theme="dark" 保持品牌识别 (登录流程一直深色),
   sidebar bg / card bg / border 在浅色下加深 0.02 提升轮廓辨识度。
Stage 5 (验证): Playwright 头无浏览器自动登录 admin + screenshot_user, 截深/浅各 12 个页面 = 24 张
   到 docs/screenshots/ (本地档, .gitignore 排除 png 不入库)。
   vitest 71fail/162pass 与改造前基线完全一致, 无新增回归。

完成报告: docs/todo/亮色主题切换-完成报告.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:35 +08:00

142 lines
5.2 KiB
JavaScript

/**
* Theme switching visual regression — captures dark + light screenshots of key pages.
*
* Run from web/ directory after starting backend (port 8000) + dev server (port 5173):
* node test/theme-screenshots.mjs
*
* Output: ../docs/screenshots/<page>__<theme>.png
*/
import { chromium } from '@playwright/test';
import { mkdir } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(__dirname, '../../docs/screenshots');
const BASE = 'http://localhost:5173';
const API = 'http://localhost:8000';
const ADMIN = { username: 'admin', password: 'admin123' };
const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' };
/** Set theme directly via localStorage + html attribute, no UI click needed. */
async function setTheme(page, theme) {
await page.evaluate((t) => {
localStorage.setItem('airdrama-theme', t);
document.documentElement.dataset.theme = t;
}, theme);
// Reload to ensure ECharts and any once-mounted styles re-init
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
}
/** Programmatic login: POST to API → seed tokens into localStorage → navigate. */
async function login(page, creds) {
const res = await page.request.post(`${API}/api/v1/auth/login`, { data: creds });
if (!res.ok()) throw new Error(`login ${creds.username} failed: ${res.status()} ${await res.text()}`);
const body = await res.json();
const access = body?.tokens?.access;
const refresh = body?.tokens?.refresh;
const user = body?.user;
if (!access) throw new Error(`login ${creds.username}: no access token in response`);
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.evaluate(({ access, refresh, user }) => {
localStorage.setItem('access_token', access);
if (refresh) localStorage.setItem('refresh_token', refresh);
if (user) localStorage.setItem('user', JSON.stringify(user));
}, { access, refresh, user });
}
async function shot(page, slug, theme) {
const file = resolve(OUT_DIR, `${slug}__${theme}.png`);
await page.screenshot({ path: file, fullPage: false });
console.log(`${slug}__${theme}.png`);
}
/** Visit URL, wait for network idle + a settle timeout, then screenshot in both themes. */
async function visitAndCapture(page, slug, url, opts = {}) {
for (const theme of ['dark', 'light']) {
await setTheme(page, theme);
await page.goto(`${BASE}${url}`, { waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(opts.settle ?? 800);
if (opts.afterLoad) await opts.afterLoad(page);
await shot(page, slug, theme);
}
}
async function main() {
await mkdir(OUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const page = await ctx.newPage();
// Mute console errors (API 4xx/5xx in empty DB are noisy but expected)
page.on('pageerror', () => {});
page.on('console', () => {});
console.log(`▼ Capturing to ${OUT_DIR}`);
// 1. Login page (no auth needed) — use a fresh context so localStorage is clean
console.log('\n[1/12] /login');
for (const theme of ['dark', 'light']) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await setTheme(page, theme);
await page.waitForTimeout(500);
await shot(page, '01_login', theme);
}
// 2. Login as admin → admin pages
console.log('\n[2/12] admin login');
await login(page, ADMIN);
await visitAndCapture(page, '02_admin_dashboard', '/admin/dashboard', { settle: 1500 });
console.log('[3/12] /admin/dashboard');
await visitAndCapture(page, '03_admin_users', '/admin/users');
console.log('[4/12] /admin/users');
await visitAndCapture(page, '04_admin_records', '/admin/records');
console.log('[5/12] /admin/records');
await visitAndCapture(page, '05_admin_settings', '/admin/settings');
console.log('[6/12] /admin/settings');
await visitAndCapture(page, '06_admin_security', '/admin/security');
console.log('[7/12] /admin/security');
await visitAndCapture(page, '07_admin_logs', '/admin/logs');
console.log('[8/12] /admin/logs');
await visitAndCapture(page, '08_admin_assets', '/admin/assets');
console.log('[9/12] /admin/assets');
// 3. Switch to team_admin user → generation + profile + team pages
console.log('\n[10/12] team_user login');
await ctx.clearCookies();
await page.evaluate(() => localStorage.clear());
await login(page, TEAM_USER);
await visitAndCapture(page, '09_generation', '/app', { settle: 1200 });
console.log('[10/12] /app');
await visitAndCapture(page, '10_profile', '/profile', { settle: 1200 });
console.log('[11/12] /profile');
await visitAndCapture(page, '11_team_dashboard', '/team/dashboard', { settle: 1500 });
console.log('[12/12] /team/dashboard');
await visitAndCapture(page, '12_team_members', '/team/members');
console.log('[12/12] /team/members');
await browser.close();
console.log('\n✅ done');
}
main().catch((err) => {
console.error('❌ screenshot run failed:', err);
process.exit(1);
});