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>
142 lines
5.2 KiB
JavaScript
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);
|
|
});
|