first commit
This commit is contained in:
Generated
+4808
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -0,0 +1,4 @@
|
||||
// Tauri 构建脚本入口。
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "默认权限集合",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default"
|
||||
]
|
||||
}
|
||||
@@ -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 |
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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("启动应用失败");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// 桌面端可执行入口,移动端由 mobile_entry_point 生成。
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
app_lib::run();
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user