feat: 重建 Bell GoAdmin 产品骨架 (#62)

This commit is contained in:
QiuSW
2026-08-27 18:20:40 +08:00
parent b37c350096
commit f5a3dd796b
715 changed files with 75278 additions and 0 deletions
@@ -0,0 +1,457 @@
import { test, expect, Page, BrowserContext, ConsoleMessage } from '@playwright/test';
import * as path from 'path';
import * as fs from 'fs';
import * as http from 'http';
const SCREENSHOT_DIR = '/tmp/go-admin-screenshots';
const BASE_URL = 'http://localhost:9527';
const API_URL = 'http://localhost:8001';
function ensureScreenshotDir() {
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
}
async function saveScreenshot(page: Page, name: string) {
ensureScreenshotDir();
const filePath = path.join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: filePath, fullPage: false });
console.log(`[SCREENSHOT] ${filePath}`);
}
function httpGet(url: string): Promise<any> {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const req = http.request({
hostname: urlObj.hostname,
port: parseInt(urlObj.port),
path: urlObj.pathname + urlObj.search,
method: 'GET',
}, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { resolve({ raw: data }); }
});
});
req.on('error', reject);
req.end();
});
}
function httpPost(url: string, body: object): Promise<any> {
return new Promise((resolve, reject) => {
const bodyStr = JSON.stringify(body);
const urlObj = new URL(url);
const req = http.request({
hostname: urlObj.hostname,
port: parseInt(urlObj.port),
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyStr),
},
}, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => data += chunk);
res.on('end', () => {
try { resolve(JSON.parse(data)); }
catch (e) { resolve({ raw: data }); }
});
});
req.on('error', reject);
req.write(bodyStr);
req.end();
});
}
async function getCaptcha(): Promise<{ id: string; imageBase64: string }> {
const resp = await httpGet(`${API_URL}/api/v1/captcha`);
return { id: resp.id, imageBase64: resp.data };
}
// 将验证码 base64 保存为图片文件并返回路径(供人工/Vision 识别)
async function saveCaptchaImage(base64: string, filePath: string) {
const data = base64.replace(/^data:image\/\w+;base64,/, '');
fs.writeFileSync(filePath, Buffer.from(data, 'base64'));
}
async function loginViaAPI(captchaCode: string, captchaId: string): Promise<string | null> {
const username = process.env.BELL_E2E_USERNAME;
const password = process.env.BELL_E2E_PASSWORD;
if (!username || !password) {
throw new Error('BELL_E2E_USERNAME and BELL_E2E_PASSWORD are required');
}
const resp = await httpPost(`${API_URL}/api/v1/login`, {
username,
password,
code: captchaCode,
uuid: captchaId,
});
return resp.token || null;
}
async function injectToken(context: BrowserContext, token: string) {
await context.addCookies([{
name: 'Bell-Admin-Token',
value: token,
domain: 'localhost',
path: '/',
httpOnly: false,
secure: false,
sameSite: 'Lax',
}]);
console.log('[AUTH] Token 已注入到浏览器 Cookie');
}
test.describe('侧边栏菜单导航功能验证', () => {
const jsErrors: string[] = [];
const jsWarnings: string[] = [];
test('完整导航流程验证', async ({ page, context }) => {
ensureScreenshotDir();
// 收集控制台消息
page.on('console', (msg: ConsoleMessage) => {
const type = msg.type();
const text = msg.text();
if (type === 'error') {
// 忽略已知的网络相关错误(图片加载、WebSocket 等)
if (!text.includes('Failed to load resource') && !text.includes('WebSocket')) {
jsErrors.push(text);
}
console.log(`[CONSOLE ERROR] ${text}`);
}
if (type === 'warn') {
if (text.includes('handleMouseleave') ||
text.includes('Failed to resolve component') ||
text.includes('is not a function')) {
jsWarnings.push(text);
console.log(`[CONSOLE WARN] ${text}`);
}
}
});
page.on('pageerror', (error: Error) => {
jsErrors.push(`[PAGE ERROR] ${error.message}`);
console.log('[PAGE ERROR]', error.message);
});
// ==================== 阶段 1:获取 Token 并注入 ====================
console.log('\n=== 阶段 1:API 登录获取 Token ===');
// 获取验证码
const captcha = await getCaptcha();
const captchaImagePath = path.join(SCREENSHOT_DIR, 'captcha_for_login.png');
await saveCaptchaImage(captcha.imageBase64, captchaImagePath);
console.log(`验证码 ID: ${captcha.id}`);
console.log(`验证码图片: ${captchaImagePath}`);
// 读取验证码图片进行识别(通过文件内容方式)
// 使用已知密码组合和当前验证码
// 注意:验证码有效期 600 秒,我们用 Node.js 直接在同进程中识别
// 这里需要人工传入验证码,或者通过其他手段
// 方案:先用之前已保存的 Token(如果存在且有效)
let token: string | null = null;
const savedTokenPath = '/tmp/auth_token.txt';
if (fs.existsSync(savedTokenPath)) {
const savedToken = fs.readFileSync(savedTokenPath, 'utf-8').trim();
if (savedToken && savedToken.length > 20) {
token = savedToken;
console.log('[AUTH] 使用已保存的 Token');
}
}
if (!token) {
throw new Error('没有可用的 Token,请先通过 API 登录获取 token');
}
// 注入 token 到浏览器 context
await injectToken(context, token);
// ==================== 步骤 1:打开应用首页 ====================
console.log('\n=== 步骤 1:打开应用首页 ===');
await page.goto(`${BASE_URL}/#/dashboard`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
await saveScreenshot(page, '01-initial-state');
console.log('当前 URL:', page.url());
// 检查是否还在登录页(token 可能无效)
if (page.url().includes('/login')) {
console.log('[WARN] 仍在登录页,Token 可能已过期');
// 这里不抛异常,继续记录状态
}
// ==================== 步骤 2:验证左侧菜单 ====================
console.log('\n=== 步骤 2:检查左侧菜单 ===');
await page.waitForTimeout(1000);
// 等待侧边栏加载
try {
await page.waitForSelector('.el-menu', { timeout: 5000 });
console.log('[OK] el-menu 已加载');
} catch (e) {
console.log('[WARN] 等待 .el-menu 超时');
}
// 收集菜单信息
const menuInfo = await page.evaluate(() => {
const sidebarEl = document.querySelector('.sidebar-container, .el-aside, aside, .scrollbar-wrapper');
const menuEl = document.querySelector('.el-menu');
const menuItems = document.querySelectorAll('.el-menu-item');
const subMenus = document.querySelectorAll('.el-sub-menu');
const subMenuTitles = document.querySelectorAll('.el-sub-menu__title');
const texts: string[] = [];
menuItems.forEach((el) => {
const text = el.textContent?.trim();
if (text) texts.push(text);
});
const subTexts: string[] = [];
subMenuTitles.forEach((el) => {
const text = el.textContent?.trim();
if (text) subTexts.push(text);
});
return {
hasSidebar: !!sidebarEl,
hasMenu: !!menuEl,
menuItemCount: menuItems.length,
subMenuCount: subMenus.length,
menuItemTexts: texts,
subMenuTexts: subTexts,
};
});
console.log('侧边栏存在:', menuInfo.hasSidebar);
console.log('el-menu 存在:', menuInfo.hasMenu);
console.log('菜单项数量:', menuInfo.menuItemCount);
console.log('子菜单数量:', menuInfo.subMenuCount);
console.log('菜单项文字:', JSON.stringify(menuInfo.menuItemTexts));
console.log('子菜单标题:', JSON.stringify(menuInfo.subMenuTexts));
await saveScreenshot(page, '02-sidebar-loaded');
// ==================== 步骤 3:展开系统管理子菜单 ====================
console.log('\n=== 步骤 3:展开"系统管理"子菜单 ===');
let sysMenuFound = false;
// 先找精确匹配
const sysMenuSelectors = [
'.el-sub-menu__title:has-text("系统管理")',
'.el-sub-menu__title:has-text("系统")',
];
for (const sel of sysMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await el.textContent();
await el.click();
console.log(`[OK] 点击展开子菜单: "${text?.trim()}" (${sel})`);
sysMenuFound = true;
break;
}
}
if (!sysMenuFound) {
// 点击第一个子菜单
const firstSubMenu = page.locator('.el-sub-menu__title').first();
if (await firstSubMenu.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await firstSubMenu.textContent();
await firstSubMenu.click();
console.log(`[OK] 点击第一个子菜单: "${text?.trim()}"`);
sysMenuFound = true;
} else {
console.log('[WARN] 未找到任何子菜单标题');
}
}
await page.waitForTimeout(800);
await saveScreenshot(page, '03-submenu-expanded');
// 检查子菜单项是否展开
const expandedItems = await page.evaluate(() => {
const items = document.querySelectorAll('.el-sub-menu.is-opened .el-menu-item');
return Array.from(items).map((el) => el.textContent?.trim()).filter(Boolean);
});
console.log('展开的子菜单项:', JSON.stringify(expandedItems));
// ==================== 步骤 4:点击用户管理 ====================
console.log('\n=== 步骤 4:点击"用户管理" ===');
const urlBefore4 = page.url();
console.log('点击前 URL:', urlBefore4);
let userMenuClicked = false;
const userMenuSelectors = [
'.el-menu-item:has-text("用户管理")',
'.el-menu-item:has-text("用户")',
'li.el-menu-item:has-text("用户")',
];
for (const sel of userMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
await el.click();
console.log(`[OK] 点击用户管理 (${sel})`);
userMenuClicked = true;
break;
}
}
if (!userMenuClicked && expandedItems.length > 0) {
// 点击第一个展开的子菜单项
const firstItem = page.locator('.el-sub-menu.is-opened .el-menu-item').first();
if (await firstItem.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await firstItem.textContent();
await firstItem.click();
console.log(`[OK] 点击第一个展开的子菜单项: "${text?.trim()}"`);
userMenuClicked = true;
}
}
await page.waitForTimeout(2000);
const urlAfter4 = page.url();
const urlChanged4 = urlAfter4 !== urlBefore4;
console.log('点击后 URL:', urlAfter4);
console.log(`URL 变化: ${urlChanged4 ? 'YES' : 'NO'}`);
await saveScreenshot(page, '04-user-management');
// ==================== 步骤 5:点击角色管理 ====================
console.log('\n=== 步骤 5:点击"角色管理" ===');
const urlBefore5 = page.url();
let roleMenuClicked = false;
const roleMenuSelectors = [
'.el-menu-item:has-text("角色管理")',
'.el-menu-item:has-text("角色")',
];
for (const sel of roleMenuSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
await el.click();
console.log(`[OK] 点击角色管理 (${sel})`);
roleMenuClicked = true;
break;
}
}
if (!roleMenuClicked) {
console.log('[WARN] 未找到角色管理菜单项');
}
await page.waitForTimeout(2000);
const urlAfter5 = page.url();
console.log('角色管理 URL:', urlAfter5);
console.log(`URL 变化: ${urlAfter5 !== urlBefore5 ? 'YES' : 'NO'}`);
await saveScreenshot(page, '05-role-management');
// ==================== 步骤 6:点击首页 ====================
console.log('\n=== 步骤 6:点击"首页" ===');
let homeClicked = false;
const homeSelectors = [
'.el-menu-item:has-text("首页")',
'.el-menu-item:has-text("Dashboard")',
'.el-menu-item:has-text("仪表盘")',
'.el-menu > .el-menu-item:first-child',
];
for (const sel of homeSelectors) {
const el = page.locator(sel).first();
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
const text = await el.textContent();
await el.click();
console.log(`[OK] 点击首页 (${sel}): "${text?.trim()}"`);
homeClicked = true;
break;
}
}
if (!homeClicked) {
console.log('[WARN] 未找到首页菜单项');
}
await page.waitForTimeout(2000);
const urlAfterHome = page.url();
console.log('首页 URL:', urlAfterHome);
await saveScreenshot(page, '06-home-after-navigate');
// ==================== 步骤 7:检查菜单文字样式 ====================
console.log('\n=== 步骤 7:检查菜单文字样式可见性 ===');
const styleCheck = await page.evaluate(() => {
const results: Array<{ selector: string; color: string; bg: string; visible: boolean }> = [];
const targets = [
'.el-menu-item',
'.el-sub-menu__title',
'.el-menu--dark .el-menu-item',
];
for (const sel of targets) {
const el = document.querySelector(sel);
if (el) {
const style = window.getComputedStyle(el);
const color = style.color;
const bg = style.backgroundColor;
// 检查文字是否可见(不是透明/黑色 on 黑色背景)
const visible = color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent';
results.push({ selector: sel, color, bg, visible });
}
}
return results;
});
styleCheck.forEach((item) => {
const status = item.visible ? '[OK]' : '[WARN]';
console.log(`${status} ${item.selector}: color=${item.color}, bg=${item.bg}`);
});
// ==================== 步骤 8:JS 错误汇总 ====================
console.log('\n=== 步骤 8:JavaScript 错误汇总 ===');
const handleMouseleaveErrors = jsErrors.filter((e) => e.includes('handleMouseleave'));
const componentErrors = (jsErrors.concat(jsWarnings)).filter((e) => e.includes('Failed to resolve component'));
const functionErrors = jsErrors.filter((e) => e.includes('is not a function'));
console.log(`总错误数: ${jsErrors.length}`);
console.log(`handleMouseleave 错误: ${handleMouseleaveErrors.length}`);
console.log(`Failed to resolve component 错误: ${componentErrors.length}`);
console.log(`is not a function 错误: ${functionErrors.length}`);
if (jsErrors.length > 0) {
console.log('\n错误列表:');
jsErrors.forEach((e, i) => console.log(` ${i + 1}. ${e}`));
}
if (jsWarnings.length > 0) {
console.log('\n警告列表:');
jsWarnings.forEach((w, i) => console.log(` ${i + 1}. ${w}`));
}
// ==================== 断言 ====================
console.log('\n=== 断言验证 ===');
// 1. 关键错误不应存在
expect(handleMouseleaveErrors.length, 'handleMouseleave is not a function 错误已修复').toBe(0);
expect(functionErrors.length, 'is not a function 错误应为 0').toBe(0);
// 2. 菜单应该有内容
expect(menuInfo.menuItemCount + menuInfo.subMenuCount, '侧边栏应有菜单项').toBeGreaterThan(0);
// 3. 至少有一次 URL 变化(点击菜单后导航有效)
const anyNavigation = urlChanged4 || (urlAfter5 !== urlBefore5);
expect(anyNavigation, '点击菜单后应发生页面导航').toBeTruthy();
// 4. 最终页面不应停留在登录页
expect(urlAfterHome, '最终 URL 不应包含 /login').not.toContain('/login');
console.log('\n=== 测试完成 ===');
console.log(`截图目录: ${SCREENSHOT_DIR}`);
});
});