import { chromium } from 'playwright'; import fs from 'node:fs/promises'; import path from 'node:path'; import zlib from 'node:zlib'; const BASE_URL = process.env.SEG_DEMO_URL || 'https://seg.huijutec.cn/'; const USERNAME = process.env.SEG_DEMO_USER || 'admin'; const PASSWORD = process.env.SEG_DEMO_PASSWORD || '123456'; const OUT_ROOT = process.env.SEG_DEMO_OUT_DIR || path.resolve('新撰写软著文档'); const IMAGE_DIR = path.join(OUT_ROOT, 'images'); const VIDEO_DIR = path.join(OUT_ROOT, '系统使用视频'); const TMP_DIR = path.join(OUT_ROOT, '.capture-tmp'); const viewport = { width: 1920, height: 1080 }; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const shots = [ ['01-login.png', '系统登录界面图'], ['02-dashboard.png', '系统登录后总体概况界面图'], ['03-main-layout.png', '系统主界面整体布局图'], ['04-dashboard-tasks.png', '后台任务列表及任务进度界面图'], ['05-project-library.png', '项目库列表界面图'], ['06-import-media-options.png', '导入视频与DICOM资源选择界面图'], ['07-project-copy-dialog.png', '项目复制操作界面图'], ['08-frame-parse-dialog.png', '视频生成帧配置界面图'], ['09-workspace-main.png', '分割工作区主界面图'], ['10-workspace-tools.png', '左侧标注工具栏与语义分类树界面图'], ['11-workspace-draw-mask.png', '多边形、矩形、圆形和画笔标注操作界面图'], ['12-workspace-auto-propagate-range.png', 'AI自动推理范围选择界面图'], ['13-workspace-export-dialog.png', '分割结果导出配置界面图'], ['14-gt-mask-import-preview.png', 'GT Mask导入预览界面图'], ['15-ai-page.png', 'AI智能分割模型选择界面图'], ['16-ai-prompt-tools.png', 'AI智能分割点选和框选工具界面图'], ['17-template-library.png', '模板库模板清单界面图'], ['18-template-edit-dialog.png', '模板分类树编辑界面图'], ['19-template-batch-import.png', '模板批量导入分类界面图'], ['20-user-admin.png', '管理员用户管理后台界面图'], ['21-user-create-dialog.png', '新增标注员账号界面图'], ['22-audit-reset-dialog.png', '审计日志和恢复演示出厂设置确认界面图'], ['23-logout.png', '退出登录按钮和返回登录界面图'], ]; async function ensureDirs() { await fs.mkdir(IMAGE_DIR, { recursive: true }); await fs.mkdir(VIDEO_DIR, { recursive: true }); await fs.mkdir(TMP_DIR, { recursive: true }); } async function login(page) { await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 }); await page.locator('input[type="text"]').first().fill(USERNAME); await page.locator('input[type="password"]').first().fill(PASSWORD); await page.getByRole('button', { name: /安全登录|登录/ }).click(); await page.waitForLoadState('networkidle').catch(() => {}); await sleep(1800); } async function shot(page, filename) { await page.screenshot({ path: path.join(IMAGE_DIR, filename), fullPage: false, animations: 'disabled', }); console.log(`screenshot ${filename}`); } async function clickIfVisible(locator, timeout = 2500) { try { await locator.waitFor({ state: 'visible', timeout }); await locator.click(); return true; } catch { return false; } } async function closeDialog(page) { const cancelButtons = page.getByRole('button', { name: /^取消$/ }); const count = await cancelButtons.count().catch(() => 0); if (count > 0) { await cancelButtons.last().click().catch(() => {}); await sleep(500); return; } await page.keyboard.press('Escape').catch(() => {}); await sleep(500); } async function gotoModule(page, title, settle = 1200) { await page.getByTitle(title).first().click(); await page.waitForLoadState('networkidle').catch(() => {}); await sleep(settle); } async function openWorkspaceProject(page) { await gotoModule(page, '项目库', 900); await page.getByText('演视LC视频序列').first().click(); await page.waitForLoadState('networkidle').catch(() => {}); await sleep(2200); } async function drawDemoMask(page) { await page.getByRole('button', { name: /肿瘤\/结节/ }).first().click().catch(() => {}); await page.getByTitle('创建矩形 (R)').click(); await sleep(400); const canvas = page.locator('.konvajs-content canvas').first(); const box = await canvas.boundingBox(); if (!box) return; const x1 = box.x + box.width * 0.36; const y1 = box.y + box.height * 0.38; const x2 = box.x + box.width * 0.55; const y2 = box.y + box.height * 0.58; await page.mouse.move(x1, y1); await page.mouse.down(); await page.mouse.move(x2, y2, { steps: 12 }); await page.mouse.up(); await sleep(900); } function crc32(buffer) { let crc = -1; for (const byte of buffer) { crc ^= byte; for (let i = 0; i < 8; i += 1) { crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } } return (crc ^ -1) >>> 0; } function pngChunk(type, data) { const typeBuf = Buffer.from(type); const length = Buffer.alloc(4); length.writeUInt32BE(data.length, 0); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); return Buffer.concat([length, typeBuf, data, crc]); } async function createGtMaskPng(filePath) { const width = 240; const height = 160; const raw = Buffer.alloc((width + 1) * height); for (let y = 0; y < height; y += 1) { const row = y * (width + 1); raw[row] = 0; for (let x = 0; x < width; x += 1) { raw[row + 1 + x] = x > 50 && x < 185 && y > 35 && y < 125 ? 1 : 0; } } const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 0; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; const png = Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), pngChunk('IHDR', ihdr), pngChunk('IDAT', zlib.deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0)), ]); await fs.writeFile(filePath, png); } async function captureScreenshots() { const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] }); const context = await browser.newContext({ viewport, deviceScaleFactor: 1 }); const page = await context.newPage(); await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 }); await sleep(1000); await shot(page, '01-login.png'); await login(page); await shot(page, '02-dashboard.png'); await shot(page, '03-main-layout.png'); await shot(page, '04-dashboard-tasks.png'); await gotoModule(page, '项目库'); await shot(page, '05-project-library.png'); await page.getByRole('button', { name: '导入多媒体资源' }).click(); await sleep(700); await shot(page, '06-import-media-options.png'); await page.getByRole('button', { name: '导入多媒体资源' }).click().catch(() => {}); await sleep(500); await page.getByTitle('复制项目').first().click(); await sleep(700); await shot(page, '07-project-copy-dialog.png'); await closeDialog(page); await page.getByRole('button', { name: /重新生成帧|生成帧/ }).first().click(); await sleep(900); await shot(page, '08-frame-parse-dialog.png'); await closeDialog(page); await openWorkspaceProject(page); await shot(page, '09-workspace-main.png'); await shot(page, '10-workspace-tools.png'); await drawDemoMask(page); await shot(page, '11-workspace-draw-mask.png'); await page.getByTitle('AI自动推理').click(); await sleep(1000); await shot(page, '12-workspace-auto-propagate-range.png'); await page.keyboard.press('Escape').catch(() => {}); await sleep(500); await page.getByRole('button', { name: '分割结果导出' }).click(); await sleep(900); await shot(page, '13-workspace-export-dialog.png'); await closeDialog(page); const gtPath = path.join(TMP_DIR, 'gt_mask_preview.png'); await createGtMaskPng(gtPath); const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 3000 }).catch(() => null); await page.getByTitle('导入 GT Mask').click(); const fileChooser = await fileChooserPromise; if (fileChooser) { await fileChooser.setFiles(gtPath); await sleep(2200); await shot(page, '14-gt-mask-import-preview.png'); await closeDialog(page); } else { await shot(page, '14-gt-mask-import-preview.png'); } await gotoModule(page, 'AI智能分割', 1600); await shot(page, '15-ai-page.png'); await page.getByRole('button', { name: '正向选点' }).click().catch(() => {}); await sleep(500); await shot(page, '16-ai-prompt-tools.png'); await gotoModule(page, '模板库', 1500); await shot(page, '17-template-library.png'); await page.getByRole('button', { name: '编辑模板' }).last().click(); await sleep(900); await shot(page, '18-template-edit-dialog.png'); await page.getByRole('button', { name: /批量导入/ }).click().catch(() => {}); await sleep(700); const textareas = page.locator('textarea'); if ((await textareas.count()) > 0) { await textareas.last().fill('{"colors":[[255,0,0],[0,255,0]],"names":["示例类别一","示例类别二"]}').catch(() => {}); await sleep(500); } await shot(page, '19-template-batch-import.png'); await closeDialog(page); await gotoModule(page, '用户管理', 1500); await shot(page, '20-user-admin.png'); await page.getByRole('button', { name: '新增用户' }).click(); await sleep(900); await shot(page, '21-user-create-dialog.png'); await closeDialog(page); await page.getByRole('button', { name: '恢复演示出厂设置' }).click(); await sleep(900); await shot(page, '22-audit-reset-dialog.png'); await closeDialog(page); await page.getByTitle('当前用户:admin,点击退出').hover(); await sleep(700); await page.getByTitle('当前用户:admin,点击退出').click(); await sleep(1200); await shot(page, '23-logout.png'); await browser.close(); } async function recordVideo(name, action) { const browser = await chromium.launch({ headless: true, args: ['--window-size=1920,1080'] }); const context = await browser.newContext({ viewport, deviceScaleFactor: 1, recordVideo: { dir: VIDEO_DIR, size: viewport }, }); const page = await context.newPage(); await action(page); await sleep(800); const video = page.video(); await context.close(); await browser.close(); const videoPath = await video?.path(); if (!videoPath) return null; const finalPath = path.join(VIDEO_DIR, `${name}.webm`); await fs.rm(finalPath, { force: true }); await fs.rename(videoPath, finalPath); console.log(`video ${path.basename(finalPath)}`); return finalPath; } async function captureVideos() { await recordVideo('01-登录与总体概况演示', async (page) => { await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 60000 }); await sleep(1200); await login(page); await sleep(1800); await gotoModule(page, '总体概况', 2400); }); await recordVideo('02-项目库与分割工作区演示', async (page) => { await login(page); await gotoModule(page, '项目库', 1800); await page.getByRole('button', { name: '导入多媒体资源' }).click(); await sleep(1400); await page.getByRole('button', { name: '导入多媒体资源' }).click().catch(() => {}); await sleep(600); await page.getByText('演视LC视频序列').first().click(); await sleep(2600); await drawDemoMask(page); await sleep(1600); }); await recordVideo('03-AI推理与结果导出演示', async (page) => { await login(page); await openWorkspaceProject(page); await page.getByTitle('AI自动推理').click(); await sleep(2000); await page.keyboard.press('Escape').catch(() => {}); await page.getByRole('button', { name: '分割结果导出' }).click(); await sleep(2200); await closeDialog(page); await gotoModule(page, 'AI智能分割', 2600); }); await recordVideo('04-模板库与用户管理演示', async (page) => { await login(page); await gotoModule(page, '模板库', 2200); await page.getByRole('button', { name: '编辑模板' }).last().click(); await sleep(1800); await closeDialog(page); await gotoModule(page, '用户管理', 2200); await page.getByRole('button', { name: '新增用户' }).click(); await sleep(1800); await closeDialog(page); }); } async function main() { await ensureDirs(); await captureScreenshots(); await captureVideos(); await fs.writeFile( path.join(OUT_ROOT, '功能验证与素材清单.md'), [ '# 功能验证与素材清单', '', `验证地址:${BASE_URL}`, `验证时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, '', '## 截图文件', ...shots.map(([file, title]) => `- images/${file}:${title}`), '', '## 分段视频', '- 系统使用视频/01-登录与总体概况演示.webm', '- 系统使用视频/02-项目库与分割工作区演示.webm', '- 系统使用视频/03-AI推理与结果导出演示.webm', '- 系统使用视频/04-模板库与用户管理演示.webm', '', '## 验证说明', '本次验证以管理员账号进入线上系统,逐项检查登录、总体概况、项目库、分割工作区、AI 智能分割、AI 自动推理入口、GT Mask 导入预览、分割结果导出、模板库、用户管理、审计日志和退出登录等说明书涉及功能。删除项目、恢复演示出厂设置、生成帧确认、导出下载确认等可能改变演示环境或产生下载文件的危险提交动作仅验证入口与确认界面,不执行最终提交。', '', ].join('\n'), 'utf8', ); } main().catch((error) => { console.error(error); process.exit(1); });