//! 画中画/字幕素材: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 { 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, 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, ) -> Result, 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 { 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(()) }