video-shuoshan/web/test/theme-screenshots-v2.mjs
seaislee1209 f8a39d55c7 feat(theme): 浅色主题 V2 — 玻璃质感重做 + LandingPage 浅色化 + 双语言系统
Phase A: index.css 完全重写 [data-theme="light"] block
  - 玻璃方向修正: bg-card 从 rgba(0,0,0,0.05) 黑透明 → 双 token 拆分
    --color-bg-card: #ffffff 实体白 (admin 卡)
    --color-bg-glass: rgba(255,255,255,0.65) 透明白玻璃 (sidebar/modal/banner)
  - Aurora 浅色不再 display:none, 改 pastel 紫蓝桃 0.20-0.32 alpha
  - Inset highlight 方向反转: 浅色用 rgba(255,255,255,0.50) 白高光 (玻璃顶边标志)
  - Backdrop-filter 五档标准: --bf-glass-sm/md/lg/xl (12-40px + saturate 140-180%)
  - Multi-layer shadow: --shadow-card-light (2 stops) + --shadow-glass-light (3 stops + inset)
  - 暖调 chip: --color-chip-warm-* GitBook 公告风格
  - 文字主色: #171823 微紫 → #171717 Vercel Black

Phase B: LandingPage + AuroraCanvas 浅色化
  - 移除 LandingPage 的 data-theme="dark" 强制 (V1 的回避)
  - LandingPage.module.css 21 处颜色全 var 化
  - AuroraCanvas: 订阅 useThemeStore, 新 LIGHT_ORBS 数组 pastel 紫蓝桃,
    vignette 浅色用白色, grain opacity 减半

Phase C: 13 个玻璃面升级 (3 sub-agent 并行)
  - Modal 类 (Login/ForceChange/VideoDetail.infoPanel/RecordDetail/AssetLibrary/
    Announcement/Confirm/TeamsPage.detailModal): 接入 bg-modal-glass +
    bf-glass-lg/xl + shadow-glass-light (含 inset highlight)
  - Bar/Dropdown/Toast (AnnouncementBanner/Toast/Dropdown/Select/DatePicker):
    bg-glass-strong + bf-glass-md + inset-highlight
  - Sidebar + 生成页 (Sidebar/PromptInput/GenerationCard): glass + 顶边白高光
  - AnnouncementBanner 写双套独立 [data-theme] 规则 (CSS gradient 内不能 var alpha)

Phase D: admin 实体卡 multi-layer shadow (13 处, 1 sub-agent)
  - DashboardPage / TeamsPage / UsersPage / RecordsPage / AdminAssetsPage /
    LoginRecordsPage / AuditLogsPage / ProfilePage / SettingsPage
    的 .statCard / .tableWrapper / .chartWrapper / .accordionItem 等
    加 var(--shadow-card-light) 双层柔阴影

AdminLayout 修复 (V1 漏的):
  - .layout 改 transparent, 让 AmbientBackground pastel aurora 在主区透出
  - .sidebar 加 bf-glass-md + inset highlight + 立体阴影

LoginModal / ForceChangePassword 残留 mint 清理:
  - submitBtn bg/border/color 用 mint-accent var, 字重 500→600 + 字距 0.04em
  - input:focus border 用 var(--color-mint-accent)
  - 加 bf-glass-sm + inset highlight

验证:
  - TS 编译过
  - vitest 71 fail / 162 pass 与 V1 基线完全一致, 无新增回归
  - 24 张 V2 截图位于 docs/screenshots/v2/ (本地, .gitignore 排除 png)

完成报告: docs/todo/亮色主题切换V2-完成报告.md
V2 plan: docs/todo/亮色主题切换V2.md
视觉对齐稿: docs/todo/showcase.html

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:46:55 +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/v2');
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);
});