diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index 8399bfe7..0c59ac3a 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -40,7 +40,8 @@ 1. 从首页开始,左侧菜单依次进入四个页面。 2. 每页只点上表的路径,停留 2 秒展示“必须看到”的验收文本。 -3. 若现场网络或浏览器状态异常,用本阶段通过验收的四张截图兜底:`/tmp/wygj-ai-interview-flow.png`、`/tmp/wygj-practice-flow.png`、`/tmp/wygj-cases-flow.png`、`/tmp/wygj-sop-flow.png`。 +3. 彩排前运行 `node scripts/capture-demo-screenshots.mjs` 生成四张兜底截图:`/tmp/wygj-ai-interview-flow.png`、`/tmp/wygj-practice-flow.png`、`/tmp/wygj-cases-flow.png`、`/tmp/wygj-sop-flow.png`。 +4. 若现场网络或浏览器状态异常,直接切上一步生成的截图。 ## 不演示 diff --git a/scripts/capture-demo-screenshots.mjs b/scripts/capture-demo-screenshots.mjs new file mode 100644 index 00000000..126cabf0 --- /dev/null +++ b/scripts/capture-demo-screenshots.mjs @@ -0,0 +1,229 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const adminUrl = process.env.ADMIN_URL || 'https://wygj-admin.localhost'; +const outDir = process.argv.includes('--out') + ? path.resolve(process.argv[process.argv.indexOf('--out') + 1]) + : '/tmp'; +const chromeBin = process.env.CHROME_BIN || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const clientid = readFrontendEnv().VITE_APP_CLIENT_ID; +const pages = [ + ['wygj-ai-interview-flow.png', '/recruit/interview', 'AI面试'], + ['wygj-practice-flow.png', '/train/practice', '三角色对练'], + ['wygj-cases-flow.png', '/knowledge/cases', '案例沉淀'], + ['wygj-sop-flow.png', '/knowledge/sop', 'SOP知识库'] +]; + +if (!fs.existsSync(chromeBin)) { + throw new Error(`Chrome not found: ${chromeBin}`); +} +fs.mkdirSync(outDir, { recursive: true }); + +const token = await login(); +const chrome = await launchChrome(); +try { + const page = await connectPage(chrome.port); + await page.send('Page.enable'); + await page.send('Runtime.enable'); + await page.send('Emulation.setDeviceMetricsOverride', { + width: 1440, + height: 1000, + deviceScaleFactor: 1, + mobile: false + }); + await page.send('Page.addScriptToEvaluateOnNewDocument', { + source: `localStorage.setItem('Admin-Token', ${JSON.stringify(token)});` + }); + + for (const [fileName, route, marker] of pages) { + const target = `${adminUrl}${route}`; + await page.navigate(target); + await page.waitForText(marker, 15000); + await page.sleep(800); + const screenshot = await page.send('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: true + }); + fs.writeFileSync(path.join(outDir, fileName), Buffer.from(screenshot.data, 'base64')); + console.log(`${path.join(outDir, fileName)} ${target}`); + } +} finally { + chrome.proc.kill('SIGTERM'); + try { + fs.rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); + } catch { + // ponytail: temp profile cleanup is best-effort; the OS temp sweeper can remove leftovers. + } +} + +function readFrontendEnv() { + const env = {}; + for (const line of fs.readFileSync(path.join(root, 'frontend/.env.development'), 'utf8').split(/\r?\n/)) { + const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/); + if (match) env[match[1]] = match[2].replace(/^[ '"]+|[ '"]+$/g, ''); + } + return env; +} + +function pem(label, body) { + return `-----BEGIN ${label}-----\n${body.match(/.{1,64}/g).join('\n')}\n-----END ${label}-----`; +} + +function aesEncrypt(text, keyText) { + const cipher = crypto.createCipheriv('aes-256-ecb', Buffer.from(keyText, 'utf8'), null); + cipher.setAutoPadding(true); + return Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]).toString('base64'); +} + +function aesDecrypt(base64Text, key) { + const decipher = crypto.createDecipheriv('aes-256-ecb', key, null); + decipher.setAutoPadding(true); + return Buffer.concat([decipher.update(Buffer.from(base64Text, 'base64')), decipher.final()]).toString('utf8'); +} + +async function login() { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + const env = readFrontendEnv(); + const requestPublicKey = pem('PUBLIC KEY', env.VITE_APP_RSA_PUBLIC_KEY); + const responsePrivateKey = pem('PRIVATE KEY', env.VITE_APP_RSA_PRIVATE_KEY); + const request = async (method, endpoint, body, encrypted = false) => { + const headers = { clientid, 'Content-Language': 'zh_CN' }; + let payload; + if (body !== undefined) { + headers['Content-Type'] = 'application/json;charset=utf-8'; + if (encrypted) { + const keyText = crypto.randomBytes(24).toString('base64').slice(0, 32); + payload = aesEncrypt(JSON.stringify(body), keyText); + headers['encrypt-key'] = crypto.publicEncrypt( + { key: requestPublicKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + Buffer.from(Buffer.from(keyText, 'utf8').toString('base64'), 'utf8') + ).toString('base64'); + } else { + payload = JSON.stringify(body); + } + } + const response = await fetch(`${adminUrl.replace('wygj-admin', 'wygj-api')}${endpoint}`, { method, headers, body: payload }); + const text = await response.text(); + if (!response.ok) throw new Error(`request failed ${endpoint}: ${response.status}`); + if (!response.headers.get('encrypt-key')) return JSON.parse(text); + const keyBase64 = crypto.privateDecrypt( + { key: responsePrivateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + Buffer.from(response.headers.get('encrypt-key'), 'base64') + ).toString('utf8'); + return JSON.parse(aesDecrypt(text, Buffer.from(keyBase64, 'base64'))); + }; + + const codeResponse = await request('GET', '/auth/code'); + const uuid = codeResponse.data?.uuid; + const code = execFileSync( + 'docker', + ['exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', '--raw', 'get', `global:captcha_codes:${uuid}`], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] } + ).trim().split('\n').pop().replace(/^"|"$/g, ''); + const loginResponse = await request('POST', '/auth/login', { + tenantId: '000000', + username: process.env.AIHR_VERIFY_USER || 'admin', + password: process.env.AIHR_VERIFY_PASSWORD || 'admin123', + rememberMe: false, + code, + uuid, + clientId: clientid, + grantType: 'password' + }, true); + const token = loginResponse.data?.access_token; + if (!token) throw new Error('admin login failed'); + return token; +} + +async function launchChrome() { + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wygj-demo-chrome-')); + const proc = spawn(chromeBin, [ + '--headless=new', + '--disable-gpu', + '--no-first-run', + '--ignore-certificate-errors', + '--remote-debugging-port=0', + `--user-data-dir=${userDataDir}`, + 'about:blank' + ], { stdio: ['ignore', 'ignore', 'pipe'] }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Chrome DevTools did not start')), 10000); + proc.stderr.on('data', (chunk) => { + const match = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//); + if (match) { + clearTimeout(timer); + resolve(Number(match[1])); + } + }); + proc.on('exit', (code) => reject(new Error(`Chrome exited: ${code}`))); + }); + return { proc, port, userDataDir }; +} + +async function connectPage(port) { + const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); + const wsUrl = targets.find((target) => target.type === 'page')?.webSocketDebuggerUrl; + if (!wsUrl) throw new Error('Chrome page target missing'); + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + let id = 0; + const pending = new Map(); + const waiters = []; + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + const { resolve, reject } = pending.get(message.id); + pending.delete(message.id); + message.error ? reject(new Error(message.error.message)) : resolve(message.result || {}); + return; + } + waiters.splice(0).forEach((waiter) => waiter(message)); + }; + const api = { + send(method, params = {}) { + const callId = ++id; + ws.send(JSON.stringify({ id: callId, method, params })); + return new Promise((resolve, reject) => pending.set(callId, { resolve, reject })); + }, + async navigate(url) { + await this.send('Page.navigate', { url }); + await this.sleep(1000); + }, + waitForEvent(method, timeout) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`timeout waiting for ${method}`)), timeout); + waiters.push((message) => { + if (message.method === method) { + clearTimeout(timer); + resolve(message); + } + }); + }); + }, + async waitForText(text, timeout) { + const started = Date.now(); + while (Date.now() - started < timeout) { + const result = await this.send('Runtime.evaluate', { + expression: `document.body && document.body.innerText.includes(${JSON.stringify(text)})`, + returnByValue: true + }); + if (result.result?.value) return; + await this.sleep(300); + } + throw new Error(`page text not found: ${text}`); + }, + sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + }; + return api; +}