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
+111
View File
@@ -0,0 +1,111 @@
// 浏览器自动化公共模块(Playwright + Chromium)与 HTTP 公共方法(axios)。
import axios from 'axios'
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core'
import { API_URL, BASE_URL, CHROMIUM_BIN, PASSWORD, USERNAME } from './config.js'
/** 默认视口尺寸 */
export const DEFAULT_VIEWPORT = { width: 1440, height: 1000 }
/**
* 启动 headless Chromium(复用 Playwright 缓存的浏览器)。
*
* @returns Browser 实例
*/
export async function launchBrowser(): Promise<Browser> {
return chromium.launch({
executablePath: CHROMIUM_BIN,
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
})
}
/**
* 创建浏览器上下文。
*
* @param browser Browser 实例
* @param viewport 视口尺寸
* @returns BrowserContext 实例
*/
export async function newContext(
browser: Browser,
viewport: { width: number; height: number } = DEFAULT_VIEWPORT,
): Promise<BrowserContext> {
return browser.newContext({ viewport })
}
/**
* 在上下文中注入登录态与中文语言偏好(对之后打开的页面生效)。
*
* @param context BrowserContext 实例
* @param token 访问令牌
*/
export async function injectSession(context: BrowserContext, token: string): Promise<void> {
await context.addInitScript(
([t]) => {
localStorage.setItem('token', t as string)
localStorage.setItem('locale', 'zh-CN')
},
[token],
)
}
/**
* 等待页面渲染完成:先等文档就绪,再等关键元素可见,最后做短暂稳定等待。
*
* @param page Page 实例
* @param options.selector 关键元素选择器(可选)
* @param options.settle 元素出现后的稳定等待毫秒数,默认 600ms
*/
export async function waitPage(
page: Page,
options: { selector?: string; settle?: number } = {},
): Promise<void> {
await page.waitForLoadState('domcontentloaded').catch(() => undefined)
if (options.selector) {
await page
.locator(options.selector)
.first()
.waitFor({ state: 'visible', timeout: 20000 })
.catch(() => undefined)
}
// 等待异步数据请求基本完成后再截图,避免表格/图表空白;
// SSE 页面(运行日志)不会进入空闲,超时忽略即可。
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined)
await page.waitForTimeout(options.settle ?? 900)
}
/**
* 按可见文本点击按钮。
*
* @param page Page 实例
* @param text 按钮文本
* @returns 是否点击成功
*/
export async function clickText(page: Page, text: string): Promise<boolean> {
const button = page.locator('button', { hasText: text }).first()
try {
// 按钮可能随异步配置(如 AI 启用状态)延迟渲染,等待其出现
await button.waitFor({ state: 'visible', timeout: 15000 })
} catch {
return false
}
// 部分 Naive UI 按钮对 Playwright 合成鼠标事件不响应,改用 DOM 原生 click
await button.evaluate((el) => (el as HTMLElement).click())
return true
}
/**
* 登录测试账号并返回 JWT。
*
* @returns 访问令牌
*/
export async function login(): Promise<string> {
const res = await axios.post(`${API_URL}/user/login`, { username: USERNAME, password: PASSWORD })
return res.data.data.token
}
/** 打开首页并等待(用于非截图场景的简单访问) */
export async function openHome(page: Page): Promise<void> {
await page.goto(BASE_URL)
await page.waitForTimeout(1500)
}
+43
View File
@@ -0,0 +1,43 @@
// 一键重新截图:覆盖文档站全部配图(整页 + 元素 + 卡片 + 账单解析预览)。
//
// 可重复执行,重新生成并替换已有截图。运行前请确保:
// - 应用实例已启动且测试库中已配置 AI(用于截取启用 AI 后的界面);
// - scripts/samples/alipay_sample.zip 样例账单存在。
import { mkdir } from 'node:fs/promises'
import { injectSession, launchBrowser, login, newContext } from './browser.js'
import { captureAiRule, captureBillParse, captureCards, captureEditRecord, captureModals, captureSettingsPanels, captureTrendJump } from './capture-elements.js'
import { capturePages } from './capture-pages.js'
import { ELEM_DIR } from './config.js'
async function main(): Promise<void> {
await mkdir(ELEM_DIR, { recursive: true })
const browser = await launchBrowser()
try {
// 1. 未登录状态:登录/注册卡片(独立上下文,不注入登录态)
const guestContext = await newContext(browser, { width: 1440, height: 900 })
const guestPage = await guestContext.newPage()
await captureCards(guestPage)
await guestContext.close()
// 2. 登录态上下文:整页 + 弹窗 + 账单解析预览
const token = await login()
const context = await newContext(browser)
await injectSession(context, token)
const page = await context.newPage()
await capturePages(page)
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()
}
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+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)
})
}
+66
View File
@@ -0,0 +1,66 @@
// 整页截图:遍历页面清单,截取完整页面并写入 public/screenshots/。
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import type { Page } from 'playwright-core'
import { injectSession, launchBrowser, login, newContext, waitPage } from './browser.js'
import { BASE_URL, OUT_DIR } from './config.js'
/** 整页截图清单:文件名、路由、视口宽度、视口高度、关键元素选择器 */
const PAGES: Array<[string, string, number, number, string]> = [
['login', '/login', 1440, 900, '.n-card'],
['register', '/register', 1440, 900, '.n-card'],
['dashboard', '/dashboard', 1440, 1280, 'canvas'],
['transactions', '/transactions', 1720, 1200, '.n-data-table-tbody tr'],
['categories', '/categories', 1440, 1100, '.n-data-table-tbody tr'],
['tags', '/tags', 1440, 1100, '.n-data-table-tbody tr'],
['rules', '/rules', 1440, 1100, '.n-data-table-tbody tr'],
['email-bills-configs', '/email-bills/configs', 1440, 1000, '.n-data-table-tbody tr'],
['email-bills-sender-rules', '/email-bills/senderRules', 1440, 1000, '.n-data-table-tbody tr'],
['email-bills-messages', '/email-bills/messages', 1440, 1000, '.n-data-table-tbody tr'],
['bill-import', '/bill-import', 1440, 1000, '.n-card'],
['settings', '/settings', 1440, 1320, '.config-card'],
['ai-usage', '/ai-usage', 1440, 1000, '.n-card'],
['logs-http', '/logs/http', 1440, 1000, '.n-data-table-tbody tr'],
['logs-login', '/logs/login', 1440, 1000, '.n-data-table-tbody tr'],
['logs-app', '/logs/app', 1440, 1000, '.n-log'],
]
/**
* 截取全部整页。
*
* @param page Page 实例
*/
export async function capturePages(page: Page): Promise<void> {
for (const [name, path, width, height, selector] of PAGES) {
try {
await page.setViewportSize({ width, height })
await page.goto(BASE_URL + path)
await waitPage(page, { selector })
await page.screenshot({ path: resolve(OUT_DIR, `${name}.png`) })
console.log(`[OK] page/${name}`)
} catch (err) {
console.log(`[FAIL] page/${name}: ${err}`)
}
}
}
async function main(): Promise<void> {
const browser = await launchBrowser()
try {
const token = await login()
const context = await newContext(browser)
await injectSession(context, token)
const page = await context.newPage()
await capturePages(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)
})
}
+26
View File
@@ -0,0 +1,26 @@
// 文档站维护脚本的公共配置:访问地址、账号、浏览器与输出路径。
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = dirname(fileURLToPath(import.meta.url))
/** 应用访问入口(默认 jar 一体化实例) */
export const BASE_URL = process.env.FINANCE_WEB ?? 'http://localhost:18082'
/** 后端 API 地址 */
export const API_URL = process.env.FINANCE_API ?? 'http://localhost:18082/api'
/** 测试账号 */
export const USERNAME = process.env.FINANCE_USER ?? 'docuser'
export const PASSWORD = process.env.FINANCE_PASSWORD ?? 'doc123456'
/** Chromium 可执行文件(复用 Playwright 缓存,无需系统安装) */
export const CHROMIUM_BIN =
process.env.CHROMIUM_BIN ?? '/home/neko/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome'
/** doc-site 根目录 */
export const ROOT = resolve(currentDir, '..', '..')
/** 整页截图输出目录 */
export const OUT_DIR = resolve(ROOT, 'public', 'screenshots')
/** 元素截图输出目录 */
export const ELEM_DIR = resolve(OUT_DIR, 'elements')
/** 样例账单文件(虚构数据、未导入,用于「账单解析预览」截图) */
export const SAMPLE_BILL = resolve(ROOT, 'scripts', 'samples', 'alipay_fake_preview.csv')
+46
View File
@@ -0,0 +1,46 @@
// 使用无头浏览器抓取指定页面的可见文本并保存到 refs/。
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { launchBrowser, newContext } from './browser.js'
import { ROOT } from './config.js'
/** 待抓取的参考资料:文件名与 URL */
const URLS: Array<[string, string]> = [
['deepseek_share', 'https://chat.deepseek.com/share/mwnjui93cvhspkrs1z'],
]
const OUT_DIR = resolve(ROOT, 'refs')
async function main(): Promise<void> {
await mkdir(OUT_DIR, { recursive: true })
const browser = await launchBrowser()
const context = await newContext(browser, { width: 1440, height: 1200 })
const page = await context.newPage()
try {
for (const [name, url] of URLS) {
try {
await page.goto(url)
await page.waitForTimeout(10000)
for (let i = 0; i < 6; i++) {
await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight / 6))
await page.waitForTimeout(1500)
}
await page.evaluate(() => window.scrollTo(0, 0))
await page.waitForTimeout(1000)
const text = await page.evaluate(() => document.body.innerText)
await writeFile(resolve(OUT_DIR, `${name}.txt`), text, 'utf8')
console.log(`[OK] ${name} -> ${text.length} chars`)
} catch (err) {
console.log(`[FAIL] ${name}: ${err}`)
}
}
} finally {
await context.close()
await browser.close()
}
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+412
View File
@@ -0,0 +1,412 @@
// 生成虚构的支付宝账单样例(用于演示数据与截图,全部为虚假数据)。
//
// 输出两份文件(UTF-8),格式与支付宝「交易记录明细查询」一致,可被 AlipayBillParser 解析:
// - alipay_fake.csv:导入测试库,作为演示交易数据;
// - alipay_fake_preview.csv:交易号不同、未导入,用于截取「账单解析预览」(显示为可导入)。
import { writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import ExcelJS from 'exceljs'
import { ROOT } from './config.js'
const OUT = resolve(ROOT, 'scripts', 'samples', 'alipay_fake.csv')
const OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'alipay_fake_preview.csv')
// 虚构淘宝订单样例(xlsx):导入用与预览用各一份,全部为虚假数据。
const TAOBAO_OUT = resolve(ROOT, 'scripts', 'samples', 'taobao_fake.xlsx')
const TAOBAO_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'taobao_fake_preview.xlsx')
// 虚构京东交易流水样例(CSV):导入用与预览用各一份,全部为虚假数据。
const JD_OUT = resolve(ROOT, 'scripts', 'samples', 'jd_fake.csv')
const JD_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'jd_fake_preview.csv')
/** 虚构支出商户:交易对方、商品名称、金额区间 [最小, 最大] */
const EXPENSE_MERCHANTS: Array<[string, string, number, number]> = [
['星巴克', '拿铁咖啡', 28, 45],
['瑞幸咖啡', '生椰拿铁', 15, 25],
['滴滴出行', '快车行程', 12, 68],
['麦当劳', '午餐套餐', 22, 55],
['肯德基', '全家桶', 35, 99],
['美团外卖', '外卖订单', 18, 68],
['京东商城', '日用品', 39, 299],
['淘宝网', '服饰鞋包', 59, 499],
['永辉超市', '生鲜食品', 35, 220],
['屈臣氏', '洗护用品', 25, 160],
['万达影城', '电影票', 35, 80],
['中国移动', '话费充值', 50, 100],
['国家电网', '电费缴纳', 60, 200],
['中石化加油站', '加油', 200, 400],
['一兆韦德健身房', '健身月卡', 199, 399],
['当当网', '图书', 30, 120],
['网易云音乐', '会员订阅', 15, 25],
['顺丰速运', '快递费', 12, 30],
['海底捞', '聚餐', 120, 360],
['全家便利店', '零食饮料', 8, 45],
]
/** 虚构收入来源:交易对方、商品名称、金额区间 */
const INCOME_SOURCES: Array<[string, string, number, number]> = [
['某某科技有限公司', '工资', 12000, 18000],
['某某科技有限公司', '季度奖金', 3000, 8000],
['李娜', '转账', 100, 800],
['王芳', '还款', 50, 500],
]
/** 虚构人名(用于转账类支出) */
const PEOPLE = ['张伟', '李娜', '王芳', '刘洋', '陈静', '赵磊', '孙悦', '周涛']
/** 补零 */
function pad(n: number): string {
return n < 10 ? `0${n}` : `${n}`
}
/** 格式化时间为 yyyy-MM-dd HH:mm:ss */
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(
d.getMinutes(),
)}:${pad(d.getSeconds())}`
}
/** 确定性伪随机(保证多次生成结果一致) */
function makeRandom(seed: number): () => number {
let state = seed
return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff
return state / 0x7fffffff
}
}
/** 生成一条账单数据行 */
function buildRow(
seq: number,
time: Date,
counterparty: string,
product: string,
amount: number,
direction: '支出' | '收入' | '不计收支',
fundStatus: string,
prefix: string,
): string {
const tradeNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String(
100000 + seq,
).slice(-6)}`
const t = fmt(time)
const status = direction === '不计收支' ? '退款成功' : '交易成功'
const cols = [
tradeNo,
`M${tradeNo}`,
t,
t,
t,
'',
'即时到账',
counterparty,
product,
amount.toFixed(2),
direction,
status,
'0.00',
'0.00',
'',
fundStatus,
]
return cols.join(',')
}
/**
* 生成一份虚构账单文本。
*
* @param seed 伪随机种子
* @param prefix 交易号前缀(区分不同文件)
* @param startMonth 起始月份(1-12)
* @param months 覆盖月数
* @returns 账单文本
*/
function buildCsv(seed: number, prefix: string, startMonth: number, months: number): string {
const rand = makeRandom(seed)
const lines: string[] = [
'支付宝交易记录明细查询',
'账号:[demo@example.com]',
'起始日期:[2026-01-01 00:00:00] 终止日期:[2026-12-31 00:00:00]',
'---------------------------------交易记录明细列表------------------------------------',
'交易号,商家订单号,交易创建时间,付款时间,最近修改时间,交易来源地,类型,交易对方,商品名称,金额(元),收/支,交易状态,服务费(元),成功退款(元),备注,资金状态',
]
let seq = 0
for (let m = 0; m < months; m++) {
const month = startMonth + m
const count = 28 + Math.floor(rand() * 7)
for (let i = 0; i < count; i++) {
seq++
const day = 1 + Math.floor(rand() * 27)
const hour = 7 + Math.floor(rand() * 15)
const minute = Math.floor(rand() * 60)
const time = new Date(2026, month - 1, day, hour, minute, Math.floor(rand() * 60))
const roll = rand()
if (roll < 0.06) {
const [cp, prod, lo, hi] = INCOME_SOURCES[Math.floor(rand() * INCOME_SOURCES.length)]
const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100
lines.push(buildRow(seq, time, cp, prod, amount, '收入', '已收入', prefix))
} else if (roll < 0.09) {
const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)]
const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100
lines.push(buildRow(seq, time, cp, prod, amount, '不计收支', '已支出', prefix))
} else {
const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)]
const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100
lines.push(buildRow(seq, time, cp, prod, amount, '支出', '已支出', prefix))
}
}
}
// 若干转账类支出(虚构人名)
for (let i = 0; i < 6; i++) {
seq++
const day = 3 + i * 25
const time = new Date(2026, startMonth + 1, day > 28 ? 28 : day, 20, 30, 0)
const person = PEOPLE[i % PEOPLE.length]
lines.push(buildRow(seq, time, person, '转账', 200 + i * 50, '支出', '已支出', prefix))
}
lines.push('------------------------------------------------------------------------------------')
lines.push(`共${seq}笔记录`)
return lines.join('\n')
}
/** 淘宝订单数据表头(与淘宝导出列一致) */
const TAOBAO_HEADER = [
'订单号',
'订单提交时间',
'订单状态',
'店铺名称',
'商品名称',
'商品链接',
'型号款式',
'商品数量',
'商品金额',
'实付金额',
'运费',
'物流公司(当前仅支持未完结订单)',
'物流单号(当前仅支持未完结订单)',
]
/** 虚构淘宝店铺与商品:[店铺名, 商品名, 型号, 单价下限, 单价上限] */
const TAOBAO_SHOPS: Array<[string, string, string, number, number]> = [
['优选数码专营店', '蓝牙接收发射器 二合一', '蓝牙5.3适配器', 15, 60],
['居家良品旗舰店', '加厚收纳箱 特大号', '150# 奶油白', 40, 130],
['潮流服饰专营店', '夏季冰丝家居服套装', '雾霾蓝 XL', 45, 99],
['乐活食品旗舰店', '每日坚果混合装', '30 包整箱', 30, 120],
['清饮茶业旗舰店', '冷萃乌龙茶包', '混合口味 20 袋', 20, 80],
['简造数码企业店', '手机壳 磨砂防摔', '透明款', 12, 35],
['趣玩玩具旗舰店', '益智积木拼装', '1000 颗粒', 59, 199],
['洁家日用旗舰店', '一次性丁腈手套', 'M 码 100 只', 15, 45],
]
/** 淘宝订单状态分布:交易成功居多,少量交易关闭与卖家已发货 */
function pickTaobaoStatus(rand: () => number): string {
const roll = rand()
if (roll < 0.1) return '交易关闭'
if (roll < 0.14) return '卖家已发货'
return '交易成功'
}
/**
* 生成一份虚构的淘宝订单 xlsx。
*
* @param path 输出文件路径
* @param seed 伪随机种子
* @param prefix 订单号前缀(区分不同文件)
* @param startMonth 起始月份(1-12)
* @param months 覆盖月数
*/
async function buildTaobaoFile(
path: string,
seed: number,
prefix: string,
startMonth: number,
months: number,
): Promise<void> {
const rand = makeRandom(seed)
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('订单数据')
sheet.addRow(TAOBAO_HEADER)
let seq = 0
for (let m = 0; m < months; m++) {
const month = startMonth + m
const count = 10 + Math.floor(rand() * 6)
for (let i = 0; i < count; i++) {
seq++
const day = 1 + Math.floor(rand() * 27)
const hour = 8 + Math.floor(rand() * 14)
const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60))
const [shop, product, model, lo, hi] = TAOBAO_SHOPS[Math.floor(rand() * TAOBAO_SHOPS.length)]
const status = pickTaobaoStatus(rand)
// 每个订单包含 1-3 个商品,多商品订单使用合并单元格(仅首行有订单级字段)
const itemCount = 1 + Math.floor(rand() * 3)
const items: Array<[string, string, number, number]> = []
let itemsTotal = 0
for (let k = 0; k < itemCount; k++) {
const quantity = 1 + Math.floor(rand() * 2)
const unitPrice = Math.round((lo + rand() * (hi - lo)) * 100) / 100
const name = k === 0 ? product : `${product} 配件${k}`
items.push([name, model, quantity, unitPrice])
itemsTotal += quantity * unitPrice
}
const shipping = rand() < 0.3 ? 2.5 : 0
// 卖家已发货视为未完成,实付金额为 0
const paid = status === '卖家已发货' ? 0 : Math.round((itemsTotal + shipping) * 100) / 100
const orderNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String(
100000 + seq,
).slice(-6)}`
const submitTime = fmt(time)
const firstRow = sheet.rowCount + 1
items.forEach(([name, spec, quantity, unitPrice], idx) => {
sheet.addRow([
idx === 0 ? orderNo : null,
idx === 0 ? submitTime : null,
idx === 0 ? status : null,
idx === 0 ? shop : null,
name,
`https://item.taobao.com/item.htm?id=${1000000000000 + seq * 10 + idx}`,
spec,
quantity,
unitPrice,
idx === 0 ? paid : null,
idx === 0 ? shipping : null,
idx === 0 ? '圆通速递' : null,
idx === 0 ? `YT${orderNo.slice(-12)}` : null,
])
})
// 多商品订单合并订单级列(订单号、提交时间、状态、店铺、实付金额、运费)
if (itemCount > 1) {
const lastRow = sheet.rowCount
for (const col of [1, 2, 3, 4, 10, 11]) {
sheet.mergeCells(firstRow, col, lastRow, col)
}
}
}
}
await workbook.xlsx.writeFile(path)
}
/** 虚构京东消费商户:[商户名称, 交易说明, 交易分类, 金额下限, 金额上限] */
const JD_MERCHANTS: Array<[string, string, string, number, number]> = [
['京东平台商户', '卫加猫咪零食猫条整箱混合口味', '其他网购', 20, 60],
['京东平台商户', 'tritan塑料水杯大容量便携运动水壶', '日用百货', 20, 60],
['京东超市', '抽纸整箱家用实惠装', '食品饮料', 30, 90],
['京东自营', '蓝牙耳机 无线降噪', '数码家电', 80, 400],
['京东平台商户', '洗衣液 大瓶装补充装', '日用百货', 25, 80],
['京东自营', '坚果零食大礼包', '食品饮料', 40, 130],
]
/** 京东流水表头 */
const JD_HEADER = '交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类,交易订单号,商家订单号,备注'
/**
* 生成一份虚构的京东交易流水 CSV。
*
* @param seed 伪随机种子
* @param prefix 交易订单号前缀(区分不同文件)
* @param startMonth 起始月份(1-12)
* @param months 覆盖月数
* @returns 账单文本(含 BOM)
*/
function buildJdCsv(seed: number, prefix: string, startMonth: number, months: number): string {
const rand = makeRandom(seed)
const dataLines: string[] = []
let seq = 0
const counts = { income: 0, expense: 0, neutral: 0 }
const sums = { income: 0, expense: 0, neutral: 0 }
const nextOrderNo = (time: Date): string =>
`${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String(100000 + seq).slice(-6)}`
const push = (time: Date, merchant: string, desc: string, amount: number, pay: string, status: string, direction: string, category: string, remark: string) => {
const orderNo = nextOrderNo(time)
dataLines.push(
[fmt(time), merchant, desc, amount.toFixed(2), pay, status, direction, category, orderNo, `112${orderNo}`, remark].join(','),
)
if (direction === '收入') {
counts.income++
sums.income += amount
} else if (direction === '不计收支') {
counts.neutral++
sums.neutral += amount
} else {
counts.expense++
sums.expense += amount
}
}
for (let m = 0; m < months; m++) {
const month = startMonth + m
const count = 6 + Math.floor(rand() * 5)
for (let i = 0; i < count; i++) {
seq++
const day = 1 + Math.floor(rand() * 27)
const hour = 8 + Math.floor(rand() * 14)
const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60))
const [merchant, desc, category, lo, hi] = JD_MERCHANTS[Math.floor(rand() * JD_MERCHANTS.length)]
const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100
const pay = rand() < 0.6 ? '京东白条' : rand() < 0.5 ? '京东小金库' : '招商银行储蓄卡(3702)'
push(time, merchant, desc, amount, pay, '交易成功', '支出', category, '计入当月账单,还款日次月28日')
}
// 每月一笔白条还款与一笔小金库转入(不计收支)
seq++
const repayTime = new Date(2026, month - 1, 25, 19, 5, 37)
const repayAmount = Math.round((500 + rand() * 800) * 100) / 100
push(repayTime, '京东白条', '白条主动还款', repayAmount, '京东小金库', '还款成功', '不计收支', '白条', '还款至当月账单')
seq++
const transferTime = new Date(2026, month - 1, 25, 19, 5, 36)
push(transferTime, '京东小金库', '京东小金库-转入', repayAmount, '招商银行储蓄卡(3702)', '交易成功', '不计收支', '小金库', ' ')
}
// 一笔虚构退款(收入)
seq++
push(new Date(2026, startMonth, 12, 10, 30, 0), '京东平台商户', '蓝牙耳机 退款', 129.0, '京东白条', '退款成功', '收入', '数码家电', '全额退款')
const total = counts.income + counts.expense + counts.neutral
const header = [
'\uFEFF导出信息:',
'京东账号名:demo_jd_user',
'申请时间:2026-09-21 12:28:54',
`日期区间:2026-${pad(startMonth)}-01 至 2026-${pad(startMonth + months)}-21`,
'导出交易类型:全部',
'导出交易场景:全部',
`共:${total}笔记录`,
`收入:${counts.income}笔,${sums.income.toFixed(2)}元`,
`支出:${counts.expense}笔,${sums.expense.toFixed(2)}元`,
`不计收支:${counts.neutral}笔,${sums.neutral.toFixed(2)}元`,
'',
'特别提示',
'1.本明细为每笔订单支付的明细,不包括已删除的记录;',
'2.个人资金互转、全额退款等记为【不计收支】类;',
'3.本明细仅供个人对账使用。',
'',
JD_HEADER,
]
return [...header, ...dataLines].join('\n')
}
async function main(): Promise<void> {
// 导入用:2026-03 ~ 2026-09
const imported = buildCsv(20260920, '1', 3, 7)
await writeFile(OUT, imported, 'utf8')
// 预览用:2026-01 ~ 2026-03,交易号不同(未导入)
const preview = buildCsv(20260101, '9', 1, 3)
await writeFile(OUT_PREVIEW, preview, 'utf8')
// 淘宝订单(xlsx):导入用与预览用各一份
await buildTaobaoFile(TAOBAO_OUT, 20260921, '1', 3, 7)
await buildTaobaoFile(TAOBAO_OUT_PREVIEW, 20260101, '9', 1, 3)
// 京东交易流水(CSV):导入用与预览用各一份(订单号前缀与淘宝样例区分,避免演示数据去重冲突)
await writeFile(JD_OUT, buildJdCsv(20260921, '2', 3, 7), 'utf8')
await writeFile(JD_OUT_PREVIEW, buildJdCsv(20260101, '8', 1, 3), 'utf8')
console.log(
`已生成虚构账单:\n - ${OUT}\n - ${OUT_PREVIEW}\n - ${TAOBAO_OUT}\n - ${TAOBAO_OUT_PREVIEW}\n - ${JD_OUT}\n - ${JD_OUT_PREVIEW}`,
)
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+48
View File
@@ -0,0 +1,48 @@
// 使用无头浏览器检索资料:按关键词搜索并保存结果页可见文本到 refs/。
import { mkdir, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { launchBrowser, newContext } from './browser.js'
import { ROOT } from './config.js'
/** 待检索的关键词:输出文件名与搜索词 */
const SEARCHES: Array<[string, string]> = [
['bing_alipay2', '支付宝 我的 账单 开具交易流水证明 下载 csv 邮箱'],
['bing_meituan2', '美团App 我的 钱包 账单 下载账单 导出 3个月'],
['bing_bocom2', '交通银行 个人手机银行 我的账户 交易明细 电子版 导出'],
]
const OUT_DIR = resolve(ROOT, 'refs')
async function main(): Promise<void> {
await mkdir(OUT_DIR, { recursive: true })
const browser = await launchBrowser()
const context = await newContext(browser, { width: 1440, height: 1400 })
const page = await context.newPage()
try {
for (const [name, keyword] of SEARCHES) {
const url = `https://cn.bing.com/search?q=${encodeURIComponent(keyword)}&setlang=zh-CN`
try {
await page.goto(url)
await page.waitForTimeout(6000)
for (let i = 0; i < 4; i++) {
await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight / 4))
await page.waitForTimeout(1000)
}
const text = await page.evaluate(() => document.body.innerText)
await writeFile(resolve(OUT_DIR, `${name}.txt`), text, 'utf8')
console.log(`[OK] ${name} -> ${text.length} chars`)
} catch (err) {
console.log(`[FAIL] ${name}: ${err}`)
}
await page.waitForTimeout(8000)
}
} finally {
await context.close()
await browser.close()
}
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+104
View File
@@ -0,0 +1,104 @@
// 向测试环境灌入演示数据:登录测试账号,解析并确认导入样例账单。
import { readFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import axios from 'axios'
import { login } from './browser.js'
import { API_URL, ROOT } from './config.js'
/** 样例账单目录(doc-site 内的虚构样例,全部为虚假数据,禁止使用真实账单) */
const BILL_DIR = resolve(ROOT, 'scripts', 'samples')
/** 待导入样例:账单类型、相对路径、解压密码(仅使用虚构数据) */
const SAMPLES: Array<[string, string, string | null]> = [
['ALIPAY', 'alipay_fake.csv', null],
['TAOBAO', 'taobao_fake.xlsx', null],
['JD', 'jd_fake.csv', null],
]
interface ParsedRow {
externalNo: string
transactionTime: string
type: string
amount: number
counterparty?: string | null
product?: string | null
includeInStats?: boolean
importable?: boolean
rawData?: string | null
}
/**
* 调用解析接口解析账单文件。
*
* @param token 访问令牌
* @param billType 账单类型
* @param path 账单文件路径
* @param password 解压密码
* @returns 解析结果数据
*/
async function parseBill(token: string, billType: string, path: string, password: string | null): Promise<any> {
const buf = await readFile(path)
const form = new FormData()
form.append('file', new Blob([buf]), basename(path))
form.append('billType', billType)
if (password) form.append('password', password)
const res = await axios.post(`${API_URL}/transaction/import/parse`, form, {
headers: { Authorization: `Bearer ${token}` },
timeout: 120000,
})
if (res.data.code !== 200) throw new Error(res.data.message)
return res.data.data
}
/**
* 确认导入解析出的账单行。
*
* @param token 访问令牌
* @param billType 账单类型
* @param rows 解析结果行
* @returns 导入结果数据
*/
async function confirmBill(token: string, billType: string, rows: ParsedRow[]): Promise<any> {
const items = rows
.filter((r) => r.importable !== false)
.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty ?? null,
product: r.product ?? null,
includeInStats: r.includeInStats ?? true,
rawData: r.rawData ?? null,
}))
const res = await axios.post(
`${API_URL}/transaction/import/confirm`,
{ billType, items },
{ headers: { Authorization: `Bearer ${token}` }, timeout: 120000 },
)
if (res.data.code !== 200) throw new Error(res.data.message)
return res.data.data
}
async function main(): Promise<void> {
const token = await login()
console.log('登录成功')
for (const [billType, relPath, password] of SAMPLES) {
const path = join(BILL_DIR, relPath)
try {
const data = await parseBill(token, billType, path, password)
const rows: ParsedRow[] = data.rows ?? []
const importable = rows.filter((r) => r.importable !== false)
console.log(`[解析成功] ${billType}: 共 ${rows.length} 行,可导入 ${importable.length} 行`)
const result = await confirmBill(token, billType, rows)
console.log(`[导入成功] ${billType}: ${JSON.stringify(result)}`)
} catch (err) {
console.log(`[失败] ${billType}: ${err}`)
}
}
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+165
View File
@@ -0,0 +1,165 @@
// 向测试环境补充标签与自定义规则演示数据。
import axios from 'axios'
import { login } from './browser.js'
import { API_URL } from './config.js'
/** 需要补充的标签:名称与颜色 */
const TAGS: Array<{ name: string; color: string }> = [
{ name: '咖啡', color: '#8B5A2B' },
{ name: '外卖', color: '#F97316' },
]
/**
* 创建标签并返回其主键。
*
* @param token 访问令牌
* @param name 标签名称
* @param color 标签颜色
* @returns 标签主键
*/
async function createTag(token: string, name: string, color: string): Promise<number> {
const headers = { Authorization: `Bearer ${token}` }
const res = await axios.post(`${API_URL}/tag`, { name, color }, { headers })
console.log(`标签 ${name} -> ${res.data.code}`)
// 创建接口不返回主键,统一从列表中按名称查找
const list = await axios.get(`${API_URL}/tag/list`, { headers })
const found = (list.data.data as Array<{ id: number; name: string }>).find((t) => t.name === name)
if (!found) throw new Error(`未找到标签: ${name}`)
return found.id
}
/**
* 创建自定义归类规则。
*
* @param token 访问令牌
* @param payload 规则请求体
*/
async function createRule(token: string, payload: Record<string, unknown>): Promise<void> {
const res = await axios.post(`${API_URL}/rule`, payload, { headers: { Authorization: `Bearer ${token}` } })
console.log(`规则 ${payload.name} -> ${res.data.code}`)
}
/**
* 按名称查询分类主键。
*
* @param token 访问令牌
* @param name 分类名称
* @returns 分类主键
*/
async function getCategoryId(token: string, name: string): Promise<number> {
const headers = { Authorization: `Bearer ${token}` }
const res = await axios.get(`${API_URL}/category/list`, { headers })
const found = (res.data.data as Array<{ id: number; name: string }>).find((c) => c.name === name)
if (!found) throw new Error(`未找到分类: ${name}`)
return found.id
}
/**
* 按名称查询标签主键,不存在时创建。
*
* @param token 访问令牌
* @param name 标签名称
* @param color 标签颜色
* @returns 标签主键
*/
async function getTagId(token: string, name: string, color: string): Promise<number> {
const headers = { Authorization: `Bearer ${token}` }
const res = await axios.get(`${API_URL}/tag/list`, { headers })
const found = (res.data.data as Array<{ id: number; name: string }>).find((t) => t.name === name)
if (found) return found.id
return createTag(token, name, color)
}
/** 交易记录(用于给若干记录打标签,供「AI 生成规则」截图使用) */
interface TransactionRow {
id: number
categoryId: number | null
type: string
amount: number
channel: string | null
remark: string | null
transactionTime: string
includeInStats: boolean
}
/**
* 给若干记录打上标签,便于「AI 生成规则」按已打标签记录归纳。
*
* @param token 访问令牌
* @param coffeeId 咖啡标签主键
* @param takeoutId 外卖标签主键
* @param bigAmountId 大额消费标签主键
*/
async function tagSomeTransactions(
token: string,
coffeeId: number,
takeoutId: number,
bigAmountId: number,
): Promise<void> {
const headers = { Authorization: `Bearer ${token}` }
const res = await axios.get(`${API_URL}/transaction/page`, { headers, params: { page: 1, size: 50 } })
const rows = (res.data.data?.records ?? []) as TransactionRow[]
let tagged = 0
for (const row of rows) {
if (tagged >= 6) break
const remark = row.remark ?? ''
let tagIds: number[] = []
if (remark.includes('星巴克') || remark.includes('瑞幸') || remark.includes('咖啡')) {
tagIds = [coffeeId]
} else if (remark.includes('美团') || remark.includes('外卖') || remark.includes('饿了么')) {
tagIds = [takeoutId]
} else if (Number(row.amount) >= 500) {
tagIds = [bigAmountId]
}
if (tagIds.length === 0) continue
await axios.put(`${API_URL}/transaction/${row.id}`, {
categoryId: row.categoryId,
type: row.type,
amount: row.amount,
channel: row.channel,
remark: row.remark,
transactionTime: row.transactionTime,
includeInStats: row.includeInStats,
tagIds,
}, { headers })
tagged++
}
console.log(`已为 ${tagged} 条记录打标签`)
}
/** 清空现有规则,避免重复创建 */
async function clearRules(token: string): Promise<void> {
const headers = { Authorization: `Bearer ${token}` }
const res = await axios.get(`${API_URL}/rule/list`, { headers })
for (const rule of res.data.data as Array<{ id: number }>) {
await axios.delete(`${API_URL}/rule/${rule.id}`, { headers })
}
}
async function main(): Promise<void> {
const token = await login()
await clearRules(token)
const coffeeId = await getTagId(token, '咖啡', '#8B5A2B')
const takeoutId = await getTagId(token, '外卖', '#F97316')
const bigAmountId = await getTagId(token, '大额消费', '#F97316')
const diningId = await getCategoryId(token, '餐饮')
const transportId = await getCategoryId(token, '交通')
console.log('标签:', { 咖啡: coffeeId, 外卖: takeoutId, 大额消费: bigAmountId })
const rules: Array<Record<string, unknown>> = [
{ name: '星巴克归入餐饮', enabled: true, type: 'EXPENSE', keyword: '星巴克', categoryId: diningId, tagIds: [coffeeId] },
{ name: '滴滴出行归入交通', enabled: true, type: 'EXPENSE', keyword: '滴滴', categoryId: transportId },
{ name: '大额支出标记', enabled: true, type: 'EXPENSE', minAmount: 500, tagIds: [bigAmountId] },
]
for (const rule of rules) {
await createRule(token, rule)
}
// 给若干记录打标签,供「AI 生成规则」截图
await tagSomeTransactions(token, coffeeId, takeoutId, bigAmountId)
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+26
View File
@@ -0,0 +1,26 @@
// 最小验证脚本:确认 Playwright + Chromium 能访问应用并截图。
import { launchBrowser, newContext } from './browser.js'
import { BASE_URL } from './config.js'
const OUT = '/tmp/opencode/ts-browser-test.png'
async function main(): Promise<void> {
const browser = await launchBrowser()
const context = await newContext(browser, { width: 1440, height: 900 })
const page = await context.newPage()
try {
await page.goto(BASE_URL)
await page.waitForTimeout(4000)
const title = await page.title()
await page.screenshot({ path: OUT })
console.log('OK title=', title, 'saved=', OUT)
} finally {
await context.close()
await browser.close()
}
}
main().catch((err) => {
console.error('FAIL', err)
process.exit(1)
})
+268
View File
@@ -0,0 +1,268 @@
// 将 public/screenshots 下的文档配图上传到阿里云 OSS。
//
// 流程:读取 .env.local 配置 -> STS AssumeRole 换取临时凭证 -> 遍历本地截图 ->
// 逐张上传到 <OSS_KEY_PREFIX>/<相对路径> -> HEAD 校验远端大小 -> 打印公共访问 URL。
//
// 凭证来自 .env.local(不纳入版本管理),也可用同名环境变量覆盖。
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import OSS from 'ali-oss'
import type { AssumeRoleRequest as AssumeRoleRequestType } from '@alicloud/sts20150401'
import { $OpenApiUtil } from '@alicloud/openapi-core'
// @alicloud/sts20150401 为 CommonJS 包,其类型声明在 NodeNext 下无法直接解析为构造签名,
// 故用 createRequire 加载并显式声明最小类型。
const require = createRequire(import.meta.url)
const stsSdk = require('@alicloud/sts20150401') as {
default: StsClientConstructor
AssumeRoleRequest: new (options: {
roleArn: string
roleSessionName: string
durationSeconds: number
}) => AssumeRoleRequestType
}
const StsClient = stsSdk.default
const AssumeRoleRequest = stsSdk.AssumeRoleRequest
const currentDir = dirname(fileURLToPath(import.meta.url))
/** doc-site 根目录 */
const ROOT = resolve(currentDir, '..', '..')
/** 截图输出目录(截图脚本的产物,也是本脚本的上传源) */
const SCREENSHOT_DIR = resolve(ROOT, 'public', 'screenshots')
/** 本地凭证文件(优先级低于环境变量) */
const ENV_FILE = resolve(ROOT, '.env.local')
/** STS 服务接入点 */
const STS_ENDPOINT = 'sts.aliyuncs.com'
/** 上传并发数 */
const CONCURRENCY = 5
/** OSS 上传配置 */
interface OssConfig {
/** 调用 STS 的长期访问密钥 ID */
accessKeyId: string
/** 调用 STS 的长期访问密钥 Secret */
accessKeySecret: string
/** 被扮演的 RAM 角色 ARN */
roleArn: string
/** 目标桶名 */
bucket: string
/** 区域(如 cn-beijing) */
region: string
/** 对象键前缀(截图统一放置其下) */
keyPrefix: string
}
/** STS 临时凭证 */
interface StsCredentials {
/** 临时访问密钥 ID */
accessKeyId: string
/** 临时访问密钥 Secret */
accessKeySecret: string
/** 临时安全令牌 */
securityToken: string
/** 过期时间(UTC) */
expiration: string
}
/** STS AssumeRole 响应体(仅声明用到的字段) */
interface AssumeRoleResult {
body?: {
credentials?: {
accessKeyId?: string
accessKeySecret?: string
securityToken?: string
expiration?: string
}
}
}
/** STS 客户端实例的最小签名 */
interface StsClientInstance {
assumeRole(request: AssumeRoleRequestType): Promise<AssumeRoleResult>
}
/** STS 客户端构造函数 */
type StsClientConstructor = new (config: $OpenApiUtil.Config) => StsClientInstance
/**
* 解析 .env 文件为键值对,忽略空行与以 # 开头的注释。
*
* @param filePath .env 文件路径
* @returns 键值对映射
*/
function parseEnvFile(filePath: string): Record<string, string> {
const result: Record<string, string> = {}
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const index = trimmed.indexOf('=')
if (index === -1) continue
const key = trimmed.slice(0, index).trim()
let value = trimmed.slice(index + 1).trim()
const quoted =
(value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
if (quoted) value = value.slice(1, -1)
result[key] = value
}
return result
}
/**
* 读取 OSS 上传配置,环境变量优先于 .env.local。
*
* @returns OSS 上传配置
* @throws 当必填项缺失时抛出异常
*/
function loadConfig(): OssConfig {
const fromFile = existsSync(ENV_FILE) ? parseEnvFile(ENV_FILE) : {}
const pick = (name: string): string => process.env[name] ?? fromFile[name] ?? ''
const config: OssConfig = {
accessKeyId: pick('OSS_ACCESS_KEY_ID'),
accessKeySecret: pick('OSS_ACCESS_KEY_SECRET'),
roleArn: pick('OSS_ROLE_ARN'),
bucket: pick('OSS_BUCKET'),
region: pick('OSS_REGION'),
keyPrefix: pick('OSS_KEY_PREFIX') || 'finance-app-docs/screenshots',
}
const missing = (['accessKeyId', 'accessKeySecret', 'roleArn', 'bucket', 'region'] as const).filter(
(key) => !config[key],
)
if (missing.length > 0) {
throw new Error(`缺少 OSS 配置: ${missing.join(', ')}(请在 .env.local 中配置)`)
}
return config
}
/**
* 递归收集目录下的全部文件(按路径排序,保证输出稳定)。
*
* @param dir 目标目录
* @returns 文件的绝对路径列表
*/
function collectFiles(dir: string): string[] {
const files: string[] = []
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) files.push(...collectFiles(full))
else if (entry.isFile()) files.push(full)
}
return files.sort()
}
/**
* 通过 STS AssumeRole 获取临时凭证。
*
* @param config OSS 上传配置
* @returns STS 临时凭证
* @throws 当 STS 未返回完整凭证时抛出异常
*/
async function assumeRole(config: OssConfig): Promise<StsCredentials> {
const client = new StsClient(
new $OpenApiUtil.Config({
accessKeyId: config.accessKeyId,
accessKeySecret: config.accessKeySecret,
endpoint: STS_ENDPOINT,
}),
)
const request = new AssumeRoleRequest({
roleArn: config.roleArn,
roleSessionName: 'doc-site-screenshot-upload',
durationSeconds: 3600,
})
const response = await client.assumeRole(request)
const credentials = response.body?.credentials
if (!credentials?.accessKeyId || !credentials.accessKeySecret || !credentials.securityToken) {
throw new Error('STS AssumeRole 未返回有效的临时凭证')
}
return {
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
securityToken: credentials.securityToken,
expiration: credentials.expiration ?? '',
}
}
/**
* 上传单张截图,并通过 HEAD 请求校验远端大小。
*
* @param client 已认证的 OSS 客户端
* @param key 对象键
* @param file 本地文件绝对路径
* @returns 对象键与文件大小
* @throws 当远端大小与本地不一致时抛出异常
*/
async function uploadOne(client: OSS, key: string, file: string): Promise<{ key: string; size: number }> {
const content = readFileSync(file)
await client.put(key, content, {
mime: 'image/png',
headers: { 'Cache-Control': 'public, max-age=86400' },
})
const head = await client.head(key)
const headers = head.res.headers as Record<string, string | undefined>
const remoteSize = Number(headers['content-length'] ?? headers['Content-Length'])
if (remoteSize !== content.length) {
throw new Error(`大小校验失败: ${key} 远端=${remoteSize} 本地=${content.length}`)
}
return { key, size: content.length }
}
/**
* 按并发上限批量执行上传任务。
*
* @param tasks 返回 Promise 的任务函数列表
* @returns 各任务结果(顺序与输入一致)
*/
async function runWithConcurrency<T>(tasks: (() => Promise<T>)[]): Promise<T[]> {
const results: T[] = []
for (let i = 0; i < tasks.length; i += CONCURRENCY) {
const batch = tasks.slice(i, i + CONCURRENCY)
results.push(...(await Promise.all(batch.map((task) => task()))))
}
return results
}
/**
* 脚本入口:上传全部截图并打印公共访问 URL。
*/
async function main(): Promise<void> {
const config = loadConfig()
if (!existsSync(SCREENSHOT_DIR)) {
throw new Error(`截图目录不存在: ${SCREENSHOT_DIR}(请先执行 npm run capture)`)
}
const files = collectFiles(SCREENSHOT_DIR).filter((file) => file.toLowerCase().endsWith('.png'))
if (files.length === 0) {
console.log('未找到可上传的截图')
return
}
const credentials = await assumeRole(config)
console.log(`STS 凭证获取成功,有效期至 ${credentials.expiration}`)
const client = new OSS({
region: `oss-${config.region}`,
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
stsToken: credentials.securityToken,
bucket: config.bucket,
secure: true,
})
const endpoint = `${config.bucket}.oss-${config.region}.aliyuncs.com`
const tasks = files.map((file) => {
const relativePath = relative(SCREENSHOT_DIR, file).split(sep).join('/')
return () => uploadOne(client, `${config.keyPrefix}/${relativePath}`, file)
})
const results = await runWithConcurrency(tasks)
for (const result of results) {
console.log(`OK ${result.key} (${result.size} 字节) https://${endpoint}/${result.key}`)
}
console.log(`\n共上传 ${results.length} 张截图到 oss://${config.bucket}/${config.keyPrefix}/`)
}
main().catch((error: unknown) => {
console.error(`上传失败: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})