// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 public/screenshots/elements/。 import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' import type { Locator, Page } from 'playwright-core' import { clickText, injectSession, launchBrowser, login, newContext, waitPage } from './browser.js' import { BASE_URL, ELEM_DIR, OUT_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]> = [ ['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 实例 * @param only 仅截取指定场景名(可选,缺省截取全部) */ export async function captureModals(page: Page, only?: string[]): Promise { const scenes = only ? MODAL_SCENES.filter(([name]) => only.includes(name)) : MODAL_SCENES for (const [name, path, button] of 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}`) } } } /** * 截取「添加渠道」弹窗的飞书配置表单(elements/add-channel-feishu.png)。 * * 打开设置页的添加渠道弹窗后,将「渠道类型」切换为飞书,展示 Webhook 地址与签名密钥表单项。 * * @param page Page 实例 */ export async function captureChannelFeishu(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/settings') await waitPage(page, { selector: PAGE_READY }) if (!(await clickText(page, '添加渠道'))) throw new Error('未找到「添加渠道」按钮') const modal = page.locator('.n-modal').first() try { await modal.waitFor({ state: 'visible', timeout: 8000 }) } catch { // 首次点击可能未生效(页面状态未就绪),重试一次 await clickText(page, '添加渠道') await modal.waitFor({ state: 'visible', timeout: 15000 }) } // 弹窗内首个下拉为「渠道类型」,切换为飞书以展示 Webhook 与签名密钥表单 await modal.locator('.n-base-selection').first().click() await page.getByText('飞书', { exact: true }).last().click() await page.waitForTimeout(600) await modal.screenshot({ path: resolve(ELEM_DIR, 'add-channel-feishu.png') }) console.log('[OK] modal/add-channel-feishu') } catch (err) { console.log(`[FAIL] add-channel-feishu: ${err}`) } } /** * 截取新增记录弹窗(elements/add-record.png)。 * * 先二次加载页面,确保模块级 AI 启用状态已就绪,使弹窗完整展示 AI 辅助按钮。 * * @param page Page 实例 */ export async function captureAddRecord(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: PAGE_READY }) await page.reload() await waitPage(page, { selector: PAGE_READY }) await page.waitForTimeout(1500) if (!(await clickText(page, '新增记录'))) throw new Error('未找到「新增记录」按钮') const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(900) await modal.screenshot({ path: resolve(ELEM_DIR, 'add-record.png') }) console.log('[OK] modal/add-record') } catch (err) { console.log(`[FAIL] modal/add-record: ${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}`) } } /** * 截取「账单详情」弹窗(含基本信息、标签、账单关系、来源图片与原始数据)。 * * 优先选择「有来源图片、无账单关系」的记录,使弹窗聚焦来源图片与原始数据。 * * @param page Page 实例 */ export async function captureBillDetail(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1200 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: '.n-data-table-tbody tr' }) const rows = page.locator('.n-data-table-tbody tr') const count = await rows.count() for (let i = 0; i < count; i++) { const detailBtn = rows.nth(i).locator('button', { hasText: '详情' }).first() if ((await detailBtn.count()) === 0) continue await detailBtn.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: 15000 }).catch(() => undefined) await page.waitForTimeout(800) const imageSection = modal.locator('.detail-section', { hasText: '来源图片' }).first() const imgCount = await imageSection.locator('img').count() const relationSection = modal.locator('.detail-section', { hasText: '账单关系' }).first() const relationText = (await relationSection.innerText().catch(() => '')) || '' const noRelation = relationText.includes('暂无关联账单') if (imgCount > 0 && noRelation) { // 加大视口并取消弹窗内部滚动,使来源图片与原始数据一并入镜 await page.setViewportSize({ width: 1440, height: 2400 }) await page.evaluate(() => { document .querySelectorAll('.n-modal, .n-modal .n-card, .n-modal .n-card__content') .forEach((el) => { el.style.maxHeight = 'none' el.style.overflow = 'visible' }) }) await page.waitForTimeout(500) await modal.screenshot({ path: resolve(ELEM_DIR, 'bill-detail.png') }) console.log('[OK] modal/bill-detail') return } // 不满足条件则关闭弹窗,继续查看下一行 await page.locator('.n-modal .n-base-close').first().click().catch(() => undefined) await page.keyboard.press('Escape').catch(() => undefined) await page.waitForTimeout(400) } throw new Error('未找到「有来源图片且无账单关系」的记录') } catch (err) { console.log(`[FAIL] modal/bill-detail: ${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('ledgerly.dashboard.prefs', JSON.stringify({ rangeType: '12month', granularity: 'month', filterStatsScope: 'INCLUDED', filterType: [], filterCategory: [], filterChannel: [], filterTag: [], filterMinAmount: null, filterMaxAmount: null, filterRemark: '', })) localStorage.removeItem('ledgerly.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-account-security', '账号安全'], ['panel-database', '数据库配置'], ['panel-registration', '注册设置'], ['panel-bill-source-stats', '账单来源统计默认值'], ['panel-display', '外观设置'], ['panel-nav-menu', '导航设置'], ['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}`) } } /** * 截取记账弹窗内「新建分类」子弹窗(体现新增/编辑记录时可直接新建分类)。 * * @param page Page 实例 */ export async function captureInlineCreate(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 }) if (!(await clickText(page, '新建分类'))) throw new Error('未找到「新建分类」按钮') await page.waitForTimeout(800) // 子弹窗为后打开的最后一层弹窗 const sub = page.locator('.n-modal').last() await sub.waitFor({ state: 'visible', timeout: 8000 }) await sub.screenshot({ path: resolve(ELEM_DIR, 'record-new-category.png') }) console.log('[OK] modal/record-new-category') } catch (err) { console.log(`[FAIL] modal/record-new-category: ${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}`) } } /** * 截取「收支记录 → 批量操作 → AI 生成规则」弹窗(基于勾选账单生成规则)。 * *

仅触发规则建议生成(只读),不点击「创建勾选规则」,不修改数据。 * 需目标环境已启用 AI 且存在可勾选账单。

* * @param page Page 实例 */ export async function captureAiRuleFromTransactions(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('未找到「批量操作」按钮') await page.waitForTimeout(500) const checkboxes = page.locator('.n-data-table-tbody .n-checkbox') const count = Math.min(await checkboxes.count(), 5) for (let i = 0; i < count; i++) { await checkboxes.nth(i).click({ force: true }).catch(() => undefined) } await page.waitForTimeout(300) // 点击「AI 生成规则」,等待弹窗与规则建议表(真实 AI 调用) if (!(await clickText(page, 'AI 生成规则'))) throw new Error('未找到「AI 生成规则」按钮') const modal = page.locator('.n-modal').first() await modal.waitFor({ state: 'visible', timeout: 30000 }) await modal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 90000 }) await page.waitForLoadState('networkidle', { timeout: 90000 }).catch(() => undefined) await page.waitForTimeout(1000) await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-rule-transactions.png') }) console.log('[OK] modal/ai-rule-transactions') } catch (err) { console.log(`[FAIL] modal/ai-rule-transactions: ${err}`) } } /** * 截取规则管理页「应用」命中的账单预览弹窗(elements/rule-apply-preview.png)。 * *

打开首条规则的行内「应用」按钮,等待命中账单预览表加载后截图;只预览、不点击「确认应用」,不修改数据。 * 需目标环境存在至少一条可命中账单的规则。

* * @param page Page 实例 */ export async function captureRuleApplyPreview(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/rules') await waitPage(page, { selector: PAGE_READY }) // 首条规则行的「应用」按钮(避免误点顶部的「应用匹配账单」) const applyBtn = page.locator('.n-data-table-tbody tr').first().locator('button', { hasText: '应用' }).first() await applyBtn.waitFor({ state: 'visible', timeout: 20000 }) await applyBtn.evaluate((el) => (el as HTMLElement).click()) 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.waitForLoadState('networkidle', { timeout: 20000 }).catch(() => undefined) await page.waitForTimeout(800) await modal.screenshot({ path: resolve(ELEM_DIR, 'rule-apply-preview.png') }) console.log('[OK] modal/rule-apply-preview') } catch (err) { console.log(`[FAIL] modal/rule-apply-preview: ${err}`) } } /** * 勾选表格前 n 行(用于进入批量模式后展示多选效果)。 * * @param page Page 实例 * @param table 目标表格 Locator * @param n 勾选行数 */ async function pickRows(page: Page, table: Locator, n: number): Promise { const rows = table.locator('.n-data-table-tbody tr') const count = Math.min(n, await rows.count()) for (let i = 0; i < count; i++) { await rows.nth(i).locator('.n-checkbox').first().click({ force: true }).catch(() => undefined) } await page.waitForTimeout(300) } /** * 截取标签/分类管理页的「批量操作模式」(勾选列与批量删除入口)。 * *

进入批量选择模式后勾选若干行,展示勾选列;仅截图,不执行任何删除。

* * @param page Page 实例 */ export async function captureBatchModes(page: Page): Promise { // 标签管理批量模式 try { await page.setViewportSize({ width: 1440, height: 900 }) await page.goto(BASE_URL + '/tags') await waitPage(page, { selector: '.n-data-table-tbody tr' }) if (!(await clickText(page, '批量操作'))) throw new Error('未找到「批量操作」按钮') await pickRows(page, page.locator('.n-data-table').first(), 2) await page.screenshot({ path: resolve(OUT_DIR, 'tags-batch.png') }) console.log('[OK] page/tags-batch') } catch (err) { console.log(`[FAIL] tags-batch: ${err}`) } // 分类管理批量模式(支出/收入两表各勾选若干) try { await page.setViewportSize({ width: 1440, height: 1100 }) await page.goto(BASE_URL + '/categories') await waitPage(page, { selector: '.n-data-table-tbody tr' }) if (!(await clickText(page, '批量操作'))) throw new Error('未找到「批量操作」按钮') const tables = page.locator('.n-data-table') await pickRows(page, tables.nth(0), 2) await pickRows(page, tables.nth(1), 2) await page.screenshot({ path: resolve(OUT_DIR, 'categories-batch.png') }) console.log('[OK] page/categories-batch') } catch (err) { console.log(`[FAIL] categories-batch: ${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}`) } } } /** * 截取 AI 查询浮窗(统计面板顶部的「AI 搜索」入口)。 * * @param page Page 实例 */ export async function captureAiQueryWindow(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/dashboard') await waitPage(page, { selector: 'canvas' }) if (!(await clickText(page, 'AI 搜索'))) throw new Error('未找到「AI 搜索」按钮') const panel = page.locator('.floating-panel').first() await panel.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(600) await panel.screenshot({ path: resolve(ELEM_DIR, 'ai-query-window.png') }) console.log('[OK] modal/ai-query-window') } catch (err) { console.log(`[FAIL] modal/ai-query-window: ${err}`) } } /** * 截取账单关系:关系查看面板(含关系图)与新建关系弹窗。 * * @param page Page 实例 */ export async function captureRelations(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1100 }) await page.goto(BASE_URL + '/transactions') await waitPage(page, { selector: '.n-data-table-tbody tr' }) const relBtn = page.locator('button', { hasText: /个关系/ }).first() await relBtn.waitFor({ state: 'visible', timeout: 20000 }) await relBtn.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, 'relation-panel.png') }) console.log('[OK] modal/relation-panel') const createBtn = modal.locator('button', { hasText: '新建关系' }).first() await createBtn.waitFor({ state: 'visible', timeout: 15000 }) await createBtn.evaluate((el) => (el as HTMLElement).click()) const editor = page.locator('.n-modal', { hasText: '新建账单关系' }).first() await editor.waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(800) await editor.screenshot({ path: resolve(ELEM_DIR, 'relation-editor.png') }) console.log('[OK] modal/relation-editor') } catch (err) { console.log(`[FAIL] modal/relations: ${err}`) } } /** * 截取智能查数整页(先执行一次查询再截图)。 * * @param page Page 实例 */ export async function captureSqlQuery(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1180 }) await page.goto(BASE_URL + '/smart-query') await waitPage(page, { selector: '.n-card' }) const input = page.locator('textarea').first() await input.waitFor({ state: 'visible', timeout: 20000 }) await input.fill('按分类统计今年的支出') await Promise.all([ page.waitForResponse( (r) => r.url().includes('/ai/sql') && r.request().method() === 'POST', { timeout: 130000 }, ), page.locator('button', { hasText: '查询' }).first().click(), ]) await page.waitForLoadState('networkidle', { timeout: 30000 }).catch(() => undefined) await page.waitForTimeout(1500) await page.screenshot({ path: resolve(OUT_DIR, 'sql-query.png') }) console.log('[OK] page/sql-query') } catch (err) { console.log(`[FAIL] page/sql-query: ${err}`) } } /** * 截取日历「月视图」整页:写入 screenshots/calendar.png。 * * @param page Page 实例 */ export async function captureCalendarMonth(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1280 }) await page.goto(BASE_URL + '/calendar?view=month') await waitPage(page, { selector: '.n-calendar' }) await page.locator('.day-cell__row').first().waitFor({ state: 'visible', timeout: 20000 }).catch(() => undefined) await page.waitForTimeout(600) await page.screenshot({ path: resolve(OUT_DIR, 'calendar.png') }) console.log('[OK] calendar') } catch (err) { console.log(`[FAIL] calendar: ${err}`) } } /** * 截取日历「年视图」:切换到年视图后截取。 * * @param page Page 实例 */ export async function captureCalendarYear(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1000 }) await page.goto(BASE_URL + '/calendar?view=month') await waitPage(page, { selector: '.n-calendar' }) const viewBtn = page.locator('.cal-toolbar__view').first() await viewBtn.waitFor({ state: 'visible', timeout: 15000 }) await viewBtn.evaluate((el) => (el as HTMLElement).click()) await page.locator('.year-cell').first().waitFor({ state: 'visible', timeout: 15000 }) await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined) await page.waitForTimeout(800) await page.screenshot({ path: resolve(ELEM_DIR, 'calendar-year.png') }) console.log('[OK] calendar-year') } catch (err) { console.log(`[FAIL] calendar-year: ${err}`) } } /** * 截取日历「当日明细浮窗」:点击某天有数据的日期后截取浮窗。 * * @param page Page 实例 */ export async function captureCalendarDetail(page: Page): Promise { try { await page.setViewportSize({ width: 1440, height: 1100 }) await page.goto(BASE_URL + '/calendar?view=month') await waitPage(page, { selector: '.n-calendar' }) await page.locator('.day-cell__row').first().waitFor({ state: 'visible', timeout: 20000 }) const cell = page .locator('.n-calendar-cell') .filter({ has: page.locator('.day-cell__row') }) .first() await cell.evaluate((el) => (el as HTMLElement).click()) const modal = page.locator('.n-modal', { hasText: '交易明细' }).first() await modal.waitFor({ state: 'visible', timeout: 20000 }) // 等待明细列表加载完成(出现条目后再截图,避免截到加载中状态) await modal.locator('.detail-item').first().waitFor({ state: 'visible', timeout: 20000 }) await page.waitForTimeout(600) await modal.screenshot({ path: resolve(ELEM_DIR, 'calendar-day-detail.png') }) console.log('[OK] calendar-day-detail') } catch (err) { console.log(`[FAIL] calendar-day-detail: ${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 captureCalendarMonth(page) await captureCalendarYear(page) await captureCalendarDetail(page) await captureAddRecord(page) await captureModals(page) await captureChannelFeishu(page) await captureEditRecord(page) await captureInlineCreate(page) await captureAiRule(page) await captureAiRuleFromTransactions(page) await captureRuleApplyPreview(page) await captureBatchModes(page) await captureAiRecordImage(page) await captureBillDetail(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) }) }