diff --git a/package-lock.json b/package-lock.json index e5dd8ad..7c24926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@primeuix/themes": "^1.2.1", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "monaco-editor": "^0.55.1", "monaco-editor-vue3": "^1.0.5", @@ -1396,6 +1397,14 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz", + "integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/package.json b/package.json index 796792c..af9b04d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@primeuix/themes": "^1.2.1", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-opener": "^2", "monaco-editor": "^0.55.1", "monaco-editor-vue3": "^1.0.5", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e78c39a..4983a2f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d65520d..ece4769 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cf0cd1e..117aba4 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:event:default", "core:event:allow-listen", "core:event:allow-unlisten", - "opener:default" + "opener:default", + "dialog:default" ] } diff --git a/src-tauri/src/audio_db.rs b/src-tauri/src/audio_db.rs new file mode 100644 index 0000000..482f9eb --- /dev/null +++ b/src-tauri/src/audio_db.rs @@ -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, +} + +impl AudioDb { + pub fn open(path: PathBuf) -> Result { + 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, 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, 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 { + 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 { + 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) + } +} diff --git a/src-tauri/src/audio_store.rs b/src-tauri/src/audio_store.rs new file mode 100644 index 0000000..ea1cac7 --- /dev/null +++ b/src-tauri/src/audio_store.rs @@ -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>>>, +} + +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(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + 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, 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 { + 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 { + self.with_db(move |db| db.delete(id).map_err(|e| e.to_string())) + .await + } +} diff --git a/src-tauri/src/avatar_db.rs b/src-tauri/src/avatar_db.rs new file mode 100644 index 0000000..9f04588 --- /dev/null +++ b/src-tauri/src/avatar_db.rs @@ -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, +} + +impl AvatarDb { + pub fn open(path: PathBuf) -> Result { + 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, 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, 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) -> Result { + 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 { + 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, 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)) + } +} diff --git a/src-tauri/src/avatar_store.rs b/src-tauri/src/avatar_store.rs new file mode 100644 index 0000000..fa08938 --- /dev/null +++ b/src-tauri/src/avatar_store.rs @@ -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>>>, +} + +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(&self, f: F) -> Result + where + F: FnOnce(Arc) -> Result + 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, 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 { + 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, 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) -> Result { + let name = name.trim().to_string(); + self.with_db(move |db| { + db.name_exists(&name, exclude_id) + .map_err(|e| e.to_string()) + }) + .await + } +} diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs new file mode 100644 index 0000000..241d67d --- /dev/null +++ b/src-tauri/src/commands/audio.rs @@ -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, String> { + store.list().await +} + +#[tauri::command] +pub async fn insert_generated_audio( + store: State<'_, AudioStore>, + name: String, + file_path: String, +) -> Result { + 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(()) +} diff --git a/src-tauri/src/commands/avatar.rs b/src-tauri/src/commands/avatar.rs new file mode 100644 index 0000000..463fca9 --- /dev/null +++ b/src-tauri/src/commands/avatar.rs @@ -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 { + 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, String> { + store.list().await +} + +#[tauri::command] +pub async fn insert_avatar( + store: State<'_, AvatarStore>, + name: String, + file_path: String, +) -> Result { + 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, +) -> Result { + 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 { + 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(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6753aec..449ee0e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81dc91e..01b9f42 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::(); + let avatar_store = handle.state::(); + let audio_store = handle.state::(); 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 { 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"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9546477..a808e3d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": { diff --git a/src/components/avatar/AvatarEditDialog.vue b/src/components/avatar/AvatarEditDialog.vue new file mode 100644 index 0000000..3611242 --- /dev/null +++ b/src/components/avatar/AvatarEditDialog.vue @@ -0,0 +1,151 @@ + + + \ No newline at end of file diff --git a/src/components/workflow/Step02AudioVideo.vue b/src/components/workflow/Step02AudioVideo.vue index 87edbdf..1fb6946 100644 --- a/src/components/workflow/Step02AudioVideo.vue +++ b/src/components/workflow/Step02AudioVideo.vue @@ -1,10 +1,12 @@