// ============================================================ // 操作手册截图自动化 v2:全新演示种子数据(不复制现场 world) // 隔离后端(8100) + Vite(5399) + Playwright,结束杀子进程。 // ============================================================ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; const ROOT = path.resolve(import.meta.dirname, '..'); const require = createRequire(path.join(ROOT, 'apps', 'web', 'package.json')); const { chromium } = require('playwright'); const SHOTS = path.join(ROOT, '_shots'); const TMP = path.join(SHOTS, 'tmp2'); fs.mkdirSync(SHOTS, { recursive: true }); fs.rmSync(TMP, { recursive: true, force: true }); fs.mkdirSync(TMP, { recursive: true }); const PY = path.join(ROOT, '.venv', 'Scripts', 'python.exe'); const VITE = path.join(ROOT, 'apps', 'web', 'node_modules', 'vite', 'bin', 'vite.js'); const API = 'http://127.0.0.1:8100'; const UI = 'http://localhost:5399'; const JMS = { tenant: process.env.E2E_JMS_TENANT?.trim() || '', username: process.env.E2E_JMS_USER?.trim() || '', password: process.env.E2E_JMS_PASSWORD || '', }; const missingJmsConfig = Object.entries(JMS).filter(([, value]) => !value).map(([key]) => key); if (missingJmsConfig.length) { throw new Error(`截图脚本缺少 JMS 登录环境变量:${missingJmsConfig.join(', ')}`); } // 只带知识与配置,世界/主库走全新演示种子 for (const f of ['knowledge.json', 'config.json']) { const src = path.join(ROOT, 'server', 'data', f); if (fs.existsSync(src)) fs.copyFileSync(src, path.join(TMP, f)); } const children = []; function run(name, cmd, args, opts = {}) { const log = fs.createWriteStream(path.join(SHOTS, `${name}2.log`)); const p = spawn(cmd, args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], ...opts }); p.stdout.pipe(log); p.stderr.pipe(log); children.push(p); return p; } function cleanup() { for (const p of children) { try { spawn('taskkill', ['/PID', String(p.pid), '/T', '/F'], { stdio: 'ignore' }); } catch {} } } process.on('exit', cleanup); async function waitHttp(url, timeoutMs) { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { try { const r = await fetch(url); if (r.status < 500) return; } catch {} await new Promise(r => setTimeout(r, 1000)); } throw new Error('waitHttp timeout: ' + url); } run('backend', PY, ['-m', 'uvicorn', 'server.main:app', '--port', '8100'], { env: { ...process.env, APS_PORT: '8100', APS_HOME: TMP, APS_WORLD_PATH: path.join(TMP, 'world.json'), APS_DB_PATH: path.join(TMP, 'master.db'), APS_APPROVAL_PATH: path.join(TMP, 'approvals.json'), APS_KNOWLEDGE_PATH: path.join(TMP, 'knowledge.json'), APS_WEB_DEMO_SEED: '1', }, }); run('frontend', process.execPath, [VITE, '--port', '5399', '--strictPort'], { cwd: path.join(ROOT, 'apps', 'web'), env: { ...process.env, VITE_API_TARGET: API }, }); console.log('等待服务就绪…'); await waitHttp(API + '/api/health', 120_000); await waitHttp(UI, 120_000); console.log('服务就绪'); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1.5 }); const results = []; async function shot(name) { await page.waitForTimeout(700); await page.screenshot({ path: path.join(SHOTS, name + '.png') }); results.push(name); console.log(' ✓', name); } async function tryStep(name, fn, timeoutMs = 12_000) { try { await Promise.race([fn(), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs))]); return true; } catch (e) { console.log(' ✗ 跳过', name, '-', e.message.slice(0, 60)); return false; } } // 01 登录弹窗 await page.goto(UI); await page.locator('.guest-login-trigger').waitFor({ state: 'visible', timeout: 60_000 }); await page.locator('.guest-login-trigger').click(); const dialog = page.locator('.auth-login-dialog'); await dialog.waitFor({ state: 'visible' }); await shot('01-登录'); await dialog.locator('input[placeholder="请输入企业名称"]').fill(JMS.tenant); await dialog.locator('input[placeholder="请输入用户名"]').fill(JMS.username); await dialog.locator('input[placeholder="请输入密码"]').fill(JMS.password); await dialog.locator('.login-submit').click(); const composer = page.locator('textarea[placeholder^="输入排产指令"]'); await composer.waitFor({ state: 'visible', timeout: 60_000 }); await tryStep('主界面稳定', async () => { await page.locator('text=快捷指令').first().waitFor({ state: 'visible', timeout: 40_000 }); }, 45_000); await page.waitForTimeout(2000); await shot('02-主界面'); // 03 对话:固定轨试排(交期优先)→ 甘特有数据 await composer.fill('试排一版交期优先'); await composer.press('Enter'); await tryStep('试排结果', async () => { await page.locator('.msg.agent .bubble', { hasText: /排产|版本|工单/ }).last() .waitFor({ state: 'visible', timeout: 150_000 }); }, 160_000); await page.waitForTimeout(2500); await shot('03-对话试排'); // 视口页签(固定轨视图) const resultBtn = page.locator('.right-rail button[data-tip="会话结果"], .right-rail button[data-tip="再点收起"]').first(); if (!(await page.locator('.vp-toolbar').isVisible().catch(() => false))) { await tryStep('打开会话结果', () => resultBtn.click()); } await page.locator('.vp-toolbar').waitFor({ state: 'visible', timeout: 20_000 }).catch(() => {}); const tab = async (label, name) => { const ok = await tryStep('页签:' + label, async () => { await page.locator('.seg .tab', { hasText: label }).first().click(); await page.waitForTimeout(1200); }); if (ok) await shot(name); }; await tab('甘特图', '04-甘特图'); await tab('负荷热力', '05-负荷热力'); await tab('交期承诺', '06-交期承诺'); // 07 对话:柔性排产 → 柔性视图有数据 await tryStep('柔性排产', async () => { await composer.fill('跑一版柔性排产'); await composer.press('Enter'); await page.locator('.flex-block-title', { hasText: '柔性排产' }).first() .waitFor({ state: 'visible', timeout: 150_000 }); }, 160_000); await page.waitForTimeout(1500); await shot('07-对话柔性排产'); // 柔性相关视口页签 if (!(await page.locator('.vp-toolbar').isVisible().catch(() => false))) { await tryStep('再开会话结果', () => resultBtn.click()); } await tab('柔性甘特', '08-柔性甘特'); await tab('产能池', '09-产能池'); await tab('分桶计划', '10-分桶计划'); await tab('KPI', '11-KPI仪表盘'); await tab('对比表', '12-对比表'); // 右侧工具面板 const rail = async (tip, name, waitSel) => { const ok = await tryStep('面板:' + tip, async () => { await page.locator(`.right-rail button[data-tip="${tip}"]`).click(); if (waitSel) await page.locator(waitSel).waitFor({ state: 'visible', timeout: 12_000 }); await page.waitForTimeout(1000); }); if (ok) await shot(name); await tryStep('收起', () => page.locator('.right-rail button[data-tip="再点收起"]').click(), 4_000); }; await rail('柔性工作台', '13-柔性工作台', '.flex-bench'); await rail('订单管理', '14-订单管理'); await rail('主数据', '15-主数据'); await rail('排产结构', '16-排产结构'); await rail('多智能体编排', '17-多智能体编排'); await rail('图纸识别', '18-图纸识别'); // 19 命令面板 await page.keyboard.press('Control+K'); await tryStep('命令面板', async () => { await page.locator('.palette input').waitFor({ state: 'visible', timeout: 8_000 }); await page.locator('.palette input').fill('柔性'); await page.waitForTimeout(500); }); await shot('19-命令面板'); await page.keyboard.press('Escape'); // 20 对话:多策略对比 await tryStep('策略对比', async () => { await composer.fill('对比几种策略'); await composer.press('Enter'); await page.locator('text=采用').first().waitFor({ state: 'visible', timeout: 130_000 }); }, 140_000); await page.waitForTimeout(1000); await shot('20-对话策略对比'); await browser.close(); fs.writeFileSync(path.join(SHOTS, 'manifest2.json'), JSON.stringify(results, null, 2)); console.log('完成,共', results.length, '张'); cleanup(); process.exit(0);