#!/usr/bin/env node import { mkdirSync, existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; interface CliOptions { browser: 'chromium' | 'msedge'; headless: boolean; password: string; screenshotPath: string; storageStatePath: string; targetUrl: string; timeout: number; url: string; username: string; } const dirname = path.dirname(fileURLToPath(import.meta.url)); const workspaceRoot = path.resolve( process.env.KT_WORKSPACE_ROOT || path.join(dirname, '../../..'), ); const adminRoot = path.join(workspaceRoot, 'Vue/kt-template-admin'); const defaultArtifactDir = path.join(workspaceRoot, '.kt-workspace/verify/admin-login'); /** * @param value 原始命令行布尔值。 * @returns 将 CLI 字符串转换为布尔值。 */ function toBoolean(value: string | undefined): boolean { if (!value) return true; return !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); } /** * @param args 已解析的命令行参数。 * @param key 布尔参数名。 * @param envValue 对应环境变量值。 * @param defaultValue 未传参且无环境变量时的默认值。 * @returns 最终布尔值。 */ function getBooleanArg( args: Map, key: string, envValue: string | undefined, defaultValue: boolean, ): boolean { if (args.has(key)) return toBoolean(args.get(key)); if (envValue !== undefined) return toBoolean(envValue); return defaultValue; } /** * @param value 原始路径,允许相对 KT 根目录。 * @returns 解析后的绝对路径。 */ function resolveWorkspacePath(value: string): string { return path.isAbsolute(value) ? value : path.resolve(workspaceRoot, value); } /** * @param argv 传入脚本的命令行参数。 * @returns 登录脚本配置。 */ function parseArgs(argv: string[]): CliOptions { const args = new Map(); for (let index = 0; index < argv.length; index += 1) { const item = argv[index]; if (!item.startsWith('--')) continue; const [key, inlineValue] = item.slice(2).split('=', 2); if (inlineValue !== undefined) { args.set(key, inlineValue); continue; } const next = argv[index + 1]; if (next && !next.startsWith('--')) { args.set(key, next); index += 1; } else { args.set(key, undefined); } } if (args.has('help') || args.has('h')) { printHelp(); process.exit(0); } const storageStatePath = resolveWorkspacePath( args.get('storage-state') || process.env.KT_ADMIN_STORAGE_STATE || path.join(defaultArtifactDir, 'admin-storage-state.json'), ); const screenshotPath = resolveWorkspacePath( args.get('screenshot') || process.env.KT_ADMIN_LOGIN_SCREENSHOT || path.join(defaultArtifactDir, 'admin-login.png'), ); const browser = args.get('browser') || process.env.KT_ADMIN_BROWSER || 'msedge'; if (!['chromium', 'msedge'].includes(browser)) { throw new Error(`不支持的浏览器类型: ${browser}`); } return { browser: browser as CliOptions['browser'], headless: getBooleanArg( args, 'headless', process.env.KT_ADMIN_HEADLESS, false, ), password: args.get('password') || process.env.KT_ADMIN_PASSWORD || '123456', screenshotPath, storageStatePath, targetUrl: args.get('target') || process.env.KT_ADMIN_TARGET_URL || '/workspace', timeout: Number(args.get('timeout') || process.env.KT_ADMIN_TIMEOUT || 30_000), url: args.get('url') || process.env.KT_ADMIN_URL || 'http://127.0.0.1:5999/#/auth/login', username: args.get('username') || process.env.KT_ADMIN_USERNAME || 'admin', }; } /** * 输出脚本帮助文本。 */ function printHelp(): void { console.log(`KT Admin 登录冒烟脚本 用法: pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login 常用参数: --browser 浏览器类型:msedge(默认)或 chromium --username 默认读取 KT_ADMIN_USERNAME,兜底 admin --password 默认读取 KT_ADMIN_PASSWORD,兜底 123456 --url Admin 登录页 URL --target 登录后验证目标,默认 /workspace --storage-state 保存 Playwright storageState --screenshot 保存登录后截图 --headless=false 默认可见 Edge;传 true 可无头运行 --timeout 默认 30000 `); } /** * @param adminPackageRoot Admin 项目根目录。 * @returns 从 Admin 项目依赖中解析出的 Playwright。 */ function resolvePlaywright(adminPackageRoot: string): any { const adminRequire = createRequire(path.join(adminPackageRoot, 'package.json')); try { return adminRequire('playwright'); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error( `未能从 kt-template-admin 解析 playwright。请先在 ${adminPackageRoot} 执行 pnpm install。原始错误: ${message}`, ); } } /** * @param target 需要确保父目录存在的文件路径。 */ function ensureParentDir(target: string): void { const parent = path.dirname(target); if (!existsSync(parent)) { mkdirSync(parent, { recursive: true }); } } /** * @param page Playwright 页面对象。 * @param timeout 等待超时时间。 * @param username 登录用户名。 * @param password 登录密码。 */ async function fillCredentials( page: any, timeout: number, username: string, password: string, ): Promise { const usernameInput = page .getByPlaceholder(/请输入用户名|Please enter username/i) .first(); await usernameInput.waitFor({ state: 'visible', timeout }); await usernameInput.fill(username); const passwordInput = page.locator('input[type="password"]').first(); await passwordInput.waitFor({ state: 'visible', timeout }); await passwordInput.fill(password); } /** * @param page Playwright 页面对象。 * @param timeout 等待超时时间。 */ async function passSliderCaptcha(page: any, timeout: number): Promise { const action = page.locator('[name="captcha-action"]').first(); await action.waitFor({ state: 'visible', timeout }); const points = await action.evaluate((element: any) => { const wrapper = element.parentElement; if (!wrapper) { throw new Error('未找到滑块父容器'); } const actionBox = element.getBoundingClientRect(); const wrapperBox = wrapper.getBoundingClientRect(); const y = actionBox.top + actionBox.height / 2; return { endX: wrapperBox.right - 6, startX: actionBox.left + actionBox.width / 2, y, }; }); await page.mouse.move(points.startX, points.y); await page.mouse.down(); await page.mouse.move(points.endX, points.y, { steps: 24 }); await page.mouse.up(); await page .getByText(/验证通过|Passed/i) .first() .waitFor({ state: 'visible', timeout: Math.min(timeout, 5_000) }); } /** * @param page Playwright 页面对象。 * @param timeout 等待超时时间。 * @returns 登录接口响应。 */ async function clickLogin(page: any, timeout: number): Promise { const responsePromise = page .waitForResponse( (response: any) => response.request().method() === 'POST' && response.url().includes('/api/auth/login'), { timeout }, ) .catch(() => null); const loginByName = page.getByRole('button', { name: /^login$/i }).first(); if (await loginByName.isVisible().catch(() => false)) { await loginByName.click(); } else { await page .getByRole('button', { name: /登录|Login/i }) .first() .click({ timeout }); } return responsePromise; } /** * @param baseUrl 当前登录页 URL。 * @param target 登录后目标,可以是绝对 URL、hash 路由或普通路径。 * @returns 可以传给 page.goto 的完整 URL。 */ function resolveTargetUrl(baseUrl: string, target: string): string { if (/^https?:\/\//i.test(target)) return target; const url = new URL(baseUrl); if (target.startsWith('#')) { url.hash = target; return url.toString(); } url.hash = target.startsWith('/') ? target : `/${target}`; return url.toString(); } /** * @param page Playwright 页面对象。 * @param timeout 等待超时时间。 */ async function waitForLoggedIn(page: any, timeout: number): Promise { await page.waitForFunction( () => { const href = (globalThis as any).location.href; return !href.includes('/auth/login') && !href.includes('#/login'); }, undefined, { timeout }, ); } /** * @param response 登录接口响应。 */ async function assertLoginResponse(response: any | null): Promise { if (!response) return; const status = response.status(); let body: Record | null = null; try { body = await response.json(); } catch { body = null; } if (status >= 400) { throw new Error(`登录接口失败: HTTP ${status}`); } if (body && typeof body.code === 'number' && ![0, 200].includes(body.code)) { throw new Error( `登录接口失败: code=${body.code}, msg=${String( body.msg || body.message || '', )}`, ); } } /** * 执行 Admin 登录并保存可复用的页面验证状态。 */ async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const { chromium } = resolvePlaywright(adminRoot); ensureParentDir(options.storageStatePath); ensureParentDir(options.screenshotPath); const browser = await chromium.launch({ ...(options.browser === 'msedge' ? { channel: 'msedge' } : {}), headless: options.headless, slowMo: options.headless ? 0 : 60, }); const context = await browser.newContext({ viewport: { height: 960, width: 1440 }, }); const page = await context.newPage(); try { await page.goto(options.url, { timeout: options.timeout, waitUntil: 'domcontentloaded', }); await fillCredentials( page, options.timeout, options.username, options.password, ); await passSliderCaptcha(page, options.timeout); const loginResponse = await clickLogin(page, options.timeout); await assertLoginResponse(loginResponse); await waitForLoggedIn(page, options.timeout); const targetUrl = resolveTargetUrl(options.url, options.targetUrl); if (targetUrl !== page.url()) { await page.goto(targetUrl, { timeout: options.timeout, waitUntil: 'domcontentloaded', }); } await page.screenshot({ fullPage: true, path: options.screenshotPath }); await context.storageState({ path: options.storageStatePath }); console.log( JSON.stringify( { ok: true, screenshotPath: options.screenshotPath, storageStatePath: options.storageStatePath, url: page.url(), username: options.username, }, null, 2, ), ); } catch (error) { const failedScreenshot = options.screenshotPath.replace( /(\.\w+)?$/, '.failed.png', ); await page .screenshot({ fullPage: true, path: failedScreenshot }) .catch(() => undefined); throw new Error( `Admin 登录脚本失败,截图已保存到 ${failedScreenshot}: ${ error instanceof Error ? error.message : String(error) }`, ); } finally { await context.close().catch(() => undefined); await browser.close().catch(() => undefined); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });