From 84309e9c9ac439f25a1c8e7a066ed371ceef9c2c Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 30 May 2026 20:52:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BA=E5=8C=96Admin=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=86=92=E7=83=9F=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 + package.json | 1 + scripts/admin-login-smoke.ts | 395 +++++++++++++++++++++++++++++++++++ tsconfig.json | 2 +- 4 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 scripts/admin-login-smoke.ts diff --git a/README.md b/README.md index c112dae..0652490 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ pnpm install pnpm run typecheck pnpm run self-test pnpm run cleanup-history -- --dry-run +pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login ``` 如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use ` 切换到目标版本;不要先扫盘找 `node.exe` 路径。 @@ -69,6 +70,12 @@ pnpm run cleanup-history -- --dry-run | `kt_append_task_record` | 预览或写入 `TASKS.md` 改动记录 | | `kt_commit_checklist` | 生成提交前检查清单并校验 commit message | +## 可复用脚本 + +| 脚本 | 用途 | +| --- | --- | +| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 | + ## 项目别名 | 别名 | 路径 | @@ -94,6 +101,7 @@ pnpm run cleanup-history -- --dry-run - 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。 - 验证前调用 `kt_suggest_verification`,避免盲跑全量构建。 - 页面测试前调用 `kt_create_page_test_case`,再执行 Playwright/浏览器测试。 +- Admin 页面测试前可先执行 `pnpm run admin-login -- --url ` 固化登录态,输出的 `storageState` 可作为后续 Playwright 用例前置状态。 - 接口改动后调用 `kt_api_test_plan`,并真实请求一次接口。 - 验证启动过本地服务后调用 `kt_cleanup_process_plan`,清掉本次进程。 - 每轮测试/验证结束后调用 `kt_cleanup_history`,或运行 `pnpm run cleanup-history -- --keep=3`,让历史产物只保留最近 3 轮。 diff --git a/package.json b/package.json index 29ec1f5..7491368 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "description": "Reusable MCP workflow tools for the KT workspace.", "scripts": { + "admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts", "cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history", "self-test": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --self-test", "start": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts", diff --git a/scripts/admin-login-smoke.ts b/scripts/admin-login-smoke.ts new file mode 100644 index 0000000..b230b86 --- /dev/null +++ b/scripts/admin-login-smoke.ts @@ -0,0 +1,395 @@ +#!/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 { + 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, '.codex-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'), + ); + + return { + 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 + +常用参数: + --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 || '', + )}`, + ); + } +} + +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({ + 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); +}); diff --git a/tsconfig.json b/tsconfig.json index cf5f9db..d8522a1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "target": "ES2024", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["scripts/**/*.ts", "src/**/*.ts"] }