From 5f302581123423eafd04aa3152be1b756f2f49b6 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 11 May 2026 21:03:53 +0800 Subject: [PATCH] =?UTF-8?q?test+fix(theme):=20V2=20smoke=20test=20(25/25?= =?UTF-8?q?=20pass)=20+=20=E4=BF=AE=E5=A4=8D=20AdminLayout=20=E7=BC=BA?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能回归 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) --- web/src/pages/AdminLayout.module.css | 22 ++ web/src/pages/AdminLayout.tsx | 22 ++ web/test/v2-smoke.mjs | 287 +++++++++++++++++++++++++++ 3 files changed, 331 insertions(+) create mode 100644 web/test/v2-smoke.mjs diff --git a/web/src/pages/AdminLayout.module.css b/web/src/pages/AdminLayout.module.css index 781d7e0..7e13de2 100644 --- a/web/src/pages/AdminLayout.module.css +++ b/web/src/pages/AdminLayout.module.css @@ -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; diff --git a/web/src/pages/AdminLayout.tsx b/web/src/pages/AdminLayout.tsx index 23ae22e..06b357f 100644 --- a/web/src/pages/AdminLayout.tsx +++ b/web/src/pages/AdminLayout.tsx @@ -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() {
+ {/* 主题切换 — 月亮/太阳 SVG,跟 components/Sidebar 一致 */} +
{user?.username.charAt(0).toUpperCase()}
{!collapsed && ( diff --git a/web/test/v2-smoke.mjs b/web/test/v2-smoke.mjs new file mode 100644 index 0000000..017ebc0 --- /dev/null +++ b/web/test/v2-smoke.mjs @@ -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); +});