11
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::commands::fs_util;
|
||||||
use chrono::{Datelike, Local, Timelike};
|
use chrono::{Datelike, Local, Timelike};
|
||||||
use rusqlite::{params, Connection};
|
use rusqlite::{params, Connection};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -182,16 +183,33 @@ impl AudioDb {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let excess = count - MAX_RECORDS;
|
let excess = count - MAX_RECORDS;
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT file_path FROM generated_audios ORDER BY id ASC LIMIT ?1",
|
||||||
|
)?;
|
||||||
|
let paths: Vec<String> = stmt
|
||||||
|
.query_map(params![excess], |row| row.get(0))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM generated_audios WHERE id IN (
|
"DELETE FROM generated_audios WHERE id IN (
|
||||||
SELECT id FROM generated_audios ORDER BY id ASC LIMIT ?1
|
SELECT id FROM generated_audios ORDER BY id ASC LIMIT ?1
|
||||||
)",
|
)",
|
||||||
params![excess],
|
params![excess],
|
||||||
)?;
|
)?;
|
||||||
|
for path in paths {
|
||||||
|
if let Err(e) = fs_util::remove_allowed_local_media_file(&path) {
|
||||||
|
log::warn!(target: "audio", "裁剪历史时删除音频文件失败 {path}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, id: i64) -> Result<bool, AudioDbError> {
|
pub fn delete(&self, id: i64) -> Result<bool, AudioDbError> {
|
||||||
|
let Some(record) = self.get(id)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
fs_util::remove_allowed_local_media_file(&record.file_path)
|
||||||
|
.map_err(AudioDbError::Message)?;
|
||||||
let conn = self.conn.lock().map_err(|_| {
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
AudioDbError::Message("语音数据库锁异常".into())
|
AudioDbError::Message("语音数据库锁异常".into())
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
|||||||
|| lower.contains("aiclient-subtitle-bgm")
|
|| lower.contains("aiclient-subtitle-bgm")
|
||||||
|| lower.contains("aiclient-cover")
|
|| lower.contains("aiclient-cover")
|
||||||
|| lower.contains("aiclient-cover-previews")
|
|| lower.contains("aiclient-cover-previews")
|
||||||
|
|| lower.contains("video-material")
|
||||||
|| lower.contains("avatars")
|
|| lower.contains("avatars")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -38,6 +39,24 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 删除受信任目录下的本地媒体文件(语音/视频历史记录删除时清理磁盘)。
|
||||||
|
pub(crate) fn remove_allowed_local_media_file(path: &str) -> Result<(), String> {
|
||||||
|
let normalized = normalize_path(path.trim());
|
||||||
|
if normalized.as_os_str().is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !normalized.is_file() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if !is_allowed_local_media(&normalized) {
|
||||||
|
return Err(format!(
|
||||||
|
"不允许删除该路径下的文件: {}",
|
||||||
|
normalized.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::fs::remove_file(&normalized).map_err(|e| format!("删除文件失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
/// 读取本地文件为 base64(用于 `<audio src="data:audio/wav;base64,...">`)
|
/// 读取本地文件为 base64(用于 `<audio src="data:audio/wav;base64,...">`)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_local_file_base64(path: String) -> Result<String, String> {
|
pub async fn read_local_file_base64(path: String) -> Result<String, String> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use serde_json::json;
|
|||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::commands::fs_util;
|
||||||
use crate::material_db::MaterialDb;
|
use crate::material_db::MaterialDb;
|
||||||
use crate::material_store::MaterialStore;
|
use crate::material_store::MaterialStore;
|
||||||
|
|
||||||
@@ -30,15 +31,6 @@ fn materials_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
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]
|
#[tauri::command]
|
||||||
pub async fn list_materials(store: State<'_, MaterialStore>) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
|
pub async fn list_materials(store: State<'_, MaterialStore>) -> Result<Vec<crate::material_db::MaterialRecord>, String> {
|
||||||
store.list().await
|
store.list().await
|
||||||
@@ -54,14 +46,14 @@ pub async fn update_material_name(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_material(
|
pub async fn delete_material(store: State<'_, MaterialStore>, id: i64) -> Result<(), String> {
|
||||||
app: AppHandle,
|
let record = store
|
||||||
store: State<'_, MaterialStore>,
|
.get(id)
|
||||||
id: i64,
|
.await?
|
||||||
) -> Result<(), String> {
|
.ok_or_else(|| "素材不存在".to_string())?;
|
||||||
let file_path = store.delete(id).await?;
|
fs_util::remove_allowed_local_media_file(&record.path)?;
|
||||||
if let Some(path) = file_path {
|
if !store.delete(id).await? {
|
||||||
delete_material_file_if_allowed(&app, &path).await?;
|
return Err("素材不存在".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -128,16 +120,3 @@ async fn copy_to_materials_dir(
|
|||||||
Ok(dest.to_string_lossy().to_string())
|
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(())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -163,6 +163,21 @@ impl MaterialDb {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: i64) -> Result<Option<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 WHERE id = ?1",
|
||||||
|
)?;
|
||||||
|
let mut rows = stmt.query_map(params![id], Self::row_to_record)?;
|
||||||
|
match rows.next() {
|
||||||
|
Some(row) => Ok(Some(row?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_name(&self, id: i64, name: &str) -> Result<(), MaterialDbError> {
|
pub fn update_name(&self, id: i64, name: &str) -> Result<(), MaterialDbError> {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -182,21 +197,11 @@ impl MaterialDb {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, id: i64) -> Result<Option<String>, MaterialDbError> {
|
pub fn delete(&self, id: i64) -> Result<bool, MaterialDbError> {
|
||||||
let conn = self.conn.lock().map_err(|_| {
|
let conn = self.conn.lock().map_err(|_| {
|
||||||
MaterialDbError::Message("素材数据库锁异常".into())
|
MaterialDbError::Message("素材数据库锁异常".into())
|
||||||
})?;
|
})?;
|
||||||
let path: Option<String> = conn
|
let n = conn.execute("DELETE FROM video_materials WHERE id = ?1", params![id])?;
|
||||||
.query_row(
|
Ok(n > 0)
|
||||||
"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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,12 @@ impl MaterialStore {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, id: i64) -> Result<Option<String>, String> {
|
pub async fn get(&self, id: i64) -> Result<Option<MaterialRecord>, String> {
|
||||||
|
self.with_db(move |db| db.get(id).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()))
|
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
|||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useMaterialStore } from "../stores/material.js";
|
import { useMaterialStore } from "../stores/material.js";
|
||||||
import { pickMaterialFiles, localMediaToPlayableUrl } from "../services/materialMedia.js";
|
import { pickMaterialFiles, localMediaToPlayableUrl } from "../services/materialMedia.js";
|
||||||
|
import { revealLocalFileInFolder } from "../services/fileExport.js";
|
||||||
|
|
||||||
const materialStore = useMaterialStore();
|
const materialStore = useMaterialStore();
|
||||||
const { items, loading, uploading } = storeToRefs(materialStore);
|
const { items, loading, uploading } = storeToRefs(materialStore);
|
||||||
@@ -79,10 +80,26 @@ async function onUpload() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onOpenInExplorer(item) {
|
||||||
|
if (!item?.path) {
|
||||||
|
showFeedback("error", "文件不存在");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await revealLocalFileInFolder(item.path);
|
||||||
|
} catch (err) {
|
||||||
|
showFeedback("error", err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onDelete(item) {
|
async function onDelete(item) {
|
||||||
if (!window.confirm(`确认删除素材「${item.name}」?`)) return;
|
if (!window.confirm(`确认删除素材「${item.name}」?`)) return;
|
||||||
await materialStore.removeItem(item);
|
try {
|
||||||
showFeedback("success", "已删除");
|
await materialStore.removeItem(item);
|
||||||
|
showFeedback("success", "已删除");
|
||||||
|
} catch (err) {
|
||||||
|
showFeedback("error", err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -219,7 +236,15 @@ async function onDelete(item) {
|
|||||||
{{ item.path }}
|
{{ item.path }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
label="打开"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-folder-open"
|
||||||
|
@click="onOpenInExplorer(item)"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="删除"
|
label="删除"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
Reference in New Issue
Block a user