1
This commit is contained in:
9
package-lock.json
generated
9
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primeuix/themes": "^1.2.1",
|
"@primeuix/themes": "^1.2.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"monaco-editor-vue3": "^1.0.5",
|
"monaco-editor-vue3": "^1.0.5",
|
||||||
@@ -1396,6 +1397,14 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primeuix/themes": "^1.2.1",
|
"@primeuix/themes": "^1.2.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"monaco-editor-vue3": "^1.0.5",
|
"monaco-editor-vue3": "^1.0.5",
|
||||||
|
|||||||
75
src-tauri/Cargo.lock
generated
75
src-tauri/Cargo.lock
generated
@@ -1758,6 +1758,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -2568,6 +2574,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.1",
|
"bitflags 2.11.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3321,6 +3328,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -4038,6 +4069,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"http-range",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4098,6 +4130,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
@@ -4185,6 +4218,48 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-plugin-opener"
|
name = "tauri-plugin-opener"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
|||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"core:event:default",
|
"core:event:default",
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
"core:event:allow-unlisten",
|
"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 api;
|
||||||
pub mod app_config;
|
pub mod app_config;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod audio;
|
||||||
|
pub mod avatar;
|
||||||
pub mod fs_util;
|
pub mod fs_util;
|
||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
pub mod quickjs;
|
pub mod quickjs;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
pub mod api_client;
|
pub mod api_client;
|
||||||
pub mod api_crypto;
|
pub mod api_crypto;
|
||||||
pub mod app_config;
|
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 local_config;
|
||||||
pub mod asr;
|
pub mod asr;
|
||||||
pub mod auth_session;
|
pub mod auth_session;
|
||||||
@@ -21,16 +25,22 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.manage(auth_session::AuthSession::default())
|
.manage(auth_session::AuthSession::default())
|
||||||
.manage(app_config::AppConfig::new())
|
.manage(app_config::AppConfig::new())
|
||||||
|
.manage(avatar_store::AvatarStore::new())
|
||||||
|
.manage(audio_store::AudioStore::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
tauri::async_runtime::block_on(async move {
|
tauri::async_runtime::block_on(async move {
|
||||||
let app_config = handle.state::<app_config::AppConfig>();
|
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
|
let dir = handle
|
||||||
.path()
|
.path()
|
||||||
.app_data_dir()
|
.app_data_dir()
|
||||||
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
.map_err(|e| format!("无法获取应用数据目录: {e}"))?;
|
||||||
let db_path = dir.join("local_config.db");
|
let db_path = dir.join("local_config.db");
|
||||||
app_config.init_local_store(db_path).await?;
|
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>(())
|
Ok::<(), String>(())
|
||||||
})
|
})
|
||||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
.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_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::api::api_request,
|
commands::api::api_request,
|
||||||
commands::auth::sync_auth_session,
|
commands::auth::sync_auth_session,
|
||||||
@@ -61,6 +72,15 @@ pub fn run() {
|
|||||||
commands::nodejs::run_nodejs_script_source,
|
commands::nodejs::run_nodejs_script_source,
|
||||||
commands::nodejs::list_nodejs_scripts,
|
commands::nodejs::list_nodejs_scripts,
|
||||||
commands::fs_util::read_local_file_base64,
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": ["**"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
151
src/components/avatar/AvatarEditDialog.vue
Normal file
151
src/components/avatar/AvatarEditDialog.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useAvatarStore } from "../../stores/avatar.js";
|
||||||
|
import { pickVideoFile, importAvatarVideo } from "../../services/avatarVideo.js";
|
||||||
|
import { localVideoToPlayableUrl } from "../../services/localVideo.js";
|
||||||
|
import { avatarNameExists } from "../../services/avatarDb.js";
|
||||||
|
|
||||||
|
const emit = defineEmits(["saved"]);
|
||||||
|
|
||||||
|
const avatarStore = useAvatarStore();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const name = ref("");
|
||||||
|
const localSourcePath = ref("");
|
||||||
|
const previewSrc = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
name.value = "";
|
||||||
|
localSourcePath.value = "";
|
||||||
|
previewSrc.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
resetForm();
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickVideo() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
try {
|
||||||
|
const path = await pickVideoFile();
|
||||||
|
if (!path) return;
|
||||||
|
localSourcePath.value = path;
|
||||||
|
previewSrc.value = localVideoToPlayableUrl(path);
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value =
|
||||||
|
err instanceof Error ? err.message : String(err || "选择文件失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
const title = name.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
errorMessage.value = "请输入名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (await avatarNameExists(title)) {
|
||||||
|
errorMessage.value = "名称重复";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!localSourcePath.value) {
|
||||||
|
errorMessage.value = "请选择视频";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const savedPath = await importAvatarVideo(localSourcePath.value);
|
||||||
|
await avatarStore.addTemplate({ name: title, filePath: savedPath });
|
||||||
|
visible.value = false;
|
||||||
|
emit("saved");
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value =
|
||||||
|
err instanceof Error ? err.message : String(err || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openAdd });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visible"
|
||||||
|
modal
|
||||||
|
header="添加视频形象"
|
||||||
|
:style="{ width: '720px' }"
|
||||||
|
:draggable="false"
|
||||||
|
class="avatar-edit-dialog"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">
|
||||||
|
名称 <span class="text-red-400">*</span>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model="name"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
placeholder="例如:主播小美"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">视频 <span class="text-red-400">*</span></div>
|
||||||
|
<div
|
||||||
|
v-if="!previewSrc"
|
||||||
|
class="flex h-48 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-white/20 bg-white/5 text-center text-sm text-slate-400 transition-colors hover:border-blue-400/40 hover:bg-white/10"
|
||||||
|
@click="onPickVideo"
|
||||||
|
>
|
||||||
|
<i class="pi pi-video mb-2 text-2xl text-slate-500" />
|
||||||
|
<span>点击选择视频文件</span>
|
||||||
|
<span class="mt-1 text-xs">支持 mp4 / mov / webm 等</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div class="overflow-hidden rounded-lg bg-black p-1">
|
||||||
|
<video :src="previewSrc" controls class="max-h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="重新选择"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
class="w-full"
|
||||||
|
@click="onPickVideo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400">
|
||||||
|
<div class="mb-2 text-base font-medium text-slate-200">形象示例</div>
|
||||||
|
<div class="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||||
|
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">正脸自拍</div>
|
||||||
|
<div class="rounded-lg bg-emerald-500/10 p-2 text-emerald-400">可张口闭口</div>
|
||||||
|
<div class="rounded-lg bg-red-500/10 p-2 text-red-400">面部有干扰</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-field-label mb-2">形象视频要求</div>
|
||||||
|
<ul class="space-y-1.5 text-xs leading-relaxed">
|
||||||
|
<li>视频时长建议 10~30 秒,格式 MP4,分辨率 1080p~4K</li>
|
||||||
|
<li>每一帧需正面露脸、无遮挡,且仅出现同一人脸</li>
|
||||||
|
<li>建议闭口或微微张口,张口幅度不宜过大</li>
|
||||||
|
<li>可正常语气循环说「一二三四五六七八九」等文字</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" @click="onCancel" />
|
||||||
|
<Button label="保存" :loading="saving" @click="onSave" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, onMounted } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
|
import { useAvatarStore } from "../../stores/avatar.js";
|
||||||
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
|
const avatarStore = useAvatarStore();
|
||||||
const {
|
const {
|
||||||
voiceEmotion,
|
voiceEmotion,
|
||||||
speechRate,
|
speechRate,
|
||||||
@@ -22,7 +24,7 @@ const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
|
|||||||
|
|
||||||
const audioHistoryOptions = computed(() =>
|
const audioHistoryOptions = computed(() =>
|
||||||
audioHistory.value.map((item) => ({
|
audioHistory.value.map((item) => ({
|
||||||
label: item.label,
|
label: item.name,
|
||||||
value: item.id,
|
value: item.id,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -44,10 +46,15 @@ async function onGenerateSpeech() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPickAudioHistory() {
|
async function onPickAudioHistory() {
|
||||||
if (!currentAudioId.value) return;
|
if (!currentAudioId.value) return;
|
||||||
workflow.pickAudioHistory(currentAudioId.value);
|
await workflow.pickAudioHistory(currentAudioId.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
avatarStore.refresh();
|
||||||
|
workflow.refreshAudioHistory();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -139,7 +146,7 @@ function onPickAudioHistory() {
|
|||||||
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
||||||
<Select
|
<Select
|
||||||
v-model="avatarSelect"
|
v-model="avatarSelect"
|
||||||
:options="workflow.avatarOptions"
|
:options="avatarStore.selectOptions"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
option-value="value"
|
option-value="value"
|
||||||
placeholder="请选择形象"
|
placeholder="请选择形象"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
name: "avatar",
|
name: "avatar",
|
||||||
|
|
||||||
component: placeholder,
|
component: () => import("../views/AvatarView.vue"),
|
||||||
|
|
||||||
meta: { title: "形象" },
|
meta: { title: "形象" },
|
||||||
|
|
||||||
|
|||||||
35
src/services/audioDb.js
Normal file
35
src/services/audioDb.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
function ensureTauri() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("语音历史需在 Tauri 桌面端使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} GeneratedAudioRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {Promise<GeneratedAudioRecord[]>} */
|
||||||
|
export async function listGeneratedAudios() {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("list_generated_audios");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name 可为空,空时由后端填入当前时间
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {Promise<GeneratedAudioRecord>}
|
||||||
|
*/
|
||||||
|
export async function insertGeneratedAudio(name, filePath) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("insert_generated_audio", { name, filePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
export async function deleteGeneratedAudio(id) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("delete_generated_audio", { id });
|
||||||
|
}
|
||||||
89
src/services/avatarDb.js
Normal file
89
src/services/avatarDb.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const LEGACY_STORAGE_KEY = "aiclient_video_templates";
|
||||||
|
|
||||||
|
function ensureTauri() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("形象管理需在 Tauri 桌面端使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} AvatarRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {Promise<AvatarRecord[]>} */
|
||||||
|
export async function listAvatars() {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("list_avatars");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {Promise<AvatarRecord>}
|
||||||
|
*/
|
||||||
|
export async function insertAvatar(name, filePath) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("insert_avatar", { name, filePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
export async function updateAvatarName(id, name) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("update_avatar_name", { id, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
export async function deleteAvatar(id) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("delete_avatar", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number | null} [excludeId]
|
||||||
|
*/
|
||||||
|
export async function avatarNameExists(name, excludeId = null) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("avatar_name_exists", {
|
||||||
|
name,
|
||||||
|
excludeId: excludeId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将旧版 localStorage 数据迁入 SQLite(仅当库为空且存在 legacy 数据时) */
|
||||||
|
export async function migrateLegacyAvatarsIfNeeded() {
|
||||||
|
ensureTauri();
|
||||||
|
const current = await listAvatars();
|
||||||
|
if (current.length > 0) return;
|
||||||
|
|
||||||
|
let legacy = [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
legacy = Array.isArray(parsed) ? parsed : [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
legacy = [];
|
||||||
|
}
|
||||||
|
if (!legacy.length) return;
|
||||||
|
|
||||||
|
for (const item of legacy) {
|
||||||
|
const name = String(item.name || "").trim();
|
||||||
|
const filePath = String(item.video || item.filePath || "").trim();
|
||||||
|
if (!name || !filePath) continue;
|
||||||
|
try {
|
||||||
|
await insertAvatar(name, filePath);
|
||||||
|
} catch {
|
||||||
|
/* 重名等跳过 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||||
|
}
|
||||||
32
src/services/avatarVideo.js
Normal file
32
src/services/avatarVideo.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string | null>}
|
||||||
|
*/
|
||||||
|
export async function pickVideoFile() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("请在 Tauri 桌面端选择视频文件");
|
||||||
|
}
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "视频",
|
||||||
|
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "avi"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (selected == null) return null;
|
||||||
|
if (Array.isArray(selected)) return selected[0] || null;
|
||||||
|
return String(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} sourcePath
|
||||||
|
* @returns {Promise<string>} 应用内保存路径
|
||||||
|
*/
|
||||||
|
export async function importAvatarVideo(sourcePath) {
|
||||||
|
return invoke("import_avatar_video", { sourcePath });
|
||||||
|
}
|
||||||
|
|
||||||
12
src/services/localVideo.js
Normal file
12
src/services/localVideo.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { convertFileSrc, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地视频文件转为 WebView 可播放地址
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
export function localVideoToPlayableUrl(filePath) {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("请在 Tauri 桌面端预览视频");
|
||||||
|
}
|
||||||
|
return convertFileSrc(filePath);
|
||||||
|
}
|
||||||
@@ -1,99 +1,96 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
|
||||||
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
|
||||||
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
|
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
|
||||||
|
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||||
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
|
|
||||||
|
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
|
||||||
/**
|
|
||||||
* 生成语音(对齐 Electron generateAudio CosyVoice)
|
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
|
||||||
*/
|
import { insertGeneratedAudio } from "./audioDb.js";
|
||||||
export async function executeGenerateSpeech(store) {
|
|
||||||
const text = String(store.scriptContent || "").trim();
|
|
||||||
if (!text) {
|
|
||||||
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
|
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
|
||||||
}
|
|
||||||
|
|
||||||
if (!store.selectedVoiceId) {
|
|
||||||
return { ok: false, message: "请先选择音色" };
|
/**
|
||||||
}
|
|
||||||
|
* 生成语音(对齐 Electron generateAudio CosyVoice)
|
||||||
const cfg = await ensureAppConfigReady();
|
|
||||||
if (!cfg.ok) {
|
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||||
return { ok: false, message: cfg.message };
|
|
||||||
}
|
*/
|
||||||
|
|
||||||
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
|
export async function executeGenerateSpeech(store) {
|
||||||
const instruction = buildTtsInstruction(
|
|
||||||
store.voiceLanguage,
|
const text = String(store.scriptContent || "").trim();
|
||||||
store.selectedVoiceId,
|
|
||||||
store.voiceEmotion,
|
if (!text) {
|
||||||
store.speechRate,
|
|
||||||
);
|
return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
|
||||||
const languageType = buildTtsLanguageType(store.voiceLanguage);
|
|
||||||
|
}
|
||||||
store.speechGenerating = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await invoke("run_nodejs_script", {
|
if (!store.selectedVoiceId) {
|
||||||
scriptName: VOICE_GENERATE_SCRIPT,
|
|
||||||
params: {
|
return { ok: false, message: "请先选择音色" };
|
||||||
text,
|
|
||||||
voice,
|
}
|
||||||
instruction,
|
|
||||||
languageType: languageType || null,
|
|
||||||
format: "mp3",
|
|
||||||
},
|
const cfg = await ensureAppConfigReady();
|
||||||
});
|
|
||||||
|
if (!cfg.ok) {
|
||||||
if (!result?.success || !result?.audioPath) {
|
|
||||||
return {
|
return { ok: false, message: cfg.message };
|
||||||
ok: false,
|
|
||||||
message: String(result?.error || "语音生成失败,请重试"),
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const audioSrc = await localAudioToPlayableUrl(result.audioPath);
|
const voice = resolveAliyunVoiceId(store.selectedVoiceId);
|
||||||
const label = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
|
|
||||||
const entry = {
|
const instruction = buildTtsInstruction(
|
||||||
id: `audio_${Date.now()}`,
|
|
||||||
label,
|
store.voiceLanguage,
|
||||||
audioPath: result.audioPath,
|
|
||||||
audioSrc,
|
store.selectedVoiceId,
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
store.voiceEmotion,
|
||||||
|
|
||||||
store.generatedAudioPath = result.audioPath;
|
store.speechRate,
|
||||||
store.generatedAudioSrc = audioSrc;
|
|
||||||
store.currentAudioId = entry.id;
|
);
|
||||||
store.audioHistory = [entry, ...store.audioHistory].slice(0, 50);
|
|
||||||
|
const languageType = buildTtsLanguageType(store.voiceLanguage);
|
||||||
return { ok: true, message: "语音生成完成" };
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
|
||||||
return { ok: false, message: `语音生成失败: ${msg}` };
|
store.speechGenerating = true;
|
||||||
} finally {
|
|
||||||
store.speechGenerating = false;
|
|
||||||
}
|
|
||||||
}
|
try {
|
||||||
|
|
||||||
function formatHistoryTime() {
|
const result = await invoke("run_nodejs_script", {
|
||||||
const d = new Date();
|
|
||||||
const p = (n) => String(n).padStart(2, "0");
|
scriptName: VOICE_GENERATE_SCRIPT,
|
||||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
||||||
}
|
params: {
|
||||||
|
|
||||||
/**
|
text,
|
||||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
|
||||||
* @param {string} audioId
|
voice,
|
||||||
*/
|
|
||||||
export function selectAudioHistory(store, audioId) {
|
instruction,
|
||||||
const item = store.audioHistory.find((h) => h.id === audioId);
|
|
||||||
if (!item) return;
|
languageType: languageType || null,
|
||||||
store.currentAudioId = item.id;
|
|
||||||
store.generatedAudioPath = item.audioPath;
|
|
||||||
store.generatedAudioSrc = item.audioSrc;
|
|
||||||
}
|
|
||||||
75
src/stores/avatar.js
Normal file
75
src/stores/avatar.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||||
|
import {
|
||||||
|
listAvatars,
|
||||||
|
insertAvatar,
|
||||||
|
updateAvatarName,
|
||||||
|
deleteAvatar,
|
||||||
|
avatarNameExists,
|
||||||
|
migrateLegacyAvatarsIfNeeded,
|
||||||
|
} from "../services/avatarDb.js";
|
||||||
|
|
||||||
|
export const useAvatarStore = defineStore("avatar", {
|
||||||
|
state: () => ({
|
||||||
|
templates: [],
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
selectOptions: (state) => [
|
||||||
|
{ label: "请选择形象", value: null },
|
||||||
|
...state.templates.map((t) => ({
|
||||||
|
label: t.name,
|
||||||
|
value: t.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
await migrateLegacyAvatarsIfNeeded();
|
||||||
|
this.templates = await listAvatars();
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ name: string, filePath: string }} payload
|
||||||
|
*/
|
||||||
|
async addTemplate(payload) {
|
||||||
|
const record = await insertAvatar(payload.name, payload.filePath);
|
||||||
|
this.templates = await listAvatars();
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
async renameTemplate(id, name) {
|
||||||
|
const exists = await avatarNameExists(name, id);
|
||||||
|
if (exists) {
|
||||||
|
return { ok: false, message: "名称重复" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateAvatarName(id, name);
|
||||||
|
this.templates = await listAvatars();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, message: msg || "重命名失败" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeTemplate(item) {
|
||||||
|
await deleteAvatar(item.id);
|
||||||
|
this.templates = await listAvatars();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useAvatarStore, import.meta.hot));
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
executeGenerateSpeech,
|
executeGenerateSpeech,
|
||||||
selectAudioHistory,
|
selectAudioHistory,
|
||||||
} from "../services/voiceGenerate.js";
|
} from "../services/voiceGenerate.js";
|
||||||
|
import { listGeneratedAudios } from "../services/audioDb.js";
|
||||||
|
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
currentAudioId: null,
|
currentAudioId: null,
|
||||||
|
|
||||||
/** @type {Array<{ id: string, label: string, audioPath: string, audioSrc: string, createdAt: number }>} */
|
/** @type {Array<{ id: number, name: string, filePath: string, createdAt: string, audioSrc?: string }>} */
|
||||||
audioHistory: [],
|
audioHistory: [],
|
||||||
|
|
||||||
|
|
||||||
@@ -451,10 +453,27 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
pickAudioHistory(audioId) {
|
async refreshAudioHistory() {
|
||||||
|
try {
|
||||||
selectAudioHistory(this, audioId);
|
const rows = await listGeneratedAudios();
|
||||||
|
this.audioHistory = await Promise.all(
|
||||||
|
rows.map(async (row) => {
|
||||||
|
let audioSrc = "";
|
||||||
|
try {
|
||||||
|
audioSrc = await localAudioToPlayableUrl(row.filePath);
|
||||||
|
} catch {
|
||||||
|
audioSrc = "";
|
||||||
|
}
|
||||||
|
return { ...row, audioSrc };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
this.audioHistory = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async pickAudioHistory(audioId) {
|
||||||
|
await selectAudioHistory(this, audioId);
|
||||||
},
|
},
|
||||||
|
|
||||||
resetAll() {
|
resetAll() {
|
||||||
|
|||||||
185
src/views/AvatarView.vue
Normal file
185
src/views/AvatarView.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useAvatarStore } from "../stores/avatar.js";
|
||||||
|
import AvatarEditDialog from "../components/avatar/AvatarEditDialog.vue";
|
||||||
|
import { localVideoToPlayableUrl } from "../services/localVideo.js";
|
||||||
|
|
||||||
|
const avatarStore = useAvatarStore();
|
||||||
|
const { templates, loading } = storeToRefs(avatarStore);
|
||||||
|
|
||||||
|
const editDialogRef = ref(null);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
const editingId = ref(null);
|
||||||
|
const editingName = ref("");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
avatarStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showFeedback(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoSrc(item) {
|
||||||
|
if (!item?.filePath) return "";
|
||||||
|
try {
|
||||||
|
return localVideoToPlayableUrl(item.filePath);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAdd() {
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
editDialogRef.value?.openAdd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCloud() {
|
||||||
|
showFeedback("warn", "云端形象功能即将上线");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(item) {
|
||||||
|
editingId.value = item.id;
|
||||||
|
editingName.value = item.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelRename() {
|
||||||
|
editingId.value = null;
|
||||||
|
editingName.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitRename(item) {
|
||||||
|
const result = await avatarStore.renameTemplate(item.id, editingName.value);
|
||||||
|
if (!result.ok) {
|
||||||
|
showFeedback("error", result.message || "重命名失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(item) {
|
||||||
|
if (!window.confirm(`确认删除形象「${item.name}」?`)) return;
|
||||||
|
await avatarStore.removeTemplate(item);
|
||||||
|
showFeedback("success", "已删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSaved() {
|
||||||
|
showFeedback("success", "形象已添加");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="avatar-page custom-scrollbar h-full overflow-y-auto p-5">
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-100">数字人形象</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">管理多个数字人形象</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
label="云端形象"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-cloud"
|
||||||
|
@click="onCloud"
|
||||||
|
/>
|
||||||
|
<Button label="添加" size="small" icon="pi pi-plus" @click="onAdd" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="feedback.message"
|
||||||
|
class="mb-3 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-red-400': feedback.severity === 'error',
|
||||||
|
'text-amber-400': feedback.severity === 'warn',
|
||||||
|
'text-emerald-400': feedback.severity === 'success',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="loading" class="py-12 text-center text-sm text-slate-500">加载中...</p>
|
||||||
|
<p
|
||||||
|
v-else-if="templates.length === 0"
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 py-16 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
暂无形象,点击「添加」上传视频
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-else class="-mx-2 flex flex-wrap">
|
||||||
|
<div
|
||||||
|
v-for="item in templates"
|
||||||
|
:key="item.id"
|
||||||
|
class="w-full p-2 md:w-1/2 xl:w-1/3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-shadow hover:border-purple-500/40 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
v-if="editingId !== item.id"
|
||||||
|
class="inline-flex max-w-full items-center rounded-full bg-blue-500/15 px-2 py-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="cursor-pointer truncate text-sm font-medium text-slate-100"
|
||||||
|
:title="item.name"
|
||||||
|
@click="startRename(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 shrink-0 text-xs text-slate-500 hover:text-blue-400"
|
||||||
|
aria-label="重命名"
|
||||||
|
@click="startRename(item)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex gap-1">
|
||||||
|
<InputText v-model="editingName" size="small" class="flex-1" />
|
||||||
|
<Button
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
aria-label="确认"
|
||||||
|
@click="commitRename(item)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-times"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
aria-label="取消"
|
||||||
|
@click="cancelRename"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
aria-label="删除"
|
||||||
|
@click="onDelete(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden rounded-lg bg-black p-1">
|
||||||
|
<video
|
||||||
|
v-if="videoSrc(item)"
|
||||||
|
:src="videoSrc(item)"
|
||||||
|
controls
|
||||||
|
class="aspect-video w-full bg-black object-contain"
|
||||||
|
/>
|
||||||
|
<p v-else class="py-12 text-center text-xs text-slate-500">无法预览视频</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AvatarEditDialog ref="editDialogRef" @saved="onSaved" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user