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