test+fix(theme): V2 smoke test (25/25 pass) + 修复 AdminLayout 缺主题切换按钮
新增功能回归 smoke test 覆盖 V2 改动点:
test/v2-smoke.mjs — 25 个端到端检查,涵盖:
1) LoginPage + LoginModal 浅色渲染 (4 项)
2) admin 真实登录流程 + access_token 持久化 (2 项)
3) 主题切换端到端 — 默认 dark / 点切换 → light / localStorage 持久化 /
刷新保持 / 来回切换 (6 项)
4) Admin 7 个路由侧栏导航 (7 项)
5) 团管 Sidebar + 生成页 InputBar 核心元素 (4 项 + 公告 soft-skip)
收尾: 全程无 console.error / 无 page crash (2 项)
发现并修复 1 个真 bug:
AdminLayout (super admin 后台) 缺主题切换按钮。
原因:V1 只给 components/Sidebar.tsx (76px 团管侧栏) 加了 toggle 按钮,
pages/AdminLayout.tsx (220px super admin 侧栏) 漏了。super admin 无法切主题。
修复:在 AdminLayout sidebarFooter 加月亮/太阳 SVG 切换按钮,
跟 components/Sidebar 一致;收起态只显示 icon,展开态 icon + 文字。
新增 .themeToggle class 样式 (hover bg + primary color)。
验证: tsc 过 / smoke 25/25 / 无 console.error / 无 page crash
本地 commit dev 不 push
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0c9aaa11cd
commit
5f30258112
@ -110,6 +110,28 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Theme toggle button (super admin sidebar) */
|
||||
.themeToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.themeToggle:hover {
|
||||
background: var(--color-sidebar-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useThemeStore } from '../store/theme';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { authApi } from '../lib/api';
|
||||
import logoImg from '../assets/logo_32.png';
|
||||
@ -20,6 +21,8 @@ const navItems = [
|
||||
export function AdminLayout() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
const toggleTheme = useThemeStore((s) => s.toggleTheme);
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [pwModalOpen, setPwModalOpen] = useState(false);
|
||||
@ -94,6 +97,25 @@ export function AdminLayout() {
|
||||
</nav>
|
||||
|
||||
<div className={styles.sidebarFooter}>
|
||||
{/* 主题切换 — 月亮/太阳 SVG,跟 components/Sidebar 一致 */}
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
|
||||
aria-label={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '浅色' : '深色'}</span>}
|
||||
</button>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
|
||||
{!collapsed && (
|
||||
|
||||
287
web/test/v2-smoke.mjs
Normal file
287
web/test/v2-smoke.mjs
Normal file
@ -0,0 +1,287 @@
|
||||
/**
|
||||
* V2 Smoke Test — 验证主题切换/登录/导航/Modal 等关键交互在 V2 改动下没坏。
|
||||
*
|
||||
* Run: node test/v2-smoke.mjs
|
||||
* Pre-req: backend 8000 + frontend 5173 都已启动
|
||||
*/
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const BASE = 'http://localhost:5173';
|
||||
const API = 'http://localhost:8000';
|
||||
const ADMIN = { username: 'admin', password: 'admin123' };
|
||||
const TEAM_USER = { username: 'screenshot_user', password: 'shotpass123' };
|
||||
|
||||
const results = [];
|
||||
const errors = [];
|
||||
|
||||
function pass(name) {
|
||||
results.push({ name, status: '✓ PASS' });
|
||||
console.log(` ✓ ${name}`);
|
||||
}
|
||||
function fail(name, err) {
|
||||
results.push({ name, status: '✗ FAIL' });
|
||||
errors.push({ name, err: err?.message || String(err) });
|
||||
console.log(` ✗ ${name}: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
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}: ${res.status()}`);
|
||||
const body = await res.json();
|
||||
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: body?.tokens?.access, refresh: body?.tokens?.refresh, user: body?.user });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
const consoleErrors = [];
|
||||
const pageErrors = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
const t = msg.text();
|
||||
// 忽略已知 API 401/404 (test_db 数据缺失) + DevTools React 警告
|
||||
if (/401|404|Failed to load resource|Download the React DevTools/.test(t)) return;
|
||||
consoleErrors.push(t);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (e) => pageErrors.push(e.message));
|
||||
|
||||
console.log('\n══════════════════════════════════════════');
|
||||
console.log(' V2 SMOKE TEST — 主题切换 / 登录 / 导航');
|
||||
console.log('══════════════════════════════════════════');
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Group 1: Login page (no auth) + LoginModal submitBtn
|
||||
// ─────────────────────────────────────
|
||||
console.log('\n[1/5] Login Page + LoginModal');
|
||||
try {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// LoginModal 应自动弹出 (autoLogin)
|
||||
const hasLoginModal = await page.locator('input[type="text"]').first().isVisible();
|
||||
if (hasLoginModal) pass('1.1 LoginModal 自动弹出 (autoLogin)');
|
||||
else fail('1.1 LoginModal 自动弹出', new Error('找不到用户名输入框'));
|
||||
|
||||
// 数据-theme 属性默认应 dark
|
||||
const theme1 = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (theme1 === 'dark') pass('1.2 默认主题=dark');
|
||||
else fail('1.2 默认主题=dark', new Error(`got ${theme1}`));
|
||||
|
||||
// V2: 浅色切换后 LoginModal 仍可见 (LandingPage data-theme="dark" 被移除)
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('airdrama-theme', 'light');
|
||||
document.documentElement.dataset.theme = 'light';
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
const lightLoginVisible = await page.locator('input[type="text"]').first().isVisible();
|
||||
if (lightLoginVisible) pass('1.3 浅色 LoginModal 仍渲染 (V2 关键)');
|
||||
else fail('1.3 浅色 LoginModal 仍渲染', new Error('浅色下 LoginModal 不可见'));
|
||||
|
||||
// submitBtn 仍可点 (V2 字重 600 + 加 box-shadow,不改 onClick)
|
||||
const submitBtn = page.locator('button:has-text("登录")').first();
|
||||
const submitVisible = await submitBtn.isVisible();
|
||||
if (submitVisible) pass('1.4 submitBtn 在浅色下仍可见可点');
|
||||
else fail('1.4 submitBtn', new Error('登录按钮不可见'));
|
||||
} catch (e) {
|
||||
fail('1.x LoginPage block', e);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Group 2: 实际登录 — admin
|
||||
// ─────────────────────────────────────
|
||||
console.log('\n[2/5] 登录 admin 走完真实流程');
|
||||
try {
|
||||
await page.evaluate(() => { localStorage.clear(); });
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
await page.locator('input[type="text"]').first().fill(ADMIN.username);
|
||||
await page.locator('input[type="password"]').first().fill(ADMIN.password);
|
||||
await page.locator('button:has-text("登录")').first().click();
|
||||
await page.waitForURL((url) => url.pathname.startsWith('/admin'), { timeout: 8000 });
|
||||
pass('2.1 admin 登录成功 → 跳转 /admin/*');
|
||||
|
||||
// access_token 已持久化
|
||||
const hasToken = await page.evaluate(() => !!localStorage.getItem('access_token'));
|
||||
if (hasToken) pass('2.2 access_token 写入 localStorage');
|
||||
else fail('2.2 access_token', new Error('localStorage 没有 access_token'));
|
||||
} catch (e) {
|
||||
fail('2.x admin 登录', e);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Group 3: Theme toggle 端到端
|
||||
// ─────────────────────────────────────
|
||||
console.log('\n[3/5] Theme Toggle 端到端');
|
||||
try {
|
||||
// 确保深色态进入
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('airdrama-theme', 'dark');
|
||||
document.documentElement.dataset.theme = 'dark';
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (themeBefore === 'dark') pass('3.1 初始 theme=dark');
|
||||
else fail('3.1 初始 theme', new Error(`got ${themeBefore}`));
|
||||
|
||||
// 找 Sidebar 切换按钮 (aria-label 含 "切换到浅色主题")
|
||||
const toggle = page.locator('button[aria-label*="切换"]').first();
|
||||
const toggleVisible = await toggle.isVisible();
|
||||
if (toggleVisible) pass('3.2 Sidebar 主题切换按钮可见');
|
||||
else fail('3.2 切换按钮', new Error('找不到切换按钮'));
|
||||
|
||||
if (toggleVisible) {
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(500);
|
||||
const themeAfter = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (themeAfter === 'light') pass('3.3 点按钮 → theme=light');
|
||||
else fail('3.3 切换到 light', new Error(`got ${themeAfter}`));
|
||||
|
||||
// localStorage 持久化
|
||||
const stored = await page.evaluate(() => localStorage.getItem('airdrama-theme'));
|
||||
if (stored === 'light') pass('3.4 localStorage 持久化 light');
|
||||
else fail('3.4 localStorage', new Error(`got ${stored}`));
|
||||
|
||||
// 刷新后保持
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
const themePersisted = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (themePersisted === 'light') pass('3.5 刷新后 theme=light 保持');
|
||||
else fail('3.5 刷新持久化', new Error(`got ${themePersisted}`));
|
||||
|
||||
// 切回深色
|
||||
await toggle.click();
|
||||
await page.waitForTimeout(300);
|
||||
const themeBack = await page.evaluate(() => document.documentElement.dataset.theme);
|
||||
if (themeBack === 'dark') pass('3.6 再点 → theme=dark (来回切换 OK)');
|
||||
else fail('3.6 切回 dark', new Error(`got ${themeBack}`));
|
||||
}
|
||||
} catch (e) {
|
||||
fail('3.x theme toggle', e);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Group 4: Admin sidebar nav (V2 AdminLayout transparent + glass 改动)
|
||||
// ─────────────────────────────────────
|
||||
console.log('\n[4/5] Admin 侧栏导航');
|
||||
const adminRoutes = [
|
||||
{ name: '用户管理', url: '/admin/users' },
|
||||
{ name: '消费记录', url: '/admin/records' },
|
||||
{ name: '内容资产', url: '/admin/assets' },
|
||||
{ name: '系统设置', url: '/admin/settings' },
|
||||
{ name: '安全日志', url: '/admin/security' },
|
||||
{ name: '操作日志', url: '/admin/logs' },
|
||||
{ name: '团队管理', url: '/admin/teams' },
|
||||
];
|
||||
try {
|
||||
for (const r of adminRoutes) {
|
||||
try {
|
||||
await page.goto(`${BASE}${r.url}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(600);
|
||||
// 页面 mount 标志:body 不为空 + 找到 sidebar 主导航
|
||||
const sidebarVisible = await page.locator('text=AirDrama Admin').first().isVisible().catch(() => false);
|
||||
if (sidebarVisible) pass(`4.x ${r.name} (${r.url}) 渲染 OK + sidebar 在`);
|
||||
else fail(`4.x ${r.name}`, new Error('sidebar 或页面没渲染'));
|
||||
} catch (e) {
|
||||
fail(`4.x ${r.name}`, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
fail('4.x admin 导航', e);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// Group 5: Team user → 生成页 + Modal 交互
|
||||
// ─────────────────────────────────────
|
||||
console.log('\n[5/5] 团管用户 → 生成页 + AnnouncementModal');
|
||||
try {
|
||||
await ctx.clearCookies();
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await login(page, TEAM_USER);
|
||||
await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// 生成页 sidebar 可见 (component/Sidebar 76px 窄版,V2 玻璃)
|
||||
const genSidebar = page.locator('aside').first();
|
||||
const sidebarOk = await genSidebar.isVisible();
|
||||
if (sidebarOk) pass('5.1 生成页 Sidebar (76px 玻璃版) 可见');
|
||||
else fail('5.1 生成页 Sidebar', new Error('找不到 aside 元素'));
|
||||
|
||||
// 主题切换按钮在
|
||||
const themeBtn = page.locator('button[aria-label*="切换"]').first();
|
||||
const themeBtnOk = await themeBtn.isVisible();
|
||||
if (themeBtnOk) pass('5.2 团管 Sidebar 主题切换按钮在');
|
||||
else fail('5.2 主题切换按钮', new Error('团管侧栏没切换按钮'));
|
||||
|
||||
// 公告弹窗 — first-visit only,所以用 soft check (有就关掉,没有跳过)
|
||||
const announcement = page.locator('text=AirDrama 使用说明').first();
|
||||
const annOk = await announcement.isVisible({ timeout: 1500 }).catch(() => false);
|
||||
if (annOk) {
|
||||
pass('5.3 AnnouncementModal 弹出 (首次访问)');
|
||||
const closeBtn = page.locator('button:has-text("我知道了")').first();
|
||||
if (await closeBtn.isVisible().catch(() => false)) {
|
||||
await closeBtn.click();
|
||||
await page.waitForTimeout(400);
|
||||
const annStillOpen = await announcement.isVisible().catch(() => false);
|
||||
if (!annStillOpen) pass('5.4 公告 modal 关闭按钮 OK');
|
||||
else fail('5.4 公告关闭', new Error('点了我知道了 modal 还在'));
|
||||
}
|
||||
} else {
|
||||
pass('5.3 公告 modal soft-skip (已读过)');
|
||||
}
|
||||
|
||||
// 5.5 生成页 InputBar 核心元素 — prompt 输入区可见
|
||||
const promptInput = page.locator('[contenteditable="true"]').first();
|
||||
const inputOk = await promptInput.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
if (inputOk) pass('5.5 InputBar prompt 输入区可见 (生成页核心)');
|
||||
else fail('5.5 InputBar', new Error('找不到 contenteditable prompt 输入'));
|
||||
} catch (e) {
|
||||
fail('5.x team user 生成页', e);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────
|
||||
// 收尾 — console / pageerror
|
||||
// ─────────────────────────────────────
|
||||
if (consoleErrors.length > 0) {
|
||||
fail('JS 运行错误', new Error(`${consoleErrors.length} 个 console.error:\n ` + consoleErrors.slice(0, 5).join('\n ')));
|
||||
} else {
|
||||
pass('全程无 console.error');
|
||||
}
|
||||
if (pageErrors.length > 0) {
|
||||
fail('JS 异常崩溃', new Error(`${pageErrors.length} 个未捕获:\n ` + pageErrors.slice(0, 5).join('\n ')));
|
||||
} else {
|
||||
pass('全程无 page crash');
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// 汇总
|
||||
console.log('\n══════════════════════════════════════════');
|
||||
console.log(' 汇总');
|
||||
console.log('══════════════════════════════════════════');
|
||||
const passCount = results.filter(r => r.status.includes('PASS')).length;
|
||||
const failCount = results.filter(r => r.status.includes('FAIL')).length;
|
||||
console.log(`Pass: ${passCount} Fail: ${failCount}`);
|
||||
if (failCount > 0) {
|
||||
console.log('\n❌ 失败明细:');
|
||||
errors.forEach(e => console.log(` • ${e.name}: ${e.err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\n✅ 全部 smoke check 通过');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('❌ Smoke test crashed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user