feat: add reviewed OCR and AI document workflow
This commit is contained in:
@@ -14,3 +14,5 @@ vite.config.d.ts
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config/local.providers.toml
|
||||
tupian/
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# 小白记账:本地第三方服务配置模板
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 复制本文件并命名为 local.providers.toml(项目已配置为不提交该文件)。
|
||||
# 2. 只在复制出的文件中填写真实密钥,不要修改本示例文件。
|
||||
# 3. enabled 保持 false,填写完整并接入测试功能后再改为 true。
|
||||
#
|
||||
# 注意:当前文件结构仅用于本地开发测试。正式的客户端/服务端版本中,
|
||||
# 百度 OCR 与 LLM 密钥必须存放在服务端,不能随安装包分发,也不能由客户端读取。
|
||||
|
||||
[baidu_ocr]
|
||||
enabled = false
|
||||
|
||||
# 推荐使用 api_key_secret,由程序换取和刷新短期 access token。
|
||||
# 如果你拿到的只有现成 token,可改为 access_token。
|
||||
auth_mode = "api_key_secret"
|
||||
api_key = ""
|
||||
secret_key = ""
|
||||
access_token = ""
|
||||
|
||||
# 先保留默认值;接入时会根据你的百度 OCR 产品权限确认具体接口。
|
||||
service = "mixed_invoice"
|
||||
request_timeout_seconds = 30
|
||||
|
||||
[llm]
|
||||
enabled = false
|
||||
|
||||
# 例如兼容 OpenAI API 的服务地址;不要在结尾填写具体模型名。
|
||||
base_url = ""
|
||||
api_key = ""
|
||||
model = ""
|
||||
|
||||
# 当前计划首先支持 openai_compatible。若服务商协议不同,接入时再增加适配器。
|
||||
protocol = "openai_compatible"
|
||||
request_timeout_seconds = 60
|
||||
|
||||
# 只控制本地调试日志,任何情况下都不会记录密钥、票据全文或完整模型输入。
|
||||
[diagnostics]
|
||||
log_level = "info"
|
||||
redact_sensitive_data = true
|
||||
@@ -63,3 +63,15 @@
|
||||
- 桌面底层桥接:保持少量 Rust 代码,不在其中堆积业务规则
|
||||
|
||||
当前仓库首先实现可运行的界面与纯领域逻辑;本地数据库、Tauri 容器和云端服务按阶段接入,不用模拟实现冒充生产能力。
|
||||
|
||||
## OCR 与 LLM 迁移边界
|
||||
|
||||
当前纯本地测试版通过 `providers` 适配层访问百度 OCR 和 OpenAI 兼容 LLM。`documents` 模块只依赖统一的识别与建议函数,不读取供应商密钥,也不拼装 HTTP 请求。这样迁移到服务端时,可将 `providers` 的实现移动到服务端,客户端改为调用小白记账版本化 API,票据复核页面与字段校验规则无需重写。
|
||||
|
||||
客户端与服务端分离后遵守以下边界:
|
||||
|
||||
- 供应商密钥只存在服务端密钥管理系统或受保护环境变量中。
|
||||
- 客户端只持有用户登录后的短期会话凭据,不持有 OCR 或 LLM 密钥。
|
||||
- 服务端按经营主体做鉴权、限流、调用审计和数据隔离。
|
||||
- OCR 与 LLM 的响应都先经过服务端结构校验;客户端仍保留金额、日期和枚举白名单等确定性校验。
|
||||
- AI 只生成建议,不能直接写入账簿、修改税额或代替人工确认。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# 本地 OCR 与 LLM 配置
|
||||
|
||||
## 文件用途
|
||||
|
||||
- `config/local.providers.example.toml` 是字段说明模板,可以提交到 Git,不含真实密钥。
|
||||
- `config/local.providers.toml` 是本机实际配置,已被 `.gitignore` 排除,禁止提交或发送。
|
||||
- 当前配置只服务于纯本地开发测试;票据中心会在本地读取配置并调用服务,正式客户端不会打包这份文件。
|
||||
|
||||
## 填写规则
|
||||
|
||||
百度 OCR 优先填写 `api_key` 和 `secret_key`,并保持 `auth_mode = "api_key_secret"`。程序接入后将自行获取短期 access token,不长期保存换取到的 token。只有在服务商明确仅提供现成 token 时,才填写 `access_token` 并把认证方式改为 `access_token`。
|
||||
|
||||
LLM 需要填写 `base_url`、`api_key` 和 `model`。如果接口不是 OpenAI 兼容协议,先不要启用,接入时需要按服务商官方协议增加独立适配器。
|
||||
|
||||
首次填写时两个 `enabled` 都保持 `false`,逐项确认地址和密钥后再启用。不要在聊天、截图、报错信息、文档或 Git 提交中展示真实密钥。
|
||||
|
||||
应用只向百度 OCR 发送当前主动选择的图片;发票原图、OCR 原始响应、归一化字段、AI 原始响应和人工复核记录均写入本地 SQLCipher 加密账本。LLM 只接收归一化后的必要票据信息,不接收本地数据库或其他票据。
|
||||
|
||||
## 后续迁移原则
|
||||
|
||||
客户端/服务端版本中,百度 OCR 与 LLM 的供应商密钥只保存在服务端的密钥管理系统或受保护环境变量中。Windows 客户端不再保存这些密钥,只连接小白记账服务端,并使用用户登录后取得的短期会话凭据。
|
||||
|
||||
迁移时将保留统一的 OCR、LLM 适配器接口,因此业务功能不需要因密钥位置变化而重写;主要变化是把当前本地调用实现移动到服务端 API 后面。
|
||||
@@ -0,0 +1,42 @@
|
||||
# 智能票据闭环
|
||||
|
||||
## 当前能力
|
||||
|
||||
票据中心已经形成“主动上传 → 加密存档 → OCR 识别 → 字段核对 → AI 建议 → 人工确认”的本地闭环。发票与收付款流水是两类不同证据,因此人工确认票据不会自动生成收支流水,避免把“取得发票”误当成“已经付款”。后续应通过流水匹配或单独的凭证流程完成入账。
|
||||
|
||||
当前开放增值税发票图片,单张不超过 2.5 MB,支持 PNG、JPEG、BMP 和 WebP。文件按内容哈希去重,重复选择同一图片不会重复建档。
|
||||
|
||||
## 状态与数据
|
||||
|
||||
- `documents`:保存加密原图、内容哈希、文件类型和处理状态。
|
||||
- `ocr_runs`:追加保存每次识别的供应商、耗时、状态,以及服务可解析时的原始响应。
|
||||
- `document_extractions`:保存经过归一化的可编辑字段,金额统一使用整数分。
|
||||
- `ai_runs`:追加保存模型调用状态、服务可解析时的原始响应、校验后的建议和置信度。
|
||||
- `document_reviews`:追加保存每次人工确认时的字段快照与时间。
|
||||
|
||||
原始 OCR 结果不会因人工修改而被覆盖。失败调用同样留下审计状态,但日志和界面不显示供应商密钥。
|
||||
|
||||
## 安全阀
|
||||
|
||||
- 文件签名与大小在发出网络请求前校验。
|
||||
- OCR 只发送用户当前主动选择的图片。
|
||||
- LLM 只接收识别后的必要字段,不读取整本账簿。
|
||||
- LLM 返回必须是结构化 JSON,并通过方向、分类白名单、金额一致性、日期和置信度校验。
|
||||
- 任何解析或校验失败都只显示失败,不产生记账结果。
|
||||
- AI 建议与人工确认是两个独立动作,AI 永远不能自动入账。
|
||||
|
||||
## 服务端迁移
|
||||
|
||||
正式客户端发布前,把 `providers` 中的供应商 HTTP 调用移至服务端。客户端保留上传、复核和状态展示,通过小白记账 API 获取识别结果与建议。服务端负责供应商密钥、租户隔离、限流、重试和调用成本审计。
|
||||
|
||||
## 回归检查
|
||||
|
||||
每次修改票据功能至少验证:
|
||||
|
||||
1. 不支持或超限文件在本地被拒绝。
|
||||
2. 同一文件不会重复建档。
|
||||
3. OCR 金额满足“不含税金额 + 税额 = 价税合计”的允许误差。
|
||||
4. LLM 非 JSON、非法分类或金额不一致时不会产生建议。
|
||||
5. 人工修改后保存的是新复核快照,OCR 原始结果仍可追溯。
|
||||
6. 人工确认不会改变收支流水与税务计算结果。
|
||||
7. 本机真实配置、测试票据和密钥不会进入 Git 或安装包。
|
||||
Generated
+298
-2
@@ -321,6 +321,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -402,6 +419,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -859,6 +885,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1039,8 +1066,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1062,8 +1091,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1331,6 +1363,22 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -1743,6 +1791,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2363,6 +2417,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
@@ -2384,6 +2494,32 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2459,6 +2595,46 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2493,6 +2669,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
@@ -2522,6 +2712,41 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -2724,6 +2949,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2794,7 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -2922,6 +3159,12 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -3085,7 +3328,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3386,6 +3629,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -3676,6 +3929,12 @@ version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -3869,6 +4128,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -3925,6 +4194,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4155,6 +4433,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -4427,14 +4714,17 @@ dependencies = [
|
||||
name = "xiaobai-bookkeeping"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"csv",
|
||||
"getrandom 0.2.17",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"toml 0.8.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
@@ -4502,6 +4792,12 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.4"
|
||||
|
||||
@@ -13,13 +13,16 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
csv = "1.3"
|
||||
getrandom = "0.2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
|
||||
rusqlite = { version = "0.32", features = ["backup", "bundled-sqlcipher-vendored-openssl"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tauri = { version = "2", features = [] }
|
||||
toml = "0.8"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
use crate::providers::{self, ProviderFailure};
|
||||
use crate::{open_database, parse_amount_to_cents, sha256_hex};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use tauri::AppHandle;
|
||||
|
||||
const MAX_IMAGE_BYTES: usize = 2_500_000;
|
||||
const EXTRACTION_SCHEMA_VERSION: &str = "invoice-extraction-v1";
|
||||
const PROMPT_VERSION: &str = "bookkeeping-suggestion-v1";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InvoiceLineItem {
|
||||
name: String,
|
||||
amount_in_cents: Option<i64>,
|
||||
tax_rate: String,
|
||||
tax_in_cents: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InvoiceExtraction {
|
||||
invoice_type: String,
|
||||
invoice_number: String,
|
||||
invoice_date: String,
|
||||
purchaser_name: String,
|
||||
purchaser_tax_id: String,
|
||||
seller_name: String,
|
||||
seller_tax_id: String,
|
||||
total_amount_in_cents: Option<i64>,
|
||||
total_tax_in_cents: Option<i64>,
|
||||
total_with_tax_in_cents: Option<i64>,
|
||||
amount_in_words: String,
|
||||
drawer: String,
|
||||
remarks: String,
|
||||
confidence: f64,
|
||||
line_items: Vec<InvoiceLineItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BookkeepingSuggestion {
|
||||
direction: String,
|
||||
category: String,
|
||||
counterparty: String,
|
||||
occurred_on: String,
|
||||
amount_in_cents: i64,
|
||||
business_purpose: String,
|
||||
confidence: f64,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct StoredDocument {
|
||||
id: String,
|
||||
file_name: String,
|
||||
mime_type: String,
|
||||
size_bytes: i64,
|
||||
status: String,
|
||||
last_error: Option<String>,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
extraction: Option<InvoiceExtraction>,
|
||||
suggestion: Option<BookkeepingSuggestion>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct RecognizeDocumentInput {
|
||||
file_name: String,
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ConfirmDocumentInput {
|
||||
document_id: String,
|
||||
invoice_number: String,
|
||||
invoice_date: String,
|
||||
purchaser_name: String,
|
||||
purchaser_tax_id: String,
|
||||
seller_name: String,
|
||||
seller_tax_id: String,
|
||||
total_amount_in_cents: Option<i64>,
|
||||
total_tax_in_cents: Option<i64>,
|
||||
total_with_tax_in_cents: Option<i64>,
|
||||
}
|
||||
|
||||
fn one_word(result: &Value, name: &str) -> String {
|
||||
result
|
||||
.get(name)
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.and_then(|item| item.get("word"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn row_words(result: &Value, name: &str) -> BTreeMap<String, String> {
|
||||
result
|
||||
.get(name)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
let word = item.get("word")?.as_str()?.trim();
|
||||
if word.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let row = item
|
||||
.get("row")
|
||||
.and_then(|value| value.as_str().map(str::to_string).or_else(|| value.as_i64().map(|number| number.to_string())))
|
||||
.unwrap_or_else(|| (index + 1).to_string());
|
||||
Some((row, word.to_string()))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn optional_cents(value: &str) -> Option<i64> {
|
||||
if value.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_amount_to_cents(value).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_date(value: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.replace('年', "-")
|
||||
.replace('月', "-")
|
||||
.replace('日', "")
|
||||
.replace('/', "-")
|
||||
.replace('.', "-");
|
||||
let parts: Vec<_> = normalized.split('-').collect();
|
||||
if parts.len() == 3 {
|
||||
if let (Ok(year), Ok(month), Ok(day)) = (
|
||||
parts[0].parse::<u16>(),
|
||||
parts[1].parse::<u8>(),
|
||||
parts[2].parse::<u8>(),
|
||||
) {
|
||||
if (2000..=2100).contains(&year) && (1..=12).contains(&month) && (1..=31).contains(&day) {
|
||||
return format!("{year:04}-{month:02}-{day:02}");
|
||||
}
|
||||
}
|
||||
}
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
fn build_line_items(result: &Value) -> Vec<InvoiceLineItem> {
|
||||
let names = row_words(result, "CommodityName");
|
||||
let amounts = row_words(result, "CommodityAmount");
|
||||
let rates = row_words(result, "CommodityTaxRate");
|
||||
let taxes = row_words(result, "CommodityTax");
|
||||
let mut rows = BTreeMap::<String, InvoiceLineItem>::new();
|
||||
for row in names.keys().chain(amounts.keys()).chain(rates.keys()).chain(taxes.keys()) {
|
||||
rows.entry(row.clone()).or_insert_with(|| InvoiceLineItem {
|
||||
name: String::new(),
|
||||
amount_in_cents: None,
|
||||
tax_rate: String::new(),
|
||||
tax_in_cents: None,
|
||||
});
|
||||
}
|
||||
for (row, item) in &mut rows {
|
||||
item.name = names.get(row).cloned().unwrap_or_default();
|
||||
item.amount_in_cents = amounts.get(row).and_then(|value| optional_cents(value));
|
||||
item.tax_rate = rates.get(row).cloned().unwrap_or_default();
|
||||
item.tax_in_cents = taxes.get(row).and_then(|value| optional_cents(value));
|
||||
}
|
||||
rows.into_values().collect()
|
||||
}
|
||||
|
||||
fn parse_baidu_invoice(response: &Value) -> Result<InvoiceExtraction, String> {
|
||||
let invoice = response
|
||||
.get("words_result")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|items| items.first())
|
||||
.ok_or_else(|| "没有识别到可用票据".to_string())?;
|
||||
let invoice_type_code = invoice.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if invoice_type_code != "vat_invoice" {
|
||||
return Err(format!("当前版本只开放增值税发票字段核对,识别类型为 {invoice_type_code}"));
|
||||
}
|
||||
let result = invoice.get("result").ok_or_else(|| "票据结果缺少结构化字段".to_string())?;
|
||||
let type_name = one_word(result, "InvoiceType");
|
||||
let title = one_word(result, "InvoiceTypeOrg");
|
||||
Ok(InvoiceExtraction {
|
||||
invoice_type: if type_name.is_empty() { title } else { type_name },
|
||||
invoice_number: one_word(result, "InvoiceNum"),
|
||||
invoice_date: normalized_date(&one_word(result, "InvoiceDate")),
|
||||
purchaser_name: one_word(result, "PurchaserName"),
|
||||
purchaser_tax_id: one_word(result, "PurchaserRegisterNum"),
|
||||
seller_name: one_word(result, "SellerName"),
|
||||
seller_tax_id: one_word(result, "SellerRegisterNum"),
|
||||
total_amount_in_cents: optional_cents(&one_word(result, "TotalAmount")),
|
||||
total_tax_in_cents: optional_cents(&one_word(result, "TotalTax")),
|
||||
total_with_tax_in_cents: optional_cents(&one_word(result, "AmountInFiguers")),
|
||||
amount_in_words: one_word(result, "AmountInWords"),
|
||||
drawer: one_word(result, "NoteDrawer"),
|
||||
remarks: one_word(result, "Remarks"),
|
||||
confidence: invoice.get("probability").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
line_items: build_line_items(result),
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_image_signature(mime_type: &str, bytes: &[u8]) -> bool {
|
||||
match mime_type {
|
||||
"image/png" => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
|
||||
"image/jpeg" => bytes.starts_with(&[0xff, 0xd8, 0xff]),
|
||||
"image/bmp" => bytes.starts_with(b"BM"),
|
||||
"image/webp" => bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP",
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_upload(input: &RecognizeDocumentInput) -> Result<(), String> {
|
||||
let name_length = input.file_name.trim().chars().count();
|
||||
if !(1..=180).contains(&name_length) {
|
||||
return Err("文件名长度不正确".to_string());
|
||||
}
|
||||
if input.bytes.is_empty() || input.bytes.len() > MAX_IMAGE_BYTES {
|
||||
return Err("图片应小于 2.5 MB".to_string());
|
||||
}
|
||||
if !valid_image_signature(&input.mime_type, &input.bytes) {
|
||||
return Err("图片格式与文件内容不一致,当前支持 PNG、JPG、BMP 和 WebP".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_json<T: for<'de> Deserialize<'de>>(value: Option<String>) -> Option<T> {
|
||||
value.and_then(|text| serde_json::from_str(&text).ok())
|
||||
}
|
||||
|
||||
fn document_from_connection(connection: &Connection, id: &str) -> Result<StoredDocument, String> {
|
||||
connection
|
||||
.query_row(
|
||||
"
|
||||
SELECT d.id, d.file_name, d.mime_type, d.size_bytes, d.status, d.last_error,
|
||||
d.created_at, d.updated_at,
|
||||
(SELECT reviewed_json FROM document_reviews WHERE document_id = d.id ORDER BY id DESC LIMIT 1),
|
||||
(SELECT normalized_json FROM document_extractions WHERE document_id = d.id ORDER BY id DESC LIMIT 1),
|
||||
(SELECT normalized_json FROM ai_runs WHERE document_id = d.id AND status = 'succeeded' ORDER BY id DESC LIMIT 1)
|
||||
FROM documents d WHERE d.id = ?1
|
||||
",
|
||||
[id],
|
||||
|row| {
|
||||
let reviewed: Option<String> = row.get(8)?;
|
||||
let extracted: Option<String> = row.get(9)?;
|
||||
let suggestion: Option<String> = row.get(10)?;
|
||||
Ok(StoredDocument {
|
||||
id: row.get(0)?,
|
||||
file_name: row.get(1)?,
|
||||
mime_type: row.get(2)?,
|
||||
size_bytes: row.get(3)?,
|
||||
status: row.get(4)?,
|
||||
last_error: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
extraction: parse_json(reviewed.or(extracted)),
|
||||
suggestion: parse_json(suggestion),
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("读取票据失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_documents(app: AppHandle) -> Result<Vec<StoredDocument>, String> {
|
||||
let connection = open_database(&app)?;
|
||||
let mut statement = connection
|
||||
.prepare("SELECT id FROM documents ORDER BY created_at DESC, id DESC")
|
||||
.map_err(|error| format!("读取票据列表失败:{error}"))?;
|
||||
let ids = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|error| format!("读取票据列表失败:{error}"))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| format!("读取票据列表失败:{error}"))?;
|
||||
ids.iter().map(|id| document_from_connection(&connection, id)).collect()
|
||||
}
|
||||
|
||||
fn mark_ocr_failure(app: &AppHandle, document_id: &str, run_id: i64, failure: &ProviderFailure) {
|
||||
if let Ok(connection) = open_database(app) {
|
||||
let _ = connection.execute(
|
||||
"UPDATE ocr_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||
params![failure.code, run_id],
|
||||
);
|
||||
let _ = connection.execute(
|
||||
"UPDATE documents SET status = 'failed', last_error = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||
params![failure.message, document_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn recognize_document(
|
||||
app: AppHandle,
|
||||
input: RecognizeDocumentInput,
|
||||
) -> Result<StoredDocument, String> {
|
||||
validate_upload(&input)?;
|
||||
let content_hash = sha256_hex(&input.bytes);
|
||||
let document_id = format!("DOC-{}", &content_hash[..20]);
|
||||
let connection = open_database(&app)?;
|
||||
connection
|
||||
.execute(
|
||||
"
|
||||
INSERT OR IGNORE INTO documents(
|
||||
id, content_hash, file_name, mime_type, size_bytes, encrypted_content, status
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'stored')
|
||||
",
|
||||
params![&document_id, &content_hash, input.file_name.trim(), &input.mime_type, input.bytes.len() as i64, &input.bytes],
|
||||
)
|
||||
.map_err(|error| format!("保存加密票据失败:{error}"))?;
|
||||
let existing = document_from_connection(&connection, &document_id)?;
|
||||
if existing.extraction.is_some() && matches!(existing.status.as_str(), "needs_review" | "confirmed") {
|
||||
return Ok(existing);
|
||||
}
|
||||
connection
|
||||
.execute(
|
||||
"UPDATE documents SET status = 'processing', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
[&document_id],
|
||||
)
|
||||
.map_err(|error| format!("更新票据状态失败:{error}"))?;
|
||||
connection
|
||||
.execute(
|
||||
"INSERT INTO ocr_runs(document_id, provider, operation, request_version, status) VALUES (?1, 'baidu', 'multiple_invoice', '2026-06', 'running')",
|
||||
[&document_id],
|
||||
)
|
||||
.map_err(|error| format!("创建 OCR 审计记录失败:{error}"))?;
|
||||
let run_id = connection.last_insert_rowid();
|
||||
drop(connection);
|
||||
|
||||
let config = providers::load_config()?;
|
||||
let mime_type = input.mime_type;
|
||||
let bytes = input.bytes;
|
||||
let task = tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::recognize_financial_document(&config, &mime_type, &bytes)
|
||||
});
|
||||
let response = match task.await {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(failure)) => {
|
||||
mark_ocr_failure(&app, &document_id, run_id, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
Err(_) => {
|
||||
let failure = ProviderFailure { code: "ocr_worker".to_string(), message: "OCR 任务意外中止,请重试".to_string() };
|
||||
mark_ocr_failure(&app, &document_id, run_id, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
};
|
||||
let extraction = match parse_baidu_invoice(&response.raw) {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
let failure = ProviderFailure { code: "unsupported_extraction".to_string(), message };
|
||||
mark_ocr_failure(&app, &document_id, run_id, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
};
|
||||
let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 OCR 原始响应".to_string())?;
|
||||
let normalized_json = serde_json::to_string(&extraction).map_err(|_| "无法保存 OCR 结构化结果".to_string())?;
|
||||
let mut connection = open_database(&app)?;
|
||||
let transaction = connection.transaction().map_err(|error| format!("无法提交 OCR 结果:{error}"))?;
|
||||
transaction.execute(
|
||||
"UPDATE ocr_runs SET status = 'succeeded', raw_response_json = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||
params![raw_json, run_id],
|
||||
).map_err(|error| format!("保存 OCR 审计记录失败:{error}"))?;
|
||||
transaction.execute(
|
||||
"
|
||||
INSERT INTO document_extractions(
|
||||
document_id, ocr_run_id, schema_version, invoice_type, invoice_number, invoice_date,
|
||||
purchaser_name, purchaser_tax_id, seller_name, seller_tax_id, total_amount_in_cents,
|
||||
total_tax_in_cents, total_with_tax_in_cents, confidence, normalized_json
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
||||
",
|
||||
params![
|
||||
document_id, run_id, EXTRACTION_SCHEMA_VERSION, extraction.invoice_type,
|
||||
extraction.invoice_number, extraction.invoice_date, extraction.purchaser_name,
|
||||
extraction.purchaser_tax_id, extraction.seller_name, extraction.seller_tax_id,
|
||||
extraction.total_amount_in_cents, extraction.total_tax_in_cents,
|
||||
extraction.total_with_tax_in_cents, extraction.confidence, normalized_json,
|
||||
],
|
||||
).map_err(|error| format!("保存 OCR 结构化结果失败:{error}"))?;
|
||||
transaction.execute(
|
||||
"UPDATE documents SET status = 'needs_review', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
[&document_id],
|
||||
).map_err(|error| format!("更新票据状态失败:{error}"))?;
|
||||
transaction.commit().map_err(|error| format!("提交 OCR 结果失败:{error}"))?;
|
||||
document_from_connection(&connection, &document_id)
|
||||
}
|
||||
|
||||
fn valid_iso_date(value: &str) -> bool {
|
||||
let bytes = value.as_bytes();
|
||||
if bytes.len() != 10
|
||||
|| bytes[4] != b'-'
|
||||
|| bytes[7] != b'-'
|
||||
|| bytes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, value)| index != 4 && index != 7 && !value.is_ascii_digit())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let year = value[0..4].parse::<u16>().ok();
|
||||
let month = value[5..7].parse::<u8>().ok();
|
||||
let day = value[8..10].parse::<u8>().ok();
|
||||
matches!((year, month, day), (Some(2000..=2100), Some(1..=12), Some(1..=31)))
|
||||
}
|
||||
|
||||
fn validate_suggestion(value: Value, extraction: &InvoiceExtraction) -> Result<BookkeepingSuggestion, String> {
|
||||
let suggestion: BookkeepingSuggestion = serde_json::from_value(value)
|
||||
.map_err(|_| "LLM 建议缺少必要字段或字段类型错误".to_string())?;
|
||||
let categories = [
|
||||
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅",
|
||||
"业务招待", "平台服务费", "税费支出", "其他经营支出",
|
||||
];
|
||||
if !matches!(suggestion.direction.as_str(), "income" | "expense") {
|
||||
return Err("LLM 建议的收支方向无效".to_string());
|
||||
}
|
||||
if !categories.contains(&suggestion.category.as_str()) {
|
||||
return Err("LLM 建议的分类不在允许范围内".to_string());
|
||||
}
|
||||
if !valid_iso_date(&suggestion.occurred_on) {
|
||||
return Err("LLM 建议的业务日期格式无效".to_string());
|
||||
}
|
||||
if extraction.total_with_tax_in_cents != Some(suggestion.amount_in_cents) {
|
||||
return Err("LLM 建议金额与票据价税合计不一致".to_string());
|
||||
}
|
||||
if suggestion.counterparty.trim().is_empty() || suggestion.counterparty.chars().count() > 100 {
|
||||
return Err("LLM 建议的交易对方无效".to_string());
|
||||
}
|
||||
if suggestion.business_purpose.chars().count() > 40 || suggestion.reason.chars().count() > 80 {
|
||||
return Err("LLM 建议的说明过长".to_string());
|
||||
}
|
||||
if !(0.0..=1.0).contains(&suggestion.confidence) {
|
||||
return Err("LLM 建议置信度无效".to_string());
|
||||
}
|
||||
Ok(suggestion)
|
||||
}
|
||||
|
||||
fn suggestion_input(extraction: &InvoiceExtraction) -> Value {
|
||||
json!({
|
||||
"invoiceType": extraction.invoice_type,
|
||||
"invoiceDate": extraction.invoice_date,
|
||||
"purchaserName": extraction.purchaser_name,
|
||||
"sellerName": extraction.seller_name,
|
||||
"totalWithTaxInCents": extraction.total_with_tax_in_cents,
|
||||
"items": extraction.line_items.iter().map(|item| &item.name).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_ai_failure(app: &AppHandle, run_id: i64, failure: &ProviderFailure) {
|
||||
if let Ok(connection) = open_database(app) {
|
||||
let _ = connection.execute(
|
||||
"UPDATE ai_runs SET status = 'failed', error_code = ?1, completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
|
||||
params![failure.code, run_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_ai_validation_failure(app: &AppHandle, run_id: i64, raw: &Value, failure: &ProviderFailure) {
|
||||
if let Ok(connection) = open_database(app) {
|
||||
let raw_json = serde_json::to_string(raw).ok();
|
||||
let _ = connection.execute(
|
||||
"UPDATE ai_runs SET status = 'failed', response_json = ?1, error_code = ?2, completed_at = CURRENT_TIMESTAMP WHERE id = ?3",
|
||||
params![raw_json, failure.code, run_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn generate_document_suggestion(
|
||||
app: AppHandle,
|
||||
document_id: String,
|
||||
) -> Result<StoredDocument, String> {
|
||||
let connection = open_database(&app)?;
|
||||
let extraction_json: Option<String> = connection.query_row(
|
||||
"SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1",
|
||||
[&document_id],
|
||||
|row| row.get(0),
|
||||
).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?;
|
||||
let extraction: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能生成记账建议".to_string())?;
|
||||
let input = suggestion_input(&extraction);
|
||||
let input_json = serde_json::to_string(&input).map_err(|_| "无法准备 LLM 输入".to_string())?;
|
||||
let input_hash = sha256_hex(input_json.as_bytes());
|
||||
let config = providers::load_config()?;
|
||||
connection.execute(
|
||||
"INSERT INTO ai_runs(document_id, provider, model, prompt_version, input_hash, status) VALUES (?1, 'openai_compatible', ?2, ?3, ?4, 'running')",
|
||||
params![document_id, config.llm.model, PROMPT_VERSION, input_hash],
|
||||
).map_err(|error| format!("创建 LLM 审计记录失败:{error}"))?;
|
||||
let run_id = connection.last_insert_rowid();
|
||||
drop(connection);
|
||||
let task = tauri::async_runtime::spawn_blocking(move || providers::request_bookkeeping_suggestion(&config, &input));
|
||||
let response = match task.await {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(failure)) => {
|
||||
mark_ai_failure(&app, run_id, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
Err(_) => {
|
||||
let failure = ProviderFailure { code: "llm_worker".to_string(), message: "LLM 任务意外中止,请重试".to_string() };
|
||||
mark_ai_failure(&app, run_id, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
};
|
||||
let suggestion = match validate_suggestion(response.content.clone(), &extraction) {
|
||||
Ok(value) => value,
|
||||
Err(message) => {
|
||||
let failure = ProviderFailure { code: "llm_validation".to_string(), message };
|
||||
mark_ai_validation_failure(&app, run_id, &response.raw, &failure);
|
||||
return Err(failure.message);
|
||||
}
|
||||
};
|
||||
let raw_json = serde_json::to_string(&response.raw).map_err(|_| "无法保存 LLM 原始响应".to_string())?;
|
||||
let normalized_json = serde_json::to_string(&suggestion).map_err(|_| "无法保存记账建议".to_string())?;
|
||||
let connection = open_database(&app)?;
|
||||
connection.execute(
|
||||
"UPDATE ai_runs SET status = 'succeeded', model = ?1, response_json = ?2, normalized_json = ?3, completed_at = CURRENT_TIMESTAMP WHERE id = ?4",
|
||||
params![response.model, raw_json, normalized_json, run_id],
|
||||
).map_err(|error| format!("保存 LLM 建议失败:{error}"))?;
|
||||
document_from_connection(&connection, &document_id)
|
||||
}
|
||||
|
||||
fn validate_review(input: &ConfirmDocumentInput, original: &InvoiceExtraction) -> Result<InvoiceExtraction, String> {
|
||||
let invoice_number = input.invoice_number.trim().to_string();
|
||||
let invoice_date = input.invoice_date.trim().to_string();
|
||||
let seller_name = input.seller_name.trim().to_string();
|
||||
if invoice_number.is_empty() || invoice_number.chars().count() > 40 {
|
||||
return Err("请核对并填写有效的发票号码".to_string());
|
||||
}
|
||||
if !valid_iso_date(&invoice_date) {
|
||||
return Err("开票日期应为 YYYY-MM-DD".to_string());
|
||||
}
|
||||
if seller_name.is_empty() || seller_name.chars().count() > 100 {
|
||||
return Err("请核对并填写销售方名称".to_string());
|
||||
}
|
||||
for amount in [input.total_amount_in_cents, input.total_tax_in_cents, input.total_with_tax_in_cents].into_iter().flatten() {
|
||||
if amount < 0 {
|
||||
return Err("票据合计金额不能为负数".to_string());
|
||||
}
|
||||
}
|
||||
if let (Some(amount), Some(tax), Some(total)) = (
|
||||
input.total_amount_in_cents,
|
||||
input.total_tax_in_cents,
|
||||
input.total_with_tax_in_cents,
|
||||
) {
|
||||
if (amount + tax - total).abs() > 1 {
|
||||
return Err("不含税金额与税额之和不等于价税合计".to_string());
|
||||
}
|
||||
}
|
||||
if input.total_with_tax_in_cents.is_none() {
|
||||
return Err("请核对价税合计".to_string());
|
||||
}
|
||||
let mut reviewed = original.clone();
|
||||
reviewed.invoice_number = invoice_number;
|
||||
reviewed.invoice_date = invoice_date;
|
||||
reviewed.purchaser_name = input.purchaser_name.trim().to_string();
|
||||
reviewed.purchaser_tax_id = input.purchaser_tax_id.trim().to_string();
|
||||
reviewed.seller_name = seller_name;
|
||||
reviewed.seller_tax_id = input.seller_tax_id.trim().to_string();
|
||||
reviewed.total_amount_in_cents = input.total_amount_in_cents;
|
||||
reviewed.total_tax_in_cents = input.total_tax_in_cents;
|
||||
reviewed.total_with_tax_in_cents = input.total_with_tax_in_cents;
|
||||
reviewed.confidence = 1.0;
|
||||
Ok(reviewed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn confirm_document(
|
||||
app: AppHandle,
|
||||
input: ConfirmDocumentInput,
|
||||
) -> Result<StoredDocument, String> {
|
||||
let mut connection = open_database(&app)?;
|
||||
let extraction_json: Option<String> = connection.query_row(
|
||||
"SELECT normalized_json FROM document_extractions WHERE document_id = ?1 ORDER BY id DESC LIMIT 1",
|
||||
[&input.document_id],
|
||||
|row| row.get(0),
|
||||
).optional().map_err(|error| format!("读取票据识别结果失败:{error}"))?;
|
||||
let original: InvoiceExtraction = parse_json(extraction_json).ok_or_else(|| "票据尚未完成 OCR,不能确认".to_string())?;
|
||||
let reviewed = validate_review(&input, &original)?;
|
||||
let reviewed_json = serde_json::to_string(&reviewed).map_err(|_| "无法保存人工核对结果".to_string())?;
|
||||
let transaction = connection.transaction().map_err(|error| format!("无法开始票据确认:{error}"))?;
|
||||
transaction.execute(
|
||||
"INSERT INTO document_reviews(document_id, reviewed_json, actor) VALUES (?1, ?2, 'user')",
|
||||
params![input.document_id, reviewed_json],
|
||||
).map_err(|error| format!("保存人工核对记录失败:{error}"))?;
|
||||
transaction.execute(
|
||||
"UPDATE documents SET status = 'confirmed', last_error = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?1",
|
||||
[&input.document_id],
|
||||
).map_err(|error| format!("更新票据状态失败:{error}"))?;
|
||||
transaction.commit().map_err(|error| format!("提交票据确认失败:{error}"))?;
|
||||
document_from_connection(&connection, &input.document_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_baidu_vat_invoice_into_integer_cents() {
|
||||
let value = json!({
|
||||
"words_result_num": 1,
|
||||
"words_result": [{
|
||||
"type": "vat_invoice",
|
||||
"probability": 0.959,
|
||||
"result": {
|
||||
"InvoiceType": [{"word": "电子发票(普通发票)"}],
|
||||
"InvoiceNum": [{"word": "26427000000291271373"}],
|
||||
"InvoiceDate": [{"word": "2026年03月16日"}],
|
||||
"SellerName": [{"word": "武汉测试贸易有限公司"}],
|
||||
"TotalAmount": [{"word": "56.48"}],
|
||||
"TotalTax": [{"word": "7.33"}],
|
||||
"AmountInFiguers": [{"word": "63.81"}],
|
||||
"CommodityName": [{"row": "1", "word": "清洁用品"}],
|
||||
"CommodityAmount": [{"row": "1", "word": "56.48"}],
|
||||
"CommodityTaxRate": [{"row": "1", "word": "13%"}],
|
||||
"CommodityTax": [{"row": "1", "word": "7.33"}]
|
||||
}
|
||||
}]
|
||||
});
|
||||
let parsed = parse_baidu_invoice(&value).unwrap();
|
||||
assert_eq!(parsed.invoice_date, "2026-03-16");
|
||||
assert_eq!(parsed.total_with_tax_in_cents, Some(6_381));
|
||||
assert_eq!(parsed.line_items[0].tax_in_cents, Some(733));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_llm_amounts_that_differ_from_invoice() {
|
||||
let extraction = InvoiceExtraction {
|
||||
invoice_type: "电子普通发票".to_string(),
|
||||
invoice_number: "1".to_string(),
|
||||
invoice_date: "2026-03-16".to_string(),
|
||||
purchaser_name: String::new(),
|
||||
purchaser_tax_id: String::new(),
|
||||
seller_name: "测试商户".to_string(),
|
||||
seller_tax_id: String::new(),
|
||||
total_amount_in_cents: Some(5_648),
|
||||
total_tax_in_cents: Some(733),
|
||||
total_with_tax_in_cents: Some(6_381),
|
||||
amount_in_words: String::new(),
|
||||
drawer: String::new(),
|
||||
remarks: String::new(),
|
||||
confidence: 0.95,
|
||||
line_items: vec![],
|
||||
};
|
||||
let candidate = json!({
|
||||
"direction": "expense",
|
||||
"category": "进货成本",
|
||||
"counterparty": "测试商户",
|
||||
"occurredOn": "2026-03-16",
|
||||
"amountInCents": 6380,
|
||||
"businessPurpose": "购买耗材",
|
||||
"confidence": 0.8,
|
||||
"reason": "票据内容显示为经营耗材"
|
||||
});
|
||||
assert!(validate_suggestion(candidate, &extraction).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_file_signature_instead_of_only_trusting_mime_type() {
|
||||
assert!(valid_image_signature("image/png", b"\x89PNG\r\n\x1a\nrest"));
|
||||
assert!(!valid_image_signature("image/png", b"not an image"));
|
||||
}
|
||||
}
|
||||
+98
-2
@@ -1,3 +1,6 @@
|
||||
mod documents;
|
||||
mod providers;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -329,6 +332,94 @@ fn migrate(connection: &Connection) -> Result<(), String> {
|
||||
ON transaction_corrections(transaction_id, id DESC);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (4);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
content_hash TEXT NOT NULL UNIQUE,
|
||||
file_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL CHECK (size_bytes > 0),
|
||||
encrypted_content BLOB NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('stored', 'processing', 'needs_review', 'confirmed', 'failed')),
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocr_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
request_version TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||
raw_response_json TEXT,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ocr_runs_document
|
||||
ON ocr_runs(document_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_extractions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
ocr_run_id INTEGER NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
invoice_type TEXT NOT NULL,
|
||||
invoice_number TEXT NOT NULL,
|
||||
invoice_date TEXT NOT NULL,
|
||||
purchaser_name TEXT NOT NULL,
|
||||
purchaser_tax_id TEXT NOT NULL,
|
||||
seller_name TEXT NOT NULL,
|
||||
seller_tax_id TEXT NOT NULL,
|
||||
total_amount_in_cents INTEGER,
|
||||
total_tax_in_cents INTEGER,
|
||||
total_with_tax_in_cents INTEGER,
|
||||
confidence REAL NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
|
||||
normalized_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id),
|
||||
FOREIGN KEY (ocr_run_id) REFERENCES ocr_runs(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_extractions_latest
|
||||
ON document_extractions(document_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
prompt_version TEXT NOT NULL,
|
||||
input_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')),
|
||||
response_json TEXT,
|
||||
normalized_json TEXT,
|
||||
error_code TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_runs_document
|
||||
ON ai_runs(document_id, id DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS document_reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
reviewed_json TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (document_id) REFERENCES documents(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_document_reviews_latest
|
||||
ON document_reviews(document_id, id DESC);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (5);
|
||||
",
|
||||
)
|
||||
.map_err(|error| format!("本地账本升级失败:{error}"))?;
|
||||
@@ -1296,7 +1387,12 @@ pub fn run() {
|
||||
restore_local_backup,
|
||||
create_manual_transaction,
|
||||
correct_transaction,
|
||||
export_ledger_csv
|
||||
export_ledger_csv,
|
||||
providers::get_provider_status,
|
||||
documents::list_documents,
|
||||
documents::recognize_document,
|
||||
documents::generate_document_suggestion,
|
||||
documents::confirm_document
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("failed to start Xiaobai Bookkeeping");
|
||||
@@ -1564,7 +1660,7 @@ mod tests {
|
||||
let migration_count: i64 = connection
|
||||
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(migration_count, 4);
|
||||
assert_eq!(migration_count, 5);
|
||||
let listed = list_backups_in_directory(&directory, None).unwrap();
|
||||
assert!(!listed[0].is_valid);
|
||||
let _ = fs::remove_dir_all(directory);
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const BAIDU_TOKEN_URL: &str = "https://aip.baidubce.com/oauth/2.0/token";
|
||||
const BAIDU_MULTIPLE_INVOICE_URL: &str =
|
||||
"https://aip.baidubce.com/rest/2.0/ocr/v1/multiple_invoice";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct ProviderConfig {
|
||||
pub(crate) baidu_ocr: BaiduOcrConfig,
|
||||
pub(crate) llm: LlmConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct BaiduOcrConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) auth_mode: String,
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) secret_key: String,
|
||||
pub(crate) access_token: String,
|
||||
pub(crate) service: String,
|
||||
pub(crate) request_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct LlmConfig {
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) model: String,
|
||||
pub(crate) protocol: String,
|
||||
pub(crate) request_timeout_seconds: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderStatus {
|
||||
ocr_configured: bool,
|
||||
ocr_enabled: bool,
|
||||
ocr_provider: &'static str,
|
||||
llm_configured: bool,
|
||||
llm_enabled: bool,
|
||||
llm_provider: &'static str,
|
||||
llm_model: String,
|
||||
local_development_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ProviderFailure {
|
||||
pub(crate) code: String,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl ProviderFailure {
|
||||
fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self { code: code.into(), message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct OcrResponse {
|
||||
pub(crate) raw: Value,
|
||||
}
|
||||
|
||||
pub(crate) struct LlmResponse {
|
||||
pub(crate) raw: Value,
|
||||
pub(crate) content: Value,
|
||||
pub(crate) model: String,
|
||||
}
|
||||
|
||||
fn find_config_from(start: &Path) -> Option<PathBuf> {
|
||||
start
|
||||
.ancestors()
|
||||
.map(|directory| directory.join("config").join("local.providers.toml"))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn config_path() -> Result<PathBuf, String> {
|
||||
if let Some(path) = env::var_os("XIAOBAI_PROVIDER_CONFIG") {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_file() {
|
||||
return Ok(path);
|
||||
}
|
||||
return Err("XIAOBAI_PROVIDER_CONFIG 指向的配置文件不存在".to_string());
|
||||
}
|
||||
if let Ok(current) = env::current_dir() {
|
||||
if let Some(path) = find_config_from(¤t) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Ok(executable) = env::current_exe() {
|
||||
if let Some(parent) = executable.parent() {
|
||||
if let Some(path) = find_config_from(parent) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err("找不到本地服务配置 config/local.providers.toml".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn load_config() -> Result<ProviderConfig, String> {
|
||||
let path = config_path()?;
|
||||
let text = fs::read_to_string(path).map_err(|_| "无法读取本地服务配置".to_string())?;
|
||||
toml::from_str(&text).map_err(|error| format!("本地服务配置格式不正确:{error}"))
|
||||
}
|
||||
|
||||
fn configured_ocr(config: &BaiduOcrConfig) -> bool {
|
||||
match config.auth_mode.as_str() {
|
||||
"api_key_secret" => !config.api_key.trim().is_empty() && !config.secret_key.trim().is_empty(),
|
||||
"access_token" => !config.access_token.trim().is_empty(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_llm(config: &LlmConfig) -> bool {
|
||||
!config.base_url.trim().is_empty()
|
||||
&& !config.api_key.trim().is_empty()
|
||||
&& !config.model.trim().is_empty()
|
||||
&& config.protocol == "openai_compatible"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_provider_status() -> Result<ProviderStatus, String> {
|
||||
let config = load_config()?;
|
||||
Ok(ProviderStatus {
|
||||
ocr_configured: configured_ocr(&config.baidu_ocr),
|
||||
ocr_enabled: config.baidu_ocr.enabled,
|
||||
ocr_provider: "百度智能财务票据识别",
|
||||
llm_configured: configured_llm(&config.llm),
|
||||
llm_enabled: config.llm.enabled,
|
||||
llm_provider: "兼容 LLM 服务",
|
||||
llm_model: config.llm.model,
|
||||
local_development_only: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn timeout(seconds: u64) -> Duration {
|
||||
Duration::from_secs(seconds.clamp(5, 120))
|
||||
}
|
||||
|
||||
fn client(seconds: u64) -> Result<Client, ProviderFailure> {
|
||||
Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(timeout(seconds))
|
||||
.user_agent("XiaobaiBookkeeping-LocalDevelopment/0.10")
|
||||
.build()
|
||||
.map_err(|_| ProviderFailure::new("client_init", "无法初始化安全网络连接"))
|
||||
}
|
||||
|
||||
fn baidu_access_token(config: &BaiduOcrConfig) -> Result<String, ProviderFailure> {
|
||||
if config.auth_mode == "access_token" {
|
||||
if config.access_token.trim().is_empty() {
|
||||
return Err(ProviderFailure::new("missing_token", "百度 OCR access token 未填写"));
|
||||
}
|
||||
return Ok(config.access_token.trim().to_string());
|
||||
}
|
||||
if config.auth_mode != "api_key_secret" {
|
||||
return Err(ProviderFailure::new("unsupported_auth", "百度 OCR 认证方式不受支持"));
|
||||
}
|
||||
if config.api_key.trim().is_empty() || config.secret_key.trim().is_empty() {
|
||||
return Err(ProviderFailure::new("missing_credentials", "百度 OCR API Key 或 Secret Key 未填写"));
|
||||
}
|
||||
let response = client(config.request_timeout_seconds)?
|
||||
.post(BAIDU_TOKEN_URL)
|
||||
.form(&[
|
||||
("grant_type", "client_credentials"),
|
||||
("client_id", config.api_key.trim()),
|
||||
("client_secret", config.secret_key.trim()),
|
||||
])
|
||||
.send()
|
||||
.map_err(|_| ProviderFailure::new("baidu_auth_network", "无法连接百度 OCR 鉴权服务"))?;
|
||||
let status = response.status();
|
||||
let value: Value = response
|
||||
.json()
|
||||
.map_err(|_| ProviderFailure::new("baidu_auth_response", "百度 OCR 鉴权响应无法解析"))?;
|
||||
if !status.is_success() {
|
||||
return Err(ProviderFailure::new("baidu_auth_http", format!("百度 OCR 鉴权失败,HTTP {}", status.as_u16())));
|
||||
}
|
||||
value
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
let code = value.get("error").and_then(Value::as_str).unwrap_or("missing_access_token");
|
||||
ProviderFailure::new(code, "百度 OCR 凭据无效或无权获取 access token")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn recognize_financial_document(
|
||||
config: &ProviderConfig,
|
||||
mime_type: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<OcrResponse, ProviderFailure> {
|
||||
if !config.baidu_ocr.enabled {
|
||||
return Err(ProviderFailure::new("ocr_disabled", "百度 OCR 已配置但尚未启用"));
|
||||
}
|
||||
if config.baidu_ocr.service != "mixed_invoice" {
|
||||
return Err(ProviderFailure::new("unsupported_service", "当前只支持智能财务票据识别"));
|
||||
}
|
||||
if !matches!(mime_type, "image/png" | "image/jpeg" | "image/bmp" | "image/webp") {
|
||||
return Err(ProviderFailure::new("unsupported_file", "当前只支持 PNG、JPG、BMP 或 WebP 图片"));
|
||||
}
|
||||
let token = baidu_access_token(&config.baidu_ocr)?;
|
||||
let image = BASE64.encode(bytes);
|
||||
let response = client(config.baidu_ocr.request_timeout_seconds)?
|
||||
.post(BAIDU_MULTIPLE_INVOICE_URL)
|
||||
.query(&[("access_token", token)])
|
||||
.form(&[("image", image)])
|
||||
.send()
|
||||
.map_err(|_| ProviderFailure::new("baidu_ocr_network", "百度 OCR 请求失败,请检查网络后重试"))?;
|
||||
let status = response.status();
|
||||
let value: Value = response
|
||||
.json()
|
||||
.map_err(|_| ProviderFailure::new("baidu_ocr_response", "百度 OCR 响应无法解析"))?;
|
||||
if !status.is_success() {
|
||||
return Err(ProviderFailure::new("baidu_ocr_http", format!("百度 OCR 返回 HTTP {}", status.as_u16())));
|
||||
}
|
||||
if let Some(code) = value.get("error_code") {
|
||||
return Err(ProviderFailure::new(
|
||||
format!("baidu_{}", code.as_i64().unwrap_or_default()),
|
||||
"百度 OCR 未能完成识别,请确认接口权限、额度和图片格式",
|
||||
));
|
||||
}
|
||||
Ok(OcrResponse { raw: value })
|
||||
}
|
||||
|
||||
fn chat_endpoint(base_url: &str) -> String {
|
||||
let base = base_url.trim().trim_end_matches('/');
|
||||
if base.ends_with("/chat/completions") {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/chat/completions")
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_bookkeeping_suggestion(
|
||||
config: &ProviderConfig,
|
||||
input: &Value,
|
||||
) -> Result<LlmResponse, ProviderFailure> {
|
||||
if !config.llm.enabled {
|
||||
return Err(ProviderFailure::new("llm_disabled", "LLM 已配置但尚未启用"));
|
||||
}
|
||||
if !configured_llm(&config.llm) {
|
||||
return Err(ProviderFailure::new("llm_incomplete", "LLM 配置不完整或协议不受支持"));
|
||||
}
|
||||
let allowed_categories = [
|
||||
"销售收入", "进货成本", "经营房租", "水电燃气", "办公支出", "交通差旅",
|
||||
"业务招待", "平台服务费", "税费支出", "其他经营支出",
|
||||
];
|
||||
let system = format!(
|
||||
"你是小白记账的记账建议模块。只根据给定票据字段生成建议,不计算税务,不编造缺失事实。只能输出一个 JSON 对象,不要输出 Markdown、解释或思考过程。JSON 结构必须精确为:{{\"direction\":\"expense\",\"category\":\"进货成本\",\"counterparty\":\"交易对方\",\"occurredOn\":\"2026-01-01\",\"amountInCents\":100,\"businessPurpose\":\"经营用途\",\"confidence\":0.8,\"reason\":\"判断依据\"}}。category 只能是:{}。direction 只能是 income 或 expense。amountInCents 必须原样使用输入的价税合计分值。confidence 必须为 0 到 1。businessPurpose 不超过 40 个汉字,reason 不超过 80 个汉字。",
|
||||
allowed_categories.join("、")
|
||||
);
|
||||
let body = json!({
|
||||
"model": config.llm.model,
|
||||
"temperature": 0,
|
||||
"max_tokens": 900,
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string())}
|
||||
]
|
||||
});
|
||||
let response = client(config.llm.request_timeout_seconds)?
|
||||
.post(chat_endpoint(&config.llm.base_url))
|
||||
.bearer_auth(config.llm.api_key.trim())
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|_| ProviderFailure::new("llm_network", "无法连接 LLM 服务"))?;
|
||||
let status = response.status();
|
||||
let raw: Value = response
|
||||
.json()
|
||||
.map_err(|_| ProviderFailure::new("llm_response", "LLM 响应无法解析"))?;
|
||||
if !status.is_success() {
|
||||
return Err(ProviderFailure::new("llm_http", format!("LLM 服务返回 HTTP {}", status.as_u16())));
|
||||
}
|
||||
let text = raw
|
||||
.pointer("/choices/0/message/content")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| ProviderFailure::new("llm_format", "LLM 没有返回兼容的消息内容"))?;
|
||||
let cleaned = extract_json_object(text)
|
||||
.ok_or_else(|| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?;
|
||||
let content: Value = serde_json::from_str(cleaned)
|
||||
.map_err(|_| ProviderFailure::new("llm_invalid_json", "LLM 未返回有效的 JSON 建议"))?;
|
||||
Ok(LlmResponse { raw, content, model: config.llm.model.clone() })
|
||||
}
|
||||
|
||||
fn extract_json_object(text: &str) -> Option<&str> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.starts_with('{') && trimmed.ends_with('}') {
|
||||
return Some(trimmed);
|
||||
}
|
||||
let start = trimmed.find('{')?;
|
||||
let end = trimmed.rfind('}')?;
|
||||
(end > start).then_some(&trimmed[start..=end])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chat_endpoint_accepts_base_and_complete_urls() {
|
||||
assert_eq!(chat_endpoint("https://example.test/v1"), "https://example.test/v1/chat/completions");
|
||||
assert_eq!(chat_endpoint("https://example.test/v1/chat/completions"), "https://example.test/v1/chat/completions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_configuration_checks_auth_modes_without_exposing_values() {
|
||||
let config = BaiduOcrConfig {
|
||||
enabled: false,
|
||||
auth_mode: "api_key_secret".to_string(),
|
||||
api_key: "key".to_string(),
|
||||
secret_key: "secret".to_string(),
|
||||
access_token: String::new(),
|
||||
service: "mixed_invoice".to_string(),
|
||||
request_timeout_seconds: 30,
|
||||
};
|
||||
assert!(configured_ocr(&config));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_json_from_fences_and_reasoning_prefixes() {
|
||||
assert_eq!(extract_json_object("```json\n{\"ok\":true}\n```"), Some("{\"ok\":true}"));
|
||||
assert_eq!(extract_json_object("<think>internal</think>\n{\"ok\":true}"), Some("{\"ok\":true}"));
|
||||
assert_eq!(extract_json_object("没有对象"), None);
|
||||
}
|
||||
}
|
||||
+216
-2
@@ -108,6 +108,66 @@ interface ExportInfo {
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
interface ProviderStatus {
|
||||
ocrConfigured: boolean;
|
||||
ocrEnabled: boolean;
|
||||
ocrProvider: string;
|
||||
llmConfigured: boolean;
|
||||
llmEnabled: boolean;
|
||||
llmProvider: string;
|
||||
llmModel: string;
|
||||
localDevelopmentOnly: boolean;
|
||||
}
|
||||
|
||||
interface InvoiceLineItem {
|
||||
name: string;
|
||||
amountInCents: number | null;
|
||||
taxRate: string;
|
||||
taxInCents: number | null;
|
||||
}
|
||||
|
||||
interface InvoiceExtraction {
|
||||
invoiceType: string;
|
||||
invoiceNumber: string;
|
||||
invoiceDate: string;
|
||||
purchaserName: string;
|
||||
purchaserTaxId: string;
|
||||
sellerName: string;
|
||||
sellerTaxId: string;
|
||||
totalAmountInCents: number | null;
|
||||
totalTaxInCents: number | null;
|
||||
totalWithTaxInCents: number | null;
|
||||
amountInWords: string;
|
||||
drawer: string;
|
||||
remarks: string;
|
||||
confidence: number;
|
||||
lineItems: InvoiceLineItem[];
|
||||
}
|
||||
|
||||
interface BookkeepingSuggestion {
|
||||
direction: "income" | "expense";
|
||||
category: string;
|
||||
counterparty: string;
|
||||
occurredOn: string;
|
||||
amountInCents: number;
|
||||
businessPurpose: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface StoredDocument {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
status: "stored" | "processing" | "needs_review" | "confirmed" | "failed";
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
extraction: InvoiceExtraction | null;
|
||||
suggestion: BookkeepingSuggestion | null;
|
||||
}
|
||||
|
||||
function entityTypeLabel(entityType: BusinessProfile["entityType"]) {
|
||||
return entityType === "sole_proprietor" ? "个体工商户" : "小微企业";
|
||||
}
|
||||
@@ -485,9 +545,163 @@ function ConfirmPage({ pending, isLocal, reviewingId, onConfirm, onNotify }: {
|
||||
</div>;
|
||||
}
|
||||
|
||||
function amountInput(cents: number | null) {
|
||||
return cents === null ? "" : (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
function inputCents(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return null;
|
||||
const [whole, decimal = ""] = trimmed.split(".");
|
||||
return Number(whole) * 100 + Number(decimal.padEnd(2, "0"));
|
||||
}
|
||||
|
||||
function documentStatus(document: StoredDocument) {
|
||||
if (document.status === "confirmed") return { label: "已核对", className: "done" };
|
||||
if (document.status === "needs_review") return { label: "待核对", className: "waiting" };
|
||||
if (document.status === "processing") return { label: "识别中", className: "waiting" };
|
||||
if (document.status === "failed") return { label: "需重试", className: "error" };
|
||||
return { label: "已归档", className: "personal" };
|
||||
}
|
||||
|
||||
function DocumentReview({ document, llmReady, busy, onConfirm, onSuggest }: {
|
||||
document: StoredDocument;
|
||||
llmReady: boolean;
|
||||
busy: "ocr" | "llm" | "confirm" | null;
|
||||
onConfirm: (input: Record<string, unknown>) => Promise<void>;
|
||||
onSuggest: () => Promise<void>;
|
||||
}) {
|
||||
const extraction = document.extraction;
|
||||
const [invoiceNumber, setInvoiceNumber] = useState(extraction?.invoiceNumber ?? "");
|
||||
const [invoiceDate, setInvoiceDate] = useState(extraction?.invoiceDate ?? "");
|
||||
const [purchaserName, setPurchaserName] = useState(extraction?.purchaserName ?? "");
|
||||
const [purchaserTaxId, setPurchaserTaxId] = useState(extraction?.purchaserTaxId ?? "");
|
||||
const [sellerName, setSellerName] = useState(extraction?.sellerName ?? "");
|
||||
const [sellerTaxId, setSellerTaxId] = useState(extraction?.sellerTaxId ?? "");
|
||||
const [totalAmount, setTotalAmount] = useState(amountInput(extraction?.totalAmountInCents ?? null));
|
||||
const [totalTax, setTotalTax] = useState(amountInput(extraction?.totalTaxInCents ?? null));
|
||||
const [totalWithTax, setTotalWithTax] = useState(amountInput(extraction?.totalWithTaxInCents ?? null));
|
||||
|
||||
if (!extraction) {
|
||||
return <div className="document-detail-empty"><Icon name={document.status === "failed" ? "bell" : "file"} size={28}/><h2>{document.status === "failed" ? "这张票据暂未识别成功" : "正在准备识别结果"}</h2><p>{document.lastError ?? "完成后会在这里显示可核对字段。"}</p></div>;
|
||||
}
|
||||
|
||||
const confirm = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const amounts = [totalAmount, totalTax, totalWithTax].map((value) => value.trim() ? inputCents(value) : null);
|
||||
if (amounts.some((value, index) => [totalAmount, totalTax, totalWithTax][index].trim() && value === null)) return;
|
||||
await onConfirm({
|
||||
documentId: document.id,
|
||||
invoiceNumber,
|
||||
invoiceDate,
|
||||
purchaserName,
|
||||
purchaserTaxId,
|
||||
sellerName,
|
||||
sellerTaxId,
|
||||
totalAmountInCents: amounts[0],
|
||||
totalTaxInCents: amounts[1],
|
||||
totalWithTaxInCents: amounts[2],
|
||||
});
|
||||
};
|
||||
|
||||
return <div className="document-detail-content">
|
||||
<div className="document-detail-heading"><div><span>{extraction.invoiceType || "增值税发票"}</span><h2>{document.fileName}</h2><p>OCR 分类置信度 {(extraction.confidence * 100).toFixed(1)}%,请以票面原件为准。</p></div><span className={`status ${documentStatus(document).className}`}>{documentStatus(document).label}</span></div>
|
||||
<form className="invoice-review-form" onSubmit={confirm}>
|
||||
<div className="invoice-fields">
|
||||
<label><span>发票号码</span><input value={invoiceNumber} onChange={(event) => setInvoiceNumber(event.target.value)} required/></label>
|
||||
<label><span>开票日期</span><input type="date" value={invoiceDate} onChange={(event) => setInvoiceDate(event.target.value)} required/></label>
|
||||
<label className="field-wide"><span>销售方名称</span><input value={sellerName} onChange={(event) => setSellerName(event.target.value)} required/></label>
|
||||
<label className="field-wide"><span>销售方税号</span><input value={sellerTaxId} onChange={(event) => setSellerTaxId(event.target.value)}/></label>
|
||||
<label className="field-wide"><span>购买方名称</span><input value={purchaserName} onChange={(event) => setPurchaserName(event.target.value)}/></label>
|
||||
<label className="field-wide"><span>购买方税号</span><input value={purchaserTaxId} onChange={(event) => setPurchaserTaxId(event.target.value)}/></label>
|
||||
<label><span>不含税金额</span><input inputMode="decimal" value={totalAmount} onChange={(event) => setTotalAmount(event.target.value)} placeholder="0.00"/></label>
|
||||
<label><span>税额</span><input inputMode="decimal" value={totalTax} onChange={(event) => setTotalTax(event.target.value)} placeholder="0.00"/></label>
|
||||
<label><span>价税合计</span><input inputMode="decimal" value={totalWithTax} onChange={(event) => setTotalWithTax(event.target.value)} placeholder="0.00" required/></label>
|
||||
</div>
|
||||
{extraction.lineItems.length ? <div className="invoice-items"><div className="invoice-items-heading"><strong>商品与服务明细</strong><span>{extraction.lineItems.length} 行</span></div>{extraction.lineItems.map((item, index) => <div className="invoice-item" key={`${item.name}-${index}`}><span title={item.name}>{item.name || "未识别名称"}</span><small>{item.taxRate || "税率未知"}</small><b>{item.amountInCents === null ? "金额未知" : formatCurrency(item.amountInCents)}</b></div>)}</div> : null}
|
||||
{document.suggestion ? <section className="suggestion-box"><div><span>AI 记账建议</span><strong>{document.suggestion.direction === "income" ? "经营收入" : "经营支出"},{document.suggestion.category}</strong><p>{document.suggestion.businessPurpose}。{document.suggestion.reason}</p></div><b>{Math.round(document.suggestion.confidence * 100)}% 把握</b></section> : null}
|
||||
<div className="document-review-actions"><button className="primary-button" disabled={busy !== null || document.status === "confirmed"} type="submit">{busy === "confirm" ? "正在保存" : document.status === "confirmed" ? "已完成人工核对" : "确认字段无误"}</button><button className="secondary-button" disabled={busy !== null || !llmReady} type="button" onClick={onSuggest}>{busy === "llm" ? "正在生成" : document.suggestion ? "重新生成记账建议" : "生成记账建议"}</button><span>AI 建议不会直接入账</span></div>
|
||||
</form>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function DocumentsPage({ onNotify }: { onNotify: (message: string) => void }) {
|
||||
return <div className="page"><PageIntro eyebrow="票据中心" title="票据归档正在准备中" description="0.9 内测版尚未启用票据上传和识别,避免把预览数据误当成真实票据。" />
|
||||
<section className="empty-state feature-preview"><Icon name="file" size={32}/><span className="safe-pill">后续功能</span><h2>这里将保存原始票据与识别结果</h2><p>票据文件、识别字段和人工修订会分开保存;在功能通过完整验收前不会开放入口。</p><button className="secondary-button" onClick={() => onNotify("票据模块会在原始文件存储与恢复测试通过后开放")}>了解开放条件</button></section>
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [providerStatus, setProviderStatus] = useState<ProviderStatus | null>(null);
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<"ocr" | "llm" | "confirm" | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const selected = documents.find((item) => item.id === selectedId) ?? documents[0] ?? null;
|
||||
const ocrReady = Boolean(providerStatus?.ocrConfigured && providerStatus.ocrEnabled);
|
||||
const llmReady = Boolean(providerStatus?.llmConfigured && providerStatus.llmEnabled);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
const stored = await invoke<StoredDocument[]>("list_documents");
|
||||
setDocuments(stored);
|
||||
setSelectedId((current) => current && stored.some((item) => item.id === current) ? current : stored[0]?.id ?? null);
|
||||
return stored;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauriRuntime()) { setLoading(false); return; }
|
||||
Promise.all([invoke<ProviderStatus>("get_provider_status"), loadDocuments()])
|
||||
.then(([status]) => setProviderStatus(status))
|
||||
.catch((error) => onNotify(`票据中心初始化失败:${String(error)}`))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const chooseFile = () => {
|
||||
if (!isTauriRuntime()) { onNotify("票据识别请在 Windows 客户端中使用"); return; }
|
||||
if (!ocrReady) { onNotify("请先在本地配置中启用百度 OCR"); return; }
|
||||
fileInput.current?.click();
|
||||
};
|
||||
|
||||
const recognize = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 2_500_000) { onNotify("图片不能超过 2.5 MB"); return; }
|
||||
const mimeType = file.type || (file.name.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg");
|
||||
setBusy("ocr");
|
||||
try {
|
||||
const stored = await invoke<StoredDocument>("recognize_document", { input: { fileName: file.name, mimeType, bytes: Array.from(new Uint8Array(await file.arrayBuffer())) } });
|
||||
await loadDocuments();
|
||||
setSelectedId(stored.id);
|
||||
onNotify(stored.status === "confirmed" ? "这张票据已经归档并核对" : "票据识别完成,请核对字段");
|
||||
} catch (error) {
|
||||
await loadDocuments().catch(() => undefined);
|
||||
onNotify(`票据识别失败:${String(error)}`);
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const generateSuggestion = async () => {
|
||||
if (!selected) return;
|
||||
setBusy("llm");
|
||||
try {
|
||||
await invoke<StoredDocument>("generate_document_suggestion", { documentId: selected.id });
|
||||
await loadDocuments();
|
||||
onNotify("记账建议已生成,仍需人工确认");
|
||||
} catch (error) { onNotify(`生成建议失败:${String(error)}`); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const confirm = async (input: Record<string, unknown>) => {
|
||||
setBusy("confirm");
|
||||
try {
|
||||
await invoke<StoredDocument>("confirm_document", { input });
|
||||
await loadDocuments();
|
||||
onNotify("人工核对结果已保存,原始 OCR 结果仍保留");
|
||||
} catch (error) { onNotify(`保存核对结果失败:${String(error)}`); throw error; }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const pendingCount = documents.filter((item) => item.status === "needs_review").length;
|
||||
const confirmedCount = documents.filter((item) => item.status === "confirmed").length;
|
||||
return <div className="page documents-page"><PageIntro eyebrow="票据中心" title="票据先识别,再由你确认" description="原图和识别记录保存在本地加密账本;上传时只有当前图片会发送给百度 OCR。" action={<div className="page-actions"><input ref={fileInput} className="visually-hidden" type="file" accept="image/png,image/jpeg,image/bmp,image/webp" onChange={recognize}/><button className="primary-button" disabled={busy !== null || !ocrReady} onClick={chooseFile}><Icon name="plus" size={17}/>{busy === "ocr" ? "正在识别" : "上传并识别"}</button></div>} />
|
||||
<section className={`provider-strip ${ocrReady ? "ready" : "blocked"}`}><Icon name={ocrReady ? "shield" : "bell"} size={20}/><div><strong>{ocrReady ? `${providerStatus?.ocrProvider}已就绪` : "OCR 当前未启用"}</strong><p>{ocrReady ? "本次上传会调用云端识别;原图同时写入本地 SQLCipher 加密账本。" : loading ? "正在检查本地开发配置。" : "请把 local.providers.toml 中的百度 OCR enabled 改为 true。"}</p></div><span>{providerStatus?.localDevelopmentOnly ? "仅本地开发" : "服务端模式"}</span></section>
|
||||
<section className="document-stats"><article><span>加密归档</span><strong>{documents.length}</strong><small>按内容哈希自动去重</small></article><article><span>等待核对</span><strong>{pendingCount}</strong><small>不会自动写入账簿</small></article><article><span>已人工确认</span><strong>{confirmedCount}</strong><small>修改记录单独保存</small></article></section>
|
||||
{loading ? <section className="empty-state document-loading"><Icon name="file" size={28}/><h2>正在读取加密票据</h2><p>请稍候,系统不会把票据内容写入日志。</p></section> : documents.length === 0 ? <section className="empty-state feature-preview"><Icon name="file" size={32}/><span className="safe-pill">本地加密归档</span><h2>上传第一张经营票据</h2><p>支持 PNG、JPG、BMP 和 WebP,单张不超过 2.5 MB。点击上传即表示同意将当前图片发送给百度 OCR。</p><button className="secondary-button" disabled={!ocrReady} onClick={chooseFile}>选择测试票据</button></section> : <section className="documents-workspace"><div className="document-list-panel"><div className="document-list-heading"><div><h2>票据记录</h2><p>最近上传优先</p></div><span>{documents.length} 张</span></div><div className="document-list">{documents.map((document) => { const status = documentStatus(document); return <button key={document.id} className={selected?.id === document.id ? "active" : ""} onClick={() => setSelectedId(document.id)}><span className="document-list-icon"><Icon name="file" size={18}/></span><span><strong title={document.fileName}>{document.fileName}</strong><small>{document.extraction?.sellerName || document.lastError || "等待识别结果"}</small></span><span className={`status ${status.className}`}>{status.label}</span></button>; })}</div></div><div className="document-detail">{selected ? <DocumentReview key={`${selected.id}-${selected.updatedAt}`} document={selected} llmReady={llmReady} busy={busy} onConfirm={confirm} onSuggest={generateSuggestion}/> : null}</div></section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -246,6 +246,7 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3)
|
||||
.status.done { color: var(--accent-700); background: var(--accent-050); }
|
||||
.status.waiting { color: var(--warning); background: var(--warning-bg); }
|
||||
.status.personal { color: #5b6472; background: #eef0f3; }
|
||||
.status.error { color: #9a3f32; background: #fff0ed; }
|
||||
|
||||
.right-rail { display: grid; gap: 16px; }
|
||||
.action-panel-title { padding: 17px 17px 11px; display: flex; align-items: center; gap: 11px; }
|
||||
@@ -291,6 +292,74 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3)
|
||||
.feature-preview .safe-pill { margin-top: 5px; }
|
||||
.feature-preview p { max-width: 520px; line-height: 1.7; }
|
||||
.feature-preview .secondary-button { margin-top: 10px; }
|
||||
.document-loading { min-height: 330px; }
|
||||
.provider-strip {
|
||||
min-height: 66px;
|
||||
margin-bottom: 14px;
|
||||
padding: 13px 17px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border: 1px solid;
|
||||
border-radius: var(--radius-surface);
|
||||
}
|
||||
.provider-strip.ready { color: #315e56; background: var(--accent-050); border-color: #cce2dc; }
|
||||
.provider-strip.blocked { color: #755600; background: var(--warning-bg); border-color: #ead99e; }
|
||||
.provider-strip strong, .provider-strip p { display: block; margin: 0; }
|
||||
.provider-strip strong { color: #283b38; font-size: 11.5px; }
|
||||
.provider-strip p { margin-top: 4px; color: #5e706c; font-size: 10px; line-height: 1.45; }
|
||||
.provider-strip > span { padding: 4px 8px; color: inherit; background: rgba(255, 255, 255, .68); border-radius: 6px; font-size: 9px; font-weight: 650; white-space: nowrap; }
|
||||
.documents-workspace { min-height: 520px; display: grid; grid-template-columns: 278px minmax(0, 1fr); gap: 14px; align-items: stretch; }
|
||||
.document-list-panel, .document-detail { overflow: hidden; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
|
||||
.document-list-heading { min-height: 64px; padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e8ecee; }
|
||||
.document-list-heading h2, .document-list-heading p { margin: 0; }
|
||||
.document-list-heading h2 { font-size: 13px; }
|
||||
.document-list-heading p { margin-top: 4px; color: var(--muted); font-size: 9.5px; }
|
||||
.document-list-heading > span { color: var(--muted); font-size: 9.5px; }
|
||||
.document-list { padding: 5px; display: grid; gap: 2px; }
|
||||
.document-list > button { width: 100%; min-height: 60px; padding: 8px 9px; display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 9px; text-align: left; color: var(--text); background: transparent; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
|
||||
.document-list > button:hover { background: #f6f8f9; }
|
||||
.document-list > button.active { background: var(--accent-050); border-color: #d2e6e0; }
|
||||
.document-list-icon { width: 32px; height: 34px; display: grid; place-items: center; color: var(--accent-700); background: #e7f2ef; border-radius: 7px; }
|
||||
.document-list button > span:nth-child(2) { min-width: 0; }
|
||||
.document-list strong, .document-list small { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.document-list strong { font-size: 10.5px; }
|
||||
.document-list small { margin-top: 5px; color: var(--muted); font-size: 9px; }
|
||||
.document-list .status { white-space: nowrap; }
|
||||
.document-detail { min-width: 0; }
|
||||
.document-detail-empty { min-height: 100%; padding: 40px; display: grid; place-content: center; justify-items: center; text-align: center; color: var(--accent-700); }
|
||||
.document-detail-empty h2 { margin: 13px 0 6px; color: var(--text); font-size: 15px; }
|
||||
.document-detail-empty p { max-width: 420px; margin: 0; color: var(--muted); font-size: 10.5px; line-height: 1.6; }
|
||||
.document-detail-content { min-width: 0; }
|
||||
.document-detail-heading { min-height: 84px; padding: 16px 19px; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; border-bottom: 1px solid #e8ecee; }
|
||||
.document-detail-heading > div { min-width: 0; }
|
||||
.document-detail-heading span:first-child { color: var(--accent-700); font-size: 9.5px; font-weight: 650; }
|
||||
.document-detail-heading h2 { margin: 5px 0 0; overflow: hidden; color: var(--text); font-size: 14px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.document-detail-heading p { margin: 5px 0 0; color: var(--muted); font-size: 9.5px; }
|
||||
.invoice-review-form { padding-bottom: 18px; }
|
||||
.invoice-fields { padding: 17px 19px 4px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; }
|
||||
.invoice-fields label > span { display: block; margin-bottom: 6px; color: #344149; font-size: 9.5px; font-weight: 650; }
|
||||
.invoice-fields input { width: 100%; height: 35px; padding: 0 9px; color: var(--text); background: #fff; border: 1px solid #cbd3d8; border-radius: var(--radius-control); outline: none; font-size: 10.5px; }
|
||||
.invoice-fields input:focus { border-color: var(--accent-600); box-shadow: 0 0 0 2px rgba(38, 124, 112, .13); }
|
||||
.invoice-fields .field-wide { grid-column: span 2; }
|
||||
.invoice-items { margin: 14px 19px 0; overflow: hidden; border: 1px solid #e2e6e9; border-radius: 9px; }
|
||||
.invoice-items-heading { min-height: 39px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; background: #f7f9fa; }
|
||||
.invoice-items-heading strong { font-size: 10px; }
|
||||
.invoice-items-heading span { color: var(--muted); font-size: 9px; }
|
||||
.invoice-item { min-height: 39px; padding: 0 12px; display: grid; grid-template-columns: minmax(0, 1fr) 58px 82px; align-items: center; gap: 10px; border-top: 1px solid #edf0f2; }
|
||||
.invoice-item > span { overflow: hidden; font-size: 9.5px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.invoice-item small { color: var(--muted); font-size: 9px; }
|
||||
.invoice-item b { text-align: right; font-size: 9.5px; font-variant-numeric: tabular-nums; }
|
||||
.suggestion-box { margin: 14px 19px 0; padding: 13px 14px; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; color: #355a54; background: var(--accent-050); border: 1px solid #cce2dc; border-radius: 9px; }
|
||||
.suggestion-box span, .suggestion-box strong { display: block; }
|
||||
.suggestion-box span { font-size: 9px; }
|
||||
.suggestion-box strong { margin-top: 4px; color: #24413c; font-size: 11px; }
|
||||
.suggestion-box p { margin: 5px 0 0; color: #58716b; font-size: 9.5px; line-height: 1.5; }
|
||||
.suggestion-box > b { font-size: 10px; white-space: nowrap; }
|
||||
.document-review-actions { margin: 16px 19px 0; padding-top: 15px; display: flex; align-items: center; gap: 8px; border-top: 1px solid #e6eaec; }
|
||||
.document-review-actions > span { margin-left: auto; color: var(--muted); font-size: 9px; }
|
||||
.document-review-actions button:disabled, .page-actions button:disabled, .feature-preview button:disabled { cursor: not-allowed; opacity: .58; }
|
||||
.confirm-card { padding: 21px; display: grid; grid-template-columns: 38px 1fr; gap: 15px; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-surface); }
|
||||
.confirm-index { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 8px; color: var(--accent-700); background: var(--accent-050); font-size: 11px; font-weight: 700; }
|
||||
.source-line { color: var(--muted); font-size: 10px; }
|
||||
@@ -438,4 +507,5 @@ th:nth-child(1) { width: 21%; } th:nth-child(2) { width: 25%; } th:nth-child(3)
|
||||
.profile-safety .icon { grid-row: 1 / span 2; }
|
||||
.transaction-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.document-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.documents-workspace { grid-template-columns: 250px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user