11
This commit is contained in:
143
src-tauri/src/commands/material.rs
Normal file
143
src-tauri/src/commands/material.rs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
//! 画中画/字幕素材:SQLite 元数据 + 本地文件管理
|
||||||
|
|
||||||
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::Local;
|
||||||
|
use serde_json::json;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::material_db::MaterialDb;
|
||||||
|
use crate::material_store::MaterialStore;
|
||||||
|
|
||||||
|
const ALLOWED_EXTS: &[&str] = &[
|
||||||
|
"mp4", "mov", "avi", "mkv", "jpg", "jpeg", "png", "gif", "bmp", "webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn normalize_path(path: &str) -> PathBuf {
|
||||||
|
Path::new(path)
|
||||||
|
.components()
|
||||||
|
.filter(|c| !matches!(c, Component::ParentDir))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn materials_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("无法获取应用数据目录: {e}"))?
|
||||||
|
.join("video-material");
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_under_materials_dir(root: &Path, target: &Path) -> bool {
|
||||||
|
if let (Ok(root), Ok(canonical)) = (root.canonicalize(), target.canonicalize()) {
|
||||||
|
return canonical.starts_with(&root);
|
||||||
|
}
|
||||||
|
let root_s = root.to_string_lossy().to_lowercase();
|
||||||
|
let target_s = target.to_string_lossy().to_lowercase();
|
||||||
|
target_s.contains("video-material") && target_s.starts_with(&root_s)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_materials(store: State<'_, MaterialStore>) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
|
||||||
|
store.list().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_material_name(
|
||||||
|
store: State<'_, MaterialStore>,
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
store.update_name(id, name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_material(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, MaterialStore>,
|
||||||
|
id: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let file_path = store.delete(id).await?;
|
||||||
|
if let Some(path) = file_path {
|
||||||
|
delete_material_file_if_allowed(&app, &path).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将用户选择的媒体文件复制到应用数据目录并写入数据库
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_material_files(
|
||||||
|
app: AppHandle,
|
||||||
|
store: State<'_, MaterialStore>,
|
||||||
|
source_paths: Vec<String>,
|
||||||
|
) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
|
||||||
|
let mut imported = Vec::new();
|
||||||
|
for raw in source_paths {
|
||||||
|
let src = normalize_path(raw.trim());
|
||||||
|
if !src.is_file() {
|
||||||
|
return Err(format!("文件不存在: {}", src.display()));
|
||||||
|
}
|
||||||
|
let ext = src
|
||||||
|
.extension()
|
||||||
|
.and_then(|e| e.to_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
if !ALLOWED_EXTS.contains(&ext.as_str()) {
|
||||||
|
return Err(format!("不支持的文件格式: .{ext}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = copy_to_materials_dir(&app, &src, &ext).await?;
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
let name = MaterialDb::material_name_from_path(raw.trim());
|
||||||
|
let material_type = MaterialDb::material_type_from_path(raw.trim());
|
||||||
|
let info = json!({ "sourcePath": raw.trim() });
|
||||||
|
let record = store
|
||||||
|
.insert(
|
||||||
|
name,
|
||||||
|
saved,
|
||||||
|
material_type,
|
||||||
|
info,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
imported.push(record);
|
||||||
|
}
|
||||||
|
Ok(imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn copy_to_materials_dir(
|
||||||
|
app: &AppHandle,
|
||||||
|
src: &Path,
|
||||||
|
ext: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let root = materials_dir(app)?;
|
||||||
|
let date = Local::now().format("%Y%m%d").to_string();
|
||||||
|
let dir = root.join(&date);
|
||||||
|
tokio::fs::create_dir_all(&dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("创建素材目录失败: {e}"))?;
|
||||||
|
|
||||||
|
let file_name = format!("{}_{}.{}", Uuid::new_v4(), Local::now().format("%H%M%S"), 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_material_file_if_allowed(app: &AppHandle, path: &str) -> Result<(), String> {
|
||||||
|
let target = normalize_path(path);
|
||||||
|
let dir = materials_dir(app)?;
|
||||||
|
if !is_under_materials_dir(&dir, &target) {
|
||||||
|
return Err("不允许删除该路径下的文件".into());
|
||||||
|
}
|
||||||
|
if target.is_file() {
|
||||||
|
tokio::fs::remove_file(&target)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("删除文件失败: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod app_config;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod audio;
|
pub mod audio;
|
||||||
pub mod avatar;
|
pub mod avatar;
|
||||||
|
pub mod material;
|
||||||
pub mod video;
|
pub mod video;
|
||||||
pub mod fs_util;
|
pub mod fs_util;
|
||||||
pub mod nodejs;
|
pub mod nodejs;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pub mod audio_db;
|
|||||||
pub mod audio_store;
|
pub mod audio_store;
|
||||||
pub mod avatar_db;
|
pub mod avatar_db;
|
||||||
pub mod avatar_store;
|
pub mod avatar_store;
|
||||||
|
pub mod material_db;
|
||||||
|
pub mod material_store;
|
||||||
pub mod video_db;
|
pub mod video_db;
|
||||||
pub mod video_store;
|
pub mod video_store;
|
||||||
pub mod local_config;
|
pub mod local_config;
|
||||||
@@ -35,6 +37,7 @@ pub fn run() {
|
|||||||
.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(avatar_store::AvatarStore::new())
|
||||||
|
.manage(material_store::MaterialStore::new())
|
||||||
.manage(audio_store::AudioStore::new())
|
.manage(audio_store::AudioStore::new())
|
||||||
.manage(video_store::VideoStore::new())
|
.manage(video_store::VideoStore::new())
|
||||||
.manage(publish_store::PublishStore::new())
|
.manage(publish_store::PublishStore::new())
|
||||||
@@ -50,6 +53,7 @@ pub fn run() {
|
|||||||
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 avatar_store = handle.state::<avatar_store::AvatarStore>();
|
||||||
|
let material_store = handle.state::<material_store::MaterialStore>();
|
||||||
let audio_store = handle.state::<audio_store::AudioStore>();
|
let audio_store = handle.state::<audio_store::AudioStore>();
|
||||||
let video_store = handle.state::<video_store::VideoStore>();
|
let video_store = handle.state::<video_store::VideoStore>();
|
||||||
let publish_store = handle.state::<publish_store::PublishStore>();
|
let publish_store = handle.state::<publish_store::PublishStore>();
|
||||||
@@ -60,6 +64,7 @@ pub fn run() {
|
|||||||
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?;
|
avatar_store.init(dir.join("avatars.db")).await?;
|
||||||
|
material_store.init(dir.join("video_materials.db")).await?;
|
||||||
audio_store.init(dir.join("generated_audios.db")).await?;
|
audio_store.init(dir.join("generated_audios.db")).await?;
|
||||||
video_store.init(dir.join("generated_videos.db")).await?;
|
video_store.init(dir.join("generated_videos.db")).await?;
|
||||||
publish_store.init(dir.join("publish_accounts.db")).await?;
|
publish_store.init(dir.join("publish_accounts.db")).await?;
|
||||||
@@ -104,6 +109,10 @@ pub fn run() {
|
|||||||
commands::avatar::delete_avatar,
|
commands::avatar::delete_avatar,
|
||||||
commands::avatar::avatar_name_exists,
|
commands::avatar::avatar_name_exists,
|
||||||
commands::avatar::import_avatar_video,
|
commands::avatar::import_avatar_video,
|
||||||
|
commands::material::list_materials,
|
||||||
|
commands::material::update_material_name,
|
||||||
|
commands::material::delete_material,
|
||||||
|
commands::material::import_material_files,
|
||||||
commands::audio::list_generated_audios,
|
commands::audio::list_generated_audios,
|
||||||
commands::audio::insert_generated_audio,
|
commands::audio::insert_generated_audio,
|
||||||
commands::audio::delete_generated_audio,
|
commands::audio::delete_generated_audio,
|
||||||
|
|||||||
202
src-tauri/src/material_db.rs
Normal file
202
src-tauri/src/material_db.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//! 画中画/字幕素材 SQLite(对齐 Electron data_video_material)
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::Serialize;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
const IMAGE_EXTS: &[&str] = &[
|
||||||
|
"jpg", "jpeg", "png", "gif", "bmp", "webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum MaterialDbError {
|
||||||
|
#[error("{0}")]
|
||||||
|
Db(#[from] rusqlite::Error),
|
||||||
|
#[error("{0}")]
|
||||||
|
Message(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MaterialRecord {
|
||||||
|
pub id: i64,
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub material_type: String,
|
||||||
|
pub info: serde_json::Value,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct MaterialDb {
|
||||||
|
conn: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterialDb {
|
||||||
|
pub fn open(path: PathBuf) -> Result<Self, MaterialDbError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| {
|
||||||
|
MaterialDbError::Message(format!("创建数据库目录失败: {e}"))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let conn = Connection::open(path)?;
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE IF NOT EXISTS video_materials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
info TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
Ok(Self {
|
||||||
|
conn: Mutex::new(conn),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn material_type_from_path(path: &str) -> String {
|
||||||
|
let ext = path
|
||||||
|
.rsplit('.')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
if IMAGE_EXTS.contains(&ext.as_str()) {
|
||||||
|
"image".to_string()
|
||||||
|
} else {
|
||||||
|
"video".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn material_name_from_path(path: &str) -> String {
|
||||||
|
let normalized = path.replace('\\', "/");
|
||||||
|
let file_name = normalized
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("未命名素材");
|
||||||
|
let stem = file_name
|
||||||
|
.rsplit_once('.')
|
||||||
|
.map(|(s, _)| s)
|
||||||
|
.unwrap_or(file_name);
|
||||||
|
if stem.is_empty() {
|
||||||
|
file_name.to_string()
|
||||||
|
} else {
|
||||||
|
stem.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_record(row: &rusqlite::Row<'_>) -> Result<MaterialRecord, rusqlite::Error> {
|
||||||
|
let info_raw: String = row.get(4)?;
|
||||||
|
let info: serde_json::Value = serde_json::from_str(&info_raw).unwrap_or(serde_json::json!({}));
|
||||||
|
Ok(MaterialRecord {
|
||||||
|
id: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
path: row.get(2)?,
|
||||||
|
material_type: row.get(3)?,
|
||||||
|
info,
|
||||||
|
created_at: row.get(5)?,
|
||||||
|
updated_at: row.get(6)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(&self) -> Result<Vec<MaterialRecord>, MaterialDbError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
MaterialDbError::Message("素材数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, name, path, type, info, created_at, updated_at
|
||||||
|
FROM video_materials
|
||||||
|
ORDER BY updated_at DESC, id DESC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], Self::row_to_record)?;
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
items.push(row?);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
path: &str,
|
||||||
|
material_type: &str,
|
||||||
|
info: serde_json::Value,
|
||||||
|
created_at: i64,
|
||||||
|
updated_at: i64,
|
||||||
|
) -> Result<MaterialRecord, MaterialDbError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(MaterialDbError::Message("名称不能为空".into()));
|
||||||
|
}
|
||||||
|
let path = path.trim();
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err(MaterialDbError::Message("文件路径不能为空".into()));
|
||||||
|
}
|
||||||
|
let info_str = serde_json::to_string(&info).unwrap_or_else(|_| "{}".into());
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
MaterialDbError::Message("素材数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO video_materials (name, path, type, info, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![name, path, material_type, info_str, created_at, updated_at],
|
||||||
|
)?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
Ok(MaterialRecord {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
path: path.to_string(),
|
||||||
|
material_type: material_type.to_string(),
|
||||||
|
info,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_name(&self, id: i64, name: &str) -> Result<(), MaterialDbError> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err(MaterialDbError::Message("名称不能为空".into()));
|
||||||
|
}
|
||||||
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
MaterialDbError::Message("素材数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
let n = conn.execute(
|
||||||
|
"UPDATE video_materials SET name = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![name, now, id],
|
||||||
|
)?;
|
||||||
|
if n == 0 {
|
||||||
|
return Err(MaterialDbError::Message("素材不存在".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete(&self, id: i64) -> Result<Option<String>, MaterialDbError> {
|
||||||
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
|
MaterialDbError::Message("素材数据库锁异常".into())
|
||||||
|
})?;
|
||||||
|
let path: Option<String> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT path FROM video_materials WHERE id = ?1",
|
||||||
|
params![id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
let Some(path) = path else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
conn.execute("DELETE FROM video_materials WHERE id = ?1", params![id])?;
|
||||||
|
Ok(Some(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src-tauri/src/material_store.rs
Normal file
94
src-tauri/src/material_store.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//! 画中画/字幕素材:SQLite 访问与 Tauri 状态
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::material_db::{MaterialDb, MaterialRecord};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MaterialStore {
|
||||||
|
inner: Arc<RwLock<Option<Arc<MaterialDb>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MaterialStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
let db = tokio::task::spawn_blocking(move || {
|
||||||
|
MaterialDb::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: "material", "素材数据库已初始化,共 {n} 条记录");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnOnce(Arc<MaterialDb>) -> 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<MaterialRecord>, String> {
|
||||||
|
self.with_db(|db| db.list().map_err(|e| e.to_string()))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert(
|
||||||
|
&self,
|
||||||
|
name: String,
|
||||||
|
path: String,
|
||||||
|
material_type: String,
|
||||||
|
info: serde_json::Value,
|
||||||
|
created_at: i64,
|
||||||
|
updated_at: i64,
|
||||||
|
) -> Result<MaterialRecord, String> {
|
||||||
|
self.with_db(move |db| {
|
||||||
|
db.insert(
|
||||||
|
&name,
|
||||||
|
&path,
|
||||||
|
&material_type,
|
||||||
|
info,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
)
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ const mainChildren = [
|
|||||||
|
|
||||||
name: "material",
|
name: "material",
|
||||||
|
|
||||||
component: placeholder,
|
component: () => import("../views/MaterialView.vue"),
|
||||||
|
|
||||||
meta: { title: "素材" },
|
meta: { title: "素材" },
|
||||||
|
|
||||||
@@ -56,65 +56,65 @@ const mainChildren = [
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
// {
|
||||||
|
|
||||||
path: "compose",
|
// path: "compose",
|
||||||
|
|
||||||
name: "compose",
|
// name: "compose",
|
||||||
|
|
||||||
component: placeholder,
|
// component: placeholder,
|
||||||
|
|
||||||
meta: { title: "合成" },
|
// meta: { title: "合成" },
|
||||||
|
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
// {
|
||||||
|
|
||||||
path: "extract",
|
// path: "extract",
|
||||||
|
|
||||||
name: "extract",
|
// name: "extract",
|
||||||
|
|
||||||
component: placeholder,
|
// component: placeholder,
|
||||||
|
|
||||||
meta: { title: "提取" },
|
// meta: { title: "提取" },
|
||||||
|
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
// {
|
||||||
|
|
||||||
path: "design",
|
// path: "design",
|
||||||
|
|
||||||
name: "design",
|
// name: "design",
|
||||||
|
|
||||||
component: placeholder,
|
// component: placeholder,
|
||||||
|
|
||||||
meta: { title: "设计" },
|
// meta: { title: "设计" },
|
||||||
|
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
// {
|
||||||
|
|
||||||
path: "model",
|
// path: "model",
|
||||||
|
|
||||||
name: "model",
|
// name: "model",
|
||||||
|
|
||||||
component: placeholder,
|
// component: placeholder,
|
||||||
|
|
||||||
meta: { title: "模型" },
|
// meta: { title: "模型" },
|
||||||
|
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
// {
|
||||||
|
|
||||||
path: "help",
|
// path: "help",
|
||||||
|
|
||||||
name: "help",
|
// name: "help",
|
||||||
|
|
||||||
component: placeholder,
|
// component: placeholder,
|
||||||
|
|
||||||
meta: { title: "帮助" },
|
// meta: { title: "帮助" },
|
||||||
|
|
||||||
},
|
// },
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|||||||
49
src/services/materialDb.js
Normal file
49
src/services/materialDb.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
function ensureTauri() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("素材管理需在 Tauri 桌面端使用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: number, name: string, path: string, type: 'image' | 'video', info: object, createdAt: number, updatedAt: number }} MaterialRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {Promise<MaterialRecord[]>} */
|
||||||
|
export async function listMaterials() {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("list_materials");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
export async function updateMaterialName(id, name) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("update_material_name", { id, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
*/
|
||||||
|
export async function deleteMaterial(id) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("delete_material", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} sourcePaths
|
||||||
|
* @returns {Promise<MaterialRecord[]>}
|
||||||
|
*/
|
||||||
|
export async function importMaterialFiles(sourcePaths) {
|
||||||
|
ensureTauri();
|
||||||
|
return invoke("import_material_files", { sourcePaths });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MATERIAL_UPDATED_EVENT = "video-material-updated";
|
||||||
|
|
||||||
|
export function dispatchMaterialUpdated() {
|
||||||
|
window.dispatchEvent(new CustomEvent(MATERIAL_UPDATED_EVENT));
|
||||||
|
}
|
||||||
50
src/services/materialMedia.js
Normal file
50
src/services/materialMedia.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
const MEDIA_EXTENSIONS = [
|
||||||
|
"mp4",
|
||||||
|
"mov",
|
||||||
|
"avi",
|
||||||
|
"mkv",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
export async function pickMaterialFiles() {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("请在 Tauri 桌面端选择素材文件");
|
||||||
|
}
|
||||||
|
const selected = await open({
|
||||||
|
multiple: true,
|
||||||
|
title: "选择素材文件",
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "媒体文件",
|
||||||
|
extensions: MEDIA_EXTENSIONS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (selected == null) return [];
|
||||||
|
if (Array.isArray(selected)) {
|
||||||
|
return selected.map(String).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [String(selected)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
export function localMediaToPlayableUrl(filePath) {
|
||||||
|
if (!isTauri()) {
|
||||||
|
throw new Error("请在 Tauri 桌面端预览本地素材");
|
||||||
|
}
|
||||||
|
return convertFileSrc(filePath);
|
||||||
|
}
|
||||||
76
src/stores/material.js
Normal file
76
src/stores/material.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { defineStore, acceptHMRUpdate } from "pinia";
|
||||||
|
import {
|
||||||
|
listMaterials,
|
||||||
|
updateMaterialName,
|
||||||
|
deleteMaterial,
|
||||||
|
importMaterialFiles,
|
||||||
|
dispatchMaterialUpdated,
|
||||||
|
} from "../services/materialDb.js";
|
||||||
|
|
||||||
|
export const useMaterialStore = defineStore("material", {
|
||||||
|
state: () => ({
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
uploading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.items = await listMaterials();
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} sourcePaths
|
||||||
|
*/
|
||||||
|
async uploadFiles(sourcePaths) {
|
||||||
|
if (!sourcePaths.length) return { ok: true };
|
||||||
|
this.uploading = true;
|
||||||
|
try {
|
||||||
|
await importMaterialFiles(sourcePaths);
|
||||||
|
await this.refresh();
|
||||||
|
dispatchMaterialUpdated();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, message: msg || "上传失败" };
|
||||||
|
} finally {
|
||||||
|
this.uploading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} id
|
||||||
|
* @param {string} name
|
||||||
|
*/
|
||||||
|
async renameItem(id, name) {
|
||||||
|
const trimmed = String(name || "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, message: "名称不能为空" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateMaterialName(id, trimmed);
|
||||||
|
await this.refresh();
|
||||||
|
dispatchMaterialUpdated();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, message: msg || "重命名失败" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeItem(item) {
|
||||||
|
await deleteMaterial(item.id);
|
||||||
|
await this.refresh();
|
||||||
|
dispatchMaterialUpdated();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.accept(acceptHMRUpdate(useMaterialStore, import.meta.hot));
|
||||||
|
}
|
||||||
236
src/views/MaterialView.vue
Normal file
236
src/views/MaterialView.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useMaterialStore } from "../stores/material.js";
|
||||||
|
import { pickMaterialFiles, localMediaToPlayableUrl } from "../services/materialMedia.js";
|
||||||
|
|
||||||
|
const materialStore = useMaterialStore();
|
||||||
|
const { items, loading, uploading } = storeToRefs(materialStore);
|
||||||
|
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
const editingId = ref(null);
|
||||||
|
const editingName = ref("");
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
materialStore.refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showFeedback(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaSrc(item) {
|
||||||
|
if (!item?.path) return "";
|
||||||
|
try {
|
||||||
|
return localMediaToPlayableUrl(item.path);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeLabel(item) {
|
||||||
|
return item?.type === "image" ? "图片" : "视频";
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSeverity(item) {
|
||||||
|
return item?.type === "image" ? "info" : "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
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 materialStore.renameItem(item.id, editingName.value);
|
||||||
|
if (!result.ok) {
|
||||||
|
showFeedback("error", result.message || "重命名失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelRename();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
await materialStore.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload() {
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
try {
|
||||||
|
const paths = await pickMaterialFiles();
|
||||||
|
if (!paths.length) return;
|
||||||
|
const result = await materialStore.uploadFiles(paths);
|
||||||
|
if (!result.ok) {
|
||||||
|
showFeedback("error", result.message || "上传失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showFeedback("success", "素材已上传");
|
||||||
|
} catch (err) {
|
||||||
|
showFeedback(
|
||||||
|
"error",
|
||||||
|
err instanceof Error ? err.message : String(err || "选择文件失败"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(item) {
|
||||||
|
if (!window.confirm(`确认删除素材「${item.name}」?`)) return;
|
||||||
|
await materialStore.removeItem(item);
|
||||||
|
showFeedback("success", "已删除");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="video-material-page custom-scrollbar h-full overflow-y-auto p-5">
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div class="flex flex-grow items-end gap-3">
|
||||||
|
<h1 class="material-page-title text-3xl font-bold text-slate-100">素材库</h1>
|
||||||
|
<p class="material-page-subtitle text-sm text-slate-500">
|
||||||
|
管理画中画和字幕逐行切换素材
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
label="刷新"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
:loading="loading"
|
||||||
|
@click="onRefresh"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="上传素材"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
:loading="uploading"
|
||||||
|
@click="onUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="material-page-tip mb-4 text-sm text-slate-500">
|
||||||
|
上传后的素材会保存到本地素材库,画中画字幕模式可直接逐行选择,不需要每次重新选文件。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="feedback.message"
|
||||||
|
class="mb-3 text-sm"
|
||||||
|
:class="{
|
||||||
|
'text-red-400': feedback.severity === 'error',
|
||||||
|
'text-emerald-400': feedback.severity === 'success',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="loading && items.length === 0" class="py-12 text-center text-sm text-slate-500">
|
||||||
|
加载中...
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else-if="items.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 items"
|
||||||
|
:key="item.id"
|
||||||
|
class="w-full flex-shrink-0 p-2 sm:w-1/2 lg:w-1/3 xl:w-1/4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="material-card rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-shadow hover:border-cyan-500/30 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-grow">
|
||||||
|
<div
|
||||||
|
v-if="editingId !== item.id"
|
||||||
|
class="material-name inline-flex max-w-full items-center rounded-full bg-blue-500/15 px-2 py-1 leading-8"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-title-text flex-grow cursor-pointer truncate text-sm font-medium text-slate-100"
|
||||||
|
:title="item.name"
|
||||||
|
@click="startRename(item)"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="material-edit-trigger ml-1 shrink-0 text-slate-500 hover:text-blue-400"
|
||||||
|
aria-label="重命名"
|
||||||
|
@click="startRename(item)"
|
||||||
|
>
|
||||||
|
<i class="pi pi-pencil text-xs" />
|
||||||
|
</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>
|
||||||
|
<Tag :value="typeLabel(item)" :severity="typeSeverity(item)" class="shrink-0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div
|
||||||
|
class="material-preview-shell flex h-48 items-center justify-center overflow-hidden rounded-lg bg-black p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.type === 'image' && mediaSrc(item)"
|
||||||
|
:src="mediaSrc(item)"
|
||||||
|
:alt="item.name"
|
||||||
|
class="max-h-full max-w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="item.type !== 'image' && mediaSrc(item)"
|
||||||
|
:src="mediaSrc(item)"
|
||||||
|
controls
|
||||||
|
class="max-h-full max-w-full rounded object-contain"
|
||||||
|
/>
|
||||||
|
<p v-else class="text-xs text-slate-500">无法预览</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="material-path mb-3 truncate text-xs text-slate-500"
|
||||||
|
:title="item.path"
|
||||||
|
>
|
||||||
|
{{ item.path }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<Button
|
||||||
|
label="删除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-trash"
|
||||||
|
@click="onDelete(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user