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>
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/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);
|
|
});
|