first commit
This commit is contained in:
commit
22ae3ac755
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
113
doc/构建.md
Normal file
113
doc/构建.md
Normal file
@ -0,0 +1,113 @@
|
||||
# 构建
|
||||
|
||||
在 WSL(Ubuntu)中构建,产物为 Android APK。桌面端可用于本地调试。
|
||||
|
||||
## 1. 一次性环境准备
|
||||
### 1.1 基础工具(已具备可跳过)
|
||||
- Node.js 18+、Rust 1.75+(`cargo`)、`rustup`。
|
||||
- Android 交叉编译目标:
|
||||
```bash
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi \
|
||||
i686-linux-android x86_64-linux-android
|
||||
```
|
||||
|
||||
### 1.2 桌面调试依赖(仅本地跑 desktop 时需要)
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||
libsoup-3.0-dev librsvg2-dev libjavascriptcoregtk-4.1-dev pkg-config
|
||||
```
|
||||
> AI 请求用 rustls,不依赖系统 OpenSSL。
|
||||
|
||||
### 1.3 Android SDK / NDK / JDK
|
||||
```bash
|
||||
sudo apt-get install -y openjdk-17-jdk
|
||||
# 下载 commandline-tools 到 ~/android-sdk,然后:
|
||||
export ANDROID_HOME=$HOME/android-sdk
|
||||
export NDK_HOME=$ANDROID_HOME/ndk/26.1.10909125 # 版本以实际为准
|
||||
sdkmanager "platform-tools" "platforms;android-34" \
|
||||
"build-tools;34.0.0" "ndk;26.1.10909125"
|
||||
```
|
||||
建议把 `ANDROID_HOME`、`NDK_HOME`、`JAVA_HOME` 写入 `~/.bashrc`。
|
||||
|
||||
### 1.4 Tauri CLI
|
||||
```bash
|
||||
cargo install tauri-cli --version "^2.0"
|
||||
# 或使用项目本地:npm i 后用 npm run tauri
|
||||
```
|
||||
|
||||
## 2. 安装依赖
|
||||
```bash
|
||||
cd /mnt/d/workbench/tauri-android-invoice
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3. 测试
|
||||
```bash
|
||||
# 前端纯逻辑测试(autofill)
|
||||
npm test
|
||||
# Rust 逻辑测试(db + ai 解析)
|
||||
cd src-tauri && cargo test --lib
|
||||
```
|
||||
当前用例:前端 1-1~1-3,Rust 2-1~2-5(数据库)、3-1~3-3(AI 解析)。
|
||||
|
||||
## 4. 桌面调试(可选)
|
||||
```bash
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
## 5. 构建 Android APK
|
||||
```bash
|
||||
# 首次初始化 Android 工程(生成 src-tauri/gen/android)
|
||||
npm run tauri android init
|
||||
|
||||
# 构建 APK(debug)
|
||||
npm run tauri android build --debug
|
||||
# 或 release(需配置签名)
|
||||
npm run tauri android build
|
||||
```
|
||||
产物路径:
|
||||
```
|
||||
src-tauri/gen/android/app/build/outputs/apk/
|
||||
```
|
||||
|
||||
## 6. 生成签名密钥(release 必需)
|
||||
debug 构建自动用调试签名,release 必须自备 keystore。
|
||||
|
||||
### 6.1 生成 keystore
|
||||
用 JDK 自带的 `keytool` 生成(有效期约 27 年):
|
||||
```bash
|
||||
keytool -genkeypair -v \
|
||||
-keystore ~/invoice-release.jks \
|
||||
-alias invoice \
|
||||
-keyalg RSA -keysize 2048 -validity 10000
|
||||
```
|
||||
按提示设置 keystore 口令、密钥口令与证书信息,妥善保管该文件与口令(丢失将无法更新已上架应用)。
|
||||
|
||||
### 6.2 配置签名给 Gradle
|
||||
在 `src-tauri/gen/android/`(执行过 `android init` 后生成)创建 `keystore.properties`:
|
||||
```properties
|
||||
storeFile=/home/<user>/invoice-release.jks
|
||||
storePassword=你的keystore口令
|
||||
keyAlias=invoice
|
||||
keyPassword=你的密钥口令
|
||||
```
|
||||
确认 `src-tauri/gen/android/app/build.gradle.kts` 读取该文件并应用到 `release` signingConfig(Tauri 模板通常已生成,如无则手动补充)。
|
||||
|
||||
> 安全提示:`keystore.properties` 与 `*.jks` 含机密,务必加入 `.gitignore`,不要提交。
|
||||
|
||||
### 6.3 构建已签名 release
|
||||
```bash
|
||||
npm run tauri android build # 生成签名后的 release APK/AAB
|
||||
```
|
||||
## 7. 安装到设备
|
||||
```bash
|
||||
adb install -r <上面生成的 apk 路径>
|
||||
```
|
||||
|
||||
## 8. 常见问题
|
||||
- 缺少 `gobject-2.0`/`openssl.pc`:补装 1.2 的桌面依赖(仅桌面构建需要)。
|
||||
- 扫码权限报 `barcode-scanner:allow-scan not found`:该权限只在移动端,已放入 `src-tauri/capabilities/mobile.json`,桌面构建不加载。
|
||||
- 图标缺失导致 `generate_context!` 失败:确认 `src-tauri/icons/icon.png` 存在,可用 `npm run tauri icon <源图>` 生成全套。
|
||||
- NDK 找不到:检查 `NDK_HOME` 是否指向真实存在的版本目录。
|
||||
|
||||
89
doc/设计.md
Normal file
89
doc/设计.md
Normal file
@ -0,0 +1,89 @@
|
||||
# 设计
|
||||
|
||||
## 1. 概述
|
||||
本 App 是一个"发票助手":扫描小票二维码 -> 打开对应 h5 页面 -> 用 AI 识别页面表单 -> 自动填入预置的开票信息 -> 用户确认提交获取发票。
|
||||
|
||||
> 说明:需求中的"trauri"按 **Tauri** 框架理解;AI 采用**硅基流动云端大模型 API**(cloud.siliconflow.cn,需联网)。
|
||||
|
||||
## 2. 技术选型
|
||||
- 框架:Tauri 2.x(编译 Android APK)。
|
||||
- 前端:Vue 3 + Vite(轻量、易写表单页)。
|
||||
- 二维码扫描:`tauri-plugin-barcode-scanner`(调用相机)。
|
||||
- h5 展示:Tauri WebviewWindow / 内嵌 iframe 加载扫描得到的 URL。
|
||||
- 本地存储:Rust 侧 `rusqlite`(bundled,内置 SQLite)保存预置开票信息。
|
||||
- AI 调用:Rust 侧 `command` 转发到硅基流动 HTTP 接口(OpenAI 兼容协议),密钥不暴露在前端;`reqwest` 使用 `rustls-tls`,免系统 OpenSSL 依赖。
|
||||
eqwest 使用
|
||||
ustls-tls 免系统 OpenSSL 依赖。
|
||||
- 权限:桌面权限在 capabilities/default.json,扫码权限单独放 capabilities/mobile.json(限定 android/iOS 平台)。
|
||||
- 工具链:在 WSL 中执行 Rust + Android NDK/SDK 构建。
|
||||
|
||||
## 3. 功能模块
|
||||
### 3.1 开票信息管理
|
||||
- 字段:抬头、税号、Email、(可选)地址、电话、开户行、账号。
|
||||
- 支持多条,标记 1 条为默认(`is_default=1`)。
|
||||
- 界面:列表 + 增/删/改 + 设为默认。
|
||||
|
||||
### 3.2 二维码扫描
|
||||
- 点击"扫一扫"调用相机识别二维码,得到 URL。
|
||||
- 得到 URL 后打开内置 WebView 展示 h5 页面。
|
||||
|
||||
### 3.3 AI 识别与自动填充
|
||||
1. 页面加载完成后,前端注入脚本抓取表单 DOM(各 input/select 的 label、name、placeholder)。
|
||||
2. 将 DOM 摘要 + 默认开票信息通过 Rust command 发送给大模型。
|
||||
3. 大模型返回"字段选择器 -> 应填值"的映射(JSON)。
|
||||
4. 前端按映射用注入脚本回填表单。
|
||||
5. 用户核对后手动点提交。
|
||||
|
||||
## 4. 数据模型
|
||||
```
|
||||
invoice_info(
|
||||
id INTEGER PK,
|
||||
title TEXT, -- 抬头
|
||||
tax_no TEXT, -- 税号
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
bank_name TEXT,
|
||||
bank_account TEXT,
|
||||
is_default INTEGER -- 1=默认,仅一条
|
||||
)
|
||||
```
|
||||
|
||||
## 5. 核心流程
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[打开 App] --> B[扫描二维码]
|
||||
B --> C[获取 URL]
|
||||
C --> D[WebView 加载 h5 页面]
|
||||
D --> E[抓取表单 DOM]
|
||||
E --> F[DOM + 默认开票信息 发送大模型]
|
||||
F --> G[返回字段映射 JSON]
|
||||
G --> H[注入脚本回填表单]
|
||||
H --> I[用户核对并提交]
|
||||
I --> J[获取发票]
|
||||
```
|
||||
|
||||
## 6. 关键接口(Tauri command)
|
||||
- `list_invoice_info()` / `save_invoice_info()` / `delete_invoice_info()` / `set_default(id)`:信息 CRUD。
|
||||
- `ai_map_fields(dom_summary, info)`:调用大模型,返回字段映射 JSON。
|
||||
|
||||
## 7. AI 接口配置
|
||||
- 服务商:硅基流动,OpenAI 兼容 `chat/completions` 接口。
|
||||
- Base URL:`https://api.siliconflow.cn/v1`
|
||||
- 密钥:`sk-drqdzsdvsnqxalvwjggsexyowbertkxrgpqyeqilhkkiqemn`(当前硬编码在 Rust 层)。
|
||||
- 为便于未来更换服务商,Rust 侧用一组常量集中管理:
|
||||
```rust
|
||||
// AI 服务配置:更换服务商时只改此处
|
||||
const AI_BASE_URL: &str = "https://api.siliconflow.cn/v1";
|
||||
const AI_API_KEY: &str = "sk-drqdzsdvsnqxalvwjggsexyowbertkxrgpqyeqilhkkiqemn";
|
||||
const AI_MODEL: &str = "Qwen/Qwen2.5-7B-Instruct"; // 具体模型待定
|
||||
```
|
||||
- 调用统一走 `ai_map_fields` command,内部封装请求,切换服务商仅需改常量(及必要的请求格式适配)。
|
||||
|
||||
> 安全提示:密钥硬编码会被打包进 APK,存在泄露风险,仅作当前阶段方案;后续建议改为服务端代理或运行时配置。
|
||||
|
||||
## 8. 待确认
|
||||
- 使用的具体模型名称(影响识别效果与费用)。
|
||||
- 是否需要邮箱自动接收发票(仅填 Email 还是集成收件)。
|
||||
- h5 页面跨域抓取 DOM 的限制处理方式。
|
||||
|
||||
17
doc/需求.md
Normal file
17
doc/需求.md
Normal file
@ -0,0 +1,17 @@
|
||||
## 核心功能
|
||||
|
||||
扫描小票上的二维码,通常会打开一个 h5 页面,然后可以填写关键信息,提交后可以获取到发票。
|
||||
|
||||
- 本 App 扫描二维码后,展示 h5 页面。就像一个自定义浏览器一样。
|
||||
|
||||
- 每种小票的 h5 页面布局不同,需求信息不同,需要基于大模型 AI 识别,然后自动填写。
|
||||
|
||||
- 关键信息需提前录入,包括发票的抬头和税号等信息。也包含 Email 信息,方便通过邮箱获取发票。
|
||||
|
||||
- 预置关键信息可以有多条,但是一条为默认。
|
||||
|
||||
## 技术约束
|
||||
|
||||
- 使用 tauri 框架,编译为 Android App
|
||||
|
||||
- 使用 wsl 中的工具链
|
||||
12
index.html
Normal file
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>发票助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3089
package-lock.json
generated
Normal file
3089
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "tauri-android-invoice",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-barcode-scanner": "^2.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"vite": "^5.2.0",
|
||||
"vitest": "^1.6.0"
|
||||
}
|
||||
}
|
||||
4808
src-tauri/Cargo.lock
generated
Normal file
4808
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
src-tauri/Cargo.toml
Normal file
22
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "tauri-android-invoice"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls"] }
|
||||
|
||||
# 扫码插件仅移动端需要
|
||||
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
|
||||
tauri-plugin-barcode-scanner = "2"
|
||||
4
src-tauri/build.rs
Normal file
4
src-tauri/build.rs
Normal file
@ -0,0 +1,4 @@
|
||||
// Tauri 构建脚本入口。
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
9
src-tauri/capabilities/default.json
Normal file
9
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "默认权限集合",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
13
src-tauri/capabilities/mobile.json
Normal file
13
src-tauri/capabilities/mobile.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/mobile-schema.json",
|
||||
"identifier": "mobile",
|
||||
"description": "移动端扫码权限",
|
||||
"platforms": ["android", "iOS"],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"barcode-scanner:allow-scan",
|
||||
"barcode-scanner:allow-cancel",
|
||||
"barcode-scanner:allow-check-permissions",
|
||||
"barcode-scanner:allow-request-permissions"
|
||||
]
|
||||
}
|
||||
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
115
src-tauri/src/ai.rs
Normal file
115
src-tauri/src/ai.rs
Normal file
@ -0,0 +1,115 @@
|
||||
// AI 字段映射:调用硅基流动(OpenAI 兼容)接口,把表单字段与开票信息映射为填充方案。
|
||||
use crate::db::InvoiceInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ===== AI 服务配置:更换服务商时只改此处 =====
|
||||
const AI_BASE_URL: &str = "https://api.siliconflow.cn/v1/chat/completions";
|
||||
const AI_API_KEY: &str = "sk-drqdzsdvsnqxalvwjggsexyowbertkxrgpqyeqilhkkiqemn";
|
||||
const AI_MODEL: &str = "Qwen/Qwen2.5-7B-Instruct";
|
||||
|
||||
/// 表单字段摘要,与前端 collectFormFields 输出结构对应。
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FormField {
|
||||
pub selector: String,
|
||||
#[serde(default)]
|
||||
pub label: String,
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub placeholder: String,
|
||||
#[serde(default)]
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
/// 单个字段的填充方案。
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FieldMapping {
|
||||
/// 目标字段的 CSS 选择器。
|
||||
pub selector: String,
|
||||
/// 应填入的值。
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// 构造提示词:要求模型只输出选择器到值的 JSON 数组。
|
||||
/// - 参数 fields:表单字段;info:开票信息。
|
||||
/// - 返回:完整的用户提示词字符串。
|
||||
fn build_prompt(fields: &[FormField], info: &InvoiceInfo) -> String {
|
||||
let fields_json = serde_json::to_string(fields).unwrap_or_default();
|
||||
let info_json = serde_json::to_string(info).unwrap_or_default();
|
||||
format!(
|
||||
"你是发票表单填写助手。下面是网页表单字段列表(JSON)和用户的开票信息(JSON)。\n\
|
||||
请判断每个字段应填入的值,只输出 JSON 数组,元素形如 {{\"selector\":\"...\",\"value\":\"...\"}},\n\
|
||||
没有合适值的字段请跳过,不要输出任何多余文字或 markdown。\n\
|
||||
表单字段:{fields_json}\n用户信息:{info_json}"
|
||||
)
|
||||
}
|
||||
|
||||
/// 从模型返回文本中提取 JSON 数组(容错去除 markdown 代码块)。
|
||||
/// - 参数 text:模型原始输出。
|
||||
/// - 返回:解析出的映射列表;解析失败返回空列表。
|
||||
fn parse_mapping(text: &str) -> Vec<FieldMapping> {
|
||||
let start = text.find('[');
|
||||
let end = text.rfind(']');
|
||||
if let (Some(s), Some(e)) = (start, end) {
|
||||
if e > s {
|
||||
if let Ok(v) = serde_json::from_str::<Vec<FieldMapping>>(&text[s..=e]) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// 调用 AI 生成字段映射。
|
||||
/// - 参数 fields:表单字段;info:开票信息。
|
||||
/// - 返回:字段映射列表;出错时返回 Err 字符串。
|
||||
/// - 注意:使用 reqwest 阻塞客户端,需在 tauri 异步 command 中通过 spawn_blocking 调用。
|
||||
pub fn map_fields(fields: &[FormField], info: &InvoiceInfo) -> Result<Vec<FieldMapping>, String> {
|
||||
let body = serde_json::json!({
|
||||
"model": AI_MODEL,
|
||||
"messages": [{ "role": "user", "content": build_prompt(fields, info) }],
|
||||
"temperature": 0.1
|
||||
});
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(AI_BASE_URL)
|
||||
.bearer_auth(AI_API_KEY)
|
||||
.json(&body)
|
||||
.send()
|
||||
.map_err(|e| format!("请求失败: {e}"))?;
|
||||
let json: serde_json::Value = resp.json().map_err(|e| format!("解析响应失败: {e}"))?;
|
||||
let content = json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
.ok_or("响应缺少 content 字段")?;
|
||||
Ok(parse_mapping(content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// 3-1 从纯 JSON 文本解析映射
|
||||
#[test]
|
||||
fn t3_1_parse_plain_json() {
|
||||
let text = r##"[{"selector":"#title","value":"某公司"}]"##;
|
||||
let m = parse_mapping(text);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].selector, "#title");
|
||||
}
|
||||
|
||||
// 3-2 从带 markdown 代码块的文本中提取
|
||||
#[test]
|
||||
fn t3_2_parse_with_markdown() {
|
||||
let text = "```json\n[{\"selector\":\"a\",\"value\":\"b\"}]\n```";
|
||||
let m = parse_mapping(text);
|
||||
assert_eq!(m.len(), 1);
|
||||
assert_eq!(m[0].value, "b");
|
||||
}
|
||||
|
||||
// 3-3 无有效 JSON 时返回空
|
||||
#[test]
|
||||
fn t3_3_parse_invalid() {
|
||||
assert!(parse_mapping("抱歉我无法处理").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
206
src-tauri/src/db.rs
Normal file
206
src-tauri/src/db.rs
Normal file
@ -0,0 +1,206 @@
|
||||
// 开票信息的本地存储:基于 SQLite(rusqlite bundled)。
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// 开票信息数据结构,字段与数据库表一一对应。
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct InvoiceInfo {
|
||||
/// 记录 id,新增时为 None。
|
||||
pub id: Option<i64>,
|
||||
/// 抬头。
|
||||
pub title: String,
|
||||
/// 税号。
|
||||
pub tax_no: String,
|
||||
/// 邮箱。
|
||||
pub email: String,
|
||||
/// 地址(可选)。
|
||||
#[serde(default)]
|
||||
pub address: String,
|
||||
/// 电话(可选)。
|
||||
#[serde(default)]
|
||||
pub phone: String,
|
||||
/// 开户行(可选)。
|
||||
#[serde(default)]
|
||||
pub bank_name: String,
|
||||
/// 银行账号(可选)。
|
||||
#[serde(default)]
|
||||
pub bank_account: String,
|
||||
/// 是否默认,1 为默认。
|
||||
#[serde(default)]
|
||||
pub is_default: i64,
|
||||
}
|
||||
|
||||
/// 数据库连接的全局状态,供各 command 共享。
|
||||
pub struct Db(pub Mutex<Connection>);
|
||||
|
||||
/// 初始化数据库并建表。
|
||||
/// - 参数 path:数据库文件路径。
|
||||
/// - 返回:建立好的连接对象。
|
||||
/// - 注意:使用 IF NOT EXISTS,重复调用安全。
|
||||
pub fn init(path: &std::path::Path) -> Connection {
|
||||
let conn = Connection::open(path).expect("打开数据库失败");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS invoice_info (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
tax_no TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
address TEXT DEFAULT '',
|
||||
phone TEXT DEFAULT '',
|
||||
bank_name TEXT DEFAULT '',
|
||||
bank_account TEXT DEFAULT '',
|
||||
is_default INTEGER DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.expect("建表失败");
|
||||
conn
|
||||
}
|
||||
|
||||
/// 查询全部开票信息,默认项排在前面。
|
||||
/// - 参数 conn:数据库连接。
|
||||
/// - 返回:信息列表。
|
||||
pub fn list(conn: &Connection) -> Vec<InvoiceInfo> {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id,title,tax_no,email,address,phone,bank_name,bank_account,is_default FROM invoice_info ORDER BY is_default DESC, id DESC")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(InvoiceInfo {
|
||||
id: Some(r.get(0)?),
|
||||
title: r.get(1)?,
|
||||
tax_no: r.get(2)?,
|
||||
email: r.get(3)?,
|
||||
address: r.get(4)?,
|
||||
phone: r.get(5)?,
|
||||
bank_name: r.get(6)?,
|
||||
bank_account: r.get(7)?,
|
||||
is_default: r.get(8)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|x| x.ok()).collect()
|
||||
}
|
||||
|
||||
/// 新增或更新一条信息(id 为 None 时新增)。
|
||||
/// - 参数 conn:数据库连接;info:信息对象。
|
||||
/// - 返回:记录 id。
|
||||
/// - 注意:若该条 is_default=1,会自动清除其他记录的默认标记。
|
||||
pub fn save(conn: &Connection, info: &InvoiceInfo) -> i64 {
|
||||
let id = match info.id {
|
||||
Some(id) => {
|
||||
conn.execute(
|
||||
"UPDATE invoice_info SET title=?1,tax_no=?2,email=?3,address=?4,phone=?5,bank_name=?6,bank_account=?7,is_default=?8 WHERE id=?9",
|
||||
rusqlite::params![info.title, info.tax_no, info.email, info.address, info.phone, info.bank_name, info.bank_account, info.is_default, id],
|
||||
).unwrap();
|
||||
id
|
||||
}
|
||||
None => {
|
||||
conn.execute(
|
||||
"INSERT INTO invoice_info(title,tax_no,email,address,phone,bank_name,bank_account,is_default) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||
rusqlite::params![info.title, info.tax_no, info.email, info.address, info.phone, info.bank_name, info.bank_account, info.is_default],
|
||||
).unwrap();
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
};
|
||||
if info.is_default == 1 {
|
||||
conn.execute("UPDATE invoice_info SET is_default=0 WHERE id<>?1", [id]).unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// 删除指定信息。
|
||||
/// - 参数 conn:数据库连接;id:记录 id。
|
||||
pub fn delete(conn: &Connection, id: i64) {
|
||||
conn.execute("DELETE FROM invoice_info WHERE id=?1", [id]).unwrap();
|
||||
}
|
||||
|
||||
/// 将指定记录设为默认,并清除其他记录的默认标记。
|
||||
/// - 参数 conn:数据库连接;id:记录 id。
|
||||
pub fn set_default(conn: &Connection, id: i64) {
|
||||
conn.execute("UPDATE invoice_info SET is_default=0", []).unwrap();
|
||||
conn.execute("UPDATE invoice_info SET is_default=1 WHERE id=?1", [id]).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造内存数据库并建表,供测试使用。
|
||||
fn mem() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE invoice_info (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL, tax_no TEXT NOT NULL, email TEXT NOT NULL,
|
||||
address TEXT DEFAULT '', phone TEXT DEFAULT '',
|
||||
bank_name TEXT DEFAULT '', bank_account TEXT DEFAULT '',
|
||||
is_default INTEGER DEFAULT 0);",
|
||||
).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
/// 构造一条测试信息。
|
||||
fn sample(title: &str, is_default: i64) -> InvoiceInfo {
|
||||
InvoiceInfo {
|
||||
id: None, title: title.into(), tax_no: "TAX".into(), email: "a@b.com".into(),
|
||||
address: String::new(), phone: String::new(), bank_name: String::new(),
|
||||
bank_account: String::new(), is_default,
|
||||
}
|
||||
}
|
||||
|
||||
// 2-1 新增后可查询到
|
||||
#[test]
|
||||
fn t2_1_save_and_list() {
|
||||
let conn = mem();
|
||||
save(&conn, &sample("公司A", 0));
|
||||
assert_eq!(list(&conn).len(), 1);
|
||||
}
|
||||
|
||||
// 2-2 新增默认项会清除其他默认标记,且默认项排在最前
|
||||
#[test]
|
||||
fn t2_2_single_default() {
|
||||
let conn = mem();
|
||||
save(&conn, &sample("公司A", 1));
|
||||
save(&conn, &sample("公司B", 1));
|
||||
let items = list(&conn);
|
||||
let defaults: Vec<_> = items.iter().filter(|x| x.is_default == 1).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(items[0].title, "公司B");
|
||||
}
|
||||
|
||||
// 2-3 更新已有记录
|
||||
#[test]
|
||||
fn t2_3_update() {
|
||||
let conn = mem();
|
||||
let id = save(&conn, &sample("旧名", 0));
|
||||
let mut info = sample("新名", 0);
|
||||
info.id = Some(id);
|
||||
save(&conn, &info);
|
||||
let items = list(&conn);
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].title, "新名");
|
||||
}
|
||||
|
||||
// 2-4 删除记录
|
||||
#[test]
|
||||
fn t2_4_delete() {
|
||||
let conn = mem();
|
||||
let id = save(&conn, &sample("公司A", 0));
|
||||
delete(&conn, id);
|
||||
assert!(list(&conn).is_empty());
|
||||
}
|
||||
|
||||
// 2-5 set_default 保证唯一默认
|
||||
#[test]
|
||||
fn t2_5_set_default() {
|
||||
let conn = mem();
|
||||
let id1 = save(&conn, &sample("公司A", 0));
|
||||
let _id2 = save(&conn, &sample("公司B", 0));
|
||||
set_default(&conn, id1);
|
||||
let items = list(&conn);
|
||||
let defaults: Vec<_> = items.iter().filter(|x| x.is_default == 1).collect();
|
||||
assert_eq!(defaults.len(), 1);
|
||||
assert_eq!(defaults[0].id, Some(id1));
|
||||
}
|
||||
}
|
||||
80
src-tauri/src/lib.rs
Normal file
80
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,80 @@
|
||||
// 应用库入口:注册数据库状态、AI 与信息管理相关 command。
|
||||
mod ai;
|
||||
mod db;
|
||||
|
||||
use ai::{FieldMapping, FormField};
|
||||
use db::{Db, InvoiceInfo};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
/// 查询全部开票信息。
|
||||
/// - 参数 state:数据库状态。
|
||||
/// - 返回:信息列表。
|
||||
#[tauri::command]
|
||||
fn list_invoice_info(state: tauri::State<Db>) -> Vec<InvoiceInfo> {
|
||||
let conn = state.0.lock().unwrap();
|
||||
db::list(&conn)
|
||||
}
|
||||
|
||||
/// 新增或更新一条开票信息。
|
||||
/// - 参数 state:数据库状态;info:信息对象。
|
||||
/// - 返回:记录 id。
|
||||
#[tauri::command]
|
||||
fn save_invoice_info(state: tauri::State<Db>, info: InvoiceInfo) -> i64 {
|
||||
let conn = state.0.lock().unwrap();
|
||||
db::save(&conn, &info)
|
||||
}
|
||||
|
||||
/// 删除一条开票信息。
|
||||
/// - 参数 state:数据库状态;id:记录 id。
|
||||
#[tauri::command]
|
||||
fn delete_invoice_info(state: tauri::State<Db>, id: i64) {
|
||||
let conn = state.0.lock().unwrap();
|
||||
db::delete(&conn, id);
|
||||
}
|
||||
|
||||
/// 将指定记录设为默认。
|
||||
/// - 参数 state:数据库状态;id:记录 id。
|
||||
#[tauri::command]
|
||||
fn set_default(state: tauri::State<Db>, id: i64) {
|
||||
let conn = state.0.lock().unwrap();
|
||||
db::set_default(&conn, id);
|
||||
}
|
||||
|
||||
/// 调用 AI 生成表单字段映射。
|
||||
/// - 参数 fields:表单字段;info:开票信息。
|
||||
/// - 返回:字段映射列表或错误信息。
|
||||
/// - 注意:AI 请求为阻塞调用,放入 spawn_blocking 避免阻塞异步运行时。
|
||||
#[tauri::command]
|
||||
async fn ai_map_fields(fields: Vec<FormField>, info: InvoiceInfo) -> Result<Vec<FieldMapping>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || ai::map_fields(&fields, &info))
|
||||
.await
|
||||
.map_err(|e| format!("任务执行失败: {e}"))?
|
||||
}
|
||||
|
||||
/// 应用运行入口:初始化插件、数据库并注册 command。
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default();
|
||||
// 扫码插件仅在移动端可用,桌面端跳过以便本地调试。
|
||||
#[cfg(mobile)]
|
||||
let builder = builder.plugin(tauri_plugin_barcode_scanner::init());
|
||||
builder
|
||||
.setup(|app| {
|
||||
let dir = app.path().app_data_dir().expect("获取数据目录失败");
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
let conn = db::init(&dir.join("invoice.db"));
|
||||
app.manage(Db(Mutex::new(conn)));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_invoice_info,
|
||||
save_invoice_info,
|
||||
delete_invoice_info,
|
||||
set_default,
|
||||
ai_map_fields
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("启动应用失败");
|
||||
}
|
||||
|
||||
6
src-tauri/src/main.rs
Normal file
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
||||
// 桌面端可执行入口,移动端由 mobile_entry_point 生成。
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
25
src-tauri/tauri.conf.json
Normal file
25
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "发票助手",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.example.invoice",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{ "title": "发票助手", "width": 400, "height": 720 }
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": ["icons/icon.png"]
|
||||
}
|
||||
}
|
||||
96
src/App.vue
Normal file
96
src/App.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { RouterView } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { computed } from "vue";
|
||||
|
||||
// 顶层布局:内容区 + 底部 Tab 栏。扫码全屏时隐藏 Tab。
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 当前是否处于扫码等全屏页面,用于隐藏底部 Tab。
|
||||
const hideTab = computed(() => route.path === "/scan");
|
||||
|
||||
/** 判断某 Tab 是否激活。@param {string} path 目标路径 @returns {boolean} */
|
||||
function isActive(path) {
|
||||
if (path === "/") return route.path === "/" || route.path.startsWith("/edit");
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
|
||||
/** 切换到指定 Tab。@param {string} path 目标路径 @returns {void} */
|
||||
function go(path) {
|
||||
if (route.path !== path) router.push(path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<main class="main" :class="{ full: hideTab }"><RouterView /></main>
|
||||
<nav v-if="!hideTab" class="tabbar">
|
||||
<button class="tab" :class="{ active: isActive('/') }" @click="go('/')">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M6 2h9l4 4v14.5a.5.5 0 0 1-.76.43L16.5 20l-2 1.2-2-1.2-2 1.2-2-1.2-1.74 1.16A.5.5 0 0 1 5 20.5V2Z" />
|
||||
<path d="M14 2v5h5" />
|
||||
<path d="M8.5 11h7M8.5 14.5h7" />
|
||||
</svg>
|
||||
<span class="tab-label">开票信息</span>
|
||||
</button>
|
||||
<button class="tab" :class="{ active: isActive('/scan') }" @click="go('/scan')">
|
||||
<svg class="tab-icon" viewBox="0 0 24 24" width="24" height="24" fill="none"
|
||||
stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7V5a2 2 0 0 1 2-2h2M17 3h2a2 2 0 0 1 2 2v2M21 17v2a2 2 0 0 1-2 2h-2M7 21H5a2 2 0 0 1-2-2v-2" />
|
||||
<path d="M3 12h18" />
|
||||
</svg>
|
||||
<span class="tab-label">扫一扫</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { font-family: system-ui, sans-serif; background: #f5f5f5; }
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
/* 内容区:顶部预留状态栏安全区,底部留出 Tab 栏空间 */
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
padding-top: calc(16px + env(safe-area-inset-top));
|
||||
padding-left: calc(16px + env(safe-area-inset-left));
|
||||
padding-right: calc(16px + env(safe-area-inset-right));
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.main.full { padding: 0; }
|
||||
|
||||
/* 底部 Tab 栏:固定、醒目、带安全区内边距 */
|
||||
.tabbar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 8px 0 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tab .tab-icon { display: block; }
|
||||
.tab.active { color: #1976d2; font-weight: 700; }
|
||||
|
||||
button { padding: 8px 14px; border: none; border-radius: 6px; background: #1976d2; color: #fff; font-size: 14px; }
|
||||
button.plain { background: #e0e0e0; color: #333; }
|
||||
input, textarea { width: 100%; padding: 8px; margin: 4px 0 10px; border: 1px solid #ccc; border-radius: 6px; font-size: 14px; }
|
||||
.card { background: #fff; border-radius: 8px; padding: 12px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
</style>
|
||||
23
src/api.js
Normal file
23
src/api.js
Normal file
@ -0,0 +1,23 @@
|
||||
// 前端与 Rust 层交互的统一封装:开票信息 CRUD 与 AI 字段映射。
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** 查询全部开票信息。@returns {Promise<Array>} 信息列表 */
|
||||
export const listInfo = () => invoke("list_invoice_info");
|
||||
|
||||
/** 新增或更新一条开票信息。@param {object} info 信息对象 @returns {Promise<number>} 记录 id */
|
||||
export const saveInfo = (info) => invoke("save_invoice_info", { info });
|
||||
|
||||
/** 删除指定信息。@param {number} id 记录 id @returns {Promise<void>} */
|
||||
export const deleteInfo = (id) => invoke("delete_invoice_info", { id });
|
||||
|
||||
/** 设为默认信息。@param {number} id 记录 id @returns {Promise<void>} */
|
||||
export const setDefault = (id) => invoke("set_default", { id });
|
||||
|
||||
/**
|
||||
* 调用 AI 生成字段填充映射。
|
||||
* @param {Array} fields 表单字段摘要
|
||||
* @param {object} info 用于填充的开票信息
|
||||
* @returns {Promise<Array<{selector:string,value:string}>>} 字段映射
|
||||
*/
|
||||
export const aiMapFields = (fields, info) =>
|
||||
invoke("ai_map_fields", { fields, info });
|
||||
80
src/autofill.js
Normal file
80
src/autofill.js
Normal file
@ -0,0 +1,80 @@
|
||||
// AI 自动填充相关的纯逻辑:抓取表单摘要、按映射回填。
|
||||
// 与 DOM 交互但不依赖 Tauri,便于单元测试。
|
||||
|
||||
/**
|
||||
* 转义 CSS 标识符,优先使用原生 CSS.escape,环境缺失时回退到简单转义。
|
||||
* @param {string} id - 需要转义的标识符。
|
||||
* @returns {string} 可安全用于选择器的字符串。
|
||||
*/
|
||||
function escapeId(id) {
|
||||
if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(id);
|
||||
return id.replace(/([^a-zA-Z0-9_-])/g, "\\$1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 抓取文档中所有表单字段的摘要信息。
|
||||
* @param {Document} doc - 目标页面的 document 对象。
|
||||
* @returns {Array<{selector:string,label:string,name:string,placeholder:string,type:string}>}
|
||||
* 每个可填写字段的摘要数组,selector 为可定位该元素的 CSS 选择器。
|
||||
* 注意:selector 优先用 id,其次用 name,最后回退到 data-ai-idx 索引。
|
||||
*/
|
||||
export function collectFormFields(doc) {
|
||||
const nodes = Array.from(doc.querySelectorAll("input, select, textarea"));
|
||||
return nodes
|
||||
.filter((el) => !["hidden", "submit", "button"].includes(el.type))
|
||||
.map((el, idx) => ({
|
||||
selector: buildSelector(el, idx),
|
||||
label: findLabel(doc, el),
|
||||
name: el.getAttribute("name") || "",
|
||||
placeholder: el.getAttribute("placeholder") || "",
|
||||
type: el.getAttribute("type") || el.tagName.toLowerCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为元素生成稳定的 CSS 选择器。
|
||||
* @param {Element} el - 目标元素。
|
||||
* @param {number} idx - 元素在集合中的序号,用于兜底。
|
||||
* @returns {string} CSS 选择器字符串。
|
||||
*/
|
||||
function buildSelector(el, idx) {
|
||||
if (el.id) return `#${escapeId(el.id)}`;
|
||||
const name = el.getAttribute("name");
|
||||
if (name) return `${el.tagName.toLowerCase()}[name="${name}"]`;
|
||||
return `${el.tagName.toLowerCase()}[data-ai-idx="${idx}"]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找元素对应的 label 文本。
|
||||
* @param {Document} doc - 页面 document。
|
||||
* @param {Element} el - 目标表单元素。
|
||||
* @returns {string} label 文本,找不到返回空字符串。
|
||||
*/
|
||||
function findLabel(doc, el) {
|
||||
if (el.id) {
|
||||
const lab = doc.querySelector(`label[for="${escapeId(el.id)}"]`);
|
||||
if (lab) return lab.textContent.trim();
|
||||
}
|
||||
const parentLabel = el.closest("label");
|
||||
return parentLabel ? parentLabel.textContent.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 AI 返回的映射回填表单,并派发 input/change 事件以触发页面响应。
|
||||
* @param {Document} doc - 页面 document。
|
||||
* @param {Array<{selector:string,value:string}>} mapping - 字段选择器到填充值的映射。
|
||||
* @returns {{filled:number,missed:string[]}} 成功填充数量与未命中的选择器列表。
|
||||
*/
|
||||
export function applyMapping(doc, mapping) {
|
||||
let filled = 0;
|
||||
const missed = [];
|
||||
for (const item of mapping || []) {
|
||||
const el = doc.querySelector(item.selector);
|
||||
if (!el) { missed.push(item.selector); continue; }
|
||||
el.value = item.value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
filled += 1;
|
||||
}
|
||||
return { filled, missed };
|
||||
}
|
||||
20
src/main.js
Normal file
20
src/main.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import App from "./App.vue";
|
||||
import InfoList from "./views/InfoList.vue";
|
||||
import InfoEdit from "./views/InfoEdit.vue";
|
||||
import Scan from "./views/Scan.vue";
|
||||
import WebForm from "./views/WebForm.vue";
|
||||
|
||||
// 路由表:信息列表 / 编辑 / 扫码 / h5 表单页
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: InfoList },
|
||||
{ path: "/edit/:id?", component: InfoEdit },
|
||||
{ path: "/scan", component: Scan },
|
||||
{ path: "/web", component: WebForm },
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
50
src/views/InfoEdit.vue
Normal file
50
src/views/InfoEdit.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { listInfo, saveInfo } from "../api";
|
||||
|
||||
// 开票信息编辑页:新增或修改一条信息。
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const form = ref({
|
||||
id: null, title: "", tax_no: "", email: "",
|
||||
address: "", phone: "", bank_name: "", bank_account: "", is_default: 0,
|
||||
});
|
||||
|
||||
/** 若为编辑模式,加载已有信息填入表单。@returns {Promise<void>} */
|
||||
async function load() {
|
||||
const id = route.params.id;
|
||||
if (!id) return;
|
||||
const list = await listInfo();
|
||||
const found = list.find((x) => String(x.id) === String(id));
|
||||
if (found) form.value = { ...found };
|
||||
}
|
||||
|
||||
/** 保存并返回列表。@returns {Promise<void>} */
|
||||
async function onSave() {
|
||||
await saveInfo(form.value);
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<label>抬头 *</label><input v-model="form.title" />
|
||||
<label>税号 *</label><input v-model="form.tax_no" />
|
||||
<label>Email *</label><input v-model="form.email" />
|
||||
<label>地址</label><input v-model="form.address" />
|
||||
<label>电话</label><input v-model="form.phone" />
|
||||
<label>开户行</label><input v-model="form.bank_name" />
|
||||
<label>银行账号</label><input v-model="form.bank_account" />
|
||||
<label style="display:flex;align-items:center;gap:8px;width:auto">
|
||||
<input type="checkbox" style="width:auto" :checked="form.is_default===1"
|
||||
@change="form.is_default = $event.target.checked ? 1 : 0" /> 设为默认
|
||||
</label>
|
||||
<div style="margin-top:12px;display:flex;gap:8px">
|
||||
<button @click="onSave">保存</button>
|
||||
<button class="plain" @click="router.back()">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
77
src/views/InfoList.vue
Normal file
77
src/views/InfoList.vue
Normal file
@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { listInfo, deleteInfo, setDefault } from "../api";
|
||||
|
||||
// 开票信息列表页:展示所有信息,支持设默认、编辑、删除。
|
||||
const router = useRouter();
|
||||
const items = ref([]);
|
||||
const loaded = ref(false);
|
||||
|
||||
/** 加载信息列表。@returns {Promise<void>} */
|
||||
async function load() {
|
||||
items.value = await listInfo();
|
||||
loaded.value = true;
|
||||
}
|
||||
|
||||
/** 删除一条信息后刷新列表。@param {number} id 记录 id */
|
||||
async function onDelete(id) {
|
||||
await deleteInfo(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
/** 设为默认后刷新列表。@param {number} id 记录 id */
|
||||
async function onSetDefault(id) {
|
||||
await setDefault(id);
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="header">
|
||||
<h2 class="title">开票信息</h2>
|
||||
<button v-if="items.length" @click="router.push('/edit')">+ 新增</button>
|
||||
</div>
|
||||
|
||||
<!-- 空状态:居中引导,避免界面过空 -->
|
||||
<div v-if="loaded && !items.length" class="empty">
|
||||
<div class="empty-icon">🧾</div>
|
||||
<p class="empty-title">还没有开票信息</p>
|
||||
<p class="empty-desc">添加抬头、税号、邮箱等信息,扫码开票时可自动填写。</p>
|
||||
<button @click="router.push('/edit')">+ 新增开票信息</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="list">
|
||||
<div v-for="it in items" :key="it.id" class="card">
|
||||
<div style="font-weight:700">
|
||||
{{ it.title }}
|
||||
<span v-if="it.is_default" class="badge">默认</span>
|
||||
</div>
|
||||
<div style="font-size:13px;color:#555">税号:{{ it.tax_no }}</div>
|
||||
<div style="font-size:13px;color:#555">Email:{{ it.email }}</div>
|
||||
<div style="margin-top:10px;display:flex;gap:8px">
|
||||
<button class="plain" v-if="!it.is_default" @click="onSetDefault(it.id)">设为默认</button>
|
||||
<button class="plain" @click="router.push('/edit/' + it.id)">编辑</button>
|
||||
<button class="plain" @click="onDelete(it.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.title { margin: 0; font-size: 20px; }
|
||||
|
||||
/* 空状态样式:垂直居中占位,界面不再空旷 */
|
||||
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 64px 24px; color: #888; }
|
||||
.empty-icon { font-size: 56px; margin-bottom: 12px; }
|
||||
.empty-title { margin: 0 0 6px; font-size: 16px; color: #555; font-weight: 600; }
|
||||
.empty-desc { margin: 0 0 20px; font-size: 13px; line-height: 1.6; max-width: 260px; }
|
||||
|
||||
.badge { margin-left: 6px; padding: 1px 8px; font-size: 11px; color: #fff; background: #1976d2; border-radius: 10px; vertical-align: middle; }
|
||||
</style>
|
||||
155
src/views/Scan.vue
Normal file
155
src/views/Scan.vue
Normal file
@ -0,0 +1,155 @@
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
scan,
|
||||
cancel,
|
||||
checkPermissions,
|
||||
requestPermissions,
|
||||
openAppSettings,
|
||||
Format,
|
||||
} from "@tauri-apps/plugin-barcode-scanner";
|
||||
|
||||
// 扫码页:调用相机扫描二维码,得到 URL 后跳转 h5 表单页。
|
||||
// Android 上相机预览渲染在 WebView 下层,扫码期间需将页面背景透明化才能看到画面。
|
||||
const router = useRouter();
|
||||
const error = ref("");
|
||||
const denied = ref(false);
|
||||
const scanning = ref(false);
|
||||
|
||||
/**
|
||||
* 将任意异常转换为可读字符串,避免出现 [object Object]。
|
||||
* @param {unknown} e 捕获到的异常。
|
||||
* @returns {string} 可读的错误描述。
|
||||
*/
|
||||
function toMessage(e) {
|
||||
if (typeof e === "string") return e;
|
||||
if (e && e.message) return e.message;
|
||||
try {
|
||||
return JSON.stringify(e);
|
||||
} catch {
|
||||
return String(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换扫码透明态:透明时可看到 WebView 下层的相机预览。
|
||||
* @param {boolean} on 是否进入透明态。
|
||||
* @returns {void}
|
||||
*/
|
||||
function setTransparent(on) {
|
||||
const val = on ? "transparent" : "";
|
||||
document.documentElement.style.background = val;
|
||||
document.body.style.background = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保已获得相机权限,未授权时主动申请。
|
||||
* @returns {Promise<boolean>} 是否已授权。
|
||||
*/
|
||||
async function ensurePermission() {
|
||||
let state = await checkPermissions();
|
||||
if (state === "prompt" || state === "prompt-with-rationale") {
|
||||
state = await requestPermissions();
|
||||
}
|
||||
denied.value = state === "denied";
|
||||
return state === "granted";
|
||||
}
|
||||
|
||||
/** 启动扫码,成功后携带 URL 跳转到 WebForm 页。@returns {Promise<void>} */
|
||||
async function startScan() {
|
||||
error.value = "";
|
||||
if (scanning.value) return;
|
||||
try {
|
||||
const granted = await ensurePermission();
|
||||
if (!granted) {
|
||||
error.value = "未获得相机权限,请授权后重试。";
|
||||
return;
|
||||
}
|
||||
scanning.value = true;
|
||||
setTransparent(true);
|
||||
const res = await scan({ windowed: true, formats: [Format.QRCode] });
|
||||
const url = res.content;
|
||||
if (/^https?:\/\//.test(url)) {
|
||||
router.push({ path: "/web", query: { url } });
|
||||
} else {
|
||||
error.value = "扫描内容不是有效网址:" + url;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = "扫码失败或已取消:" + toMessage(e);
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
setTransparent(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消正在进行的扫码。@returns {Promise<void>} */
|
||||
async function stopScan() {
|
||||
try {
|
||||
await cancel();
|
||||
} catch {
|
||||
/* 忽略取消异常 */
|
||||
}
|
||||
scanning.value = false;
|
||||
setTransparent(false);
|
||||
}
|
||||
|
||||
// 离开页面时确保关闭相机并复原背景,避免占用。
|
||||
onUnmounted(() => {
|
||||
if (scanning.value) stopScan();
|
||||
setTransparent(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 非扫码态:说明与入口 -->
|
||||
<div v-if="!scanning" class="card">
|
||||
<p>点击下方按钮,将小票二维码对准取景框即可自动识别。</p>
|
||||
<button @click="startScan">开始扫码</button>
|
||||
<p v-if="error" style="color:#d32f2f;margin-top:12px">{{ error }}</p>
|
||||
<button v-if="denied" class="plain" style="margin-top:12px" @click="openAppSettings()">
|
||||
去系统设置开启相机权限
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 扫码态:透明覆盖层,仅显示取景框与取消按钮 -->
|
||||
<div v-else class="scan-overlay">
|
||||
<div class="scan-frame">
|
||||
<span class="corner tl"></span><span class="corner tr"></span>
|
||||
<span class="corner bl"></span><span class="corner br"></span>
|
||||
</div>
|
||||
<p class="scan-tip">将二维码放入框内,自动识别</p>
|
||||
<button class="plain" @click="stopScan">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 扫码覆盖层:铺满全屏,除取景框外区域半透明以聚焦二维码 */
|
||||
.scan-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.scan-frame {
|
||||
position: relative;
|
||||
width: 66vw;
|
||||
height: 66vw;
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.45);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.corner { position: absolute; width: 24px; height: 24px; border: 3px solid #21c17a; }
|
||||
.corner.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||||
.corner.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||||
.corner.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||||
.corner.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||||
.scan-tip { color: #fff; font-size: 14px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); }
|
||||
</style>
|
||||
55
src/views/WebForm.vue
Normal file
55
src/views/WebForm.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, useTemplateRef } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { collectFormFields, applyMapping } from "../autofill";
|
||||
import { listInfo, aiMapFields } from "../api";
|
||||
|
||||
// h5 表单页:用 iframe 加载扫描到的页面,抓取表单并用 AI 自动填充。
|
||||
// 注意:iframe 跨域时无法访问其 DOM,此时提示用户手动填写(详见构建.md 限制说明)。
|
||||
const route = useRoute();
|
||||
const url = ref(route.query.url || "");
|
||||
const status = ref("");
|
||||
const iframe = useTemplateRef("iframe");
|
||||
|
||||
/** 读取默认开票信息,无默认则取第一条。@returns {Promise<object|null>} */
|
||||
async function getDefaultInfo() {
|
||||
const list = await listInfo();
|
||||
return list.find((x) => x.is_default === 1) || list[0] || null;
|
||||
}
|
||||
|
||||
/** 执行自动填充:抓取表单 -> 调 AI -> 回填。@returns {Promise<void>} */
|
||||
async function autofill() {
|
||||
status.value = "正在识别表单...";
|
||||
let doc;
|
||||
try {
|
||||
doc = iframe.value.contentDocument;
|
||||
if (!doc) throw new Error("no doc");
|
||||
} catch {
|
||||
status.value = "无法访问页面内容(可能跨域),请手动填写。";
|
||||
return;
|
||||
}
|
||||
const fields = collectFormFields(doc);
|
||||
if (!fields.length) { status.value = "未发现可填写的表单字段。"; return; }
|
||||
const info = await getDefaultInfo();
|
||||
if (!info) { status.value = "请先录入开票信息。"; return; }
|
||||
try {
|
||||
const mapping = await aiMapFields(fields, info);
|
||||
const { filled, missed } = applyMapping(doc, mapping);
|
||||
status.value = `已自动填写 ${filled} 项${missed.length ? ",未命中 " + missed.length + " 项" : ""},请核对后提交。`;
|
||||
} catch (e) {
|
||||
status.value = "AI 识别失败:" + e;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { if (!url.value) status.value = "缺少页面地址。"; });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||||
<button @click="autofill">AI 自动填写</button>
|
||||
<span style="font-size:13px;color:#555;align-self:center">{{ status }}</span>
|
||||
</div>
|
||||
<iframe ref="iframe" :src="url" style="width:100%;height:70vh;border:1px solid #ccc;border-radius:6px;background:#fff"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
42
tests/autofill.test.js
Normal file
42
tests/autofill.test.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { collectFormFields, applyMapping } from "../src/autofill.js";
|
||||
|
||||
// 针对表单抓取与回填的纯逻辑测试。
|
||||
describe("autofill", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<label for="title">抬头</label><input id="title" name="title" type="text" />
|
||||
<label>税号 <input name="taxno" placeholder="请输入税号" /></label>
|
||||
<input type="hidden" name="token" />
|
||||
<select id="type"><option>普票</option></select>
|
||||
<button type="submit">提交</button>
|
||||
</form>`;
|
||||
});
|
||||
|
||||
// 1-1 抓取字段应排除 hidden 与 button,并生成选择器
|
||||
it("1-1 collectFormFields 抓取可填字段", () => {
|
||||
const fields = collectFormFields(document);
|
||||
expect(fields.length).toBe(3);
|
||||
expect(fields[0]).toMatchObject({ selector: "#title", name: "title", label: "抬头" });
|
||||
expect(fields[1].placeholder).toBe("请输入税号");
|
||||
expect(fields.some((f) => f.name === "token")).toBe(false);
|
||||
});
|
||||
|
||||
// 1-2 按映射回填并统计命中
|
||||
it("1-2 applyMapping 回填成功项", () => {
|
||||
const res = applyMapping(document, [
|
||||
{ selector: "#title", value: "某某公司" },
|
||||
{ selector: 'input[name="taxno"]', value: "91310000MA1" },
|
||||
]);
|
||||
expect(res.filled).toBe(2);
|
||||
expect(document.querySelector("#title").value).toBe("某某公司");
|
||||
});
|
||||
|
||||
// 1-3 未命中的选择器应记录到 missed
|
||||
it("1-3 applyMapping 记录未命中", () => {
|
||||
const res = applyMapping(document, [{ selector: "#not-exist", value: "x" }]);
|
||||
expect(res.filled).toBe(0);
|
||||
expect(res.missed).toEqual(["#not-exist"]);
|
||||
});
|
||||
});
|
||||
11
vite.config.js
Normal file
11
vite.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// Tauri 前端构建配置:固定端口,产物输出到 dist
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
clearScreen: false,
|
||||
server: { port: 1420, strictPort: true },
|
||||
build: { target: "es2021", outDir: "dist" },
|
||||
test: { environment: "jsdom", include: ["tests/**/*.test.js"] },
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user