aps-agent/_shots/capture.mjs

208 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ============================================================
// 操作手册截图自动化 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);