Files
ledgerly-docs/scripts/ts/capture-elements.ts
T
rosercode d2e4a51f2b feat: 补充图片记账、交易渠道管理与前端偏好文档
- 新增「交易渠道管理」指南与「前端用户偏好」开发文档,并加入侧边栏导航
- 图片记账:补充多图上传、实时识别进度、结果勾选导入与「来源图片」回看,AI 配置新增视觉模型说明
- 数据模型补充 t_channel、t_ai_config/t_ai_image 等表与「类型+金额+时间」去重键说明,表数更新为 21 张
- API 文档补充 /api/channel、/api/transaction/batch、/api/ai/record/image(/stream)、/api/ai/image、/api/ai/channel 等接口
- 截图脚本新增渠道页、图片记账、来源图片与渠道 AI 辅助截图,并放宽导航超时
2026-09-21 22:19:55 +08:00

384 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 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<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 实例
*/
export async function captureModals(page: Page): Promise<void> {
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<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}`)
}
}
/**
* 截取「来源图片」查看弹窗(点击表格中图片记账记录的「查看(N)」)。
*
* @param page Page 实例
*/
export async function captureSourceImages(page: Page): Promise<void> {
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<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('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<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}`)
}
}
/**
* 截取「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}`)
}
}
/**
* 截取系统设置的配置面板。
*
* @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}`)
}
}
}
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 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)
})
}