// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 public/screenshots/elements/。 import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import type { Page } from 'playwright-core' import { clickText, injectSession, launchBrowser, login, newContext, waitPage } from './browser.js' import { BASE_URL, ELEM_DIR, SAMPLE_BILL, SAMPLE_IMAGE, SAMPLE_IMAGE2 } from './config.js' /** 页面就绪标志:列表页表格、设置面板或卡片 */ const PAGE_READY = '.n-data-table, .config-card, .n-card' /** 未登录状态下的卡片截图:文件名、路由、CSS 选择器 */ const CARD_SCENES: Array<[string, string, string]> = [ ['login-card', '/login', '.login-container .n-card'], ['register-card', '/register', '.login-container .n-card'], ] /** 弹窗截图:文件名、路由、触发按钮文本 */ const MODAL_SCENES: Array<[string, string, string]> = [ ['add-record', '/transactions', '新增记录'], ['batch-set', '/transactions', '一键设置'], ['import-bill', '/transactions', '导入账单'], ['ai-record', '/transactions', '智能记账'], ['ai-search', '/dashboard', 'AI 搜索'], ['add-category', '/categories', '新增分类'], ['add-tag', '/tags', '新增标签'], ['add-rule', '/rules', '新增规则'], ['add-email-config', '/email-bills/configs', '新增'], ['add-channel', '/settings', '添加渠道'], ['channel-create', '/channels', '新建渠道'], ] /** * 截取登录/注册卡片(未登录状态)。 * * @param page Page 实例 */ export async function captureCards(page: Page): Promise { for (const [name, path, selector] of CARD_SCENES) { try { await page.setViewportSize({ width: 1440, height: 900 }) await page.goto(BASE_URL + path) await waitPage(page, { selector: PAGE_READY }) // 未登录状态默认语言可能为英文,先切换为中文再刷新 await page.evaluate(() => localStorage.setItem('locale', 'zh-CN')) await page.reload() await waitPage(page, { selector, settle: 800 }) const card = page.locator(selector).first() await card.waitFor({ state: 'visible', timeout: 20000 }) await card.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) console.log(`[OK] ${name}`) } catch (err) { console.log(`[FAIL] ${name}: ${err}`) } } } /** * 截取各功能弹窗。 * * @param page Page 实例 */ export async function captureModals(page: Page): Promise { for (const [name, path, button] of MODAL_SCENES) { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + path) await waitPage(page, { selector: PAGE_READY }) if (!(await clickText(page, button))) throw new Error(`未找到按钮:${button}`) const modal = page.locator('.n-modal').first() try { await modal.waitFor({ state: 'visible', timeout: 8000 }) } catch { // 首次点击可能未生效(页面状态未就绪),重试一次 await clickText(page, button) await modal.waitFor({ state: 'visible', timeout: 15000 }) } // 弹窗内的数据(如 AI 规则候选记录)为异步加载,等待请求完成后再截图 await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined) await page.waitForTimeout(900) await modal.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) console.log(`[OK] modal/${name}`) } catch (err) { console.log(`[FAIL] modal/${name}: ${err}`) } } } /** * 截取账单解析预览:上传样例账单后解析。 * * @param page Page 实例 */ export async function captureBillParse(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: PAGE_READY }) if (!(await clickText(page, '导入账单'))) throw new Error('未找到「导入账单」按钮') const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) await modal.locator("input[type='file']").setInputFiles(SAMPLE_BILL) await page.waitForTimeout(800) await modal.locator('button', { hasText: '解析' }).first().click() // 等待预览表格出现 await page.locator('.n-modal .n-data-table').first().waitFor({ state: 'visible', timeout: 40000 }) await page.waitForTimeout(800) await modal.screenshot({ path: resolve(ELEM_DIR, 'bill-parse-preview.png') }) console.log('[OK] modal/bill-parse-preview') } catch (err) { console.log(`[FAIL] modal/bill-parse-preview: ${err}`) } } /** * 截取「图片记账」:多图上传 Tab 与识别结果弹窗。 * * @param page Page 实例 * @param importRecords 识别后是否导入勾选记录(默认 true,便于后续截取来源图片) */ export async function captureAiRecordImage(page: Page, importRecords = true): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: PAGE_READY }) if (!(await clickText(page, '智能记账'))) throw new Error('未找到「智能记账」按钮') const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) // 切到「图片记账」标签页,上传两张虚构账单截图后截取上传界面 await modal.locator('.n-tabs-tab', { hasText: '图片记账' }).first().click() await page.waitForTimeout(600) await modal.locator("input[type='file']").setInputFiles([SAMPLE_IMAGE, SAMPLE_IMAGE2]) await page.waitForTimeout(600) await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-record-image.png') }) console.log('[OK] modal/ai-record-image') const recognize = modal.locator('button', { hasText: '识别图片' }).first() await recognize.evaluate((el) => (el as HTMLElement).click()) // 结果弹窗出现(标题「图片记账结果」)且表格有数据 const resultModal = page.locator('.n-modal', { hasText: '图片记账结果' }).first() await resultModal.waitFor({ state: 'visible', timeout: 180000 }) await resultModal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 180000 }) await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => undefined) await page.waitForTimeout(800) await resultModal.screenshot({ path: resolve(ELEM_DIR, 'ai-record-result.png') }) console.log('[OK] modal/ai-record-result') // 导入勾选记录,便于后续「来源图片」截图(去重键保证重复执行不会产生新记录) if (importRecords) { const importBtn = resultModal.locator('button', { hasText: '导入勾选' }).first() await importBtn.evaluate((el) => (el as HTMLElement).click()) await resultModal.waitFor({ state: 'hidden', timeout: 60000 }).catch(() => undefined) await page.waitForTimeout(1000) console.log('[OK] modal/ai-record-import') } } catch (err) { console.log(`[FAIL] modal/ai-record-image: ${err}`) } } /** * 截取「来源图片」查看弹窗(点击表格中图片记账记录的「查看(N)」)。 * * @param page Page 实例 */ export async function captureSourceImages(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: '.n-data-table-tbody tr' }) const viewBtn = page.locator('button', { hasText: /查看(\d+)/ }).first() await viewBtn.waitFor({ state: 'visible', timeout: 20000 }) await viewBtn.evaluate((el) => (el as HTMLElement).click()) const modal = page.locator('.n-modal', { hasText: '来源图片' }).first() await modal.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => undefined) await page.waitForTimeout(1000) await modal.screenshot({ path: resolve(ELEM_DIR, 'source-images.png') }) console.log('[OK] modal/source-images') } catch (err) { console.log(`[FAIL] modal/source-images: ${err}`) } } /** * 截取编辑记录弹窗中「交易渠道」的 AI 推荐与新建按钮。 * * @param page Page 实例 */ export async function captureChannelAi(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: PAGE_READY }) const editBtn = page.locator('tbody tr').first().locator('button', { hasText: '编辑' }).first() await editBtn.waitFor({ state: 'visible', timeout: 20000 }) await editBtn.click() const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(800) await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-channel-suggest.png') }) console.log('[OK] modal/ai-channel-suggest') } catch (err) { console.log(`[FAIL] modal/ai-channel-suggest: ${err}`) } } /** * 截取「点击趋势柱状图跳转收支记录并自动带入筛选条件」的筛选卡片。 * * @param page Page 实例 */ export async function captureTrendJump(page: Page): Promise { try { await page.setViewportSize({ width: 1720, height: 1000 }) await page.goto(BASE_URL + '/dashboard') await waitPage(page, { selector: 'canvas' }) // 切到「近12月 + 按月」,柱子更宽便于稳定点中;同时清空收支记录筛选偏好,避免旧条件干扰 await page.evaluate(() => { localStorage.setItem('finance.dashboard.prefs', JSON.stringify({ rangeType: '12month', granularity: 'month', filterStatsScope: 'INCLUDED', filterType: [], filterCategory: [], filterChannel: [], filterTag: [], filterMinAmount: null, filterMaxAmount: null, filterRemark: '', })) localStorage.removeItem('finance.transaction.filterPrefs') }) await page.reload() await waitPage(page, { selector: 'canvas' }) await page.waitForTimeout(1500) // 趋势图是页面首个 canvas;沿 x 轴扫描点击柱体,命中后跳转到收支记录 const canvas = page.locator('canvas').first() const box = await canvas.boundingBox() if (!box) throw new Error('趋势图 canvas 不可见') const left = box.x + 60 const right = box.x + box.width - 20 const y = box.y + box.height - 48 let jumped = false for (let x = left + 4; x < right && !jumped; x += 8) { await page.mouse.click(x, y) await page.waitForTimeout(60) if (page.url().includes('/transactions')) jumped = true } if (!jumped) throw new Error('点击趋势图未跳转到收支记录') await waitPage(page, { selector: '.active-filters' }) const card = page.locator('.n-card').first() await card.screenshot({ path: resolve(ELEM_DIR, 'dashboard-trend-jump.png') }) console.log('[OK] dashboard-trend-jump') } catch (err) { console.log(`[FAIL] dashboard-trend-jump: ${err}`) } } /** 系统设置面板:输出文件名与面板标题 */ const SETTINGS_PANELS: Array<[string, string]> = [ ['panel-database', '数据库配置'], ['panel-registration', '注册设置'], ['panel-bill-source-stats', '账单来源统计默认值'], ['panel-display', '外观设置'], ['panel-ai', 'AI 模型配置'], ['panel-notification', '定时通知'], ] /** * 截取编辑记录弹窗(点击表格首行的「编辑」)。 * * @param page Page 实例 */ export async function captureEditRecord(page: Page): Promise { try { await page.setViewportSize({ width: 1720, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: PAGE_READY }) const editBtn = page.locator('tbody tr').first().locator('button', { hasText: '编辑' }).first() await editBtn.waitFor({ state: 'visible', timeout: 20000 }) await editBtn.click() const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(800) await modal.screenshot({ path: resolve(ELEM_DIR, 'edit-record.png') }) console.log('[OK] modal/edit-record') } catch (err) { console.log(`[FAIL] modal/edit-record: ${err}`) } } /** * 截取「AI 生成规则」的两步弹窗:步骤一选择记录、步骤二预览规则建议。 * * @param page Page 实例 */ export async function captureAiRule(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/rules') await waitPage(page, { selector: PAGE_READY }) if (!(await clickText(page, 'AI 生成规则'))) throw new Error('未找到「AI 生成规则」按钮') const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) // 步骤一:候选记录异步加载,等待数据行出现后再截图 await modal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(800) await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-rule-select.png') }) console.log('[OK] modal/ai-rule-select') // 步骤二:点击「生成规则」,等待 AI 返回并渲染规则建议表 const generateBtn = modal.locator('button', { hasText: '生成规则' }).first() await generateBtn.waitFor({ state: 'visible', timeout: 15000 }) await generateBtn.evaluate((el) => (el as HTMLElement).click()) // 步骤二特有按钮出现,说明已切换到预览步骤 await modal.locator('button', { hasText: '创建勾选规则' }).first().waitFor({ state: 'visible', timeout: 60000 }) await modal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 60000 }) // 等待 AI 请求结束与 loading 遮罩消失 await page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => undefined) await page.waitForTimeout(1000) await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-rule-preview.png') }) console.log('[OK] modal/ai-rule-preview') } catch (err) { console.log(`[FAIL] modal/ai-rule: ${err}`) } } /** * 截取系统设置的配置面板。 * * @param page Page 实例 * @param only 仅截取指定标题的面板(可选,缺省截取全部) */ export async function captureSettingsPanels(page: Page, only?: string[]): Promise { await page.setViewportSize({ width: 1440, height: 1200 }) await page.goto(BASE_URL + '/settings') await waitPage(page, { selector: PAGE_READY }) const panels = only ? SETTINGS_PANELS.filter(([, title]) => only.includes(title)) : SETTINGS_PANELS for (const [name, title] of panels) { try { const card = page.locator('.config-card', { hasText: title }).first() await card.waitFor({ state: 'visible', timeout: 20000 }) await card.scrollIntoViewIfNeeded() await page.waitForTimeout(500) await card.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) console.log(`[OK] ${name}`) } catch (err) { console.log(`[FAIL] ${name}: ${err}`) } } } async function main(): Promise { const browser = await launchBrowser() try { const guestContext = await newContext(browser, { width: 1440, height: 900 }) const guestPage = await guestContext.newPage() await captureCards(guestPage) await guestContext.close() const token = await login() const context = await newContext(browser) await injectSession(context, token) const page = await context.newPage() await captureModals(page) await captureEditRecord(page) await captureAiRule(page) await captureAiRecordImage(page) await captureSourceImages(page) await captureChannelAi(page) await captureSettingsPanels(page) await captureBillParse(page) await captureTrendJump(page) await context.close() } finally { await browser.close() } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((err) => { console.error('FAIL', err) process.exit(1) }) }