Files
ledgerly-docs/scripts/ts/capture-elements.ts
T
rosercode d94d4e1ed5 docs: 补充收支记录页 AI 生成规则、CORS/反向代理与邮箱检查进度时间戳
同步近期 ledgerly 功能与配置变更到文档站:新增收支记录页「AI 生成规则」入口的用户与开发说明、补充新接口与规则应用逻辑、修正 CORS/反向代理与运行环境编码说明,并更新邮箱检查进度时间戳描述。

变更内容:
- guide/transactions.md 批量操作区新增「AI 生成规则」按钮与「AI 生成规则(从勾选账单)」小节(含「同时应用到勾选账单」),引用新截图。
- guide/ai.md、guide/rules.md、guide/pages.md 补充两处生成入口与「同时应用」说明。
- dev/api.md 新增 POST /api/ai/rule/apply-transactions;dev/rule-engine.md、dev/ai.md 补充 applyToTransactions 与两个生成入口。
- dev/build-deploy.md 将 CORS 更新为放开全部来源并信任反向代理转发头,新增运行环境 UTF-8 locale 说明。
- guide/email-bills.md 补充「立即检查」进度每行带 HH:mm:ss 时间戳。
- scripts/ts/capture-elements.ts 新增 captureAiRuleFromTransactions 截图场景(交易页 AI 生成规则弹窗)。

说明:
- 新截图 elements/ai-rule-transactions.png 已上传 OSS,正文以 OSS 长期地址引用。
2026-10-13 22:39:40 +08:00

701 lines
29 KiB
TypeScript

// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 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]> = [
['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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<HTMLElement>('.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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 生成规则」弹窗(基于勾选账单生成规则)。
*
* <p>仅触发规则建议生成(只读),不点击「创建勾选规则」,不修改数据。
* 需目标环境已启用 AI 且存在可勾选账单。</p>
*
* @param page Page 实例
*/
export async function captureAiRuleFromTransactions(page: Page): Promise<void> {
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}`)
}
}
/**
* 截取系统设置的配置面板。
*
* @param page Page 实例
* @param only 仅截取指定标题的面板(可选,缺省截取全部)
*/
export async function captureSettingsPanels(page: Page, only?: string[]): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 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)
})
}