feat: add reviewed OCR and AI document workflow

This commit is contained in:
leefer
2026-08-01 15:59:28 +08:00
parent 3ef13b5674
commit a3203f95da
12 changed files with 1808 additions and 6 deletions
+2
View File
@@ -14,3 +14,5 @@ vite.config.d.ts
.env
.env.*
!.env.example
config/local.providers.toml
tupian/
+40
View File
@@ -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
+12
View File
@@ -63,3 +63,15 @@
- 桌面底层桥接:保持少量 Rust 代码,不在其中堆积业务规则
当前仓库首先实现可运行的界面与纯领域逻辑;本地数据库、Tauri 容器和云端服务按阶段接入,不用模拟实现冒充生产能力。
## OCR 与 LLM 迁移边界
当前纯本地测试版通过 `providers` 适配层访问百度 OCR 和 OpenAI 兼容 LLM。`documents` 模块只依赖统一的识别与建议函数,不读取供应商密钥,也不拼装 HTTP 请求。这样迁移到服务端时,可将 `providers` 的实现移动到服务端,客户端改为调用小白记账版本化 API,票据复核页面与字段校验规则无需重写。
客户端与服务端分离后遵守以下边界:
- 供应商密钥只存在服务端密钥管理系统或受保护环境变量中。
- 客户端只持有用户登录后的短期会话凭据,不持有 OCR 或 LLM 密钥。
- 服务端按经营主体做鉴权、限流、调用审计和数据隔离。
- OCR 与 LLM 的响应都先经过服务端结构校验;客户端仍保留金额、日期和枚举白名单等确定性校验。
- AI 只生成建议,不能直接写入账簿、修改税额或代替人工确认。
+23
View File
@@ -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 后面。
+42
View File
@@ -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 或安装包。
+298 -2
View File
@@ -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"
+3
View File
@@ -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"] }
+671
View File
@@ -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
View File
@@ -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);
+333
View File
@@ -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(&current) {
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
View File
@@ -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> PNGJPGBMP 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>;
}
+70
View File
@@ -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); }
}