chore: 初始化文档站仓库
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user