first commit

This commit is contained in:
Cheney
2026-07-08 18:01:27 +08:00
commit 22ae3ac755
28 changed files with 9247 additions and 0 deletions
+4808
View File
File diff suppressed because it is too large Load Diff
+22
View 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
View File
@@ -0,0 +1,4 @@
// Tauri 构建脚本入口。
fn main() {
tauri_build::build()
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认权限集合",
"windows": ["main"],
"permissions": [
"core:default"
]
}
+13
View 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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+115
View 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
View 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
View 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
View File
@@ -0,0 +1,6 @@
// 桌面端可执行入口,移动端由 mobile_entry_point 生成。
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
app_lib::run();
}
+25
View 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"]
}
}