chore: 初始化文档站仓库
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# 截图上传 OSS 所需的凭证与目标配置。
|
||||||
|
# 复制为 .env.local 并填入真实值;.env.local 不纳入版本管理。
|
||||||
|
# 上传脚本:npm run oss:upload
|
||||||
|
|
||||||
|
# 用于调用 STS AssumeRole 的长期访问密钥
|
||||||
|
OSS_ACCESS_KEY_ID=
|
||||||
|
OSS_ACCESS_KEY_SECRET=
|
||||||
|
|
||||||
|
# 被扮演的 RAM 角色 ARN
|
||||||
|
OSS_ROLE_ARN=acs:ram::<account_id>:role/<role_name>
|
||||||
|
|
||||||
|
# 目标 OSS 桶与区域
|
||||||
|
OSS_BUCKET=
|
||||||
|
OSS_REGION=cn-beijing
|
||||||
|
|
||||||
|
# 对象键前缀(截图统一放在该前缀下,保留 elements/ 子目录结构)
|
||||||
|
OSS_KEY_PREFIX=finance-app-docs/screenshots
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# 本地私服配置与依赖锁(不纳入版本管理)
|
||||||
|
.npmrc
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# 本地凭证(OSS 上传),模板见 .env.example
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# AI 助手说明(本地维护,不纳入版本管理)
|
||||||
|
AGENTS.md
|
||||||
|
|
||||||
|
# VitePress 构建产物与缓存
|
||||||
|
.vitepress/dist/
|
||||||
|
.vitepress/cache/
|
||||||
|
|
||||||
|
# 运行/参考资料(不参与构建)
|
||||||
|
refs/
|
||||||
|
jar-home/
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 样例账单(虚构数据,仅本地生成用于截图)
|
||||||
|
scripts/samples/
|
||||||
|
|
||||||
|
# 文档配图托管于阿里云 OSS,本地截图不提交
|
||||||
|
public/screenshots/
|
||||||
|
|
||||||
|
# 根目录临时截图(public/ 下的站点资源除外)
|
||||||
|
/*.png
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { defineConfig } from 'vitepress'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
lang: 'zh-CN',
|
||||||
|
title: '收支管理系统',
|
||||||
|
description: '个人收支管理系统使用与开发文档',
|
||||||
|
head: [['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }]],
|
||||||
|
lastUpdated: true,
|
||||||
|
cleanUrls: true,
|
||||||
|
ignoreDeadLinks: true,
|
||||||
|
// 维护类文档不参与站点构建
|
||||||
|
srcExclude: ['AGENTS.md'],
|
||||||
|
// 文档站默认端口从 5173 调整为 5175,把 5173 让给 finance-app 前端
|
||||||
|
vite: {
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5175,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themeConfig: {
|
||||||
|
logo: '/logo.svg',
|
||||||
|
nav: [
|
||||||
|
{ text: '使用文档', link: '/guide/introduction', activeMatch: '/guide/' },
|
||||||
|
{ text: '开发文档', link: '/dev/architecture', activeMatch: '/dev/' },
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
'/guide/': [
|
||||||
|
{
|
||||||
|
text: '开始使用',
|
||||||
|
items: [
|
||||||
|
{ text: '项目简介', link: '/guide/introduction' },
|
||||||
|
{ text: '快速开始', link: '/guide/getting-started' },
|
||||||
|
{ text: '统计面板', link: '/guide/dashboard' },
|
||||||
|
{ text: '页面总览', link: '/guide/pages' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '记账与整理',
|
||||||
|
items: [
|
||||||
|
{ text: '收支记录', link: '/guide/transactions' },
|
||||||
|
{ text: '分类与标签', link: '/guide/categories-tags' },
|
||||||
|
{ text: '规则管理', link: '/guide/rules' },
|
||||||
|
{ text: '邮箱账单', link: '/guide/email-bills' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '智能与自动化',
|
||||||
|
items: [
|
||||||
|
{ text: 'AI 能力', link: '/guide/ai' },
|
||||||
|
{ text: '定时提醒与通知', link: '/guide/notifications' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '系统',
|
||||||
|
items: [
|
||||||
|
{ text: '系统设置', link: '/guide/settings' },
|
||||||
|
{ text: '系统日志', link: '/guide/logs' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '账单获取',
|
||||||
|
items: [
|
||||||
|
{ text: '账单类型总览', link: '/guide/bills/overview' },
|
||||||
|
{ text: '支付宝账单', link: '/guide/bills/alipay' },
|
||||||
|
{ text: '微信支付账单', link: '/guide/bills/wechat' },
|
||||||
|
{ text: '美团账单', link: '/guide/bills/meituan' },
|
||||||
|
{ text: '淘宝账单', link: '/guide/bills/taobao' },
|
||||||
|
{ text: '京东账单', link: '/guide/bills/jd' },
|
||||||
|
{ text: '招商银行账单', link: '/guide/bills/cmb' },
|
||||||
|
{ text: '交通银行账单', link: '/guide/bills/bocom' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '其他',
|
||||||
|
items: [{ text: '常见问题', link: '/guide/faq' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'/dev/': [
|
||||||
|
{
|
||||||
|
text: '开发文档',
|
||||||
|
items: [
|
||||||
|
{ text: '总览', link: '/dev/' },
|
||||||
|
{ text: '整体架构', link: '/dev/architecture' },
|
||||||
|
{ text: '数据模型', link: '/dev/data-model' },
|
||||||
|
{ text: '账单解析', link: '/dev/bill-parsing' },
|
||||||
|
{ text: '规则引擎', link: '/dev/rule-engine' },
|
||||||
|
{ text: 'AI 集成', link: '/dev/ai' },
|
||||||
|
{ text: '定时通知', link: '/dev/notifications' },
|
||||||
|
{ text: '动态数据源', link: '/dev/database' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '工程与规范',
|
||||||
|
items: [
|
||||||
|
{ text: '项目结构', link: '/dev/project-structure' },
|
||||||
|
{ text: '构建与部署', link: '/dev/build-deploy' },
|
||||||
|
{ text: '开发环境与规范', link: '/dev/development' },
|
||||||
|
{ text: '账单字段说明', link: '/dev/bill-fields' },
|
||||||
|
{ text: 'API 文档', link: '/dev/api' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
socialLinks: [],
|
||||||
|
footer: {
|
||||||
|
message: '基于 Spring Boot 3 + Vue 3 的个人收支管理系统',
|
||||||
|
},
|
||||||
|
docFooter: {
|
||||||
|
prev: '上一页',
|
||||||
|
next: '下一页',
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
label: '本页目录',
|
||||||
|
level: [2, 3],
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
provider: 'local',
|
||||||
|
options: {
|
||||||
|
translations: {
|
||||||
|
button: {
|
||||||
|
buttonText: '搜索文档',
|
||||||
|
buttonAriaLabel: '搜索文档',
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
noResultsText: '无法找到相关结果',
|
||||||
|
resetButtonTitle: '清除查询条件',
|
||||||
|
footer: {
|
||||||
|
selectText: '选择',
|
||||||
|
navigateText: '切换',
|
||||||
|
closeText: '关闭',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/* 文档图片点击全屏查看 */
|
||||||
|
|
||||||
|
/* 正文图片:加边框、圆角与阴影,避免白色截图与正文融在一起 */
|
||||||
|
.vp-doc img {
|
||||||
|
display: block;
|
||||||
|
margin: 16px auto;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--vp-c-bg-soft);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可点击的正文图片:鼠标样式与轻微过渡 */
|
||||||
|
.vp-doc img.doc-zoomable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
transition: opacity 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vp-doc img.doc-zoomable:hover {
|
||||||
|
opacity: 0.94;
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全屏查看遮罩:默认隐藏,激活时铺满视口 */
|
||||||
|
.doc-image-viewer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000000;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-image-viewer.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片按比例铺满屏幕(contain,保持完整不裁剪) */
|
||||||
|
.doc-image-viewer img {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
object-fit: contain;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// 自定义主题:为文档图片启用「点击全屏查看」
|
||||||
|
//
|
||||||
|
// 点击正文(.vp-doc)内的图片后,在全屏遮罩中铺满展示原图;
|
||||||
|
// 再次点击或按 Esc 关闭。路由切换时重新绑定,避免遗漏动态渲染的图片。
|
||||||
|
import { onMounted, watch, nextTick } from 'vue'
|
||||||
|
import { useRoute } from 'vitepress'
|
||||||
|
import DefaultTheme from 'vitepress/theme'
|
||||||
|
import './custom.css'
|
||||||
|
|
||||||
|
// 全屏查看器遮罩元素(全站复用一个)
|
||||||
|
let viewer: HTMLDivElement | null = null
|
||||||
|
|
||||||
|
/** 关闭全屏查看器 */
|
||||||
|
function closeViewer() {
|
||||||
|
viewer?.classList.remove('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开全屏查看器并展示指定图片
|
||||||
|
|
||||||
|
* :param src: 图片地址
|
||||||
|
* :param alt: 图片替代文本
|
||||||
|
*/
|
||||||
|
function openViewer(src: string, alt: string) {
|
||||||
|
if (!viewer) return
|
||||||
|
const img = viewer.querySelector('img')
|
||||||
|
if (!img) return
|
||||||
|
img.src = src
|
||||||
|
img.alt = alt
|
||||||
|
viewer.classList.add('active')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建全屏查看器并注册关闭事件 */
|
||||||
|
function ensureViewer() {
|
||||||
|
if (viewer) return
|
||||||
|
viewer = document.createElement('div')
|
||||||
|
viewer.className = 'doc-image-viewer'
|
||||||
|
viewer.innerHTML = '<img alt="" />'
|
||||||
|
// 点击遮罩或图片本身关闭
|
||||||
|
viewer.addEventListener('click', closeViewer)
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape') closeViewer()
|
||||||
|
})
|
||||||
|
document.body.appendChild(viewer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为正文图片绑定点击全屏事件(幂等) */
|
||||||
|
function bindImages() {
|
||||||
|
const images = document.querySelectorAll<HTMLImageElement>('.vp-doc img')
|
||||||
|
images.forEach((img) => {
|
||||||
|
if (img.dataset.zoomBound === '1') return
|
||||||
|
img.dataset.zoomBound = '1'
|
||||||
|
img.classList.add('doc-zoomable')
|
||||||
|
img.addEventListener('click', () => {
|
||||||
|
openViewer(img.currentSrc || img.src, img.alt)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
extends: DefaultTheme,
|
||||||
|
setup() {
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ensureViewer()
|
||||||
|
bindImages()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由切换后重新绑定(文章内容异步渲染)
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
closeViewer()
|
||||||
|
nextTick(() => bindImages())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 收支管理系统 · 文档站
|
||||||
|
|
||||||
|
本目录是「收支管理系统」(同级目录 `../finance-app`)的**使用文档**与**开发文档**站点,基于 [VitePress](https://vitepress.dev/) 构建,并附带一套 Node/TypeScript 维护脚本(截图、演示数据、资料抓取)。
|
||||||
|
|
||||||
|
> **在线体验**:[https://finance.rosercode.cn/](https://finance.rosercode.cn/)(演示账号 `demo` / `demo123456`,使用独立的 SQLite 演示库与虚构数据)
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 安装依赖(走本地 verdaccio 私服,见 .npmrc)
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 本地开发(默认 http://localhost:5175)
|
||||||
|
npm run docs:dev
|
||||||
|
|
||||||
|
# 构建静态站点到 .vitepress/dist
|
||||||
|
npm run docs:build
|
||||||
|
|
||||||
|
# 预览构建产物
|
||||||
|
npm run docs:preview
|
||||||
|
```
|
||||||
|
|
||||||
|
对外访问(局域网/远程):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./node_modules/.bin/vitepress dev --host 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
站点分为两大板块:
|
||||||
|
|
||||||
|
- **使用文档**(`guide/`):面向使用者,讲「怎么用」。
|
||||||
|
- 开始使用、记账与整理、智能与自动化、系统
|
||||||
|
- `guide/bills/`:各类账单的获取与字段说明
|
||||||
|
- `guide/faq.md`:常见问题
|
||||||
|
- **开发文档**(`dev/`):面向开发者,讲「怎么跑、怎么设计、怎么改」。
|
||||||
|
- 整体架构、数据模型、账单解析、规则引擎、AI 集成、定时通知、动态数据源
|
||||||
|
- 项目结构、构建与部署、开发环境与规范、账单字段说明、API 文档
|
||||||
|
|
||||||
|
首页 `index.md` 提供两个板块的入口。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
doc-site/
|
||||||
|
├── index.md # 首页
|
||||||
|
├── guide/ # 使用文档(含 bills/)
|
||||||
|
├── dev/ # 开发文档
|
||||||
|
├── public/ # 静态资源
|
||||||
|
│ ├── logo.svg
|
||||||
|
│ └── screenshots/ # 文档配图(整页 + elements/ 关键元素)
|
||||||
|
├── .vitepress/
|
||||||
|
│ ├── config.mts # 站点配置(导航、侧边栏、搜索、端口 5175)
|
||||||
|
│ └── theme/ # 自定义主题(图片全屏查看、图片样式)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── ts/ # 维护脚本(TypeScript)
|
||||||
|
│ └── samples/ # 样例账单
|
||||||
|
├── refs/ # 联网检索到的资料原文(不参与构建)
|
||||||
|
├── jar-home/ # jar 一体化实例运行目录(测试库副本)
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 维护脚本
|
||||||
|
|
||||||
|
脚本位于 `scripts/ts/`,用 `tsx` 直接运行 TypeScript,浏览器自动化基于 `playwright-core`(Chromium),HTTP 基于 `axios`。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run capture # 一键重新截图(整页 + 元素 + 卡片 + 编辑/设置面板 + 解析预览)
|
||||||
|
npm run capture:pages # 仅整页截图
|
||||||
|
npm run capture:elements # 仅元素截图
|
||||||
|
npm run seed # 导入样例账单(演示数据)
|
||||||
|
npm run seed:rules # 补充标签与规则
|
||||||
|
npm run refs:search # 检索资料到 refs/
|
||||||
|
npm run refs:fetch # 抓取指定 URL 到 refs/
|
||||||
|
npm run test:browser # 验证浏览器链路
|
||||||
|
npm run typecheck:scripts # 类型检查
|
||||||
|
```
|
||||||
|
|
||||||
|
运行截图前请确保:
|
||||||
|
|
||||||
|
1. 应用实例已启动(默认访问 `jar-home/` 下的 jar 实例 `localhost:18082`);
|
||||||
|
2. 测试库中已配置 AI(用于截取启用 AI 后的界面)。
|
||||||
|
|
||||||
|
可通过环境变量覆盖:`FINANCE_WEB`、`FINANCE_API`、`FINANCE_USER`、`FINANCE_PASSWORD`。
|
||||||
|
|
||||||
|
> **环境安全**:截图与演示数据只操作测试环境,禁止访问真实环境(5173 / 8080 / MySQL 真实库)。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- **Node.js**(建议 20+,本项目使用 v24)
|
||||||
|
- npm 依赖走本地 verdaccio 私服(`.npmrc` 已配置 `registry=http://localhost:4873/`)
|
||||||
|
- 浏览器:复用 Playwright 缓存的 Chromium,无需系统安装 Chrome/Firefox
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
维护本目录时请遵守 [AGENTS.md](./AGENTS.md)(文档归类、截图规范、构建校验等)。
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# AI 集成
|
||||||
|
|
||||||
|
系统集成 **DeepSeek** 大模型,用于分类打标、自然语言查询与记账等场景,并完整记录调用用量。
|
||||||
|
|
||||||
|
## 客户端
|
||||||
|
|
||||||
|
`DeepSeekClient` 基于 **OpenAI 官方 Java SDK**(`com.openai:openai-java`)调用,通过自定义 `baseUrl` 指向 DeepSeek 的 OpenAI 兼容接口:
|
||||||
|
|
||||||
|
- 使用 `OpenAIOkHttpClient`,按 `(baseUrl, apiKey)` 缓存客户端(上限 32,超出后清空重建)。
|
||||||
|
- `temperature = 0.2`,`response_format = {"type":"json_object"}`,非流式,`maxRetries(0)`,请求超时 60 秒。
|
||||||
|
- 取 `choices[0].message.content` 与 `usage`(输入/输出/总 token;缓存命中 token 取 `promptTokensDetails.cachedTokens`,DeepSeek 私有字段读不到时为 0)。
|
||||||
|
- 对外保持 `chat(baseUrl, apiKey, model, systemPrompt, userPrompt)` 与 `DeepSeekResponse` 不变,各 `Ai*ServiceImpl` 无需改动。
|
||||||
|
|
||||||
|
配置存储于 `t_ai_config`(每用户一条):`enabled`、`api_key`、`base_url`(默认 `https://api.deepseek.com`)、`model`(默认 `deepseek-chat`)。API Key 对外脱敏,支持连通性测试。
|
||||||
|
|
||||||
|
## 数据外发范围(隐私)
|
||||||
|
|
||||||
|
调用大模型时**仅发送功能所需的最小字段**,禁止外发敏感数据:
|
||||||
|
|
||||||
|
- **禁止**:`rawData`(账单原始 JSON)、金额、交易号、账户/卡号、姓名、地址、电话等。
|
||||||
|
- **允许**:收支类型、交易渠道、备注(必要时截断/脱敏)、分类与标签的名称/ID、用户主动输入的自然语言文本。
|
||||||
|
|
||||||
|
## 调用类型
|
||||||
|
|
||||||
|
`AiCallType` 定义了 9 类调用,全部记录用量:
|
||||||
|
|
||||||
|
| 枚举值 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `SUGGEST` | 交易分类/标签建议(批量 + 单条) |
|
||||||
|
| `TEST` | 连接测试 |
|
||||||
|
| `QUERY` | 自然语言查询解析为筛选条件 |
|
||||||
|
| `RECORD` | 自然语言记账解析为交易记录 |
|
||||||
|
| `DECORATE` | 分类 Emoji / 标签颜色建议 |
|
||||||
|
| `CATEGORY` | 自然语言生成分类 |
|
||||||
|
| `TAG` | 自然语言生成标签 |
|
||||||
|
| `RULE` | 由已打标签记录生成分类与标签规则 |
|
||||||
|
| `STATISTICS` | 自然语言统计查询解析 |
|
||||||
|
|
||||||
|
## 分类/标签建议
|
||||||
|
|
||||||
|
`AiSuggestionServiceImpl`:
|
||||||
|
|
||||||
|
- 先按「类型 + 交易渠道 + 备注」聚合去重,仅将代表记录送模型;单批 ≤ 100 条、总量 ≤ 500 条,超出则分批调用。
|
||||||
|
- 支持严格模式(仅从已有分类/标签中选择)与增强模式(可返回新分类/新标签)。
|
||||||
|
- 应用时分类覆盖写入、标签追加;新建分类按「类型 + 名称」去重,新建标签按名称去重并校验颜色格式。
|
||||||
|
|
||||||
|
## 自然语言查询
|
||||||
|
|
||||||
|
`AiQueryServiceImpl` 在提示词中注入当前分类、标签与当前日期,解析出:类型、交易渠道、分类名、标签名、日期范围预设(今天/昨天/本周/上周/本月/上月/近7天/近30天/今年/去年/自定义/无)、金额区间、备注、是否计入统计、是否未分类等,并在服务端把预设换算为绝对时间。
|
||||||
|
|
||||||
|
## 自然语言记账
|
||||||
|
|
||||||
|
`AiRecordServiceImpl` 解析类型、金额、分类、标签、交易渠道、交易时间、备注、计入统计等字段;金额无效直接报错,时间缺失回退当前时间。
|
||||||
|
|
||||||
|
## 规则生成
|
||||||
|
|
||||||
|
`AiRuleServiceImpl` 由用户勾选的**已打标签记录**反推分类/标签规则:
|
||||||
|
|
||||||
|
- 仅加载属于当前用户且被勾选的记录(单次 ≤ 500 条),附带其标签名称与分类名;提示词字段为 `type / remark / channel / category / tags`(不含金额与 `rawData`),并对相同组合去重。
|
||||||
|
- 解析并校验模型输出(分类 ID 归属与类型、标签归属、名称长度、颜色格式等)。
|
||||||
|
- **去重**:按「类型 + 归一化关键词 + 分类 + 排序标签集合」计算签名;与已有规则或本次建议内部重复的条目标记 `duplicate=true`(前端默认不勾选且禁止导入),`apply` 时后端再次校验并跳过。
|
||||||
|
|
||||||
|
## 统计搜索
|
||||||
|
|
||||||
|
`AiStatisticsServiceImpl` 在提示词中注入分类、标签与当前日期,将自然语言解析为统计面板参数:时间范围(预设→绝对时间)、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径(`statsScope`)与趋势粒度(`dimension`)。
|
||||||
|
|
||||||
|
## 用量记录
|
||||||
|
|
||||||
|
- 每次调用写入 `t_ai_usage`:调用类型、模型、分析条数、输入/输出/总/缓存 token、是否成功、错误信息、耗时。
|
||||||
|
- 记录失败仅告警,不影响主流程。
|
||||||
|
- `GET /api/ai/usage` 返回汇总与最近明细,对应「模型使用情况」页面。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
账单类型识别**不使用 AI**,而是由邮箱账单的**发件人规则**确定,结果确定、可预期。详见[定时通知](/dev/notifications)与[邮箱账单](/guide/email-bills)。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [AI 能力(使用说明)](/guide/ai)
|
||||||
|
- [整体架构](/dev/architecture)
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# API 文档
|
||||||
|
|
||||||
|
系统使用 **Knife4j(OpenAPI 3)** 生成接口文档,后端启动后可直接访问。
|
||||||
|
|
||||||
|
## 访问入口
|
||||||
|
|
||||||
|
| 入口 | 地址 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| Knife4j UI | `http://<host>:<port>/doc.html` | 增强版接口文档界面 |
|
||||||
|
| OpenAPI JSON | `http://<host>:<port>/v3/api-docs` | 标准 OpenAPI 描述 |
|
||||||
|
| Swagger UI | `http://<host>:<port>/swagger-ui/index.html` | 原生界面 |
|
||||||
|
|
||||||
|
开发模式下默认端口为 8080,例如 http://localhost:8080/doc.html。
|
||||||
|
|
||||||
|
## 认证
|
||||||
|
|
||||||
|
- 接口统一使用 **JWT Bearer** 鉴权,登录接口返回 token 后,在请求头携带:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Knife4j 配置中已声明全局 `Bearer` 安全方案,可在文档界面直接填入 token 调试。
|
||||||
|
- 无需鉴权的接口:`/api/user/login`、`/api/user/register`、`/api/user/registration-status`、`/api/database/status`。
|
||||||
|
- 日志实时推送等 SSE 接口因 `EventSource` 无法自定义请求头,兼容通过 `?token=` 查询参数鉴权。
|
||||||
|
|
||||||
|
## 接口分组
|
||||||
|
|
||||||
|
| 控制器 | 路径前缀 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `UserController` | `/api/user` | 登录、注册、用户信息、注册开关 |
|
||||||
|
| `TransactionController` | `/api/transaction` | 收支记录增删改查、批量操作、导出、交易渠道列表 |
|
||||||
|
| `BillImportController` | `/api/transaction/import` | 账单解析与确认导入 |
|
||||||
|
| `CategoryController` | `/api/category` | 分类管理 |
|
||||||
|
| `TagController` | `/api/tag` | 标签管理 |
|
||||||
|
| `RuleController` | `/api/rule` | 自定义规则(列表、分页查询、增删改、启停) |
|
||||||
|
| `StatisticsController` | `/api/statistics` | 统计汇总、分类、趋势、分组 |
|
||||||
|
| `AiController` | `/api/ai` | AI 建议、查询、记账、规则生成、统计搜索、用量 |
|
||||||
|
| `NotificationController` | `/api/notification` | 通知配置、渠道、发送日志 |
|
||||||
|
| `EmailBillController` | `/api/email-bill` | 邮箱配置、发件人规则、待处理账单 |
|
||||||
|
| `LogController` | `/api/log` | 访问日志、登录日志、运行日志与 SSE |
|
||||||
|
| `DatabaseController` | `/api/database` | 数据库状态、测试与配置 |
|
||||||
|
| `AppConfigController` | `/api/app` | 应用配置(通知链接、日志保留) |
|
||||||
|
| `UserSettingController` | `/api/setting` | 用户偏好(账单来源统计默认值、不计入统计展示样式) |
|
||||||
|
|
||||||
|
## 关键接口
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/transaction/channels` | 获取系统支持的交易渠道编码列表 |
|
||||||
|
| GET | `/api/rule/page` | 分页查询规则(`keyword` / `enabled` / `type`) |
|
||||||
|
| POST | `/api/ai/rule/suggest` | 依据所选已打标签记录生成规则建议(含 `duplicate` 标记) |
|
||||||
|
| POST | `/api/ai/rule/apply` | 批量创建规则(自动去重、按需新建分类/标签) |
|
||||||
|
| POST | `/api/ai/statistics` | 自然语言解析为统计面板参数 |
|
||||||
|
| GET / PUT | `/api/app/bill-source-include-in-stats` | 查询 / 更新按账单来源的「计入统计」默认值 |
|
||||||
|
|
||||||
|
## 统一响应结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `code` 非 200 表示业务失败,`message` 为错误信息。
|
||||||
|
- 分页接口的 `data` 为 `PageResult`(`records` / `total` 等)。
|
||||||
|
- 数据库未配置时返回业务码 `5031`,前端据此跳转初始化引导页。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [整体架构](/dev/architecture)
|
||||||
|
- [项目结构](/dev/project-structure)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 整体架构
|
||||||
|
|
||||||
|
本章介绍系统的分层结构与关键技术设计。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus 3.5.12 |
|
||||||
|
| 数据库 | SQLite(本地单文件)/ MySQL |
|
||||||
|
| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts + Pinia + vue-i18n |
|
||||||
|
| 认证 | JWT(HMAC) |
|
||||||
|
| 对象映射 | MapStruct 1.6.3 |
|
||||||
|
| 账单解析 | EasyExcel(xlsx)/ Apache POI(xls)+ commons-csv + PDFBox + Zip4j |
|
||||||
|
| AI | DeepSeek(OpenAI Java SDK) |
|
||||||
|
| 通知 | Jakarta Mail(SMTP / IMAP)+ Server 酱 |
|
||||||
|
| API 文档 | Knife4j(OpenAPI 3) |
|
||||||
|
|
||||||
|
## 后端分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
controller 接口层,统一返回 Result<T>,经 UserContext 取当前用户
|
||||||
|
│
|
||||||
|
service 业务层(接口 + impl),事务、跨表校验、聚合
|
||||||
|
│
|
||||||
|
mapper MyBatis-Plus BaseMapper,少量自定义 SQL
|
||||||
|
│
|
||||||
|
entity 数据库表映射实体
|
||||||
|
```
|
||||||
|
|
||||||
|
辅助包:
|
||||||
|
|
||||||
|
| 包 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `dto` / `vo` | 入参请求对象 / 出参视图对象 |
|
||||||
|
| `converter` | MapStruct 实体↔VO 转换(`VoConverter`) |
|
||||||
|
| `importer` | 账单解析器(`BillParser` 及实现、`BillArchiveExtractor`) |
|
||||||
|
| `exporter` | Excel 导出样式处理 |
|
||||||
|
| `notification` | 通知渠道发送器(工厂 + 各渠道实现) |
|
||||||
|
| `scheduler` | 定时任务(通知、日志清理、邮箱账单) |
|
||||||
|
| `interceptor` | JWT 鉴权、访问日志 |
|
||||||
|
| `config` | Web、JWT、Knife4j、MyBatis-Plus、动态数据源、SPA 路由回退 |
|
||||||
|
| `client` | DeepSeek 客户端 |
|
||||||
|
| `email` | IMAP 邮箱账单抓取(监听、拉取、解析、存储) |
|
||||||
|
| `common` | `Result`、`PageResult`、业务异常、全局异常处理 |
|
||||||
|
| `util` | `JwtUtil`、`UserContext`、`AppLogUtils` 等 |
|
||||||
|
| `enums` | `BillType`、`AiCallType`、`StatisticsDimension` 等 |
|
||||||
|
|
||||||
|
## 请求处理流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
浏览器
|
||||||
|
│ /api/**
|
||||||
|
▼
|
||||||
|
AccessLogInterceptor 记录访问日志(方法/路径/耗时/状态/IP/用户)
|
||||||
|
│
|
||||||
|
JwtInterceptor 校验 Bearer Token(兼容 ?token= 供 SSE),写入 userId
|
||||||
|
│
|
||||||
|
Controller UserContext.getUserId()
|
||||||
|
│
|
||||||
|
Service → Mapper → 数据库(动态数据源)
|
||||||
|
│
|
||||||
|
GlobalExceptionHandler 统一异常 → Result{code,message,data}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 登录、注册、注册状态、数据库状态等接口无需鉴权。
|
||||||
|
- 数据库未配置时,`UnconfiguredDataSource` 抛出的异常会转为业务码 `5031`,前端据此跳转初始化引导页。
|
||||||
|
|
||||||
|
## 认证与鉴权
|
||||||
|
|
||||||
|
- `JwtUtil` 使用 HMAC 密钥签发 JWT,`subject` 为用户 ID,附带用户名 claim,默认有效期 24 小时。
|
||||||
|
- `JwtInterceptor` 从 `Authorization: Bearer <token>` 解析;由于 `EventSource` 无法自定义请求头,同时兼容 `?token=` 查询参数(用于日志实时推送等 SSE 场景)。
|
||||||
|
- `UserContext` 基于 `RequestContextHolder` 读取拦截器写入的 `userId`,使控制器无需声明 `HttpServletRequest`。
|
||||||
|
|
||||||
|
## 前端结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── api/ 各模块 REST 接口封装(axios,统一携带 Token、处理 401/业务码)
|
||||||
|
├── views/ 页面组件(Dashboard、TransactionList、BillImport 等)
|
||||||
|
├── components/ 复用组件(账单预览、导入结果、AI 配置、通知配置等)
|
||||||
|
├── router/ 路由与导航守卫(数据库配置校验、登录校验)
|
||||||
|
├── store/ Pinia 状态(user / database / registration)
|
||||||
|
├── locales/ 中/英/日三语文案
|
||||||
|
├── composables/ 组合式函数(关键词历史)
|
||||||
|
└── utils/ 工具(交易渠道标签、SSE 封装等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键设计亮点
|
||||||
|
|
||||||
|
1. **运行时动态数据源**:启动无需数据库,配置后热切换,见[动态数据源](/dev/database)。
|
||||||
|
2. **账单两阶段导入**:解析预览 → 确认导入,按交易号去重,见[账单解析](/dev/bill-parsing)。
|
||||||
|
3. **规则引擎**:分类取最高优先级、标签取并集,见[规则引擎](/dev/rule-engine)。
|
||||||
|
4. **体系化 AI 调用**:9 类调用类型并记录 token 用量,见 [AI 集成](/dev/ai)。
|
||||||
|
5. **SSE 实时能力**:运行日志、邮箱检查进度实时推送。
|
||||||
|
6. **用户偏好缓存**:`t_user_preference` 的读取走 Spring Cache 进程内缓存(`ConcurrentMapCacheManager`),写入时按用户失效,切换数据源时整体清空。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [数据模型](/dev/data-model)
|
||||||
|
- [账单解析](/dev/bill-parsing)
|
||||||
|
- [规则引擎](/dev/rule-engine)
|
||||||
|
- [AI 集成](/dev/ai)
|
||||||
|
- [定时通知](/dev/notifications)
|
||||||
|
- [动态数据源](/dev/database)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# 账单字段说明
|
||||||
|
|
||||||
|
本章说明支付宝「交易记录明细查询」导出的 CSV 在导入本系统时,各字段的读取与丢弃情况,可作为开发新解析器或调整字段映射的参考。
|
||||||
|
|
||||||
|
## 样例与实现位置
|
||||||
|
|
||||||
|
- 编码:GBK(解析器兼容 UTF-8 / UTF-8 BOM)
|
||||||
|
- 解析器:`src/main/java/cn/rosercode/finance/importer/AlipayBillParser.java`
|
||||||
|
- 落库服务:`src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java`
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
| 位置 | 内容 | 是否处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 第 1 行 | `支付宝交易记录明细查询` | 忽略 |
|
||||||
|
| 第 2 行 | `账号:[...]` | 忽略 |
|
||||||
|
| 第 3 行 | `起始日期:[...] 终止日期:[...]` | 忽略 |
|
||||||
|
| 第 4 行 | `---交易记录明细列表---` | 忽略 |
|
||||||
|
| 第 5 行 | 表头(以「交易号」开头) | 作为列定位依据 |
|
||||||
|
| 数据行 | 每行一笔交易 | 逐行解析 |
|
||||||
|
| 结尾统计 | 待收入/已支出/待支出、导出时间、用户 | 忽略 |
|
||||||
|
|
||||||
|
解析器通过「以 `交易号` 开头」定位表头行;遇到空行、`---` 分隔线或 `共` 开头的统计行即结束。
|
||||||
|
|
||||||
|
## CSV 完整表头(16 列)
|
||||||
|
|
||||||
|
| 序号 | 列名 | 序号 | 列名 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | 交易号 | 9 | 商品名称 |
|
||||||
|
| 2 | 商家订单号 | 10 | 金额(元) |
|
||||||
|
| 3 | 交易创建时间 | 11 | 收/支 |
|
||||||
|
| 4 | 付款时间 | 12 | 交易状态 |
|
||||||
|
| 5 | 最近修改时间 | 13 | 服务费(元) |
|
||||||
|
| 6 | 交易来源地 | 14 | 成功退款(元) |
|
||||||
|
| 7 | 类型 | 15 | 备注 |
|
||||||
|
| 8 | 交易对方 | 16 | 资金状态 |
|
||||||
|
|
||||||
|
## 完全丢弃的字段(7 个)
|
||||||
|
|
||||||
|
| 列名 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 商家订单号 | 未使用 |
|
||||||
|
| 最近修改时间 | 未使用 |
|
||||||
|
| 交易来源地 | 未使用 |
|
||||||
|
| 类型 | 未使用,改用「收/支」推断类型 |
|
||||||
|
| 服务费(元) | 未使用 |
|
||||||
|
| 成功退款(元) | 未使用 |
|
||||||
|
| 备注 | 支付宝自带备注被丢弃;系统备注由「交易对方 · 商品名称」拼接生成 |
|
||||||
|
|
||||||
|
## 读取但未落库的字段(3 个)
|
||||||
|
|
||||||
|
| 列名 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| 交易状态 | 仅用于类型推断与预览展示 |
|
||||||
|
| 资金状态 | 仅用于「不计收支」行的类型推断 |
|
||||||
|
| 交易创建时间 | 仅当「付款时间」为空时作为交易时间兜底 |
|
||||||
|
|
||||||
|
## 落库映射(写入 `t_transaction`)
|
||||||
|
|
||||||
|
| CSV 列 | 数据库字段 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易号 | `external_no` | 导入去重键 |
|
||||||
|
| 付款时间(缺失时用交易创建时间) | `transaction_time` | 交易发生时间 |
|
||||||
|
| 金额(元) | `amount` | 去除千分位逗号后解析 |
|
||||||
|
| 收/支 | `type` + `include_in_stats` | 「不计收支」默认 `include_in_stats = 0` |
|
||||||
|
| 交易对方 + 商品名称 | `remark` | 拼接为「交易对方 · 商品名称」,截断至 255 字符 |
|
||||||
|
| — | `channel` | 固定为 `ALIPAY` |
|
||||||
|
| — | `category_id` | 取用户选择的默认分类;未选择则为 `NULL` |
|
||||||
|
|
||||||
|
### 类型推断规则
|
||||||
|
|
||||||
|
| 收/支 | 交易状态 / 资金状态 | 推断类型 |
|
||||||
|
|---|---|---|
|
||||||
|
| 支出 | — | `EXPENSE` |
|
||||||
|
| 收入 | — | `INCOME` |
|
||||||
|
| 不计收支 | 交易状态含「退款」 | `INCOME` |
|
||||||
|
| 不计收支 | 资金状态为「已收入」 | `INCOME` |
|
||||||
|
| 不计收支 | 其他(如「交易关闭」) | `EXPENSE` |
|
||||||
|
|
||||||
|
## 可能的后续增强
|
||||||
|
|
||||||
|
1. 将支付宝「备注」并入系统 `remark`(当前完全丢失)。
|
||||||
|
2. 新增字段记录「服务费」/「成功退款」。
|
||||||
|
3. 保留「商家订单号」作为辅助去重或展示。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [账单解析](/dev/bill-parsing)
|
||||||
|
- [支付宝账单(使用文档)](/guide/bills/alipay)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 账单解析
|
||||||
|
|
||||||
|
账单导入采用「**解析预览 → 确认导入**」两阶段流程,解析阶段不落库,用户确认后才写入。
|
||||||
|
|
||||||
|
## 解析器设计
|
||||||
|
|
||||||
|
解析器采用策略模式:`BillParser` 接口声明 `getBillType()` 与 `parse(byte[])`,各实现类对应一种账单类型。服务层构造时用 `EnumMap<BillType, BillParser>` 注册所有实现,按类型分发,便于扩展新来源。
|
||||||
|
|
||||||
|
| 解析器 | 格式 | 表头锚点 | 去重键来源 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `AlipayBillParser` | CSV(UTF-8/GBK) | 「交易号」 | 交易号 |
|
||||||
|
| `WechatBillParser` | xlsx(POI) | 「交易时间」 | 交易单号 |
|
||||||
|
| `MeituanBillParser` | CSV | 「交易创建时间」 | 交易单号 |
|
||||||
|
| `TaobaoBillParser` | xlsx(POI) | 「订单号」 | 订单号(按订单号聚合多商品行) |
|
||||||
|
| `JdBillParser` | CSV(UTF-8/GBK) | 「交易时间」 | 交易订单号 |
|
||||||
|
| `CmbBillParser` | CSV | 「交易日期」 | 生成 `CMB_日期_类型_金额_余额` |
|
||||||
|
| `CmbPdfParser` | PDF(PDFBox) | 「记账日期」 | 同上(与 CSV 互通) |
|
||||||
|
| `BocomBillParser` | xls(POI) | 「记账日期」 | 生成 `BOCOM_日期_时分秒_类型_金额` |
|
||||||
|
|
||||||
|
各解析器统一输出 `ParsedBillRow`:行号、`externalNo`、交易时间、类型、金额、交易对方、商品、交易状态、原始收/支方向、是否计入统计、是否可导入、是否重复、命中规则与建议分类/标签、`rawData`(保留原始列的 JSON)、错误信息。
|
||||||
|
|
||||||
|
### 解析要点
|
||||||
|
|
||||||
|
- **编码识别**:CSV 自动识别 UTF-8 / UTF-8 BOM / GBK。
|
||||||
|
- **表头定位**:以特定表头文字定位数据区,忽略元信息与统计行。
|
||||||
|
- **银行 PDF**:按字符坐标切列、按纵坐标合并折行还原表格;用带符号金额判断收支;按「联机余额」做连续性校验(误差 0.005),不连续的行标记为不可导入。
|
||||||
|
- **时间兼容**:微信账单兼容 Excel 日期序列号与文本时间;支付宝在付款时间缺失时回退交易创建时间;淘宝订单同样兼容文本时间与 Excel 日期序列号。
|
||||||
|
- **订单聚合**:淘宝订单数据中多商品订单的订单级列(订单号、提交时间、状态、店铺、实付金额、运费)以合并单元格呈现,仅首个商品行有值;解析器按订单号分组聚合,每个订单输出一条记录,金额优先取订单级实付金额,为空时回退「Σ(商品金额×数量)+运费」。
|
||||||
|
- **不计收支处理**:京东流水的「不计收支」行(白条还款、小金库转入、全额退款等)标记为可导入但**不计入统计且默认不勾选**,类型按「交易说明含『退款』→ INCOME,否则 EXPENSE」推断。
|
||||||
|
- **计入统计默认值**:解析完成后按**账单来源**应用「计入统计」默认值(配置于系统设置,见 [动态数据源](/dev/database) 同级的应用配置)。采用「解析器判定 && 来源默认」语义——来源默认不计入时整批置为不计入;来源默认计入时保留解析器对「不计收支/中性」行的排除。确认导入时若请求未显式提供该字段,同样以来源默认兜底。
|
||||||
|
|
||||||
|
## 压缩包解密
|
||||||
|
|
||||||
|
`BillArchiveExtractor` 处理 zip 压缩包:
|
||||||
|
|
||||||
|
- 按魔数 `PK\x03\x04` 判断是否为 zip,并区分「xlsx 本身是 zip」与「账单压缩包」。
|
||||||
|
- 支持 ZipCrypto 与 AES 加密;未提供密码返回错误码 `4001`,密码错误返回 `4002`。
|
||||||
|
- 限制条目数与总大小,防止 zip bomb;解析完成后清理临时文件。
|
||||||
|
|
||||||
|
## 两阶段流程
|
||||||
|
|
||||||
|
### parse(不落库)
|
||||||
|
|
||||||
|
1. 按账单类型分发到对应解析器;若为 zip 则先解密提取。
|
||||||
|
2. 批量查询已存在的 `externalNo`,标记重复行并取消勾选。
|
||||||
|
3. 调用规则引擎批量匹配,生成建议分类与标签(批量查询分类与标签,避免 N+1)。
|
||||||
|
|
||||||
|
### confirm(落库,事务)
|
||||||
|
|
||||||
|
1. 校验默认分类归属与类型,过滤默认标签。
|
||||||
|
2. 再次批量运行规则匹配。
|
||||||
|
3. 逐条校验类型、时间、金额;按 `externalNo` 去重(含本批次内去重)。
|
||||||
|
4. **规则建议分类优先于面板默认分类**;标签取「默认标签 ∪ 规则标签」并去重。
|
||||||
|
5. 批量插入(每 500 条一批),批次失败时回退逐条插入;标签关联在批量插入未回填主键时按 `externalNo` 回查 ID。
|
||||||
|
6. 返回成功 / 跳过 / 失败统计。
|
||||||
|
|
||||||
|
## 去重策略
|
||||||
|
|
||||||
|
- 统一使用「用户 + `externalNo`」唯一约束 `uk_user_external` 去重,`external_no` 为 NULL 的手动记录不受影响。
|
||||||
|
- 有交易号的账单直接使用交易号。
|
||||||
|
- 银行流水无交易号,则用「日期 + 类型 + 金额 + 余额(或时分秒)」生成稳定键,保证同一账单重复导入不会重复;招商银行 CSV 与 PDF 生成规则一致,可混用。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [账单类型与获取](/guide/bills/overview)
|
||||||
|
- [账单导入(使用说明)](/guide/transactions)
|
||||||
|
- [规则引擎](/dev/rule-engine)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 构建与部署
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
- **JDK 21**(Temurin 21 LTS)
|
||||||
|
- **Node.js**(构建前端时需要)
|
||||||
|
|
||||||
|
## 开发模式
|
||||||
|
|
||||||
|
前后端分离,便于调试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端(默认 8080)
|
||||||
|
cd finance-app
|
||||||
|
set JAVA_HOME=D:\venv\jdk-21
|
||||||
|
gradlew.bat bootRun
|
||||||
|
|
||||||
|
# 前端(默认 5173,接口代理到 8080)
|
||||||
|
cd finance-app/frontend
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- 后端地址:http://localhost:8080
|
||||||
|
- 后端 API 文档:http://localhost:8080/doc.html
|
||||||
|
- 前端地址:http://localhost:5173,接口经 Vite 代理到后端 8080
|
||||||
|
|
||||||
|
## 打包可执行 jar(前后端一体化)
|
||||||
|
|
||||||
|
`bootJar` 会先执行前端 `npm run build`,再把 `frontend/dist` 复制到 jar 内的 `BOOT-INF/classes/static/`,单文件同时提供页面与接口。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle bootJar
|
||||||
|
# 或使用宿主机脚本
|
||||||
|
./package.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:`build/libs/finance-app-0.0.1-SNAPSHOT.jar`
|
||||||
|
|
||||||
|
启动(默认 8080,可另指定端口):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar build/libs/finance-app-0.0.1-SNAPSHOT.jar --server.port=18081
|
||||||
|
```
|
||||||
|
|
||||||
|
访问对应端口即可;刷新 `/dashboard` 等子路由由后端 `SpaErrorController` 回退到 `index.html`。
|
||||||
|
|
||||||
|
::: tip 提示
|
||||||
|
jar 内置的前端为生产构建,访问时与后端同源,无需额外的反向代理。
|
||||||
|
:::
|
||||||
|
|
||||||
|
构建任务链(`build.gradle`):
|
||||||
|
|
||||||
|
- `npmInstall`:安装前端依赖(`node_modules` 存在且 `package.json` 未变化时跳过)
|
||||||
|
- `frontendBuild`:执行 `npm run build`
|
||||||
|
- `bootJar`:依赖 `frontendBuild`,并将前端产物打入静态资源目录
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
`run/docker-compose.yml` 定义前后端两个服务,纯复用本地镜像、不构建新镜像:
|
||||||
|
|
||||||
|
| 服务 | 镜像 | 端口 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| backend | `eclipse-temurin:21-jre` | 18080→8080 | 挂载 `run/backend/*.jar`,持久化 `config/`、`data/`、`logs/` |
|
||||||
|
| front | `nginx:alpine` | 15180→80 | 挂载 `run/front/dist` 与 Nginx 配置 |
|
||||||
|
|
||||||
|
环境变量 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD` 仅作为首次启动的默认配置来源,也可在界面中完成数据库配置。
|
||||||
|
|
||||||
|
## 桌面 GUI 启动器
|
||||||
|
|
||||||
|
`launcher` 包提供系统托盘 + 控制窗口的桌面启动器(启动/停止服务、打开浏览器、查看日志),通过 `bootGuiJar` 打包:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gradle bootGuiJar
|
||||||
|
```
|
||||||
|
|
||||||
|
- 入口类:`cn.rosercode.finance.launcher.LauncherMain`
|
||||||
|
- 依赖 JavaFX 与 dorkbox SystemTray,仅加入带 GUI 的 jar(`guiRuntime` 配置),普通 jar 不含这些依赖
|
||||||
|
- 平台默认 Windows(可通过 `-PjavafxPlatform=linux` 本地验证)
|
||||||
|
- JavaFX 与 JNA 原生库需 `requiresUnpack` 从嵌套 jar 解压
|
||||||
|
|
||||||
|
> 注意:该启动器为规划中的能力,具体实现以当前源码为准。
|
||||||
|
|
||||||
|
## 分发形态
|
||||||
|
|
||||||
|
| 形态 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| jar | `bootJar`,需 JDK 21 运行 |
|
||||||
|
| jar(带 GUI) | `bootGuiJar`,内含桌面启动器 |
|
||||||
|
| 可执行文件(控制台 / GUI) | 规划中,计划采用 `jpackage`(JDK 自带,各平台分别构建) |
|
||||||
|
| Docker 镜像 | 通过 `run/docker-compose.yml` 编排 |
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [项目结构](/dev/project-structure)
|
||||||
|
- [开发环境与规范](/dev/development)
|
||||||
|
- [快速开始(使用文档)](/guide/getting-started)
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 数据模型
|
||||||
|
|
||||||
|
系统共 **18 张表**,覆盖用户、记账、标签、规则、AI、通知、邮箱账单与日志审计。所有表主键为自增 `id`,表间关系均为**逻辑外键**(通过 `user_id`、`category_id` 等字段在应用层维护,未声明物理外键)。
|
||||||
|
|
||||||
|
## 表清单
|
||||||
|
|
||||||
|
| 业务域 | 表名 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 用户 | `t_user` | 用户表(用户名、BCrypt 密码、昵称) |
|
||||||
|
| 用户 | `t_user_preference` | 每用户多条的用户偏好(键值) |
|
||||||
|
| 记账 | `t_category` | 收支分类(收入/支出,含图标与排序) |
|
||||||
|
| 记账 | `t_transaction` | 收支记录(核心表) |
|
||||||
|
| 标签 | `t_tag` | 标签 |
|
||||||
|
| 标签 | `t_transaction_tag` | 交易-标签关联(多对多) |
|
||||||
|
| 规则 | `t_rule` | 自定义归类规则 |
|
||||||
|
| 规则 | `t_rule_tag` | 规则-标签关联(多对多) |
|
||||||
|
| AI | `t_ai_config` | 每用户一条的 AI 配置 |
|
||||||
|
| AI | `t_ai_usage` | AI 调用用量记录 |
|
||||||
|
| 通知 | `t_notification_config` | 每用户一条的提醒配置 |
|
||||||
|
| 通知 | `t_notification_channel` | 通知渠道 |
|
||||||
|
| 通知 | `t_notification_log` | 通知发送日志 |
|
||||||
|
| 邮箱账单 | `t_email_bill_config` | IMAP 邮箱配置 |
|
||||||
|
| 邮箱账单 | `t_email_bill_sender_rule` | 发件人规则 |
|
||||||
|
| 邮箱账单 | `t_email_bill_message` | 拉取到的账单消息 |
|
||||||
|
| 日志 | `t_http_log` | 接口访问日志 |
|
||||||
|
| 日志 | `t_login_log` | 登录日志 |
|
||||||
|
|
||||||
|
## 关系概览
|
||||||
|
|
||||||
|
### 以用户为中心(一对多)
|
||||||
|
|
||||||
|
| 主体 | 关系 | 对象 | 关联字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| t_user | 1 : N | t_category | user_id |
|
||||||
|
| t_user | 1 : N | t_transaction | user_id |
|
||||||
|
| t_user | 1 : N | t_tag | user_id |
|
||||||
|
| t_user | 1 : N | t_rule | user_id |
|
||||||
|
| t_user | 1 : N | t_notification_channel | user_id |
|
||||||
|
| t_user | 1 : N | t_email_bill_config | user_id |
|
||||||
|
| t_user | 1 : N | t_email_bill_sender_rule | user_id |
|
||||||
|
| t_user | 1 : N | t_email_bill_message | user_id |
|
||||||
|
| t_user | 1 : N | t_user_preference | user_id |
|
||||||
|
| t_user | 1 : N | t_ai_usage / t_http_log / t_login_log / t_notification_log | user_id |
|
||||||
|
|
||||||
|
### 一对一(每用户一条)
|
||||||
|
|
||||||
|
| 主体 | 关系 | 对象 | 关联字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| t_user | 1 : 1 | t_ai_config | user_id(唯一) |
|
||||||
|
| t_user | 1 : 1 | t_notification_config | user_id(唯一) |
|
||||||
|
|
||||||
|
### 记账与标签
|
||||||
|
|
||||||
|
| 主体 | 关系 | 对象 | 关联字段 / 中间表 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| t_category | 1 : N | t_transaction | transaction.category_id(可空=未分类) |
|
||||||
|
| t_transaction | N : M | t_tag | 中间表 t_transaction_tag(联合唯一) |
|
||||||
|
| t_category | 1 : N | t_rule | rule.category_id(命中目标分类) |
|
||||||
|
| t_rule | N : M | t_tag | 中间表 t_rule_tag(联合唯一) |
|
||||||
|
|
||||||
|
### 通知与邮箱账单
|
||||||
|
|
||||||
|
| 主体 | 关系 | 对象 | 关联字段 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| t_notification_channel | 1 : N | t_notification_log | channel_id(可空) |
|
||||||
|
| t_email_bill_config | 1 : N | t_email_bill_message | config_id |
|
||||||
|
| t_email_bill_sender_rule | 1 : N | t_email_bill_message | sender_rule_id(可空) |
|
||||||
|
|
||||||
|
> 邮箱配置与发件人规则的默认分类通过 `expense_category_id` / `income_category_id` 逻辑引用 `t_category`;默认标签以 JSON 数组字符串保存(未用关联表)。
|
||||||
|
|
||||||
|
## 核心表字段
|
||||||
|
|
||||||
|
### t_transaction(收支记录)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| user_id | 所属用户 |
|
||||||
|
| category_id | 分类(可空,未分类) |
|
||||||
|
| type | `INCOME` / `EXPENSE` |
|
||||||
|
| amount | 金额(DECIMAL(12,2)) |
|
||||||
|
| channel | 交易渠道(如 `ALIPAY` / `WECHAT` / `CASH` / `BANK`) |
|
||||||
|
| remark | 备注 |
|
||||||
|
| remark_hidden | 备注是否隐藏 |
|
||||||
|
| external_no | 外部账单交易号,导入去重 |
|
||||||
|
| transaction_time | 交易发生时间 |
|
||||||
|
| include_in_stats | 是否计入统计(默认 1) |
|
||||||
|
| raw_data | 账单导入原始 JSON(手动记录为空) |
|
||||||
|
|
||||||
|
关键索引:唯一索引 `uk_user_external (user_id, external_no)` 实现导入去重;`idx_user_time (user_id, transaction_time)` 支撑按时间查询与统计。
|
||||||
|
|
||||||
|
### t_category / t_tag
|
||||||
|
|
||||||
|
- 分类:`name`、`type`、`icon`(Emoji)、`sort_order`。
|
||||||
|
- 标签:`name`、`color`、`sort_order`,唯一索引 `uk_user_name (user_id, name)` 保证用户内标签名唯一。
|
||||||
|
|
||||||
|
### t_rule
|
||||||
|
|
||||||
|
`name`、`enabled`、`sort_order`(优先级)、`type`、`keyword`、`min_amount`、`max_amount`、`category_id`。
|
||||||
|
|
||||||
|
### t_user_preference
|
||||||
|
|
||||||
|
按用户存储的键值偏好:`pref_key` 为偏好键(如 `billSourceIncludeInStats`、`excludedDisplayStyle`),`pref_value` 为标量字符串或 JSON。唯一索引 `uk_user_pref (user_id, pref_key)` 保证每个用户每个偏好仅一条。未配置时由应用回退系统默认值。
|
||||||
|
|
||||||
|
### t_email_bill_config
|
||||||
|
|
||||||
|
IMAP 连接信息(`host`/`port`/`use_ssl`/`username`/`password`/`folder`)、状态(`status`/`last_error`/`last_connected_at`/`last_uid`)、默认归类(`expense_category_id`/`income_category_id`/`tag_ids`)与拉取范围(`fetch_scope`/`fetch_limit`)。
|
||||||
|
|
||||||
|
### t_email_bill_message
|
||||||
|
|
||||||
|
`config_id`、`uid`、`message_id`、`subject`、`from_address`、`received_at`、附件信息、命中的 `sender_rule_id` 与 `bill_type`、状态(`PULLED`/`CONFIRMED`/`IGNORED`)、`error`。唯一索引 `uk_email_bill_message (config_id, uid, attachment_index)` 防止重复拉取。
|
||||||
|
|
||||||
|
## MySQL 与 SQLite 双脚本
|
||||||
|
|
||||||
|
系统同时维护 `sql/init.sql`(MySQL)与 `sql/schema-sqlite.sql`(SQLite),表结构与语义保持一致,主要差异:
|
||||||
|
|
||||||
|
| 维度 | MySQL | SQLite |
|
||||||
|
|---|---|---|
|
||||||
|
| 主键自增 | `BIGINT AUTO_INCREMENT` | `INTEGER PRIMARY KEY AUTOINCREMENT` |
|
||||||
|
| 布尔 | `TINYINT(1)` | `INTEGER` |
|
||||||
|
| 金额 | `DECIMAL(12,2)` | `NUMERIC(12,2)` |
|
||||||
|
| JSON | `JSON` | `TEXT` |
|
||||||
|
| 时间默认值 | `DEFAULT CURRENT_TIMESTAMP` | `strftime(...,'localtime')` |
|
||||||
|
| `updated_at` 自动更新 | `ON UPDATE CURRENT_TIMESTAMP` | `AFTER UPDATE` 触发器 |
|
||||||
|
| 语句分隔符 | `;` | `;;`(兼容触发器内部的分号) |
|
||||||
|
| 列注释 | 支持 | 不支持 |
|
||||||
|
|
||||||
|
统计模块会依据当前数据源类型,切换 MySQL 与 SQLite 的时间分组函数(`DATE_FORMAT/QUARTER` 与 `strftime`)。
|
||||||
|
|
||||||
|
## 初始化与迁移
|
||||||
|
|
||||||
|
- 应用启动时由 `SchemaInitializer` 执行对应建表脚本(幂等)。
|
||||||
|
- 建表脚本只做 `CREATE TABLE IF NOT EXISTS`,无法修改已有库的列;需要就地变更列名时由 `SchemaMigrator` 在启动/切换数据源时**幂等迁移**(如 `t_transaction.payment_method` → `channel`,MySQL 用 `information_schema`、SQLite 用 `PRAGMA table_info` 检测)。
|
||||||
|
- 新用户注册时由 `DefaultDataInitializer` 初始化默认分类、标签与邮箱发件人规则。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 动态数据源
|
||||||
|
|
||||||
|
系统支持在服务启动**之后**再配置数据库,并可在 SQLite 与 MySQL 之间运行时切换,无需启动前准备。
|
||||||
|
|
||||||
|
## 设计目标
|
||||||
|
|
||||||
|
- 未配置数据库时应用仍能正常启动,前端引导进入初始化页面。
|
||||||
|
- 支持 SQLite(本地单文件)与 MySQL 两种数据库。
|
||||||
|
- 保存配置后立即生效并自动建表,无需手动执行 SQL。
|
||||||
|
|
||||||
|
## 核心组件
|
||||||
|
|
||||||
|
| 组件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `DynamicDataSource` | 继承 `AbstractRoutingDataSource`,维护目标数据源映射,切换时重建解析缓存并关闭旧连接池 |
|
||||||
|
| `DataSourceContextHolder` | 保存当前数据源 key 与类型;未配置时使用 `UNCONFIGURED` 占位 |
|
||||||
|
| `UnconfiguredDataSource` | 占位数据源,任何取连接操作抛「数据库未配置」(业务码 5031) |
|
||||||
|
| `DatabaseConfig` / `DatabaseConfigStore` | 配置模型与持久化(`config/database.json`) |
|
||||||
|
| `SchemaInitializer` | 按类型执行 `init.sql`(MySQL)或 `schema-sqlite.sql`(SQLite),幂等建表 |
|
||||||
|
| `SchemaMigrator` | 建表后的幂等结构迁移(如列重命名),启动与切换数据源时执行 |
|
||||||
|
| `DatabaseBootstrap` | 应用启动后加载配置并初始化 |
|
||||||
|
|
||||||
|
`FinanceApplication` 刻意排除了 `DataSourceAutoConfiguration`,以便「零配置也能启动」。
|
||||||
|
|
||||||
|
## 配置来源与顺序
|
||||||
|
|
||||||
|
1. 优先读取本地 `config/database.json`。
|
||||||
|
2. 若无本地配置但存在环境变量 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD`,则作为首次配置并保存。
|
||||||
|
|
||||||
|
保存流程为:**创建并测试新数据源 → 建表 → 结构迁移 → 先落盘再切换**,确保切换失败时可回退。
|
||||||
|
|
||||||
|
## 连接池策略
|
||||||
|
|
||||||
|
- SQLite:连接池固定为 1,避免文件锁冲突。
|
||||||
|
- MySQL:最大 10、最小 2。
|
||||||
|
|
||||||
|
MySQL 的 JDBC URL 会自动附带 UTF-8、关闭 SSL、`Asia/Shanghai` 时区与 `createDatabaseIfNotExist=true`。
|
||||||
|
|
||||||
|
## 跨库方言适配
|
||||||
|
|
||||||
|
统计模块依据 `DataSourceContextHolder.getType()` 选择时间分组函数:
|
||||||
|
|
||||||
|
- MySQL:`DATE_FORMAT` / `QUARTER`
|
||||||
|
- SQLite:`strftime`
|
||||||
|
|
||||||
|
## 切换行为
|
||||||
|
|
||||||
|
登录后可在「系统设置 → 数据库配置」随时切换数据库。切换后原数据库的数据保留,**不做自动迁移**。
|
||||||
|
|
||||||
|
切换成功后会清空进程内的用户偏好缓存(见[整体架构](/dev/architecture)),避免读到旧库的数据。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [快速开始](/guide/getting-started)
|
||||||
|
- [数据模型](/dev/data-model)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 开发环境与规范
|
||||||
|
|
||||||
|
本章记录本项目的开发约定,供开发者与 AI 助手参考。
|
||||||
|
|
||||||
|
## AI 测试环境
|
||||||
|
|
||||||
|
为避免污染真实数据,AI 访问网页、截图、操作界面或验证前端功能时,必须使用**测试环境入口**:
|
||||||
|
|
||||||
|
| 环境 | 前端 | 后端 | 数据库 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 测试环境 | 5174 | 8081 | 独立 SQLite 空库(`ai-home/data/ai-finance.db`) |
|
||||||
|
| 真实环境 | 5173 | 8080 | MySQL(真实数据) |
|
||||||
|
|
||||||
|
- 两个环境共用同一份源码:前端改动经 Vite HMR 两边同步;后端改动执行 `./reload.sh` 后两边 devtools 同时热重启。
|
||||||
|
- 启动脚本:`run/ai-dev/start-ai-backend.sh`(8081)、`run/ai-dev/start-ai-frontend.sh`(5174)。
|
||||||
|
- `ai-home/`、`run/ai-dev/` 为本地专用,不提交仓库。
|
||||||
|
|
||||||
|
## 前端开发
|
||||||
|
|
||||||
|
- 前端改动**不使用 `npm run build` 验证**,直接依赖 Vite 开发服务器的 HMR。
|
||||||
|
- `npm run build`(`vue-tsc -b && vite build`)的产物仅用于生产部署,与开发模式无关。
|
||||||
|
- 界面文案统一在 `frontend/src/locales/messages.ts` 维护,新增 key 需同时提供中文(zh)、英文(en)、日文(ja)三语。
|
||||||
|
|
||||||
|
## 后端开发
|
||||||
|
|
||||||
|
- 后端以开发模式运行在容器中(Gradle `bootRun`,JDK 21,`--network host`)。
|
||||||
|
- 修改 Java 代码后执行 `./reload.sh`(容器内增量编译),`spring-boot-devtools` 会在 1–3 秒内热重启。
|
||||||
|
- 修改 `src/main/resources` 下的资源文件后,需额外同步资源:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec finance-backend-dev gradle -I /project/nexus-init.gradle processResources
|
||||||
|
```
|
||||||
|
|
||||||
|
- 仅在新增依赖、修改配置等需要彻底重启时才重启容器。
|
||||||
|
- 数据库支持运行时配置与切换(SQLite / MySQL),切换数据库或新增 JDBC 驱动后需重启容器。
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
- **Javadoc**:新增 Java 文件的类级 javadoc 末尾必须加一行 `@date`,格式 `yyyy-MM-dd HH:mm:ss`,时间取文件创建时间。
|
||||||
|
- **导入顺序**:由 Spotless 管理,三组、组间空行分隔:`java`/`javax` → 第三方 → `cn.rosercode`。
|
||||||
|
- 格式化:`docker exec finance-backend-dev gradle -I /project/nexus-init.gradle spotlessApply`
|
||||||
|
- `spotlessCheck` 已挂到 `check`,`build` 时会校验。
|
||||||
|
- **日志**:统一使用 Lombok `@Slf4j`,按级别使用(`error` 未预期异常、`warn` 可预期业务失败、`info` 关键写操作与外部调用、`debug` 流程细节)。
|
||||||
|
- **控制器**:方法签名禁止声明 `HttpServletRequest` / `HttpServletResponse`,统一使用 `cn.rosercode.finance.util.UserContext`。
|
||||||
|
- **数据库表结构变更**:需同时维护 `sql/init.sql`(MySQL)与 `sql/schema-sqlite.sql`(SQLite),SQLite 以 `;;` 分隔语句。
|
||||||
|
- **对象转换**:实体与 VO 之间统一使用 MapStruct(`converter.VoConverter`),不在 Service 中手写 setter。
|
||||||
|
- **定时任务**:基于 Spring `@Scheduled`,新增调度器直接标注即可。
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
- 提交范围仅包含源码与配置;个人账单原始数据、`docs/`、`reload.sh`、`.gitignore`、`nexus-init.gradle` 及 AI 测试环境基础设施不纳入提交。
|
||||||
|
- commit message 使用 `feat: 中文描述` 风格;纯重构/规范调整用 `refactor:`,构建与依赖配置用 `chore:` 或 `build:`。
|
||||||
|
- 远端为本地 Gitea,主分支 `master`;推送使用一次性内嵌令牌的 URL,避免令牌写入 `origin` 配置。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [项目结构](/dev/project-structure)
|
||||||
|
- [构建与部署](/dev/build-deploy)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 开发文档
|
||||||
|
|
||||||
|
本章面向**开发者**,介绍系统的分层架构、关键设计、工程结构与开发规范。
|
||||||
|
|
||||||
|
## 设计与实现
|
||||||
|
|
||||||
|
- [整体架构](/dev/architecture):技术栈、后端分层、请求流程、认证与前端结构
|
||||||
|
- [数据模型](/dev/data-model):17 张表的字段与关系、双数据库脚本差异
|
||||||
|
- [账单解析](/dev/bill-parsing):解析器策略模式、两阶段导入、去重与 zip 解密
|
||||||
|
- [规则引擎](/dev/rule-engine):规则模型与匹配逻辑
|
||||||
|
- [AI 集成](/dev/ai):OpenAI SDK 客户端、9 类调用类型与用量记录
|
||||||
|
- [定时通知](/dev/notifications):调度、渠道与发送日志
|
||||||
|
- [动态数据源](/dev/database):SQLite / MySQL 运行时配置与切换
|
||||||
|
|
||||||
|
## 工程与规范
|
||||||
|
|
||||||
|
- [项目结构](/dev/project-structure):目录组织与各包职责
|
||||||
|
- [构建与部署](/dev/build-deploy):`bootJar`、Docker 编排、桌面 GUI 启动器与 jpackage
|
||||||
|
- [开发环境与规范](/dev/development):AI 测试环境、前后端开发流程、代码规范、Git 提交规范
|
||||||
|
- [账单字段说明](/dev/bill-fields):支付宝账单导入字段的读取与丢弃情况
|
||||||
|
- [API 文档](/dev/api):Knife4j(OpenAPI 3)接口文档
|
||||||
|
|
||||||
|
> 使用说明请查看[使用文档](/guide/)。
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 定时通知
|
||||||
|
|
||||||
|
系统提供账单导入定时提醒,支持多渠道推送,并记录发送日志。
|
||||||
|
|
||||||
|
## 调度
|
||||||
|
|
||||||
|
- `NotificationSchedulingConfig` 开启 `@EnableScheduling`。
|
||||||
|
- `NotificationScheduler` 每分钟检查一次(初始延迟 30 秒):对每个启用的通知配置判断「当前时间是否已到达触发点」(依据周期、提醒时间与星期/日期),到达且存在启用渠道时向所有渠道发送提醒,并更新 `last_sent_at`。
|
||||||
|
- 默认提醒周期为每月 1 日 09:00。
|
||||||
|
- 首次启用时会将 `last_sent_at` 置为当前,避免启用后立即补发历史提醒。
|
||||||
|
|
||||||
|
## 渠道
|
||||||
|
|
||||||
|
`NotificationChannelType` 定义支持的渠道:
|
||||||
|
|
||||||
|
| 渠道 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `EMAIL` | 邮箱 SMTP(如 QQ 邮箱),动态构建 `JavaMailSenderImpl`,默认 `smtp.qq.com:465` + SSL |
|
||||||
|
| `SERVERCHAN` | Server 酱 Turbo,推送到个人微信,校验返回 `code == 0` |
|
||||||
|
|
||||||
|
发送器采用工厂模式:`NotificationSender` 接口 + `NotificationSenderFactory` 按类型分发,`AbstractHttpNotificationSender` 封装 JSON/表单 POST。
|
||||||
|
|
||||||
|
## 配置与日志
|
||||||
|
|
||||||
|
- `t_notification_config`:启用开关、周期、每月日期、每周星期、提醒时间、最近发送时间。
|
||||||
|
- `t_notification_channel`:类型、名称、启用、配置 JSON(SMTP 账号或 SendKey)。邮箱密码留空表示不修改,返回时脱敏。
|
||||||
|
- `t_notification_log`:每次发送的结果(成功/失败、渠道类型、说明),面板展示最近 20 条。
|
||||||
|
|
||||||
|
## 接口
|
||||||
|
|
||||||
|
`NotificationController` 提供:配置读写、渠道增删改查与测试、立即发送、发送日志查询。
|
||||||
|
|
||||||
|
## 通知内容
|
||||||
|
|
||||||
|
提醒正文会提示导入新账单,并附带 `AppConfig.notificationUrl` 配置的链接地址(通常指向账单导入页)。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [定时提醒与通知(使用说明)](/guide/notifications)
|
||||||
|
- [邮箱账单](/guide/email-bills)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 项目结构
|
||||||
|
|
||||||
|
## 仓库根目录
|
||||||
|
|
||||||
|
```text
|
||||||
|
finance-app/
|
||||||
|
├── build.gradle # Gradle 构建脚本(依赖、打包、前端集成、Spotless)
|
||||||
|
├── settings.gradle # Gradle 项目设置
|
||||||
|
├── gradlew / gradlew.bat # Gradle Wrapper
|
||||||
|
├── gradle/wrapper/ # Wrapper 配置与 jar
|
||||||
|
├── .gitignore
|
||||||
|
├── README.md # 功能特性与技术栈概览
|
||||||
|
├── src/main/java/ # 后端源码
|
||||||
|
├── src/main/resources/ # 配置、MyBatis XML、建表脚本
|
||||||
|
└── frontend/ # Vue 3 前端
|
||||||
|
```
|
||||||
|
|
||||||
|
> 以上为纳入版本管理的目录与文件;本地开发用的运行编排、脚本与文档(如 `run/`、`reload.sh`、`docs/` 等)不在版本管理中,故不在此列出。
|
||||||
|
|
||||||
|
## 后端包结构
|
||||||
|
|
||||||
|
`src/main/java/cn/rosercode/finance/`
|
||||||
|
|
||||||
|
| 包 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `client` | 外部服务客户端(DeepSeek) |
|
||||||
|
| `common` | 通用响应(`Result`、`PageResult`)、业务异常与全局异常处理 |
|
||||||
|
| `config` | 应用配置、动态数据源、Knife4j、MyBatis-Plus、SPA 路由回退 |
|
||||||
|
| `controller` | REST 接口层 |
|
||||||
|
| `converter` | MapStruct 对象转换器(`VoConverter`) |
|
||||||
|
| `dto` | 请求对象 |
|
||||||
|
| `email` | IMAP 邮箱账单抓取(监听、拉取、解析、存储) |
|
||||||
|
| `entity` | 数据库表映射实体 |
|
||||||
|
| `enums` | 枚举(`BillType`、`AiCallType`、`StatisticsDimension` 等) |
|
||||||
|
| `exporter` | Excel 导出处理 |
|
||||||
|
| `importer` | 账单解析器(支付宝/微信/美团/淘宝/京东/银行)与压缩包解密 |
|
||||||
|
| `interceptor` | JWT 鉴权、访问日志 |
|
||||||
|
| `mapper` | 数据访问层(MyBatis-Plus) |
|
||||||
|
| `notification` | 通知渠道发送器 |
|
||||||
|
| `scheduler` | 定时任务(通知、日志清理、邮箱账单) |
|
||||||
|
| `service` / `service.impl` | 业务层 |
|
||||||
|
| `util` | 工具类(`JwtUtil`、`UserContext`、`AppLogUtils` 等) |
|
||||||
|
| `vo` | 响应对象 |
|
||||||
|
| `launcher` | 桌面 GUI 启动器(仅打入带 GUI 的 jar) |
|
||||||
|
|
||||||
|
## 资源目录
|
||||||
|
|
||||||
|
`src/main/resources/`
|
||||||
|
|
||||||
|
```text
|
||||||
|
application.yml # 应用配置(JWT、MyBatis-Plus、springdoc、端口等)
|
||||||
|
logback-spring.xml # 日志配置(控制台 + 按天滚动文件)
|
||||||
|
mapper/ # MyBatis XML
|
||||||
|
sql/
|
||||||
|
├── init.sql # MySQL 建表脚本
|
||||||
|
├── schema-sqlite.sql # SQLite 建表脚本(以 ;; 分隔语句)
|
||||||
|
├── init_categories.sql # 默认分类种子(MySQL)
|
||||||
|
└── init_tags.sql # 默认标签种子(MySQL)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端结构
|
||||||
|
|
||||||
|
`frontend/src/`
|
||||||
|
|
||||||
|
```text
|
||||||
|
App.vue # 应用根组件
|
||||||
|
main.ts # 入口(挂载应用与插件)
|
||||||
|
style.css # 全局样式
|
||||||
|
api/ # 各模块 REST 接口封装(axios)
|
||||||
|
components/ # 复用组件(账单预览、导入结果、AI/通知/数据库配置面板等)
|
||||||
|
locales/ # 中/英/日三语文案(messages.ts)
|
||||||
|
router/ # 路由与导航守卫
|
||||||
|
store/ # Pinia 状态(user / database / registration)
|
||||||
|
utils/ # 工具(交易渠道标签、SSE 封装、渲染辅助)
|
||||||
|
views/ # 页面组件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行时目录
|
||||||
|
|
||||||
|
应用运行目录下会自动创建:
|
||||||
|
|
||||||
|
| 目录/文件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `config/database.json` | 数据库配置(SQLite / MySQL) |
|
||||||
|
| `config/app.json` | 应用配置(注册开关、通知链接、日志保留天数、按账单来源的计入统计默认值) |
|
||||||
|
| `data/` | SQLite 数据文件与邮箱账单附件 |
|
||||||
|
| `logs/` | 运行日志文件 |
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [整体架构](/dev/architecture)
|
||||||
|
- [构建与部署](/dev/build-deploy)
|
||||||
|
- [开发环境与规范](/dev/development)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 规则引擎
|
||||||
|
|
||||||
|
规则引擎用于在账单导入与新建记录时,按条件自动应用分类与标签。
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
- `t_rule`:`name`、`enabled`、`sort_order`(优先级,越小越优先)、`type`(限定收支类型,可空)、`keyword`(关键词)、`min_amount` / `max_amount`(金额区间)、`category_id`(目标分类)。
|
||||||
|
- `t_rule_tag`:规则与标签的多对多关联。
|
||||||
|
|
||||||
|
## 匹配逻辑
|
||||||
|
|
||||||
|
`RuleServiceImpl.matchBatch` 的核心流程:
|
||||||
|
|
||||||
|
1. 一次性加载当前用户**已启用**的规则,按 `sort_order`、`id` 升序排序;预加载规则标签与引用分类。
|
||||||
|
2. 拼接匹配文本 = `交易对方 + 商品名称 + 备注`,统一转小写。
|
||||||
|
3. 逐条判断是否命中:
|
||||||
|
- **类型**:规则指定了 type 且与记录类型不一致则跳过。
|
||||||
|
- **关键词**:包含匹配(忽略大小写)。
|
||||||
|
- **金额区间**:`min_amount ≤ amount ≤ max_amount`(含边界,任一为空表示不限制)。
|
||||||
|
4. 命中后:
|
||||||
|
- **分类**:取首个命中且与记录类型一致的规则分类。
|
||||||
|
- **标签**:取所有命中规则标签的并集(去重)。
|
||||||
|
5. 返回结果与入参一一对应。
|
||||||
|
|
||||||
|
## 应用位置
|
||||||
|
|
||||||
|
| 场景 | 行为 |
|
||||||
|
|---|---|
|
||||||
|
| 账单解析预览 | 展示命中的规则名与建议分类/标签 |
|
||||||
|
| 账单确认导入 | 规则建议分类优先于面板默认分类;标签与默认标签合并去重 |
|
||||||
|
| 新建/编辑记录 | 不自动应用,但可借助 AI 推荐 |
|
||||||
|
|
||||||
|
## 列表查询与分页
|
||||||
|
|
||||||
|
- `GET /api/rule/list`:返回当前用户全部规则(按优先级排序),供内部复用。
|
||||||
|
- `GET /api/rule/page`:分页查询,支持 `keyword`(匹配规则名称或关键词)、`enabled`、`type` 筛选,返回 `PageResult<RuleVO>`。
|
||||||
|
|
||||||
|
## AI 生成规则
|
||||||
|
|
||||||
|
`AiRuleServiceImpl` 依据用户勾选的**已打标签记录**生成规则建议:
|
||||||
|
|
||||||
|
- 输入字段为记录的类型、备注、交易渠道、已有分类与标签(不含金额与原始账单数据)。
|
||||||
|
- 输出规则经 `RuleService.create` 落库,必要时新建分类/标签,规则默认启用。
|
||||||
|
- **去重**:以「类型 + 归一化关键词 + 分类 + 排序标签集合」为签名;与已有规则或同批已创建规则重复时跳过(`apply` 返回实际创建数),建议阶段则标记 `duplicate` 供前端禁止勾选。
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
`RuleServiceImpl.validate` 会校验:`type` 白名单、金额下限不大于上限、目标分类归属当前用户且类型一致。保存规则标签时采用全量替换。
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [规则管理(使用说明)](/guide/rules)
|
||||||
|
- [账单解析](/dev/bill-parsing)
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
# AI 能力
|
||||||
|
|
||||||
|
系统集成 **DeepSeek** 大模型,在分类打标、查询、记账等环节提供智能辅助。所有 AI 功能需先在「系统设置 → AI 模型配置」中启用并配置 API Key。
|
||||||
|
|
||||||
|
## 数据与隐私
|
||||||
|
|
||||||
|
系统仅向大模型发送完成功能所需的**最小字段**,**不会上传任何敏感数据**。所有 AI 请求均由你自行配置的接口地址发起。
|
||||||
|
|
||||||
|
**不会发送**:
|
||||||
|
|
||||||
|
- 账单导入的原始数据(`rawData`,可能含交易对方、商品名、订单号、对方账户/卡号等);
|
||||||
|
- 金额;
|
||||||
|
- 交易号、账户/卡号、姓名、地址、电话等可识别个人或账户的信息。
|
||||||
|
|
||||||
|
**实际发送**(按功能所需最小化):
|
||||||
|
|
||||||
|
- 分类/标签建议:记录的收支类型、备注、交易渠道;
|
||||||
|
- 智能查询 / 统计:你输入的自然语言,以及分类与标签名称;
|
||||||
|
- 智能记账:你输入的一句话;
|
||||||
|
- 生成分类/标签、推荐 Emoji/颜色:你输入的名称或描述;
|
||||||
|
- 生成规则:所选记录的类型、备注、交易渠道、已有分类与标签(不含金额与原始账单数据)。
|
||||||
|
|
||||||
|
API Key 仅保存在本地数据库中,仅用于向你配置的接口地址发起请求,不会随上述数据一并外发。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
在「系统设置 → AI 模型配置」中:
|
||||||
|
|
||||||
|
- 开启「启用 AI 建议」。
|
||||||
|
- 填写 **API Key**。
|
||||||
|
- 可选修改 **接口地址**(默认 `https://api.deepseek.com`)与**模型**(默认 `deepseek-chat`)。
|
||||||
|
- 点击「测试连接」验证配置。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 功能一览
|
||||||
|
|
||||||
|
| 功能 | 入口 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 分类/标签建议 | 收支记录 → 一键设置 → AI 智能建议 | 对当前页记录批量建议分类与标签,可一键采纳 |
|
||||||
|
| 单条建议 | 新增/编辑记录 → AI 推荐 | 推荐已有分类或标签 |
|
||||||
|
| 智能查询 | 收支记录 → AI 智能查询 | 自然语言解析为筛选条件 |
|
||||||
|
| 智能记账 | 收支记录 → 智能记账 | 一句话解析为一笔记录 |
|
||||||
|
| 生成分类 | 分类管理 → AI 生成 | 由用途描述生成分类 |
|
||||||
|
| 推荐 Emoji | 分类管理 → AI 推荐 | 为分类推荐 Emoji 图标 |
|
||||||
|
| 推荐颜色 | 标签管理 → AI 推荐 | 为标签推荐颜色 |
|
||||||
|
| 生成规则 | 规则管理 → AI 生成规则 | 由已打标签的记录归纳分类与标签规则 |
|
||||||
|
| 统计搜索 | 统计面板 → AI 搜索 | 自然语言解析为统计筛选条件与维度 |
|
||||||
|
|
||||||
|
## 分类与标签建议
|
||||||
|
|
||||||
|
在收支记录页点「一键设置」,选择建议范围(分类与标签 / 仅分类 / 仅标签)后点「AI 智能建议」。系统会按记录的类型、备注、交易渠道等聚合后请求模型,返回建议并进入 AI 建议弹窗。
|
||||||
|
|
||||||
|
弹窗中每条记录展示建议分类、建议标签与理由,默认全选。若建议了系统中不存在的分类或标签,会以「新建」标记,确认后自动创建。应用时分类会被覆盖、标签会被追加。
|
||||||
|
|
||||||
|
## 智能查询
|
||||||
|
|
||||||
|
在收支记录页的 AI 查询框中输入自然语言,例如:
|
||||||
|
|
||||||
|
- 「上个月餐饮」→ 时间范围 + 分类
|
||||||
|
- 「超过500的支出」→ 类型 + 金额区间
|
||||||
|
- 「待报销的支出」→ 标签 + 类型
|
||||||
|
- 「微信支付」→ 交易渠道
|
||||||
|
|
||||||
|
解析结果会直接应用为筛选条件并立即查询。
|
||||||
|
|
||||||
|
## 智能记账
|
||||||
|
|
||||||
|
在「智能记账」弹窗中描述一笔收支,AI 解析出类型、金额、分类、交易渠道、时间、备注、标签等字段,并预填到新增记录表单,确认后保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## AI 统计搜索
|
||||||
|
|
||||||
|
在统计面板顶部的 AI 搜索框中用一句自然语言描述统计意图,例如「上个月餐饮花了多少」「近半年交通支出趋势,按周看」。系统会解析为**时间范围、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径与趋势粒度**并自动回填、刷新图表。详见[统计面板](/guide/dashboard)。
|
||||||
|
|
||||||
|
## AI 生成规则
|
||||||
|
|
||||||
|
在规则管理页点「AI 生成规则」,选择用于归纳的**已打标签记录**(默认全选当前页,支持搜索、筛选与分页),AI 会依据这些记录的**类型、备注、交易渠道、已有分类与标签**生成规则建议,预览编辑后批量创建。与已有规则重复的建议会标记「已存在」并禁止导入。详见[规则管理](/guide/rules)。
|
||||||
|
|
||||||
|
## 用量统计
|
||||||
|
|
||||||
|
「模型使用情况」页面(系统设置 → AI 模型配置 →「使用情况」)展示 AI 调用汇总与明细。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **调用概览**:调用次数、成功、失败、总 Token。
|
||||||
|
- **Token 明细**:输入 Token、输出 Token、缓存命中。
|
||||||
|
- **最近调用记录**:时间、类型、分析条数、Token、耗时、状态。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- AI 建议结果仅供参考,应用前可逐条取消勾选。
|
||||||
|
- 关闭「启用 AI 建议」后,系统不会发起任何 AI 请求,相关入口也会隐藏。
|
||||||
|
- 账单类型识别(邮箱账单)使用的是**发件人规则**而非 AI,结果更确定、可预期。
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 支付宝账单
|
||||||
|
|
||||||
|
支付宝支持导出「交易记录明细」,系统以 **CSV** 或 **zip** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
### 手机 App(推荐)
|
||||||
|
|
||||||
|
1. 打开支付宝 App,进入 **我的 → 账单**。
|
||||||
|
2. 点击账单页面右上角的「**…**」,选择「**开具交易流水证明**」(部分版本显示为「开具银行流水证明」)。
|
||||||
|
3. 选择证明用途(如「用于材料证明」),点击「申请」,输入支付密码完成验证。
|
||||||
|
4. 设置交易流水范围(全部 / 收入 / 支出)与时间范围:可选 1 个月、3 个月或自定义,**最长支持 1 年**,跨年度需分多次申请。
|
||||||
|
5. 填写接收账单的**电子邮箱**并确认发送,账单会以压缩包形式发送到邮箱。
|
||||||
|
|
||||||
|
### 电脑网页版
|
||||||
|
|
||||||
|
访问支付宝官网(www.alipay.com)→ 服务大厅 → 自助服务 → 应用服务 → 收支明细证明,按提示申请。
|
||||||
|
|
||||||
|
> 支付宝导出的压缩包通常未加密,可直接交给系统解析;若设置了密码,在导入时输入。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「支付宝」,上传 CSV 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.csv`、`.zip`
|
||||||
|
- 编码:自动识别 UTF-8 / UTF-8 BOM / GBK
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
解析器以「交易号」开头的表头行定位数据区,读取以下关键字段:
|
||||||
|
|
||||||
|
| 源字段(CSV 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易号 | `external_no` | 导入去重键(`uk_user_external`) |
|
||||||
|
| 付款时间 | `transaction_time` | 交易时间;为空时回退「交易创建时间」 |
|
||||||
|
| 交易创建时间 | `transaction_time` | 仅作付款时间缺失时的兜底 |
|
||||||
|
| 金额(元) | `amount` | 去除千分位逗号后解析 |
|
||||||
|
| 收/支 | `type` | 支出→EXPENSE,收入→INCOME |
|
||||||
|
| 交易对方 | `remark` | 与商品名称拼接为「交易对方 · 商品名称」,截断至 255 字符 |
|
||||||
|
| 商品名称 | `remark` | 同上 |
|
||||||
|
| 交易状态 | 仅推断/预览 | 参与类型推断与预览展示,不落库 |
|
||||||
|
| 资金状态 | 仅推断 | 用于「不计收支」行的类型推断 |
|
||||||
|
| 收/支=不计收支 | `include_in_stats` | 默认不计入统计 |
|
||||||
|
| — | `channel` | 固定为 `ALIPAY` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
以下列**完全丢弃**:商家订单号、最近修改时间、交易来源地、类型、服务费(元)、成功退款(元)、备注(支付宝自带备注)。
|
||||||
|
|
||||||
|
### 类型推断
|
||||||
|
|
||||||
|
| 收/支 | 交易状态 / 资金状态 | 推断类型 |
|
||||||
|
|---|---|---|
|
||||||
|
| 支出 | — | EXPENSE |
|
||||||
|
| 收入 | — | INCOME |
|
||||||
|
| 不计收支 | 含「退款」 | INCOME |
|
||||||
|
| 不计收支 | 资金状态为「已收入」 | INCOME |
|
||||||
|
| 不计收支 | 其他(如交易关闭) | EXPENSE |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 支付宝 CSV 中自带的「备注」列不会被导入,系统备注由「交易对方 · 商品名称」拼接生成。
|
||||||
|
- 建议按年或按季度导出,避免单文件过大。
|
||||||
|
- 重复导入同一文件时,相同交易号的记录会被跳过。
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# 交通银行账单
|
||||||
|
|
||||||
|
交通银行支持导出「交易明细列表」,系统以 **xls** 或 **zip** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
### 手机 App(个人)
|
||||||
|
|
||||||
|
1. 打开交通银行 App,在首页点击「**我的账户**」。
|
||||||
|
2. 选择需要导出的银行卡,点击「**明细清单**」。
|
||||||
|
3. 点击「**发送至邮箱**」,选择时间范围后,交易明细会以电子版发送到指定邮箱。
|
||||||
|
4. 导出文件通常为 **Excel(xls)**,并可能以加密压缩包形式发送,解压密码以银行提示为准。
|
||||||
|
|
||||||
|
时间范围最长可选 1 年,数据量较大时建议分批次导出。
|
||||||
|
|
||||||
|
### 电脑端
|
||||||
|
|
||||||
|
登录交通银行个人网银,在账户明细查询中导出交易明细;企业网银可通过「对账单管理」下载。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「交通银行」,上传 xls 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.xls`、`.zip`
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
系统以「记账日期」定位表头,读取以下列:
|
||||||
|
|
||||||
|
| 源字段(xls 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易时间 | `transaction_time` | 缺失时回退记账日期 |
|
||||||
|
| 记账日期 | `transaction_time` | 仅作兜底 |
|
||||||
|
| 对方户名 | `remark` | 作为备注 |
|
||||||
|
| 摘要 | `remark` | 与对方户名拼接/作为商品名称 |
|
||||||
|
| 支出金额 | `amount` + `type` | 支出列有值时 type=EXPENSE |
|
||||||
|
| 收入金额 | `amount` + `type` | 收入列有值时 type=INCOME |
|
||||||
|
| 余额 | 仅生成去重键 | 参与 `BOCOM_日期_时分秒_类型_金额` 去重键 |
|
||||||
|
| — | `channel` | 固定为 `BOCOM` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
「--」等占位符会被视为空值。
|
||||||
|
|
||||||
|
## 去重
|
||||||
|
|
||||||
|
交通银行流水没有交易号,系统会以「日期 + 时分秒 + 类型 + 金额」生成稳定的去重键(如 `BOCOM_20260916_082932_收入_3406.50`)。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 导出时请选择系统支持的日期范围,单次导出过大时可分多次。
|
||||||
|
- 若导出为加密压缩包,导入时需输入密码。
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 招商银行账单
|
||||||
|
|
||||||
|
招商银行支持导出「交易流水」,系统以 **CSV**、**PDF** 或 **zip** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
招商银行提供多种导出渠道,格式与可查询时间范围各不相同,可按需选择。
|
||||||
|
|
||||||
|
### 手机 App(个人,常用)
|
||||||
|
|
||||||
|
- 打开招商银行 App,在首页搜索「**流水打印**」或「**打印流水**」,或在 **我的 → 全部** 中找到「流水打印」。
|
||||||
|
- 选择账户与时间范围,输入接收邮箱提交。
|
||||||
|
- **通过 App 导出的通常是带电子印章的 PDF**,适用于个人对账或证明材料。
|
||||||
|
- App 可查询较长时间范围(最长近 5 年),但**单次申请跨度一般不超过 1 年**,更早记录需分次申请。
|
||||||
|
- 导出的文件为加密压缩包,解压密码在 App 的「**申请记录**」页查看。
|
||||||
|
|
||||||
|
### 个人网银专业版 / 个人银行 PC 版(可导出 Excel)
|
||||||
|
|
||||||
|
- 登录个人银行 PC 版(专业版),进入 **账户管理 → 活期交易查询**。
|
||||||
|
- 选择时间段查询后点击「保存」,文件类型选择 **Excel(.xls)**。
|
||||||
|
- **仅支持查询近 13 个月**的交易记录,且仅支持中文活期交易记录;更早的流水需改用 App 或前往网点。
|
||||||
|
|
||||||
|
### 营业网点
|
||||||
|
|
||||||
|
- 携带有效身份证件与一卡通,可打印**开户至今**的流水;13 个月以内通常免费,13 个月以上部分网点可能收费。
|
||||||
|
- 如需要更早时间且为 Excel 格式,可向柜员明确提出「定制导出电子表格」的需求。
|
||||||
|
|
||||||
|
### 企业网银 / 企业手机 App
|
||||||
|
|
||||||
|
- 企业网银:**账务管理 → 账务查询 → 交易查询 → 导出 → Excel 快速导出**(可查近 5 年,单次跨度不超过 366 天)。
|
||||||
|
- 企业手机 App:首页 → 收支明细 → 右上角下载 → 选择账号与起止日期 → 输入邮箱接收 Excel。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「招商银行」,上传 CSV、PDF 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.csv`、`.pdf`、`.zip`
|
||||||
|
- CSV 与 PDF 使用同一套去重键,两种格式可以混用而不重复导入。
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
### CSV
|
||||||
|
|
||||||
|
| 源字段(CSV 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易日期 | `transaction_time` | 与交易时间组合为完整时间 |
|
||||||
|
| 交易时间 | `transaction_time` | 同上 |
|
||||||
|
| 交易备注 | `remark` | 作为备注 |
|
||||||
|
| 交易类型 | `remark` / 预览 | 作为商品名称或交易状态 |
|
||||||
|
| 收入金额 | `amount` + `type` | 有值时 type=INCOME |
|
||||||
|
| 支出金额 | `amount` + `type` | 有值时 type=EXPENSE |
|
||||||
|
| 余额 | 生成去重键 | 参与 `CMB_日期_类型_金额_余额` |
|
||||||
|
| — | `channel` | 固定为 `CMB` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
`#` 开头的行视为元信息/合计并结束解析。
|
||||||
|
|
||||||
|
### PDF
|
||||||
|
|
||||||
|
PDF 为文本型「交易流水」,系统按字符坐标还原表格,读取列:
|
||||||
|
|
||||||
|
| 源字段 | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 记账日期 | `transaction_time` | 取零点 |
|
||||||
|
| 交易金额 | `amount` + `type` | 带符号单列:负数为支出、正数为收入 |
|
||||||
|
| 联机余额 | 生成去重键 + 校验 | 参与去重键;按余额连续性校验(误差 0.005),不连续则标记为不可导入 |
|
||||||
|
| 交易摘要 / 对手信息 | `remark` | 作为备注 |
|
||||||
|
| — | `channel` | 固定为 `CMB` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
## 去重
|
||||||
|
|
||||||
|
银行流水通常没有交易号,系统会以「日期 + 类型 + 金额 + 余额」生成稳定的去重键(如 `CMB_20260916_支出_3406.50_43233.20`),因此重复导入同一账单不会产生重复记录。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 系统支持导入招商银行的 **CSV** 与 **PDF**(文本型),优先推荐 CSV。
|
||||||
|
- 若导出为加密压缩包,导入时需输入密码(密码见 App「申请记录」页)。
|
||||||
|
- PDF 账单请使用招商银行 App 导出的文本型 PDF,扫描件无法解析。
|
||||||
|
- **个人网银专业版导出的 `.xls` 暂不支持直接导入**:可在 Excel 中另存为 CSV 后再导入,或改用 App 导出 PDF。
|
||||||
|
- 招行 CSV 与 PDF 使用同一套去重键,两种格式可混用而不重复导入。
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 京东账单
|
||||||
|
|
||||||
|
京东支持导出「交易流水」,系统以 **CSV** 或 **zip**(加密压缩包)形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
1. 打开**京东 App**,进入 **我的 → 设置 → 账单**。
|
||||||
|
2. 点击右上角「**…**」,选择「**账单导出**」。
|
||||||
|
3. 设置**接收邮箱**,点击「**确定并提交**」,账单会发送到该邮箱。
|
||||||
|
4. 从邮件中下载导出的 **CSV** 文件;通常打包为 **zip** 并加密,**解压密码见邮件说明**。
|
||||||
|
|
||||||
|
::: tip 以实际界面为准
|
||||||
|
京东导出入口可能随 App 版本调整,如界面有差异,可在京东帮助中心搜索「账单导出」。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「京东」,上传 CSV 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.csv`、`.zip`
|
||||||
|
- 若为加密压缩包,解析时会提示输入解压密码。
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
系统以「交易时间」定位表头,读取以下列:
|
||||||
|
|
||||||
|
| 源字段(CSV 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易时间 | `transaction_time` | `yyyy-MM-dd HH:mm:ss` |
|
||||||
|
| 商户名称 | `remark` | 与交易说明拼接为备注 |
|
||||||
|
| 交易说明 | `remark` | 商品/交易描述 |
|
||||||
|
| 金额 | `amount` | 支出金额(部分退款已剔除) |
|
||||||
|
| 交易状态 | 仅推断/预览 | 如「交易成功」「还款成功」 |
|
||||||
|
| 收/支 | `type` + `include_in_stats` | 支出→EXPENSE,收入→INCOME,不计收支→不计入统计 |
|
||||||
|
| 交易订单号 | `external_no` | 导入去重键(去除空白) |
|
||||||
|
| 收/付款方式 | 仅存原始数据 | 如京东白条、京东小金库、储蓄卡 |
|
||||||
|
| 交易分类 | 仅存原始数据 | 如其他网购、白条、小金库 |
|
||||||
|
| 商家订单号、备注 | 仅存原始数据 | 不落库 |
|
||||||
|
| — | `channel` | 固定为 `JD` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **不计收支行**:白条还款、小金库转入、全额退款等记为「不计收支」,导入后**不计入统计**且默认**不勾选**,可按需手动勾选。
|
||||||
|
- **白条消费与还款**:用京东白条消费时,购买记录为「支出」,后续「白条还款」为「不计收支」,不会重复计入支出。
|
||||||
|
- **避免重复记账**:京东快捷支付可能同时在银行账单产生记录,请按来源设置「计入统计」,避免重复统计。
|
||||||
|
- **去重**:系统按「用户 + 交易订单号」去重,重复导入同一账单会被自动跳过。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 美团账单
|
||||||
|
|
||||||
|
美团支持导出账单证明,系统以 **CSV** 或 **zip** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
1. 打开美团 App,进入 **我的 → 钱包 → 账单**。
|
||||||
|
2. 点击右上角的「**下载账单**」(或「开具账单证明」)。
|
||||||
|
3. 选择时间范围,导出为 **CSV** 格式;账单导出周期为 3 个月,更早的记录需分段多次导出。
|
||||||
|
4. 导出的文件通常打包为 zip 并设置**解压密码**,密码在「申请记录」页查看。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「美团」,上传 CSV 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.csv`、`.zip`
|
||||||
|
- 若为加密压缩包,解析时会提示输入解压密码。
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
系统以「交易创建时间」定位表头,读取以下列:
|
||||||
|
|
||||||
|
| 源字段(CSV 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易成功时间 | `transaction_time` | 缺失时回退交易创建时间 |
|
||||||
|
| 交易创建时间 | `transaction_time` | 仅作兜底 |
|
||||||
|
| 订单标题 | `remark` | 作为备注 |
|
||||||
|
| 交易单号 | `external_no` | 导入去重键(去除空白) |
|
||||||
|
| 收/支 | `type` | 支出→EXPENSE,收入→INCOME |
|
||||||
|
| 实付金额 | `amount` | 缺失时回退订单金额 |
|
||||||
|
| 交易类型 | 仅推断/预览 | 不落库 |
|
||||||
|
| — | `channel` | 固定为 `MEITUAN` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 美团导出的压缩包一般有密码,请先记录「申请记录」页提供的解压密码。
|
||||||
|
- 若提示密码错误,请核对密码后重新输入。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 账单类型与获取
|
||||||
|
|
||||||
|
系统当前支持 **7 类账单**的导入。不同平台导出的文件格式不同,导入时需要选择对应的账单类型。
|
||||||
|
|
||||||
|
## 支持一览
|
||||||
|
|
||||||
|
| 账单类型 | 应用 | 支持格式 | 典型文件名 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 支付宝 | 支付宝 App | CSV、zip | `alipay_record_YYYYMMDD_HHMM.zip` |
|
||||||
|
| 微信 | 微信 App | xlsx、zip | `微信支付账单流水文件(起-止)_*.xlsx` |
|
||||||
|
| 美团 | 美团 App | CSV、zip | `美团账单(起-止).csv` |
|
||||||
|
| 淘宝 | 淘宝网页版 | xlsx | `订单数据.xlsx` |
|
||||||
|
| 京东 | 京东 App | CSV、zip | `京东交易流水(*).csv` |
|
||||||
|
| 招商银行 | 招商银行 App | CSV、PDF、zip | `CMB_*.csv` / `招商银行交易流水(*).pdf` |
|
||||||
|
| 交通银行 | 交通银行 App | xls、zip | `交易明细列表.xls` |
|
||||||
|
|
||||||
|
## 导出入口速查
|
||||||
|
|
||||||
|
| 账单类型 | 导出路径 |
|
||||||
|
|---|---|
|
||||||
|
| 支付宝 | App → 我的 → 账单 → 右上角「…」→ 开具交易流水证明(最长 1 年,邮箱接收) |
|
||||||
|
| 微信 | App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账(单次最长 3 个月,xlsx) |
|
||||||
|
| 美团 | App → 我的 → 钱包 → 账单 → 右上角「下载账单」(导出周期 3 个月,CSV,需解压密码) |
|
||||||
|
| 淘宝 | 网页版 → 我的淘宝 → 我的订单 → 已买到的宝贝 → 导出订单,下载 xlsx |
|
||||||
|
| 京东 | App → 我的 → 设置 → 账单 → 右上角「…」→ 账单导出(设置接收邮箱),邮件接收 CSV |
|
||||||
|
| 招商银行 | App 首页搜「流水打印」(PDF);或个人网银专业版导出 Excel(近 13 个月) |
|
||||||
|
| 交通银行 | App → 我的账户 → 明细清单 → 发送至邮箱(Excel,最长 1 年) |
|
||||||
|
|
||||||
|
### 各渠道时间范围与格式对比
|
||||||
|
|
||||||
|
| 渠道 | 可导出时间范围 | 常见格式 | 备注 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 支付宝 | 最长 1 年(可自定义,跨年分次) | CSV | 需支付密码验证,邮箱接收 |
|
||||||
|
| 微信 | 单次最长 3 个月 | xlsx | 解压密码在微信支付公众号 |
|
||||||
|
| 美团 | 单次 3 个月 | CSV | 解压密码在申请记录页 |
|
||||||
|
| 淘宝 | 由订单导出页面限定 | xlsx | 订单级导出,多商品订单会合并展示 |
|
||||||
|
| 京东 | 由账单导出页面限定 | CSV | 邮箱接收,通常打包为加密 zip,解压密码见邮件说明 |
|
||||||
|
| 招商银行 App | 最长近 5 年(单次≤1 年) | PDF(带电子印章) | 解压密码在申请记录页 |
|
||||||
|
| 招商银行 PC 专业版 | 近 13 个月 | xls / xlsx | 纯数据表格,无印章;xls 需转 CSV 后导入 |
|
||||||
|
| 交通银行 App | 最长 1 年 | xls | 邮箱接收电子版 |
|
||||||
|
|
||||||
|
::: tip 以 App 实际界面为准
|
||||||
|
各平台导出入口可能随 App 版本调整,上述路径为常见版本;如界面有差异,可在对应 App 的帮助中心搜索「账单导出」。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 导出与导入建议
|
||||||
|
|
||||||
|
1. **按时间范围分批导出**:多数平台单次导出有跨度或条数限制,可按年或按季度导出多个文件,逐个导入。
|
||||||
|
2. **记录解压密码**:部分平台(如美团、部分银行)导出的压缩包设有密码,导入时需要输入。
|
||||||
|
3. **优先选择系统直接支持的格式**:如招商银行 CSV 与 PDF 都支持,CSV 更易解析;银行账单如同时提供,优先 CSV。
|
||||||
|
4. **注意去重**:系统按「用户 + 交易号」去重。同一账单重复导入时,已存在的记录会被自动跳过;银行账单没有交易号时会以「日期+类型+金额+余额」生成稳定的去重键。
|
||||||
|
5. **按来源设置计入统计**:避免同一笔消费被重复统计。
|
||||||
|
- 来自**微信、支付宝**的账单:默认**计入统计**(除非有特殊情况)。
|
||||||
|
- 来自**其他**(银行、美团、淘宝、京东等)的账单:默认**不计入统计**——这些渠道的扣款往往最终走微信/支付宝支付,若都计入会导致统计翻倍。
|
||||||
|
- **例外**:工资、奖金等明确收入应计入统计。
|
||||||
|
|
||||||
|
## 各账单详解
|
||||||
|
|
||||||
|
- [支付宝账单](/guide/bills/alipay)
|
||||||
|
- [微信支付账单](/guide/bills/wechat)
|
||||||
|
- [美团账单](/guide/bills/meituan)
|
||||||
|
- [淘宝账单](/guide/bills/taobao)
|
||||||
|
- [京东账单](/guide/bills/jd)
|
||||||
|
- [招商银行账单](/guide/bills/cmb)
|
||||||
|
- [交通银行账单](/guide/bills/bocom)
|
||||||
|
|
||||||
|
## 导入操作
|
||||||
|
|
||||||
|
导出文件后,在「收支记录 → 导入账单」或通知邮件直达的独立页 `/bill-import` 中导入,流程见[收支记录 · 账单导入](/guide/transactions#账单导入)。
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 淘宝账单
|
||||||
|
|
||||||
|
淘宝支持导出「订单数据」,系统以 **xlsx** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
1. 打开**淘宝网页版**(电脑浏览器),登录后进入 **我的淘宝 → 我的订单 → 已买到的宝贝**。
|
||||||
|
2. 点击「**导出订单**」,选择需要导出的时间范围。
|
||||||
|
3. 导出为 **xlsx** 文件(通常命名为 `订单数据.xlsx`),按页面提示下载。
|
||||||
|
|
||||||
|
::: tip 以实际界面为准
|
||||||
|
淘宝导出入口可能随网页版本调整,如界面有差异,可在淘宝帮助中心搜索「导出订单」。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「淘宝」,上传 xlsx 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.xlsx`
|
||||||
|
- 淘宝订单均为购买支出,导入时固定记为支出(`EXPENSE`)。
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
系统以「订单号」定位表头,读取以下列:
|
||||||
|
|
||||||
|
| 源字段(xlsx 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 订单号 | `external_no` | 导入去重键;多商品订单共用同一订单号 |
|
||||||
|
| 订单提交时间 | `transaction_time` | 兼容文本时间与 Excel 日期序列号 |
|
||||||
|
| 店铺名称 | `remark` | 与商品名称拼接为备注 |
|
||||||
|
| 商品名称 | `remark` | 多商品订单以「、」拼接 |
|
||||||
|
| 实付金额 | `amount` | 订单级合计;为空时回退「Σ(商品金额×数量)+运费」 |
|
||||||
|
| 订单状态 | 仅推断/预览 | 「交易关闭」标记为不可导入 |
|
||||||
|
| — | `type` | 固定为 `EXPENSE`(支出) |
|
||||||
|
| — | `channel` | 固定为 `TAOBAO` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **多商品订单会合并为一条**:淘宝订单数据中,一个订单包含多个商品时会占用多行(订单级列以合并单元格展示),系统按订单号聚合,每个订单只生成一条记录。
|
||||||
|
- **交易关闭不导入**:订单状态为「交易关闭」的记录标记为不可导入,默认不勾选。
|
||||||
|
- **未完成订单**:如「卖家已发货」且实付金额为 0 的订单,因金额无效标记为不可导入,待订单完成后重新导出即可。
|
||||||
|
- **去重**:系统按「用户 + 订单号」去重,重复导入同一订单会被自动跳过。
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# 微信支付账单
|
||||||
|
|
||||||
|
微信支持导出「微信支付账单流水文件」,系统以 **xlsx** 或 **zip** 形式导入。
|
||||||
|
|
||||||
|
## 如何获取
|
||||||
|
|
||||||
|
1. 打开微信 App,进入 **我 → 服务 → 钱包 → 账单**。
|
||||||
|
2. 点击右上角「常见问题」。
|
||||||
|
3. 选择「下载账单」→ 用途选择「**用于个人对账**」。
|
||||||
|
4. 选择交易类型(建议「全部交易」)与时间范围,输入接收邮箱,按提示输入支付密码完成验证。
|
||||||
|
5. 账单会以**加密压缩包**形式发送到邮箱,解压密码在**微信支付公众号(服务通知)**中查看。
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- 个人对账账单**单次最长可导出 3 个月**,更早的记录需分段多次导出。
|
||||||
|
- 导出的账单**包含已删除的交易记录**。
|
||||||
|
- 账单一般在几分钟内到达邮箱,最慢 24 小时内;未收到时请检查垃圾邮件。
|
||||||
|
- 解压密码每次导出单独生成,微信登录密码、支付密码均不能用于解压。
|
||||||
|
|
||||||
|
## 导入
|
||||||
|
|
||||||
|
在[账单导入](/guide/transactions)页选择账单类型「微信」,上传 xlsx 或 zip 文件。
|
||||||
|
|
||||||
|
- 支持格式:`.xlsx`、`.zip`
|
||||||
|
- 系统按「交易单号」去重。
|
||||||
|
|
||||||
|
## 字段映射
|
||||||
|
|
||||||
|
系统以首列「交易时间」定位表头,读取以下列:
|
||||||
|
|
||||||
|
| 源字段(xlsx 列) | 落库字段 | 处理说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 交易时间 | `transaction_time` | 兼容 Excel 日期序列号与文本时间 |
|
||||||
|
| 交易对方 | `remark` | 与商品名称拼接为备注 |
|
||||||
|
| 商品 | `remark` | 同上 |
|
||||||
|
| 收/支 | `type` | 支出→EXPENSE,收入→INCOME |
|
||||||
|
| 金额(元) | `amount` | 去千分位后解析 |
|
||||||
|
| 交易状态 | 仅推断/预览 | 不落库 |
|
||||||
|
| 交易单号 | `external_no` | 导入去重键 |
|
||||||
|
| — | `channel` | 固定为 `WECHAT` |
|
||||||
|
| — | `category_id` | 取导入面板默认分类;未选为 NULL |
|
||||||
|
|
||||||
|
「/」等占位符会被视为空值。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 「用于个人对账」导出的 xlsx 为对账用途,字段完整,适合导入。
|
||||||
|
- 微信账单导出通常有一定延迟(通过邮件下发),收到文件后再导入。
|
||||||
|
- 「/」等占位符会被视为空值。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 分类与标签
|
||||||
|
|
||||||
|
分类用于区分收支的**用途**(如餐饮、交通、工资),标签用于给记录附加**自定义维度**(如报销、旅行、大额消费)。两者都可自定义。
|
||||||
|
|
||||||
|
## 分类管理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
页面分为「支出分类」与「收入分类」两张表格,展示名称(含 Emoji 图标)、图标、排序与操作。
|
||||||
|
|
||||||
|
### 新增分类
|
||||||
|
|
||||||
|
1. 在分类管理页右上角点击「**新增分类**」。
|
||||||
|
2. 在弹窗中填写:
|
||||||
|
- **分类名称**
|
||||||
|
- **类型**:支出 / 收入
|
||||||
|
- **图标**:点击选择 Emoji(启用 AI 后可点「AI 推荐」)
|
||||||
|
- **排序**:数字越小越靠前
|
||||||
|
3. 点击「确定」保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 启用 AI 后,还可在弹窗顶部输入用途描述,点「AI 生成」自动填充名称、类型与图标。
|
||||||
|
|
||||||
|
### 编辑与删除
|
||||||
|
|
||||||
|
- **编辑**:在分类行点击「编辑」修改。
|
||||||
|
- **删除**:点击「删除」并确认;已使用该分类的记录会变为「未分类」。
|
||||||
|
- **图标**:使用 Emoji 选择器挑选,也可自定义输入。
|
||||||
|
- **AI 生成分类**:启用 AI 后,输入用途描述(如「日常通勤打车地铁」),点「AI 生成」自动填充名称、类型与图标。
|
||||||
|
|
||||||
|
新注册用户会自动获得一套默认分类(如餐饮、交通、购物、居住、通讯、娱乐、医疗、教育等)。
|
||||||
|
|
||||||
|
## 标签管理
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 新增标签
|
||||||
|
|
||||||
|
1. 在标签管理页右上角点击「**新增标签**」。
|
||||||
|
2. 填写标签名称、颜色与排序(启用 AI 后可点「AI 推荐」自动配色)。
|
||||||
|
3. 点击「确定」保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **颜色**:使用颜色选择器,内置常用色板;启用 AI 后点「AI 推荐」可按标签名称推荐颜色。
|
||||||
|
- 标签以彩色标签形式在收支记录、导入预览等位置展示。
|
||||||
|
- 删除标签会从所有交易记录中移除该标签,操作前会二次确认。
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
- 分类尽量保持精简、稳定,便于统计对比;细粒度维度用标签补充。
|
||||||
|
- 建议为以下场景建立标签:报销/待报销、旅行、大额消费、订阅、礼物、家庭、工作等。
|
||||||
|
- 需要按规则自动归类时,配合[规则管理](/guide/rules)使用:规则命中后会自动应用分类与标签。
|
||||||
|
- 分类与标签都支持在[账单导入](/guide/transactions)预览阶段批量指定为默认值。
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 统计面板
|
||||||
|
|
||||||
|
「统计面板」是登录后的默认首页,用于查看指定时间范围内的收支汇总与图表。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 时间范围
|
||||||
|
|
||||||
|
顶部下拉可选择统计的时间范围:
|
||||||
|
|
||||||
|
| 选项 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| 本月 | 当月 1 日 00:00:00 ~ 当月最后一天 23:59:59(默认) |
|
||||||
|
| 近3月 | 含本月在内向前共 3 个月 |
|
||||||
|
| 近12月 | 含本月在内向前共 12 个月 |
|
||||||
|
| 自定义 | 手动选择起止日期(结束日按 23:59:59 计算) |
|
||||||
|
|
||||||
|
右侧实时显示当前查询区间。
|
||||||
|
|
||||||
|
## 时间粒度
|
||||||
|
|
||||||
|
趋势图右上角可选择粒度:**按日 / 按周 / 按月 / 按季 / 按年**。切换时间范围时会给出默认粒度(近12月→按月,近3月→按周,本月→按日)。区间内没有数据的点会补 0,保证趋势连续。
|
||||||
|
|
||||||
|
## AI 搜索
|
||||||
|
|
||||||
|
启用 AI 后,页面顶部会出现「**AI 搜索**」按钮,点击打开搜索弹窗。用一句自然语言描述统计意图,系统会调用大模型解析为统计参数并自动回填、刷新图表,例如:
|
||||||
|
|
||||||
|
- 「上个月餐饮花了多少」→ 时间范围 + 分类
|
||||||
|
- 「近半年交通支出趋势,按周看」→ 时间范围 + 分类 + 按周粒度
|
||||||
|
- 「今年各类支出占比」→ 时间范围 + 类型
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
可解析并回填的参数包括:**时间范围、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径**与**趋势粒度**。弹窗内提供示例问题,输入框会记住历史记录(可一键清空);回车或点击「AI 查询」即可执行,无可用条件时会提示且不改动当前筛选。详见 [AI 能力](/guide/ai)。
|
||||||
|
|
||||||
|
## 筛选条件
|
||||||
|
|
||||||
|
- **统计口径**:仅计入统计(默认)/ 全部记录 / 仅不计入。默认只统计「计入统计」的记录,建议按来源设置(微信/支付宝计入、其他来源不计入),避免多来源重复统计。
|
||||||
|
- **类型**:支出 / 收入(多选)。
|
||||||
|
- **分类**:多选,含「未分类」。
|
||||||
|
- **交易渠道**:多选。
|
||||||
|
- **标签**:多选。
|
||||||
|
- **金额区间**:最小 ~ 最大。
|
||||||
|
- **备注搜索**。
|
||||||
|
|
||||||
|
点击「重置筛选」可清空筛选(保留时间范围与粒度)。筛选条件会记住,下次进入自动还原。
|
||||||
|
|
||||||
|
## 汇总卡片
|
||||||
|
|
||||||
|
第一行展示四项汇总:**总收入**、**总支出**、**结余**、**交易笔数**。
|
||||||
|
|
||||||
|
第二行展示派生指标:**日均收入**、**日均支出**、**最大单笔支出**(附带备注摘要)、**平均每笔支出**。
|
||||||
|
|
||||||
|
## 图表
|
||||||
|
|
||||||
|
| 图表 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 收支趋势(柱状图) | 按所选粒度展示收入(绿)与支出(红),点击柱子或该列空白处可跳转查看对应明细 |
|
||||||
|
| 支出分类占比(环形图) | 各分类支出金额占比 |
|
||||||
|
| 收入分类占比(环形图) | 各分类收入金额占比 |
|
||||||
|
| 支出交易渠道占比(环形图) | 仅统计支出,按交易渠道 |
|
||||||
|
| 收入交易渠道占比(环形图) | 仅统计收入,按交易渠道 |
|
||||||
|
|
||||||
|
切换语言时,图表标题、图例与交易渠道名称会实时重绘。
|
||||||
|
|
||||||
|
### 点击趋势图查看明细
|
||||||
|
|
||||||
|
「收支趋势」柱状图的**整列都可点击**:点中柱子,或点击柱子顶端上方的空白处均可。点击后跳转到[收支记录](/guide/transactions),并自动带入该列对应的筛选条件:
|
||||||
|
|
||||||
|
- **类型**:点中柱子时取该柱子的收入 / 支出;点击空白处时不限定类型(沿用面板当前类型筛选);
|
||||||
|
- **日期范围**:该列代表的时间桶——按日→当天、按周→周一至周日、按月→当月、按季→当季、按年→当年;
|
||||||
|
- **其他筛选**:统计面板当前的统计口径、分类、交易渠道、标签、金额区间与备注关键字。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
跳转后可直接查看该时间段内的对应明细;筛选条件会照常记忆,收支记录页原有的每页条数与排序偏好不受影响。
|
||||||
|
|
||||||
|
## 小技巧
|
||||||
|
|
||||||
|
- 想知道「上个月餐饮花了多少」,可把时间范围设为「本月」或自定义,并在分类中只勾选「餐饮」。
|
||||||
|
- 不计入统计的记录(如借款、账户间转账)默认不会计入汇总;如需查看全部,把统计口径切换为「全部记录」。
|
||||||
|
- 想快速核对某月、某周或某天的明细,直接点击「收支趋势」上对应的列(柱子或该列空白处),即可带着时间范围跳到收支记录。
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 邮箱账单
|
||||||
|
|
||||||
|
邮箱账单功能通过 **IMAP** 监听邮箱,当账单邮件到达时自动拉取附件,识别账单类型并进入待处理列表,由你预览后再确认导入。
|
||||||
|
|
||||||
|
页面包含三个标签页:**邮箱配置**、**发件人规则**、**待处理账单**。
|
||||||
|
|
||||||
|
## 邮箱配置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
配置用于接收账单邮件的 IMAP 邮箱:
|
||||||
|
|
||||||
|
1. 点击右上角「**新增**」,填写邮箱信息:
|
||||||
|
- **名称**:便于识别的名称
|
||||||
|
- **IMAP 服务器**:可从内置列表选择或自定义(选择后自动填端口 993 并开启 SSL)
|
||||||
|
- **邮箱账号 / 授权码**:授权码在邮箱设置中开启 IMAP 服务后生成
|
||||||
|
- **监听文件夹**:默认 `INBOX`
|
||||||
|
- **默认归类**:默认支出/收入分类与标签
|
||||||
|
- **拉取范围**:仅新邮件 / 最近 N 天 / 最近 N 封
|
||||||
|
- **启用**:开启后持续监听
|
||||||
|
2. 点击「保存」。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **IMAP 服务器**:可从内置列表选择(QQ、腾讯企业邮、163、126、网易企业邮、阿里云、新浪、139、189、Gmail、Outlook、iCloud)或自定义,选择内置服务器会自动填端口 993 并开启 SSL。
|
||||||
|
- **邮箱账号 / 授权码**:授权码而非登录密码(在邮箱设置中开启 IMAP 服务后生成)。
|
||||||
|
- **监听文件夹**:默认 `INBOX`。
|
||||||
|
- **默认归类**:默认支出分类、收入分类与标签。
|
||||||
|
- **拉取范围**:仅新邮件(增量)/ 最近 N 天 / 最近 N 封。
|
||||||
|
- **启用**:开启后系统会持续监听该邮箱。
|
||||||
|
|
||||||
|
行操作:
|
||||||
|
|
||||||
|
- **测试连接**:验证 IMAP 配置。
|
||||||
|
- **立即检查**:实时展示检查进度,完成后提示新增待处理账单数量。
|
||||||
|
- **编辑 / 删除**。
|
||||||
|
|
||||||
|
## 发件人规则
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
根据邮件**发件人**识别账单类型与解析参数:
|
||||||
|
|
||||||
|
- **发件人匹配串**:每行一个,支持完整地址、`@域名` 或关键字,包含匹配且忽略大小写。
|
||||||
|
- **账单类型**:支付宝 / 微信 / 美团 / 淘宝 / 京东 / 招商银行 / 交通银行。
|
||||||
|
- **解压密码**:账单压缩包加密时填写。
|
||||||
|
- **默认归类**:默认支出分类、收入分类与标签。
|
||||||
|
- **优先级**:值越小越优先。
|
||||||
|
|
||||||
|
系统为常见账单邮箱预置了默认规则(如支付宝 `@alipay.com`、微信 `@wechatpay.com`、美团 `@meituan.com`、招行 `@message.cmbchina.com`、交行 `@bankcomm.com`)。
|
||||||
|
|
||||||
|
## 待处理账单
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
展示已拉取、待处理的邮件账单,可按状态筛选(待解析 / 已导入 / 已忽略),列表每 30 秒自动刷新。
|
||||||
|
|
||||||
|
行操作:
|
||||||
|
|
||||||
|
- **解析**:选择账单类型与解压密码后解析,进入预览,确认后导入。
|
||||||
|
- **忽略**:标记为已忽略,不再处理。
|
||||||
|
- **删除**:删除该消息及本地附件。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```text
|
||||||
|
账单邮件到达
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
IMAP 监听(IDLE 实时 / 轮询降级)
|
||||||
|
│ 提取附件并保存
|
||||||
|
▼
|
||||||
|
发件人规则匹配 → 确定账单类型、解压密码、默认归类
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
进入「待处理账单」(状态:待解析)
|
||||||
|
│ 手动点「解析」→ 预览 → 确认导入
|
||||||
|
▼
|
||||||
|
写入收支记录(状态:已导入)
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip 先预览再导入
|
||||||
|
邮箱账单不会自动写入账本,必须经过预览确认,避免误导入。
|
||||||
|
:::
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 常见问题
|
||||||
|
|
||||||
|
## 安装与启动
|
||||||
|
|
||||||
|
### 没有数据库能启动吗?
|
||||||
|
|
||||||
|
可以。系统支持启动后再配置数据库:未配置时前端会引导进入「初始化数据库」页面,选择 SQLite(零配置)或 MySQL 并保存后即可使用。
|
||||||
|
|
||||||
|
### 前端打包后刷新子路由 404?
|
||||||
|
|
||||||
|
使用 `bootJar` 打包的一体化 jar 已内置 SPA 路由回退:访问 `/dashboard` 等子路径会由后端回退到 `index.html`。若自行用 Nginx 托管前端产物,需配置 history 模式回退。
|
||||||
|
|
||||||
|
## 账单导入
|
||||||
|
|
||||||
|
### 支持哪些账单?
|
||||||
|
|
||||||
|
支付宝、微信、美团、招商银行、交通银行,详见[账单类型与获取](/guide/bills/overview)。
|
||||||
|
|
||||||
|
### 导入后重复了怎么办?
|
||||||
|
|
||||||
|
系统按「用户 + 交易号」去重,重复导入同一账单时已存在的记录会被跳过。若已产生重复,可在[收支记录](/guide/transactions)中勾选后批量删除。
|
||||||
|
|
||||||
|
### 提示需要解压密码?
|
||||||
|
|
||||||
|
部分平台(如美团、部分银行)导出的账单压缩包设有密码。请到导出时的「申请记录」或相应页面查看密码,在解析弹窗中输入。
|
||||||
|
|
||||||
|
### 为什么有些行不能导入?
|
||||||
|
|
||||||
|
常见原因:金额或时间字段无法解析、该交易号已存在、或银行 PDF 的余额连续性校验未通过。预览页会显示具体状态。
|
||||||
|
|
||||||
|
### 银行账单没有交易号,会重复吗?
|
||||||
|
|
||||||
|
不会。银行流水会以「日期 + 类型 + 金额 + 余额」生成稳定去重键;招商银行 CSV 与 PDF 生成规则一致,可混用。
|
||||||
|
|
||||||
|
## 统计与记录
|
||||||
|
|
||||||
|
### 借款、转账会计入统计吗?
|
||||||
|
|
||||||
|
默认计入。建议在记录或导入时将「计入统计」关闭,这类记录会从统计中排除,但仍保留在列表中。
|
||||||
|
|
||||||
|
### 多来源账单如何避免重复统计?
|
||||||
|
|
||||||
|
同一笔消费可能同时出现在多个账单中(例如通过微信付款、实际由招商银行卡扣款),若都计入统计会重复。推荐按来源设置「计入统计」:
|
||||||
|
|
||||||
|
- **微信、支付宝**来源:默认**计入**(除非有特殊情况)。
|
||||||
|
- **其他**来源(银行、美团、淘宝、京东等):默认**不计入**——这些渠道的扣款往往最终走微信/支付宝支付。
|
||||||
|
- **例外**:工资、奖金等明确收入应计入。
|
||||||
|
|
||||||
|
可在[账单导入](/guide/transactions)预览页用「设为计入 / 设为不计入」批量设置,或在[收支记录](/guide/transactions)中逐条切换。
|
||||||
|
|
||||||
|
### 想让备注不显示在界面上?
|
||||||
|
|
||||||
|
可在记录行使用「隐藏」功能,备注会显示为 `******`,需要时再「查看」或「取消隐藏」。
|
||||||
|
|
||||||
|
### 分类和标签有什么区别?
|
||||||
|
|
||||||
|
分类用于区分收支用途(餐饮、交通、工资),是统计的主要维度;标签用于附加自定义维度(报销、旅行、大额消费),一笔记录可有多个标签。
|
||||||
|
|
||||||
|
## AI 与通知
|
||||||
|
|
||||||
|
### AI 功能不可用?
|
||||||
|
|
||||||
|
请先在「系统设置 → AI 模型配置」中开启并填写 API Key,然后点「测试连接」验证。
|
||||||
|
|
||||||
|
### 邮箱账单会自动入库吗?
|
||||||
|
|
||||||
|
不会。邮箱账单拉取后进入「待处理账单」,需要手动解析、预览并确认后才写入账本。
|
||||||
|
|
||||||
|
### 账单类型识别用 AI 吗?
|
||||||
|
|
||||||
|
不使用。邮箱账单的类型由**发件人规则**确定,结果确定、可预期;识别不出的可在解析时手动选择类型。
|
||||||
|
|
||||||
|
### 到点没有收到提醒?
|
||||||
|
|
||||||
|
检查:提醒是否启用、是否配置了启用的渠道、提醒时间是否为 `HH:mm` 格式。可先点「立即发送」验证渠道配置,并查看发送日志。
|
||||||
|
|
||||||
|
## 账号与数据
|
||||||
|
|
||||||
|
### 忘记密码怎么办?
|
||||||
|
|
||||||
|
当前版本未提供自助找回。可在数据库中重置对应用户的密码哈希,或新建账号后迁移数据。
|
||||||
|
|
||||||
|
### 数据存在哪里?
|
||||||
|
|
||||||
|
- SQLite:默认位于服务运行目录的 `data/` 下,配置文件为 `config/database.json`。
|
||||||
|
- MySQL:位于所配置的数据库中。
|
||||||
|
|
||||||
|
### 切换数据库会迁移数据吗?
|
||||||
|
|
||||||
|
不会。切换后原数据库数据保留,需要自行迁移。
|
||||||
|
|
||||||
|
### 支持哪些界面语言?
|
||||||
|
|
||||||
|
简体中文、English、日本語,可在登录页或主界面顶部切换。
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# 快速开始
|
||||||
|
|
||||||
|
本章面向**已部署好的系统**,介绍初次使用时需要完成的配置与操作。
|
||||||
|
|
||||||
|
> 如何安装、启动与部署(开发模式、打包可执行 jar、Docker),请见开发文档的[构建与部署](/dev/build-deploy)。
|
||||||
|
|
||||||
|
## 访问系统
|
||||||
|
|
||||||
|
使用部署者提供的地址访问,例如:
|
||||||
|
|
||||||
|
- 本机部署:`http://localhost:8080`(端口以实际部署为准)
|
||||||
|
- 局域网 / 服务器:`http://<服务器 IP>:<端口>`
|
||||||
|
|
||||||
|
### 在线体验
|
||||||
|
|
||||||
|
不想自行部署?可直接访问在线演示环境:
|
||||||
|
|
||||||
|
- 地址:[https://finance.rosercode.cn/](https://finance.rosercode.cn/)
|
||||||
|
- 演示账号:`demo` / `demo123456`
|
||||||
|
|
||||||
|
> 在线环境使用独立的 SQLite 演示库与虚构数据,仅供功能体验,请勿录入真实账目。
|
||||||
|
|
||||||
|
首次访问且数据库尚未配置时,会自动进入「初始化数据库」引导页。
|
||||||
|
|
||||||
|
## 数据库配置
|
||||||
|
|
||||||
|
系统支持在服务启动**之后**再配置数据库,无需启动前准备:
|
||||||
|
|
||||||
|
- 未检测到配置时,应用仍可正常启动,前端会引导进入「初始化数据库」页面。
|
||||||
|
- 支持 **SQLite**(本地单文件,零配置)与 **MySQL** 两种数据库。
|
||||||
|
- 保存配置后立即生效并自动建表,无需手动执行 SQL。
|
||||||
|
- 配置持久化在 `config/database.json`,SQLite 数据默认位于 `data/` 目录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
首次进入时按引导选择数据库类型并填写连接信息,点击「保存并应用」即可。登录后可通过右上角用户菜单 →「数据库设置」随时切换数据库;切换后原数据库的数据保留,不做自动迁移。
|
||||||
|
|
||||||
|
## 注册与登录
|
||||||
|
|
||||||
|
- 首次使用需要注册账号。若系统尚无任何用户,注册入口始终可用。
|
||||||
|
- 注册成功后使用用户名与密码登录,登录状态通过 JWT 保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
注册功能可在「系统设置 → 注册设置」中开启或关闭。新注册用户会自动获得一套默认分类、标签与邮箱发件人规则。
|
||||||
|
|
||||||
|
## 界面语言
|
||||||
|
|
||||||
|
系统支持**简体中文、English、日本語**三种语言。登录页、注册页右上角以及主界面顶部栏均可切换语言。
|
||||||
|
|
||||||
|
## 首次使用建议
|
||||||
|
|
||||||
|
1. 完成数据库配置并注册登录。
|
||||||
|
2. 进入「分类管理」「标签管理」按自己的习惯调整分类与标签。
|
||||||
|
3. 在「规则管理」配置常用自动归类规则(如「星巴克 → 餐饮」)。
|
||||||
|
4. 导出并导入历史账单(见[账单类型与获取](/guide/bills/overview))。
|
||||||
|
5. 按需配置「AI 模型」「定时通知」「邮箱账单」。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [统计面板](/guide/dashboard)
|
||||||
|
- [收支记录](/guide/transactions)
|
||||||
|
- [账单导入](/guide/transactions)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 使用文档
|
||||||
|
|
||||||
|
本章面向**使用者**,介绍系统的安装启动、各功能的使用方法与各类账单的获取方式。
|
||||||
|
|
||||||
|
## 开始使用
|
||||||
|
|
||||||
|
- [项目简介](/guide/introduction):系统定位、核心功能与技术栈
|
||||||
|
- [快速开始](/guide/getting-started):数据库配置、注册登录、界面语言与首次使用建议
|
||||||
|
- [统计面板](/guide/dashboard):时间范围、筛选条件与图表说明
|
||||||
|
|
||||||
|
## 记账与整理
|
||||||
|
|
||||||
|
- [收支记录](/guide/transactions):记录管理、筛选、批量操作、账单导入、AI 建议与记账
|
||||||
|
- [分类与标签](/guide/categories-tags):分类与标签的维护与使用建议
|
||||||
|
- [规则管理](/guide/rules):按关键词/金额/类型自动归类打标,支持 AI 生成规则、搜索与分页
|
||||||
|
- [邮箱账单](/guide/email-bills):IMAP 自动拉取账单邮件
|
||||||
|
|
||||||
|
## 智能与自动化
|
||||||
|
|
||||||
|
- [AI 能力](/guide/ai):分类标签建议、自然语言查询与记账、AI 搜索与规则生成、用量统计
|
||||||
|
- [定时提醒与通知](/guide/notifications):多渠道账单导入提醒
|
||||||
|
|
||||||
|
## 系统
|
||||||
|
|
||||||
|
- [系统设置](/guide/settings):数据库、注册、账单来源统计、外观、AI、通知配置
|
||||||
|
- [系统日志](/guide/logs):访问日志、登录日志、运行日志
|
||||||
|
|
||||||
|
## 账单获取
|
||||||
|
|
||||||
|
- [账单类型总览](/guide/bills/overview)
|
||||||
|
- [支付宝账单](/guide/bills/alipay) / [微信支付账单](/guide/bills/wechat) / [美团账单](/guide/bills/meituan) / [淘宝账单](/guide/bills/taobao) / [京东账单](/guide/bills/jd) / [招商银行账单](/guide/bills/cmb) / [交通银行账单](/guide/bills/bocom)
|
||||||
|
|
||||||
|
## 其他
|
||||||
|
|
||||||
|
- [常见问题](/guide/faq)
|
||||||
|
|
||||||
|
> 想了解系统内部设计与二次开发?请查看[开发文档](/dev/)。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 项目简介
|
||||||
|
|
||||||
|
收支管理系统是一个**自托管的个人收支管理应用**,用于把分散在支付宝、微信、美团、银行等平台的账单汇总到一处,统一分类、打标签、统计收支,并通过自定义规则与 AI 能力减少手工整理的工作量。
|
||||||
|
|
||||||
|
## 解决什么问题
|
||||||
|
|
||||||
|
日常消费分散在多个平台:扫码用微信/支付宝、外卖在美团、大额消费走银行卡。各平台账单格式不同、导出入口不同,手动记录既繁琐又容易遗漏。本系统提供:
|
||||||
|
|
||||||
|
- **多来源账单导入**:支付宝(CSV)、微信(xlsx)、美团(CSV)、淘宝(xlsx)、京东(CSV)、招商银行(CSV/PDF)、交通银行(xls),支持加密 zip 压缩包;解析预览后再确认导入,按交易号去重。
|
||||||
|
- **自动归类**:自定义规则按关键词、收支类型、金额区间自动匹配分类与标签;AI 可对已有记录批量给出分类/标签建议。
|
||||||
|
- **统计与图表**:月度汇总、分类占比、收支趋势、交易渠道占比,支持自定义时间范围与多维度筛选。
|
||||||
|
- **自动化提醒**:按周期提醒导入新账单;也可通过 IMAP 监听邮箱自动拉取账单邮件。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
| 模块 | 能力 |
|
||||||
|
|---|---|
|
||||||
|
| 收支记录 | 增删改查、分页与多条件筛选、批量设置分类/标签、批量删除、计入统计开关、备注隐藏、导出 Excel |
|
||||||
|
| 分类与标签 | 收入/支出分类与标签的增删改查,新用户自动获得默认数据,AI 推荐 Emoji 与颜色 |
|
||||||
|
| 统计图表 | 月度收支、分类占比、收支趋势、交易渠道占比,支持按日/周/月/季/年粒度,点击趋势图可下钻对应明细 |
|
||||||
|
| 账单导入 | 七类账单解析、zip 解密、解析预览、按交易号去重、规则建议 |
|
||||||
|
| 自定义规则 | 关键词/收支类型/金额区间组合,优先级排序、启用停用,导入时自动应用;支持 AI 生成规则、搜索与分页 |
|
||||||
|
| AI 能力 | 分类标签建议、自然语言查询、自然语言记账、AI 统计搜索、AI 生成规则、分类/标签生成、Emoji/颜色推荐、用量统计 |
|
||||||
|
| 邮箱账单 | 多邮箱 IMAP IDLE 监听、发件人规则识别账单类型、待处理列表先预览再导入 |
|
||||||
|
| 定时通知 | 每天/每周/每月提醒,邮箱 SMTP 与 Server 酱渠道,发送日志 |
|
||||||
|
| 系统日志 | 访问日志、登录日志、运行日志在线查看,运行日志实时推送,自动清理 |
|
||||||
|
| 账号与多语言 | JWT 登录、注册开关、运行时数据库配置(SQLite/MySQL)、中/英/日三语 |
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus 3.5.12 + MySQL / SQLite |
|
||||||
|
| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts + Pinia |
|
||||||
|
| 构建 | Gradle 8.13(后端)/ npm(前端) |
|
||||||
|
| 认证 | JWT |
|
||||||
|
| 对象映射 | MapStruct 1.6.3 |
|
||||||
|
| 账单解析 | EasyExcel 4.0.3 + PDFBox 3.0.3 + Zip4j 2.11.5 |
|
||||||
|
| AI 能力 | DeepSeek |
|
||||||
|
| 通知渠道 | 邮箱 SMTP / Server 酱 |
|
||||||
|
| 多语言 | 中文 / 英文 / 日文(vue-i18n) |
|
||||||
|
| API 文档 | Knife4j(OpenAPI 3) |
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 个人或家庭希望把多平台账单集中管理、统一统计。
|
||||||
|
- 希望按自己的习惯自定义分类、标签与自动归类规则。
|
||||||
|
- 希望借助 AI 快速完成分类打标、自然语言查询与记账。
|
||||||
|
- 希望私有部署,数据保存在本地 SQLite 或自有 MySQL 中。
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- [快速开始](/guide/getting-started):初次使用与配置
|
||||||
|
- [账单类型与获取](/guide/bills/overview):了解支持哪些账单、如何导出
|
||||||
|
- [整体架构](/dev/architecture):了解内部设计
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 系统日志
|
||||||
|
|
||||||
|
系统日志通过右上角用户菜单 →「系统日志」进入,包含三个标签页:**访问日志**、**登录日志**、**运行日志**。页头右上角可选择日志保留时长(30 天 / 90 天 / 180 天 / 永久保留)。
|
||||||
|
|
||||||
|
## 访问日志
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
记录每一次接口访问:
|
||||||
|
|
||||||
|
- 筛选:关键字(路径/参数/IP)、请求方法、状态码。
|
||||||
|
- 表格列:时间、方法、路径、参数、状态码、耗时、用户、IP。
|
||||||
|
- 支持分页与刷新。
|
||||||
|
|
||||||
|
## 登录日志
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
记录每次登录尝试(成功与失败):
|
||||||
|
|
||||||
|
- 筛选:关键字(用户名等)、结果(成功/失败)。
|
||||||
|
- 表格列:时间、用户名、结果、说明、IP、User-Agent。
|
||||||
|
|
||||||
|
## 运行日志
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
在线查看后端运行日志:
|
||||||
|
|
||||||
|
- 筛选:日期、时间范围(最近 1/6/24 小时或全部)、级别(ERROR/WARN/INFO/DEBUG)、关键字、行数。
|
||||||
|
- 开启「实时」后通过 SSE 订阅新增日志,先回放最近内容再增量推送。
|
||||||
|
- 页面显示当前日志文件名与命中行数。
|
||||||
|
|
||||||
|
## 日志清理
|
||||||
|
|
||||||
|
访问日志与登录日志会按配置的保留天数自动清理(默认 30 天,设为永久保留则不清理),无需手动维护。
|
||||||
|
|
||||||
|
::: tip 排查问题
|
||||||
|
接口报错时,可先在「访问日志」定位失败请求(状态码 4xx/5xx),再到「运行日志」按时间与关键字查看异常堆栈。
|
||||||
|
:::
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 定时提醒与通知
|
||||||
|
|
||||||
|
系统可按周期提醒你导入新账单,并通过邮箱或 Server 酱推送通知。配置入口为「系统设置 → 定时通知」。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 提醒周期
|
||||||
|
|
||||||
|
- **通知链接地址**:通知正文中附带的跳转链接(如指向账单导入页 `/bill-import`)。
|
||||||
|
- **启用定时提醒**:开启后按周期发送。
|
||||||
|
- **提醒周期**:每天 / 每周(选择星期几)/ 每月(选择 1–28 日,默认每月 1 日)。
|
||||||
|
- **提醒时间**:`HH:mm` 格式(如 `09:00`)。
|
||||||
|
|
||||||
|
## 推送渠道
|
||||||
|
|
||||||
|
点击「添加渠道」新增,支持两种类型:
|
||||||
|
|
||||||
|
| 渠道 | 配置项 |
|
||||||
|
|---|---|
|
||||||
|
| Server 酱 | SendKey(在 sct.ftqq.com 获取),可推送到个人微信 |
|
||||||
|
| 邮箱 | SMTP 服务器、端口、邮箱账号、授权码、收件人、是否使用 SSL |
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
每个渠道可单独「测试」「编辑」「删除」,并可通过启用开关控制是否参与发送。
|
||||||
|
|
||||||
|
面板右上角的「立即发送」可向所有启用渠道立即发送一次提醒,用于验证配置。
|
||||||
|
|
||||||
|
## 发送日志
|
||||||
|
|
||||||
|
「定时通知」面板下方展示最近发送记录(最多 20 条),包含成功/失败、时间、渠道类型与说明,便于排查问题。
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 首次启用提醒时,系统会将「上次发送时间」置为当前,避免启用后立即补发历史提醒。
|
||||||
|
- 提醒正文默认提示导入新账单,并附带配置的通知链接地址。
|
||||||
|
- 若当前没有任何启用的渠道,到点时不会发送,待配置渠道后在本周期内仍可触发。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 页面总览
|
||||||
|
|
||||||
|
本节汇总系统的全部页面、用途、入口方式与相互之间的跳转关系。
|
||||||
|
|
||||||
|
## 页面清单
|
||||||
|
|
||||||
|
| 页面 | 路由 | 用途 | 入口方式 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 登录 | `/login` | 用户名密码登录 | 未登录访问受保护页面时自动跳转 |
|
||||||
|
| 注册 | `/register` | 创建账号 | 登录页「没有账号?去注册」(注册开启时) |
|
||||||
|
| 数据库初始化 | `/database-setup` | 首次配置数据库 | 数据库未配置时自动引导 |
|
||||||
|
| 统计面板 | `/dashboard` | 收支汇总与图表 | 登录后默认页;左侧菜单 |
|
||||||
|
| 收支记录 | `/transactions` | 记录管理与**账单导入** | 左侧菜单 |
|
||||||
|
| 分类管理 | `/categories` | 收入/支出分类维护 | 左侧菜单 |
|
||||||
|
| 标签管理 | `/tags` | 标签维护 | 左侧菜单 |
|
||||||
|
| 规则管理 | `/rules` | 自动归类规则 | 左侧菜单 |
|
||||||
|
| 邮箱账单 | `/email-bills/*` | 邮箱配置 / 发件人规则 / 待处理账单 | 左侧菜单 |
|
||||||
|
| 系统设置 | `/settings` | 数据库 / 注册 / 账单来源统计 / 外观 / AI / 通知配置 | 顶部用户菜单 |
|
||||||
|
| 模型使用情况 | `/ai-usage` | AI 调用统计与明细 | 设置页「AI 模型配置」→ 使用情况 |
|
||||||
|
| 系统日志 | `/logs/*` | 访问 / 登录 / 运行日志 | 顶部用户菜单 |
|
||||||
|
| 账单导入(独立页) | `/bill-import` | 手机端上传账单 | **仅由通知邮件链接直达** |
|
||||||
|
|
||||||
|
## 全局导航
|
||||||
|
|
||||||
|
### 左侧菜单
|
||||||
|
|
||||||
|
统计面板 → 收支记录 → 分类管理 → 标签管理 → 规则管理 → 邮箱账单
|
||||||
|
|
||||||
|
### 顶部用户菜单
|
||||||
|
|
||||||
|
- **系统日志**:`/logs`
|
||||||
|
- **系统设置**:`/settings`
|
||||||
|
- **退出**:清空登录态并返回 `/login`
|
||||||
|
|
||||||
|
> 「模型使用情况」没有独立菜单入口,需从「系统设置 → AI 模型配置 → 使用情况」进入。
|
||||||
|
|
||||||
|
## 典型跳转路径
|
||||||
|
|
||||||
|
- **首次使用**:`/database-setup` → `/register` → `/login` → `/dashboard`
|
||||||
|
- **手动记账**:收支记录 → 点击「新增记录」→ 表单弹窗 → 保存
|
||||||
|
- **查看趋势明细**:统计面板 → 点击「收支趋势」某一列(柱子或该列空白处)→ 收支记录(自动带入时间范围、类型与当前筛选条件)
|
||||||
|
- **账单导入**:收支记录 → 点击「导入账单」→ 解析预览 → 确认导入 → 回到收支记录
|
||||||
|
- **手机导入**:通知邮件链接 → `/bill-import`(独立页,非弹窗)→ 上传解析 → 确认导入
|
||||||
|
- **自动归类**:规则管理配置规则 → 账单导入预览时命中并给出建议
|
||||||
|
- **邮箱账单**:邮箱配置 → 发件人规则 → 待处理账单 → 解析 → 导入到收支记录
|
||||||
|
- **AI 辅助**:系统设置启用 AI → 统计面板「AI 搜索」、收支记录「一键设置 / 智能记账 / AI 查询」、分类与标签「AI 推荐」、规则管理「AI 生成规则」
|
||||||
|
- **系统维护**:顶部菜单 → 系统设置 / 系统日志
|
||||||
|
|
||||||
|
## 页面关系说明
|
||||||
|
|
||||||
|
- **收支记录**是核心页面:手动记账、账单导入、批量设置、AI 能力都从这里进入,导入完成后也回到该页查看。
|
||||||
|
- **账单导入**有两条独立路径:
|
||||||
|
- 收支记录页的「导入账单」**弹窗**(桌面端常用);
|
||||||
|
- 通知邮件直达的 **`/bill-import` 独立页**(面向手机,非弹窗,元素精简,系统内无其他入口)。
|
||||||
|
- **系统设置**聚合了数据库、注册、账单来源统计、外观、AI、通知六类配置;模型用量与日志分别从设置页与顶部菜单进入。
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 规则管理
|
||||||
|
|
||||||
|
规则用于**自动归类**:命中条件后自动应用指定的分类与标签,减少手动整理。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 创建规则
|
||||||
|
|
||||||
|
1. 在规则管理页右上角点击「**新增规则**」。
|
||||||
|
2. 填写规则名称,并按需设置条件与结果:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 类型 | 不限类型 / 支出 / 收入 |
|
||||||
|
| 关键词 | 匹配备注、交易对方、商品名称(包含即命中) |
|
||||||
|
| 金额区间 | 最小值 ~ 最大值(可只填一端) |
|
||||||
|
| 目标分类 | 命中后应用的分类 |
|
||||||
|
| 目标标签 | 命中后应用的标签(可多选) |
|
||||||
|
| 启用 / 排序 | 是否启用、优先级(越小越优先) |
|
||||||
|
|
||||||
|
3. 点击「确定」保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 规则条件
|
||||||
|
|
||||||
|
一条规则可组合以下条件:
|
||||||
|
|
||||||
|
| 条件 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 类型 | 不限类型 / 支出 / 收入 |
|
||||||
|
| 关键词 | 匹配备注、交易对方、商品名称,**包含即命中**(忽略大小写) |
|
||||||
|
| 金额区间 | 最小值 ~ 最大值(含边界,可只填一端) |
|
||||||
|
| 目标分类 | 命中后应用的分类(可不选) |
|
||||||
|
| 目标标签 | 命中后应用的标签(多选,可不选) |
|
||||||
|
| 优先级 | 排序值越小越优先 |
|
||||||
|
| 启用 | 可随时启用或停用 |
|
||||||
|
|
||||||
|
## 命中规则
|
||||||
|
|
||||||
|
- **分类**:取优先级最高(排序值最小)且与交易类型一致的命中规则。
|
||||||
|
- **标签**:取所有命中规则标签的并集。
|
||||||
|
- 规则在**账单导入的解析预览阶段**即会展示命中的规则名与建议分类/标签;确认导入时,**规则建议的分类优先于面板默认分类**,标签与默认标签合并去重。
|
||||||
|
|
||||||
|
## 搜索与分页
|
||||||
|
|
||||||
|
规则列表支持按条件检索与分页:
|
||||||
|
|
||||||
|
- **搜索**:按**规则名称或关键词**模糊匹配。
|
||||||
|
- **筛选**:**启用状态**(已启用 / 已停用)、**收支类型**(收入 / 支出)。
|
||||||
|
- **分页**:每页 10 / 20 / 50 / 100 条。
|
||||||
|
|
||||||
|
点击「搜索」按条件查询,「清空」重置全部条件。新增规则后回到第一页,便于查看新规则。
|
||||||
|
|
||||||
|
## AI 生成规则
|
||||||
|
|
||||||
|
启用 AI 后,规则管理页右上角会出现「**AI 生成规则**」按钮,可让 AI 依据**已打标签的记录**自动归纳出「关键词 → 分类/标签」规则,无需手工逐条编写。
|
||||||
|
|
||||||
|
流程为两步:
|
||||||
|
|
||||||
|
1. **选择记录**:列出当前用户**已打标签**的记录,默认全选当前页。可按**备注关键字**搜索,并按**收支类型、标签**筛选;支持分页与每页条数切换,可「全选本页」或「清空选择」。跨页选择会保留。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. **预览规则**:AI 依据所选记录的**类型、备注、交易渠道、已有分类与标签**归纳出规则建议,展示规则名称、关键词、目标分类与标签、理由。名称与关键词可编辑,勾选后点击「创建勾选规则」批量创建;系统会自动创建建议中不存在的新分类与标签。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
::: tip 与已有规则去重
|
||||||
|
AI 建议若与**数据库中已有规则重复**(按「类型 + 关键词 + 分类 + 标签集合」判定),会标注「**已存在**」,**默认不勾选且不可勾选**,无法导入,避免产生重复规则。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
| 规则名 | 条件 | 结果 |
|
||||||
|
|---|---|---|
|
||||||
|
| 星巴克归入餐饮 | 关键词「星巴克」、类型支出 | 分类:餐饮;标签:咖啡 |
|
||||||
|
| 滴滴出行归入交通 | 关键词「滴滴」、类型支出 | 分类:交通 |
|
||||||
|
| 大额支出标记 | 金额 ≥ 500、类型支出 | 标签:大额消费 |
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
- 先建立少量高频规则(餐饮、交通、外卖等),再逐步补充。
|
||||||
|
- 关键词尽量选择稳定、唯一的商户或商品名,避免误命中。
|
||||||
|
- 同一笔记录可能命中多条规则,分类取最优先的一条,标签会全部应用。
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# 系统设置
|
||||||
|
|
||||||
|
系统设置通过右上角用户菜单 →「系统设置」进入,包含六个配置面板。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 数据库配置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **数据库类型**:SQLite / MySQL。
|
||||||
|
- **SQLite**:数据库文件路径(相对于服务运行目录)。
|
||||||
|
- **MySQL**:主机、端口、数据库名、用户名、密码。
|
||||||
|
- 提供「测试连接」与「保存并应用」。保存后立即生效并自动建表;已配置时密码留空表示不修改。
|
||||||
|
|
||||||
|
系统支持启动后再配置数据库:未配置时前端会引导进入初始化页面,无需启动前准备。登录后也可随时切换数据库,切换后原数据库数据保留、不做自动迁移。
|
||||||
|
|
||||||
|
## 注册设置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
控制是否开放注册:
|
||||||
|
|
||||||
|
- 系统**无任何用户**时始终允许创建首个账号。
|
||||||
|
- 关闭后新用户无法注册(登录页不再显示注册入口)。
|
||||||
|
|
||||||
|
## 账单来源统计默认值
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
按**账单来源**分别设置导入时「是否计入统计」的默认值,用于避免同一笔消费在多来源账单中重复统计:
|
||||||
|
|
||||||
|
- 每个来源一个开关:**计入统计 / 不计入统计**。
|
||||||
|
- 默认策略:**微信、支付宝**计入,**美团、淘宝、京东、招商银行、交通银行**不计入。
|
||||||
|
- 不计入的记录仍会导入,只是不参与统计;导入预览页仍可逐条或批量修改。
|
||||||
|
- 默认值按**当前账号**保存,仅影响当前用户的账单导入。
|
||||||
|
|
||||||
|
> 解析器判定为「不计收支/中性」的行始终不计入,不受此处默认值影响。
|
||||||
|
|
||||||
|
## 外观设置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
选择「不计入统计」的记录在[收支记录](/guide/transactions)列表中的展示方式:
|
||||||
|
|
||||||
|
- **删除线(默认)**:整行加删除线,类型与金额列保持原样。
|
||||||
|
- **灰色标签**:类型列显示灰色「不计入」标签,金额列置灰且不显示正负号。
|
||||||
|
|
||||||
|
面板内提供**实时预览**,切换选项即可看到效果;该偏好按**当前账号**保存,登录后生效。
|
||||||
|
|
||||||
|
## AI 模型配置
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **启用 AI 建议**:总开关,关闭后不发起任何 AI 请求。
|
||||||
|
- **API Key**:DeepSeek 的 API Key(已配置时留空表示不修改)。
|
||||||
|
- **接口地址**:默认 `https://api.deepseek.com`。
|
||||||
|
- **模型**:默认 `deepseek-chat`。
|
||||||
|
- 提供「保存」与「测试连接」。
|
||||||
|
- 右上角「使用情况」可查看 AI 调用统计与明细,详见 [AI 能力](/guide/ai)。
|
||||||
|
|
||||||
|
## 定时通知
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
配置账单导入提醒的周期、时间与推送渠道(Server 酱 / 邮箱),并可查看发送日志。详见[定时提醒与通知](/guide/notifications)。
|
||||||
|
|
||||||
|
## 相关
|
||||||
|
|
||||||
|
- [系统日志](/guide/logs):查看访问、登录与运行日志。
|
||||||
|
- [快速开始](/guide/getting-started):数据库配置与注册登录。
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# 收支记录
|
||||||
|
|
||||||
|
「收支记录」页面用于查看和管理每一笔收入与支出。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 顶部操作
|
||||||
|
|
||||||
|
| 按钮 | 功能 |
|
||||||
|
|---|---|
|
||||||
|
| 排序 | 交易时间(新→旧 / 旧→新)、金额(高→低 / 低→高) |
|
||||||
|
| 一键设置 | 对当前表格展示的记录批量设置分类、计入统计与标签 |
|
||||||
|
| 批量删除 | 删除表格中勾选的记录 |
|
||||||
|
| 导入账单 | 打开账单导入弹窗 |
|
||||||
|
| 导出 Excel | 按当前筛选条件导出 |
|
||||||
|
| 刷新 | 按当前筛选条件与页码重新加载列表 |
|
||||||
|
| 智能记账 | 用一句话描述收支,AI 解析后预填表单(需启用 AI) |
|
||||||
|
| 新增记录 | 手动新增一笔记录 |
|
||||||
|
|
||||||
|
## 账单导入
|
||||||
|
|
||||||
|
「收支记录」页支持把支付宝、微信、美团、淘宝、京东、招商银行、交通银行的账单文件解析并导入为收支记录。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 入口
|
||||||
|
|
||||||
|
| 入口 | 形式 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| 收支记录页「**导入账单**」按钮 | 弹窗 | 桌面端常用 |
|
||||||
|
| 通知邮件中的链接 `/bill-import` | 独立页 | **仅由通知邮件直达**,面向手机;独立页面(非弹窗),元素精简、系统内无其他入口 |
|
||||||
|
|
||||||
|
> `/bill-import` 是「定时通知」邮件里附带的直达上传页,方便在手机上直接上传账单,不会从系统内其他页面跳转进入。
|
||||||
|
|
||||||
|
### 支持的账单类型与格式
|
||||||
|
|
||||||
|
| 账单类型 | 支持格式 |
|
||||||
|
|---|---|
|
||||||
|
| 支付宝 | `.csv`、`.zip` |
|
||||||
|
| 微信 | `.xlsx`、`.zip` |
|
||||||
|
| 美团 | `.csv`、`.zip` |
|
||||||
|
| 淘宝 | `.xlsx` |
|
||||||
|
| 京东 | `.csv`、`.zip` |
|
||||||
|
| 招商银行 | `.csv`、`.pdf`、`.zip` |
|
||||||
|
| 交通银行 | `.xls`、`.zip` |
|
||||||
|
|
||||||
|
各平台账单的导出方法见[账单类型与获取](/guide/bills/overview)。
|
||||||
|
|
||||||
|
### 导入流程
|
||||||
|
|
||||||
|
导入采用「解析预览 → 确认导入」两步,先解析不落库,确认后才写入。
|
||||||
|
|
||||||
|
**第一步:选择类型与文件**
|
||||||
|
|
||||||
|
1. 选择账单类型(默认记住上次选择)。
|
||||||
|
2. 点击或拖拽上传账单文件。
|
||||||
|
3. 点击「解析」。
|
||||||
|
|
||||||
|
切换账单类型会清空已选文件;页面会提示该类型支持的格式与在对应 App 中的导出路径。若上传的是**加密压缩包**,解析时会弹出密码输入框,输入后重新解析。
|
||||||
|
|
||||||
|
**第二步:解析预览**
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **默认归类**:可为本次导入设置默认的支出分类、收入分类与默认标签。
|
||||||
|
- **筛选**:关键字、类型(收入/支出/不计入)、状态(可导入/已存在/不可导入)、金额区间。
|
||||||
|
- **逐行勾选**:默认勾选解析器建议的行;只有「可导入」的行可勾选。
|
||||||
|
- **计入统计**:可对勾选行批量「设为计入 / 设为不计入」,建议按来源设置(见下)。
|
||||||
|
- **预览列**:交易时间、类型、计入统计、金额、交易对方、商品名称、状态、**匹配规则与建议**。
|
||||||
|
|
||||||
|
::: tip 按来源设置计入统计
|
||||||
|
- **微信、支付宝**来源默认**计入**;**其他**来源(银行、美团、淘宝、京东等)默认**不计入**——它们往往最终走微信/支付宝支付。
|
||||||
|
- **例外**:工资、奖金等明确收入应计入。
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: tip 去重
|
||||||
|
系统按「用户 + 交易号」去重。已存在于系统中的交易会标记为「已存在」,默认不勾选,避免重复导入。
|
||||||
|
:::
|
||||||
|
|
||||||
|
**第三步:导入结果**
|
||||||
|
|
||||||
|
显示成功、跳过、失败的数量与明细消息。完成后可在下方表格中查看。
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
- **解析失败**:确认账单类型与文件格式是否匹配;银行账单请使用系统支持的导出格式。
|
||||||
|
- **需要解压密码**:部分账单压缩包在导出时设置了密码,需在解析时输入。
|
||||||
|
- **部分行不可导入**:可能是金额/时间字段无法解析,或该交易号已存在。
|
||||||
|
- **重复导入**:同一账单重复导入时,已存在的交易会被跳过。
|
||||||
|
|
||||||
|
## 筛选与 AI 智能查询
|
||||||
|
|
||||||
|
**常用筛选**:类型、分类(含「未分类」)、日期范围、备注关键字、交易渠道。
|
||||||
|
|
||||||
|
**更多筛选**:标签(含「无标签」)、计入统计、金额区间。
|
||||||
|
|
||||||
|
启用 AI 后,顶部会出现 **AI 智能查询**输入框,可用自然语言查询,例如:
|
||||||
|
|
||||||
|
- 「上个月餐饮」
|
||||||
|
- 「超过500的支出」
|
||||||
|
- 「待报销的支出」
|
||||||
|
- 「微信支付」
|
||||||
|
|
||||||
|
解析结果会直接应用为筛选条件并立即查询。筛选条件与每页条数、排序方式会自动记忆,下次进入自动还原。
|
||||||
|
|
||||||
|
## 表格与行操作
|
||||||
|
|
||||||
|
表格展示:交易时间、类型、分类、金额、交易渠道、备注、标签、计入统计开关与操作。
|
||||||
|
|
||||||
|
- **备注隐藏**:可将敏感备注持久隐藏为 `******`,需要时临时「查看」或「取消隐藏」。
|
||||||
|
- **计入统计**:行内开关可直接切换;关闭后该记录不纳入月度统计(适合借款、转账等),行内会以删除线标示。
|
||||||
|
- **行操作**:编辑、查看「原始数据」(账单导入时的原始字段,以 JSON 展示)、删除。
|
||||||
|
|
||||||
|
分页支持每页 10/20/50/100/200/500 条。
|
||||||
|
|
||||||
|
## 查看原始数据
|
||||||
|
|
||||||
|
账单导入时,系统会完整保留每笔交易在原始账单中的字段(以 JSON 存储)。在记录行点击「**原始数据**」即可查看,用于核对金额、交易状态、对方账号、交易类型等明细。
|
||||||
|
|
||||||
|
- 仅**账单导入**的记录有原始数据;**手动新增**的记录没有,该按钮不可用。
|
||||||
|
- 原始数据是排查导入异常、核对账单的重要依据。
|
||||||
|
|
||||||
|
## 一键设置(批量设置)
|
||||||
|
|
||||||
|
对**当前表格展示的记录**批量操作:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- 设置支出分类 / 收入分类。
|
||||||
|
- 设置是否计入统计。
|
||||||
|
- 添加标签 / 移除标签。
|
||||||
|
- 启用 AI 时,可选择建议范围(分类与标签 / 仅分类 / 仅标签)并点击「AI 智能建议」,由 AI 生成建议后进入 AI 建议弹窗。
|
||||||
|
|
||||||
|
## AI 建议弹窗
|
||||||
|
|
||||||
|
展示每条记录的建议分类、建议标签与理由,默认全选,可逐条取消。若建议中包含系统中不存在的分类或标签,会以「新建」标记,确认后自动创建。应用后分类会被覆盖、标签会被追加。
|
||||||
|
|
||||||
|
## AI 智能记账
|
||||||
|
|
||||||
|
在「智能记账」弹窗中用自然语言描述一笔收支,例如「昨天中午吃饭 35 支付宝」,AI 会解析为类型、金额、分类、交易渠道、时间、备注、标签等字段,并预填到新增记录表单,确认后保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 手动添加记录
|
||||||
|
|
||||||
|
1. 在收支记录页右上角点击「**新增记录**」。
|
||||||
|
2. 在弹窗中填写表单:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 类型 | 支出 / 收入 |
|
||||||
|
| 分类 | 新增时必填 |
|
||||||
|
| 金额 | 大于 0 |
|
||||||
|
| 交易渠道 | 可选 |
|
||||||
|
| 交易时间 | 默认当前时间 |
|
||||||
|
| 备注 | 可选 |
|
||||||
|
| 标签 | 可选,可多选 |
|
||||||
|
| 计入统计 | 默认开启;关闭后不纳入统计 |
|
||||||
|
|
||||||
|
3. 点击「确定」保存。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 编辑记录
|
||||||
|
|
||||||
|
在记录行点击「编辑」即可修改记录,弹窗字段与新增一致;编辑时允许不设置分类。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 编辑时的 AI 辅助
|
||||||
|
|
||||||
|
启用 AI 后,编辑弹窗中的**分类**与**标签**旁会出现 AI 辅助按钮(与新增记录一致):
|
||||||
|
|
||||||
|
| 按钮 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| 分类 · **AI 推荐** | 根据当前类型、金额、交易渠道、备注,从已有分类中推荐并填入 |
|
||||||
|
| 分类 · **AI 新建** | 根据当前账单信息生成新分类;确认「保存并选用」后创建并选中,若已存在同名同类型分类则直接选中 |
|
||||||
|
| 标签 · **AI 推荐** | 推荐已有标签并追加(自动去重) |
|
||||||
|
| 标签 · **AI 新建** | 生成新标签(含建议颜色),确认后创建并选中;同名标签直接选中 |
|
||||||
|
|
||||||
|
> 「AI 新建」需先填写备注或金额,便于模型理解;生成结果会显示确认条,点击「保存并选用」才真正创建。
|
||||||
|
|
||||||
|
编辑完成后点击「确定」保存。其他 AI 能力(批量建议、智能记账、智能查询)见 [AI 能力](/guide/ai)。
|
||||||
|
|
||||||
|
::: tip 计入统计
|
||||||
|
「计入统计」用于区分真实消费与资金流转,建议**按账单来源**设置,避免多来源重复统计:
|
||||||
|
|
||||||
|
- **微信、支付宝**来源:默认**计入**(除非有特殊情况)。
|
||||||
|
- **其他**来源(银行、美团、淘宝、京东等):默认**不计入**——这些渠道的扣款往往最终走微信/支付宝,重复计入会导致统计翻倍。
|
||||||
|
- **例外**:工资、奖金等明确收入应计入。
|
||||||
|
|
||||||
|
此外,借款、信用卡还款、账户间转账等资金流转也建议关闭该开关,避免统计失真。
|
||||||
|
:::
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
layout: home
|
||||||
|
|
||||||
|
hero:
|
||||||
|
name: 收支管理系统
|
||||||
|
text: 个人收支管理 · 多来源账单导入 · AI 智能记账
|
||||||
|
tagline: 支持支付宝、微信、美团、淘宝、京东、招商银行、交通银行账单导入,提供分类标签管理、自定义规则、AI 建议与定时提醒
|
||||||
|
image:
|
||||||
|
src: https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/dashboard.png
|
||||||
|
alt: 统计面板
|
||||||
|
actions:
|
||||||
|
- theme: brand
|
||||||
|
text: 使用文档
|
||||||
|
link: /guide/
|
||||||
|
- theme: alt
|
||||||
|
text: 开发文档
|
||||||
|
link: /dev/
|
||||||
|
- theme: alt
|
||||||
|
text: 在线体验
|
||||||
|
link: https://finance.rosercode.cn/
|
||||||
|
|
||||||
|
features:
|
||||||
|
- title: 多来源账单导入
|
||||||
|
details: 支持支付宝、微信、美团、淘宝、京东、招商银行、交通银行账单,解析预览后确认导入,按交易号自动去重。
|
||||||
|
- title: 分类与标签
|
||||||
|
details: 自定义收入/支出分类与标签,支持批量设置,AI 可推荐 Emoji 图标与标签颜色。
|
||||||
|
- title: 自定义规则
|
||||||
|
details: 按关键词、收支类型、金额区间组合条件,命中后自动应用分类与标签,导入预览即展示建议。
|
||||||
|
- title: AI 智能能力
|
||||||
|
details: 基于 DeepSeek 的分类/标签建议、自然语言查询与记账、分类与标签生成,并统计 token 用量。
|
||||||
|
- title: 统计与图表
|
||||||
|
details: 月度收支汇总、分类占比、收支趋势、支付方式占比,支持多维度筛选与自定义时间范围。
|
||||||
|
- title: 定时提醒与通知
|
||||||
|
details: 按每天/每周/每月周期提醒导入新账单,支持邮箱 SMTP 与 Server 酱多渠道推送。
|
||||||
|
- title: 邮箱账单自动拉取
|
||||||
|
details: 通过 IMAP IDLE 监听邮箱,新账单邮件到达后自动拉取附件,先预览再确认导入。
|
||||||
|
- title: 系统日志
|
||||||
|
details: 接口访问日志、登录日志与后端运行日志在线查看,运行日志支持 SSE 实时推送。
|
||||||
|
---
|
||||||
|
|
||||||
|
## 这是什么
|
||||||
|
|
||||||
|
收支管理系统是一个自托管的个人记账应用,帮助你把分散在支付宝、微信、银行等平台的账单汇总到一处,统一分类、打标签、统计收支,并通过 AI 与自动化减少手工整理的工作量。
|
||||||
|
|
||||||
|
> **在线体验**:[https://finance.rosercode.cn/](https://finance.rosercode.cn/)(演示账号 `demo` / `demo123456`,使用独立的 SQLite 演示库与虚构数据)
|
||||||
|
|
||||||
|
- **后端**:Spring Boot 3.3.6 + Java 21 + MyBatis-Plus,支持 SQLite / MySQL
|
||||||
|
- **前端**:Vue 3 + TypeScript + Vite + Naive UI + ECharts
|
||||||
|
- **账单解析**:EasyExcel + PDFBox + Zip4j
|
||||||
|
- **AI**:DeepSeek
|
||||||
|
|
||||||
|
## 从哪里开始
|
||||||
|
|
||||||
|
### 使用文档
|
||||||
|
|
||||||
|
- 想开始使用:[快速开始](/guide/getting-started)
|
||||||
|
- 想知道各类账单怎么导出:[账单类型与获取](/guide/bills/overview)
|
||||||
|
- 想了解各功能怎么用:[统计面板](/guide/dashboard) 起逐页阅读
|
||||||
|
- 遇到问题:[常见问题](/guide/faq)
|
||||||
|
|
||||||
|
### 开发文档
|
||||||
|
|
||||||
|
- 想了解内部设计:[整体架构](/dev/architecture)
|
||||||
|
- 想参与开发:[项目结构](/dev/project-structure)、[开发环境与规范](/dev/development)
|
||||||
|
- 想自行部署:[构建与部署](/dev/build-deploy)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "finance-app-docs",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"docs:dev": "vitepress dev",
|
||||||
|
"docs:build": "vitepress build",
|
||||||
|
"docs:preview": "vitepress preview",
|
||||||
|
"capture": "tsx scripts/ts/capture-all.ts",
|
||||||
|
"capture:pages": "tsx scripts/ts/capture-pages.ts",
|
||||||
|
"capture:elements": "tsx scripts/ts/capture-elements.ts",
|
||||||
|
"seed": "tsx scripts/ts/seed-data.ts",
|
||||||
|
"seed:rules": "tsx scripts/ts/seed-rules.ts",
|
||||||
|
"make:bills": "tsx scripts/ts/make-fake-bills.ts",
|
||||||
|
"refs:search": "tsx scripts/ts/search-refs.ts",
|
||||||
|
"refs:fetch": "tsx scripts/ts/fetch-refs.ts",
|
||||||
|
"test:browser": "tsx scripts/ts/test-browser.ts",
|
||||||
|
"oss:upload": "tsx scripts/ts/upload-oss.ts",
|
||||||
|
"typecheck:scripts": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ali-oss": "^6.23.3",
|
||||||
|
"@types/node": "^26.6.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vitepress": "^1.6.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@alicloud/sts20150401": "^1.2.0",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"playwright-core": "^1.63.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||||
|
<!-- 卷尾巴 -->
|
||||||
|
<path d="M6 24c-1-2 0-5 2-5s2 3 0 4-3 0-3-2" stroke="#863bff" stroke-width="2" stroke-linecap="round" fill="none"/>
|
||||||
|
<!-- 身体 -->
|
||||||
|
<ellipse cx="22" cy="28" rx="13" ry="10" fill="#863bff"/>
|
||||||
|
<!-- 头部 -->
|
||||||
|
<ellipse cx="36" cy="26" rx="6" ry="6.5" fill="#863bff"/>
|
||||||
|
<!-- 鼻子 -->
|
||||||
|
<ellipse cx="41" cy="27" rx="3" ry="2.5" fill="#6d28d9"/>
|
||||||
|
<!-- 鼻孔 -->
|
||||||
|
<circle cx="40.2" cy="26.8" r="0.6" fill="#fff"/>
|
||||||
|
<circle cx="42" cy="26.8" r="0.6" fill="#fff"/>
|
||||||
|
<!-- 左耳 -->
|
||||||
|
<path d="M27 20 L29 13 L33 19 Z" fill="#6d28d9"/>
|
||||||
|
<!-- 右耳 -->
|
||||||
|
<path d="M33 18 L35 11 L38 17 Z" fill="#6d28d9"/>
|
||||||
|
<!-- 投币口 -->
|
||||||
|
<rect x="17" y="17.5" width="10" height="2" rx="1" fill="#6d28d9"/>
|
||||||
|
<!-- 眼睛 -->
|
||||||
|
<circle cx="34" cy="24" r="1.5" fill="#fff"/>
|
||||||
|
<circle cx="34" cy="24" r="0.7" fill="#333"/>
|
||||||
|
<!-- 四条腿 -->
|
||||||
|
<rect x="12" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||||
|
<rect x="18" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||||
|
<rect x="25" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||||
|
<rect x="31" y="36" width="4" height="6" rx="2" fill="#863bff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#36ad6a"/>
|
||||||
|
<stop offset="1" stop-color="#18a058"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="6" y="14" width="52" height="36" rx="6" fill="url(#g)"/>
|
||||||
|
<rect x="6" y="22" width="52" height="6" fill="#ffffff" opacity="0.25"/>
|
||||||
|
<circle cx="46" cy="40" r="4" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 472 B |
@@ -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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"types": ["node"],
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["scripts/ts/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user