1
This commit is contained in:
75
src-tauri/Cargo.lock
generated
75
src-tauri/Cargo.lock
generated
@@ -1758,6 +1758,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -2568,6 +2574,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -3321,6 +3328,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4038,6 +4069,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http-range",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4098,6 +4130,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tempfile",
|
||||
@@ -4185,6 +4218,48 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
|
||||
@@ -13,8 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:event:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
|
||||
168
src-tauri/src/audio_db.rs
Normal file
168
src-tauri/src/audio_db.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! 工作流生成的语音 SQLite 存储
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::{Datelike, Local, Timelike};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
const MAX_RECORDS: i64 = 50;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AudioDbError {
|
||||
#[error("{0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GeneratedAudioRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AudioDb {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl AudioDb {
|
||||
pub fn open(path: PathBuf) -> Result<Self, AudioDbError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
AudioDbError::Message(format!("创建数据库目录失败: {e}"))
|
||||
})?;
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS generated_audios (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
let n = Local::now();
|
||||
format!(
|
||||
"{:02}-{:02} {:02}:{:02}",
|
||||
n.month(),
|
||||
n.day(),
|
||||
n.hour(),
|
||||
n.minute()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<GeneratedAudioRecord>, AudioDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AudioDbError::Message("语音数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, created_at FROM generated_audios ORDER BY id DESC LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![MAX_RECORDS], |row| {
|
||||
Ok(GeneratedAudioRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<GeneratedAudioRecord>, AudioDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AudioDbError::Message("语音数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, created_at FROM generated_audios WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
Ok(GeneratedAudioRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&self, name: &str, file_path: &str) -> Result<GeneratedAudioRecord, AudioDbError> {
|
||||
let file_path = file_path.trim();
|
||||
if file_path.is_empty() {
|
||||
return Err(AudioDbError::Message("文件路径不能为空".into()));
|
||||
}
|
||||
let name = name.trim();
|
||||
let name = if name.is_empty() {
|
||||
Self::default_name()
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
let created_at = chrono::Utc::now().to_rfc3339();
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AudioDbError::Message("语音数据库锁异常".into())
|
||||
})?;
|
||||
conn.execute(
|
||||
"INSERT INTO generated_audios (name, file_path, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![name, file_path, created_at],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
self.prune_excess(&conn)?;
|
||||
Ok(GeneratedAudioRecord {
|
||||
id,
|
||||
name,
|
||||
file_path: file_path.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn prune_excess(&self, conn: &Connection) -> Result<(), AudioDbError> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(1) FROM generated_audios",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if count <= MAX_RECORDS {
|
||||
return Ok(());
|
||||
}
|
||||
let excess = count - MAX_RECORDS;
|
||||
conn.execute(
|
||||
"DELETE FROM generated_audios WHERE id IN (
|
||||
SELECT id FROM generated_audios ORDER BY id ASC LIMIT ?1
|
||||
)",
|
||||
params![excess],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: i64) -> Result<bool, AudioDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AudioDbError::Message("语音数据库锁异常".into())
|
||||
})?;
|
||||
let n = conn.execute("DELETE FROM generated_audios WHERE id = ?1", params![id])?;
|
||||
Ok(n > 0)
|
||||
}
|
||||
}
|
||||
70
src-tauri/src/audio_store.rs
Normal file
70
src-tauri/src/audio_store.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! 生成语音:SQLite 访问与 Tauri 状态
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::audio_db::{AudioDb, GeneratedAudioRecord};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AudioStore {
|
||||
inner: Arc<RwLock<Option<Arc<AudioDb>>>>,
|
||||
}
|
||||
|
||||
impl AudioStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
|
||||
let db = tokio::task::spawn_blocking(move || {
|
||||
AudioDb::open(db_path).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let count = tokio::task::spawn_blocking({
|
||||
let db = Arc::new(db);
|
||||
let db2 = db.clone();
|
||||
move || db2.list().map(|l| (db, l.len())).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let (db, n) = count;
|
||||
*self.inner.write().await = Some(db);
|
||||
log::info!(target: "audio", "语音历史数据库已初始化,共 {n} 条记录");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(Arc<AudioDb>) -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db = self
|
||||
.inner
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "语音数据库未初始化".to_string())?;
|
||||
tokio::task::spawn_blocking(move || f(db))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<GeneratedAudioRecord>, String> {
|
||||
self.with_db(|db| db.list().map_err(|e| e.to_string())).await
|
||||
}
|
||||
|
||||
pub async fn insert(&self, name: String, file_path: String) -> Result<GeneratedAudioRecord, String> {
|
||||
self.with_db(move |db| db.insert(&name, &file_path).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i64) -> Result<bool, String> {
|
||||
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
}
|
||||
184
src-tauri/src/avatar_db.rs
Normal file
184
src-tauri/src/avatar_db.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! 数字人形象 SQLite 存储(对齐 Electron data_video_template)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AvatarDbError {
|
||||
#[error("{0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AvatarRecord {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub file_path: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AvatarDb {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl AvatarDb {
|
||||
pub fn open(path: PathBuf) -> Result<Self, AvatarDbError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
AvatarDbError::Message(format!("创建数据库目录失败: {e}"))
|
||||
})?;
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS avatars (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_avatars_name ON avatars(name);
|
||||
"#,
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Result<Vec<AvatarRecord>, AvatarDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, created_at FROM avatars ORDER BY id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(AvatarRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
let mut items = Vec::new();
|
||||
for row in rows {
|
||||
items.push(row?);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<AvatarRecord>, AvatarDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, name, file_path, created_at FROM avatars WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query_map(params![id], |row| {
|
||||
Ok(AvatarRecord {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
file_path: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
match rows.next() {
|
||||
Some(row) => Ok(Some(row?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name_exists(&self, name: &str, exclude_id: Option<i64>) -> Result<bool, AvatarDbError> {
|
||||
let name = name.trim();
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
let count: i64 = if let Some(id) = exclude_id {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(1) FROM avatars WHERE name = ?1 AND id != ?2",
|
||||
params![name, id],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(1) FROM avatars WHERE name = ?1",
|
||||
params![name],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn insert(&self, name: &str, file_path: &str) -> Result<AvatarRecord, AvatarDbError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AvatarDbError::Message("名称不能为空".into()));
|
||||
}
|
||||
let file_path = file_path.trim();
|
||||
if file_path.is_empty() {
|
||||
return Err(AvatarDbError::Message("文件路径不能为空".into()));
|
||||
}
|
||||
if self.name_exists(name, None)? {
|
||||
return Err(AvatarDbError::Message("名称重复".into()));
|
||||
}
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
conn.execute(
|
||||
"INSERT INTO avatars (name, file_path, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![name, file_path, created_at],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
Ok(AvatarRecord {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
file_path: file_path.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_name(&self, id: i64, name: &str) -> Result<(), AvatarDbError> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(AvatarDbError::Message("名称不能为空".into()));
|
||||
}
|
||||
if self.name_exists(name, Some(id))? {
|
||||
return Err(AvatarDbError::Message("名称重复".into()));
|
||||
}
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
let n = conn.execute(
|
||||
"UPDATE avatars SET name = ?1 WHERE id = ?2",
|
||||
params![name, id],
|
||||
)?;
|
||||
if n == 0 {
|
||||
return Err(AvatarDbError::Message("形象不存在".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 删除记录并返回文件路径(供调用方删除磁盘文件)
|
||||
pub fn delete(&self, id: i64) -> Result<Option<String>, AvatarDbError> {
|
||||
let row = self.get(id)?;
|
||||
let Some(record) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
AvatarDbError::Message("形象数据库锁异常".into())
|
||||
})?;
|
||||
conn.execute("DELETE FROM avatars WHERE id = ?1", params![id])?;
|
||||
Ok(Some(record.file_path))
|
||||
}
|
||||
}
|
||||
84
src-tauri/src/avatar_store.rs
Normal file
84
src-tauri/src/avatar_store.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! 数字人形象:SQLite 访问与 Tauri 状态
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::avatar_db::{AvatarDb, AvatarRecord};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AvatarStore {
|
||||
inner: Arc<RwLock<Option<Arc<AvatarDb>>>>,
|
||||
}
|
||||
|
||||
impl AvatarStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
|
||||
let db = tokio::task::spawn_blocking(move || {
|
||||
AvatarDb::open(db_path).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let count = tokio::task::spawn_blocking({
|
||||
let db = Arc::new(db);
|
||||
let db2 = db.clone();
|
||||
move || db2.list().map(|l| (db, l.len())).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
|
||||
let (db, n) = count;
|
||||
*self.inner.write().await = Some(db);
|
||||
log::info!(target: "avatar", "形象数据库已初始化,共 {n} 条记录");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(Arc<AvatarDb>) -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db = self
|
||||
.inner
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "形象数据库未初始化".to_string())?;
|
||||
tokio::task::spawn_blocking(move || f(db))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub async fn list(&self) -> Result<Vec<AvatarRecord>, String> {
|
||||
self.with_db(|db| db.list().map_err(|e| e.to_string())).await
|
||||
}
|
||||
|
||||
pub async fn insert(&self, name: String, file_path: String) -> Result<AvatarRecord, String> {
|
||||
self.with_db(move |db| db.insert(&name, &file_path).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_name(&self, id: i64, name: String) -> Result<(), String> {
|
||||
self.with_db(move |db| db.update_name(id, &name).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i64) -> Result<Option<String>, String> {
|
||||
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn name_exists(&self, name: &str, exclude_id: Option<i64>) -> Result<bool, String> {
|
||||
let name = name.trim().to_string();
|
||||
self.with_db(move |db| {
|
||||
db.name_exists(&name, exclude_id)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
34
src-tauri/src/commands/audio.rs
Normal file
34
src-tauri/src/commands/audio.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! 工作流生成语音历史(SQLite)
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::audio_db::GeneratedAudioRecord;
|
||||
use crate::audio_store::AudioStore;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_generated_audios(
|
||||
store: State<'_, AudioStore>,
|
||||
) -> Result<Vec<GeneratedAudioRecord>, String> {
|
||||
store.list().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn insert_generated_audio(
|
||||
store: State<'_, AudioStore>,
|
||||
name: String,
|
||||
file_path: String,
|
||||
) -> Result<GeneratedAudioRecord, String> {
|
||||
store.insert(name, file_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_generated_audio(
|
||||
store: State<'_, AudioStore>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
let deleted = store.delete(id).await?;
|
||||
if !deleted {
|
||||
return Err("记录不存在".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
130
src-tauri/src/commands/avatar.rs
Normal file
130
src-tauri/src/commands/avatar.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
//! 数字人形象:SQLite 元数据 + 视频文件管理
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::avatar_db::AvatarRecord;
|
||||
use crate::avatar_store::AvatarStore;
|
||||
|
||||
fn normalize_path(path: &str) -> PathBuf {
|
||||
Path::new(path)
|
||||
.components()
|
||||
.filter(|c| !matches!(c, Component::ParentDir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn avatars_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?
|
||||
.join("avatars");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn is_under_avatars_dir(avatars_root: &Path, target: &Path) -> bool {
|
||||
if let (Ok(root), Ok(canonical)) = (avatars_root.canonicalize(), target.canonicalize()) {
|
||||
return canonical.starts_with(&root);
|
||||
}
|
||||
let root_s = avatars_root.to_string_lossy().to_lowercase();
|
||||
let target_s = target.to_string_lossy().to_lowercase();
|
||||
target_s.contains("avatars") && target_s.starts_with(&root_s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_avatars(store: State<'_, AvatarStore>) -> Result<Vec<AvatarRecord>, String> {
|
||||
store.list().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn insert_avatar(
|
||||
store: State<'_, AvatarStore>,
|
||||
name: String,
|
||||
file_path: String,
|
||||
) -> Result<AvatarRecord, String> {
|
||||
store.insert(name, file_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_avatar_name(
|
||||
store: State<'_, AvatarStore>,
|
||||
id: i64,
|
||||
name: String,
|
||||
) -> Result<(), String> {
|
||||
store.update_name(id, name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn avatar_name_exists(
|
||||
store: State<'_, AvatarStore>,
|
||||
name: String,
|
||||
exclude_id: Option<i64>,
|
||||
) -> Result<bool, String> {
|
||||
store.name_exists(&name, exclude_id).await
|
||||
}
|
||||
|
||||
/// 删除数据库记录并删除应用目录内的视频文件
|
||||
#[tauri::command]
|
||||
pub async fn delete_avatar(
|
||||
app: AppHandle,
|
||||
store: State<'_, AvatarStore>,
|
||||
id: i64,
|
||||
) -> Result<(), String> {
|
||||
let file_path = store.delete(id).await?;
|
||||
if let Some(path) = file_path {
|
||||
delete_avatar_file_if_allowed(&app, &path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将用户选择的视频复制到应用数据目录,返回保存后的绝对路径
|
||||
#[tauri::command]
|
||||
pub async fn import_avatar_video(
|
||||
app: AppHandle,
|
||||
source_path: String,
|
||||
) -> Result<String, String> {
|
||||
let src = normalize_path(source_path.trim());
|
||||
if !src.is_file() {
|
||||
return Err(format!("视频文件不存在: {}", src.display()));
|
||||
}
|
||||
|
||||
let ext = src
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("mp4")
|
||||
.to_lowercase();
|
||||
let allowed = ["mp4", "mov", "webm", "mkv", "m4v", "avi"];
|
||||
if !allowed.contains(&ext.as_str()) {
|
||||
return Err("仅支持 mp4、mov、webm、mkv 等常见视频格式".into());
|
||||
}
|
||||
|
||||
let dir = avatars_dir(&app)?;
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| format!("创建形象目录失败: {e}"))?;
|
||||
|
||||
let file_name = format!("{}.{}", Uuid::new_v4(), ext);
|
||||
let dest = dir.join(&file_name);
|
||||
|
||||
tokio::fs::copy(&src, &dest)
|
||||
.await
|
||||
.map_err(|e| format!("复制视频失败: {e}"))?;
|
||||
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
async fn delete_avatar_file_if_allowed(app: &AppHandle, path: &str) -> Result<(), String> {
|
||||
let target = normalize_path(path);
|
||||
let dir = avatars_dir(app)?;
|
||||
if !is_under_avatars_dir(&dir, &target) {
|
||||
return Err("不允许删除该路径下的文件".into());
|
||||
}
|
||||
if target.is_file() {
|
||||
tokio::fs::remove_file(&target)
|
||||
.await
|
||||
.map_err(|e| format!("删除文件失败: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod api;
|
||||
pub mod app_config;
|
||||
pub mod auth;
|
||||
pub mod audio;
|
||||
pub mod avatar;
|
||||
pub mod fs_util;
|
||||
pub mod nodejs;
|
||||
pub mod quickjs;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
pub mod api_client;
|
||||
pub mod api_crypto;
|
||||
pub mod app_config;
|
||||
pub mod audio_db;
|
||||
pub mod audio_store;
|
||||
pub mod avatar_db;
|
||||
pub mod avatar_store;
|
||||
pub mod local_config;
|
||||
pub mod asr;
|
||||
pub mod auth_session;
|
||||
@@ -21,16 +25,22 @@ pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(auth_session::AuthSession::default())
|
||||
.manage(app_config::AppConfig::new())
|
||||
.manage(avatar_store::AvatarStore::new())
|
||||
.manage(audio_store::AudioStore::new())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let app_config = handle.state::<app_config::AppConfig>();
|
||||
let avatar_store = handle.state::<avatar_store::AvatarStore>();
|
||||
let audio_store = handle.state::<audio_store::AudioStore>();
|
||||
let dir = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||
let db_path = dir.join("local_config.db");
|
||||
app_config.init_local_store(db_path).await?;
|
||||
avatar_store.init(dir.join("avatars.db")).await?;
|
||||
audio_store.init(dir.join("generated_audios.db")).await?;
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
@@ -44,6 +54,7 @@ pub fn run() {
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::api::api_request,
|
||||
commands::auth::sync_auth_session,
|
||||
@@ -61,6 +72,15 @@ pub fn run() {
|
||||
commands::nodejs::run_nodejs_script_source,
|
||||
commands::nodejs::list_nodejs_scripts,
|
||||
commands::fs_util::read_local_file_base64,
|
||||
commands::avatar::list_avatars,
|
||||
commands::avatar::insert_avatar,
|
||||
commands::avatar::update_avatar_name,
|
||||
commands::avatar::delete_avatar,
|
||||
commands::avatar::avatar_name_exists,
|
||||
commands::avatar::import_avatar_video,
|
||||
commands::audio::list_generated_audios,
|
||||
commands::audio::insert_generated_audio,
|
||||
commands::audio::delete_generated_audio,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -22,7 +22,11 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": null,
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": ["**"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user