// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 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, 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]> = [ ['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}`) } } /** * 截取「账单详情」弹窗(含基本信息、标签、账单关系、来源图片与原始数据)。 * * 优先选择「有来源图片、无账单关系」的记录,使弹窗聚焦来源图片与原始数据。 * * @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}`) } } /** * 截取「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}`) } } } /** * 截取 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}`) } } 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 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) }) }