定时通知新增飞书渠道后同步文档:使用文档补充飞书渠道的配置方式与示例截图,开发文档补充 FEISHU 渠道类型与发送器实现,并在功能概览、技术栈等处同步“飞书”。 变更内容: - guide/notifications.md 增加飞书渠道配置项与获取路径,新增飞书渠道配置示例图 - dev/notifications.md 渠道表新增 FEISHU(post 富文本、可选加签),补充 webhookUrl/secret 与 hasSecret 脱敏说明 - guide/introduction.md、guide/settings.md、index.md、dev/architecture.md 同步“飞书” - scripts/ts/capture-elements.ts 新增 captureChannelFeishu 并纳入 main 全量调用
662 lines
28 KiB
TypeScript
662 lines
28 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}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 截取系统设置的配置面板。
|
|
*
|
|
* @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 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)
|
|
})
|
|
}
|