chore: 初始化文档站仓库

This commit is contained in:
2026-09-21 13:38:20 +08:00
commit a4ae7ac979
58 changed files with 4622 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 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 } 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', '添加渠道'],
]
/**
* 截取登录/注册卡片(未登录状态)。
*
* @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}`)
}
}
/**
* 截取「点击趋势柱状图跳转收支记录并自动带入筛选条件」的筛选卡片。
*
* @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 实例
*/
export async function captureSettingsPanels(page: Page): Promise<void> {
await page.setViewportSize({ width: 1440, height: 1200 })
await page.goto(BASE_URL + '/settings')
await waitPage(page, { selector: PAGE_READY })
for (const [name, title] of SETTINGS_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 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)
})
}