This commit is contained in:
fengchuanhn@gmail.com
2026-05-22 18:42:26 +08:00
parent 2abc2134a8
commit d3af2e1124
19 changed files with 1856 additions and 116 deletions

170
src-tauri/src/asr_db.rs Normal file
View File

@@ -0,0 +1,170 @@
//! 语音识别历史 SQLite
use std::path::PathBuf;
use std::sync::Mutex;
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::Serialize;
use thiserror::Error;
const MAX_RECORDS: i64 = 50;
#[derive(Debug, Error)]
pub enum AsrDbError {
#[error("{0}")]
Db(#[from] rusqlite::Error),
#[error("{0}")]
Message(String),
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AsrHistoryRecord {
pub id: i64,
pub name: String,
pub source_path: String,
pub result_text: String,
pub created_at: String,
}
#[derive(Debug)]
pub struct AsrDb {
conn: Mutex<Connection>,
}
impl AsrDb {
pub fn open(path: PathBuf) -> Result<Self, AsrDbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
AsrDbError::Message(format!("创建数据库目录失败: {e}"))
})?;
}
let conn = Connection::open(path)?;
conn.execute_batch(
r#"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS asr_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
source_path TEXT NOT NULL,
result_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
"#,
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn list(&self) -> Result<Vec<AsrHistoryRecord>, AsrDbError> {
let conn = self.conn.lock().map_err(|_| {
AsrDbError::Message("ASR 数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT id, name, source_path, result_text, created_at
FROM asr_history ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map(params![MAX_RECORDS], |row| {
Ok(AsrHistoryRecord {
id: row.get(0)?,
name: row.get(1)?,
source_path: row.get(2)?,
result_text: row.get(3)?,
created_at: row.get(4)?,
})
})?;
let mut items = Vec::new();
for row in rows {
items.push(row?);
}
Ok(items)
}
pub fn insert(
&self,
name: &str,
source_path: &str,
result_text: &str,
) -> Result<AsrHistoryRecord, AsrDbError> {
let source_path = source_path.trim();
if source_path.is_empty() {
return Err(AsrDbError::Message("源文件路径不能为空".into()));
}
let name = name.trim();
let name = if name.is_empty() {
source_path
.replace('\\', "/")
.rsplit('/')
.next()
.unwrap_or("语音识别")
.to_string()
} else {
name.to_string()
};
let created_at = Utc::now().to_rfc3339();
let conn = self.conn.lock().map_err(|_| {
AsrDbError::Message("ASR 数据库锁异常".into())
})?;
conn.execute(
"INSERT INTO asr_history (name, source_path, result_text, created_at)
VALUES (?1, ?2, ?3, ?4)",
params![name, source_path, result_text.trim(), created_at],
)?;
let id = conn.last_insert_rowid();
self.prune_excess(&conn)?;
Ok(AsrHistoryRecord {
id,
name,
source_path: source_path.to_string(),
result_text: result_text.trim().to_string(),
created_at,
})
}
pub fn update_name(&self, id: i64, name: &str) -> Result<(), AsrDbError> {
let name = name.trim();
if name.is_empty() {
return Err(AsrDbError::Message("名称不能为空".into()));
}
let conn = self.conn.lock().map_err(|_| {
AsrDbError::Message("ASR 数据库锁异常".into())
})?;
let n = conn.execute(
"UPDATE asr_history SET name = ?1 WHERE id = ?2",
params![name, id],
)?;
if n == 0 {
return Err(AsrDbError::Message("记录不存在".into()));
}
Ok(())
}
fn prune_excess(&self, conn: &Connection) -> Result<(), AsrDbError> {
let count: i64 = conn.query_row(
"SELECT COUNT(1) FROM asr_history",
[],
|row| row.get(0),
)?;
if count <= MAX_RECORDS {
return Ok(());
}
let excess = count - MAX_RECORDS;
conn.execute(
"DELETE FROM asr_history WHERE id IN (
SELECT id FROM asr_history ORDER BY id ASC LIMIT ?1
)",
params![excess],
)?;
Ok(())
}
pub fn delete(&self, id: i64) -> Result<bool, AsrDbError> {
let conn = self.conn.lock().map_err(|_| {
AsrDbError::Message("ASR 数据库锁异常".into())
})?;
let n = conn.execute("DELETE FROM asr_history WHERE id = ?1", params![id])?;
Ok(n > 0)
}
}

View File

@@ -0,0 +1,83 @@
//! 语音识别历史SQLite 访问与 Tauri 状态
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::asr_db::{AsrDb, AsrHistoryRecord};
#[derive(Debug, Default)]
pub struct AsrStore {
inner: Arc<RwLock<Option<Arc<AsrDb>>>>,
}
impl AsrStore {
pub fn new() -> Self {
Self::default()
}
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
let db = tokio::task::spawn_blocking(move || {
AsrDb::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: "asr", "ASR 历史数据库已初始化,共 {n} 条记录");
Ok(())
}
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(Arc<AsrDb>) -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
let db = self
.inner
.read()
.await
.clone()
.ok_or_else(|| "ASR 数据库未初始化".to_string())?;
tokio::task::spawn_blocking(move || f(db))
.await
.map_err(|e| e.to_string())?
}
pub async fn list(&self) -> Result<Vec<AsrHistoryRecord>, String> {
self.with_db(|db| db.list().map_err(|e| e.to_string())).await
}
pub async fn insert(
&self,
name: String,
source_path: String,
result_text: String,
) -> Result<AsrHistoryRecord, String> {
self.with_db(move |db| {
db.insert(&name, &source_path, &result_text)
.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<bool, String> {
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
.await
}
}

View File

@@ -24,6 +24,7 @@ pub struct GeneratedAudioRecord {
pub id: i64,
pub name: String,
pub file_path: String,
pub source_text: String,
pub created_at: String,
}
@@ -47,10 +48,15 @@ impl AudioDb {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL,
source_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
"#,
)?;
let _ = conn.execute(
"ALTER TABLE generated_audios ADD COLUMN source_text TEXT NOT NULL DEFAULT ''",
[],
);
Ok(Self {
conn: Mutex::new(conn),
})
@@ -72,14 +78,15 @@ impl AudioDb {
AudioDbError::Message("语音数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT id, name, file_path, created_at FROM generated_audios ORDER BY id DESC LIMIT ?1",
"SELECT id, name, file_path, source_text, 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)?,
source_text: row.get(3)?,
created_at: row.get(4)?,
})
})?;
let mut items = Vec::new();
@@ -94,14 +101,15 @@ impl AudioDb {
AudioDbError::Message("语音数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT id, name, file_path, created_at FROM generated_audios WHERE id = ?1",
"SELECT id, name, file_path, source_text, 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)?,
source_text: row.get(3)?,
created_at: row.get(4)?,
})
})?;
match rows.next() {
@@ -110,7 +118,30 @@ impl AudioDb {
}
}
pub fn insert(&self, name: &str, file_path: &str) -> Result<GeneratedAudioRecord, AudioDbError> {
pub fn update_name(&self, id: i64, name: &str) -> Result<(), AudioDbError> {
let name = name.trim();
if name.is_empty() {
return Err(AudioDbError::Message("名称不能为空".into()));
}
let conn = self.conn.lock().map_err(|_| {
AudioDbError::Message("语音数据库锁异常".into())
})?;
let n = conn.execute(
"UPDATE generated_audios SET name = ?1 WHERE id = ?2",
params![name, id],
)?;
if n == 0 {
return Err(AudioDbError::Message("记录不存在".into()));
}
Ok(())
}
pub fn insert(
&self,
name: &str,
file_path: &str,
source_text: &str,
) -> Result<GeneratedAudioRecord, AudioDbError> {
let file_path = file_path.trim();
if file_path.is_empty() {
return Err(AudioDbError::Message("文件路径不能为空".into()));
@@ -125,9 +156,10 @@ impl AudioDb {
let conn = self.conn.lock().map_err(|_| {
AudioDbError::Message("语音数据库锁异常".into())
})?;
let source_text = source_text.trim().to_string();
conn.execute(
"INSERT INTO generated_audios (name, file_path, created_at) VALUES (?1, ?2, ?3)",
params![name, file_path, created_at],
"INSERT INTO generated_audios (name, file_path, source_text, created_at) VALUES (?1, ?2, ?3, ?4)",
params![name, file_path, source_text, created_at],
)?;
let id = conn.last_insert_rowid();
self.prune_excess(&conn)?;
@@ -135,6 +167,7 @@ impl AudioDb {
id,
name,
file_path: file_path.to_string(),
source_text,
created_at,
})
}

View File

@@ -58,8 +58,21 @@ impl AudioStore {
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()))
pub async fn insert(
&self,
name: String,
file_path: String,
source_text: String,
) -> Result<GeneratedAudioRecord, String> {
self.with_db(move |db| {
db.insert(&name, &file_path, &source_text)
.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
}

View File

@@ -17,8 +17,20 @@ pub async fn insert_generated_audio(
store: State<'_, AudioStore>,
name: String,
file_path: String,
#[allow(unused)] source_text: Option<String>,
) -> Result<GeneratedAudioRecord, String> {
store.insert(name, file_path).await
store
.insert(name, file_path, source_text.unwrap_or_default())
.await
}
#[tauri::command]
pub async fn update_generated_audio_name(
store: State<'_, AudioStore>,
id: i64,
name: String,
) -> Result<(), String> {
store.update_name(id, name).await
}
#[tauri::command]

View File

@@ -4,6 +4,7 @@ pub mod auth;
pub mod audio;
pub mod avatar;
pub mod material;
pub mod sound_asr;
pub mod video;
pub mod fs_util;
pub mod nodejs;

View File

@@ -0,0 +1,162 @@
//! 本地媒体文件语音识别DashScope + OSS
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use tauri::State;
use uuid::Uuid;
use crate::app_config::AppConfig;
use crate::asr;
use crate::asr_store::AsrStore;
use crate::ffmpeg;
use crate::oss::{self, OssConfig};
const VIDEO_EXTS: &[&str] = &["mp4", "avi", "mov", "mkv", "webm", "m4v", "flv"];
fn normalize_path(path: &str) -> PathBuf {
Path::new(path)
.components()
.filter(|c| !matches!(c, Component::ParentDir))
.collect()
}
fn config_get(map: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
for key in keys {
if let Some(v) = map.get(*key) {
let t = v.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
fn build_oss_config(map: &HashMap<String, String>) -> Result<OssConfig, String> {
let access_key_id = config_get(
map,
&[
"ALIYUN_OSS_ACCESS_KEY_ID",
"OSS_ACCESS_KEY_ID",
"ALIYUN_ACCESS_KEY_ID",
],
)
.ok_or_else(|| "缺少 OSS AccessKeyId请在本地配置中设置".to_string())?;
let access_key_secret = config_get(
map,
&[
"ALIYUN_OSS_ACCESS_KEY_SECRET",
"OSS_ACCESS_KEY_SECRET",
"ALIYUN_ACCESS_KEY_SECRET",
],
)
.ok_or_else(|| "缺少 OSS AccessKeySecret".to_string())?;
let bucket = config_get(map, &["ALIYUN_OSS_BUCKET", "OSS_BUCKET"])
.ok_or_else(|| "缺少 OSS Bucket".to_string())?;
let region = config_get(map, &["ALIYUN_OSS_REGION", "OSS_REGION"])
.ok_or_else(|| "缺少 OSS Region".to_string())?;
let endpoint = config_get(map, &["ALIYUN_OSS_ENDPOINT", "OSS_ENDPOINT"]);
Ok(OssConfig {
access_key_id,
access_key_secret,
bucket,
region,
endpoint,
})
}
fn is_video_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| VIDEO_EXTS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false)
}
#[tauri::command]
pub async fn list_asr_history(store: State<'_, AsrStore>) -> Result<Vec<crate::asr_db::AsrHistoryRecord>, String> {
store.list().await
}
#[tauri::command]
pub async fn delete_asr_history(store: State<'_, AsrStore>, id: i64) -> Result<(), String> {
let ok = store.delete(id).await?;
if !ok {
return Err("记录不存在".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_asr_history_name(
store: State<'_, AsrStore>,
id: i64,
name: String,
) -> Result<(), String> {
store.update_name(id, name).await
}
#[tauri::command]
pub async fn transcribe_media_file(
app_config: State<'_, AppConfig>,
asr_store: State<'_, AsrStore>,
file_path: String,
model: Option<String>,
) -> Result<crate::asr_db::AsrHistoryRecord, String> {
let src = normalize_path(file_path.trim());
if !src.is_file() {
return Err(format!("文件不存在: {}", src.display()));
}
let map = app_config.entries().await;
let api_key = config_get(
&map,
&["BAILIAN_API_KEY", "DASHSCOPE_API_KEY", "ALIYUN_BAILIAN_API_KEY"],
)
.ok_or_else(|| {
"缺少 BAILIAN_API_KEY或 DASHSCOPE_API_KEY请在本地配置中设置".to_string()
})?;
let oss_cfg = build_oss_config(&map)?;
let model = model
.filter(|m| !m.trim().is_empty())
.unwrap_or_else(|| "qwen3-asr-flash-filetrans".to_string());
let temp_dir = std::env::temp_dir().join("aiclient-asr");
tokio::fs::create_dir_all(&temp_dir)
.await
.map_err(|e| format!("创建临时目录失败: {e}"))?;
let upload_path = if is_video_path(&src) {
let wav = temp_dir.join(format!("{}.wav", Uuid::new_v4()));
ffmpeg::extract_audio(&src, &wav)
.await
.map_err(|e| format!("提取音频失败: {e}"))?;
wav
} else {
src.clone()
};
let key = format!("audio/{}.wav", Uuid::new_v4());
let audio_url = oss::put_object(&oss_cfg, &upload_path, &key, "audio/wav")
.await
.map_err(|e| format!("上传音频到 OSS 失败: {e}"))?;
let result = asr::transcribe(&audio_url, &api_key, &model)
.await
.map_err(|e| format!("语音识别失败: {e}"))?;
if !result.success {
return Err(result
.error
.unwrap_or_else(|| "语音识别失败".into()));
}
let text = result
.text
.filter(|t| !t.trim().is_empty())
.ok_or_else(|| "未识别到有效文本".to_string())?;
asr_store
.insert(String::new(), src.to_string_lossy().to_string(), text)
.await
}

View File

@@ -7,6 +7,8 @@ pub mod avatar_db;
pub mod avatar_store;
pub mod material_db;
pub mod material_store;
pub mod asr_db;
pub mod asr_store;
pub mod video_db;
pub mod video_store;
pub mod local_config;
@@ -38,6 +40,7 @@ pub fn run() {
.manage(app_config::AppConfig::new())
.manage(avatar_store::AvatarStore::new())
.manage(material_store::MaterialStore::new())
.manage(asr_store::AsrStore::new())
.manage(audio_store::AudioStore::new())
.manage(video_store::VideoStore::new())
.manage(publish_store::PublishStore::new())
@@ -54,6 +57,7 @@ pub fn run() {
let app_config = handle.state::<app_config::AppConfig>();
let avatar_store = handle.state::<avatar_store::AvatarStore>();
let material_store = handle.state::<material_store::MaterialStore>();
let asr_store = handle.state::<asr_store::AsrStore>();
let audio_store = handle.state::<audio_store::AudioStore>();
let video_store = handle.state::<video_store::VideoStore>();
let publish_store = handle.state::<publish_store::PublishStore>();
@@ -65,6 +69,7 @@ pub fn run() {
app_config.init_local_store(db_path).await?;
avatar_store.init(dir.join("avatars.db")).await?;
material_store.init(dir.join("video_materials.db")).await?;
asr_store.init(dir.join("asr_history.db")).await?;
audio_store.init(dir.join("generated_audios.db")).await?;
video_store.init(dir.join("generated_videos.db")).await?;
publish_store.init(dir.join("publish_accounts.db")).await?;
@@ -113,6 +118,11 @@ pub fn run() {
commands::material::update_material_name,
commands::material::delete_material,
commands::material::import_material_files,
commands::audio::update_generated_audio_name,
commands::sound_asr::list_asr_history,
commands::sound_asr::delete_asr_history,
commands::sound_asr::update_asr_history_name,
commands::sound_asr::transcribe_media_file,
commands::audio::list_generated_audios,
commands::audio::insert_generated_audio,
commands::audio::delete_generated_audio,

View File

@@ -0,0 +1,119 @@
<script setup>
import { ref } from "vue";
const visible = defineModel("visible", { type: Boolean, default: false });
const emit = defineEmits(["submit"]);
const lines = ref([{ text: "" }]);
const pasteVisible = ref(false);
const pasteRaw = ref("");
function addLine() {
lines.value.push({ text: "" });
}
function removeLine(index) {
lines.value.splice(index, 1);
}
function openPaste() {
pasteRaw.value = "";
pasteVisible.value = true;
}
function confirmPaste() {
const items = pasteRaw.value
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
if (items.length) {
lines.value = items.map((text) => ({ text }));
}
pasteVisible.value = false;
}
function onSubmit() {
const payload = lines.value.map((l) => ({ text: String(l.text || "").trim() }));
if (!payload.length || payload.some((l) => !l.text)) {
window.alert("所有内容不能为空");
return;
}
emit("submit", payload);
visible.value = false;
lines.value = [{ text: "" }];
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
header="批量文本合成"
:style="{ width: '80vw', maxWidth: '960px' }"
:draggable="false"
>
<div class="mb-3 flex flex-wrap items-center gap-2">
<Button label="添加一条" size="small" icon="pi pi-plus" @click="addLine" />
<Button
label="批量粘贴"
size="small"
icon="pi pi-clone"
severity="secondary"
outlined
@click="openPaste"
/>
<span class="text-sm text-slate-500"> {{ lines.length }} </span>
</div>
<div v-if="!lines.length" class="py-12 text-center text-sm text-slate-500">
暂无内容
</div>
<div v-else class="max-h-[50vh] space-y-2 overflow-y-auto pr-1">
<div
v-for="(line, index) in lines"
:key="index"
class="flex gap-2 rounded-lg border border-white/10 bg-white/5 p-3"
>
<Textarea
v-model="line.text"
rows="2"
auto-resize
class="flex-1"
placeholder="输入内容"
:maxlength="1000"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
text
aria-label="删除"
@click="removeLine(index)"
/>
</div>
</div>
<template #footer>
<Button label="取消" severity="secondary" text @click="visible = false" />
<Button label="提交合成" @click="onSubmit" />
</template>
</Dialog>
<Dialog
v-model:visible="pasteVisible"
modal
header="批量粘贴,每行一条"
:style="{ width: '70vw', maxWidth: '720px' }"
>
<Textarea
v-model="pasteRaw"
rows="12"
class="w-full"
placeholder="批量粘贴,每行一个"
/>
<template #footer>
<Button label="确定" @click="confirmPaste" />
</template>
</Dialog>
</template>

View File

@@ -13,8 +13,44 @@ import {
import { previewVoice } from "../../services/voicePreview.js";
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
const props = defineProps({
/** 声音页等独立场景:由父组件控制显隐与选中音色 */
standalone: { type: Boolean, default: false },
visible: { type: Boolean, default: false },
selectedVoiceId: { type: String, default: "sys_Cherry" },
});
const emit = defineEmits(["update:visible", "update:selectedVoiceId", "select"]);
const workflow = useWorkflowStore();
const { voiceManageModalVisible, selectedVoiceId } = storeToRefs(workflow);
const { voiceManageModalVisible, selectedVoiceId: workflowVoiceId } =
storeToRefs(workflow);
const dialogVisible = computed({
get() {
return props.standalone ? props.visible : voiceManageModalVisible.value;
},
set(v) {
if (props.standalone) {
emit("update:visible", v);
} else {
voiceManageModalVisible.value = v;
}
},
});
const currentVoiceId = computed({
get() {
return props.standalone ? props.selectedVoiceId : workflowVoiceId.value;
},
set(v) {
if (props.standalone) {
emit("update:selectedVoiceId", v);
} else {
workflowVoiceId.value = v;
}
},
});
const activeTab = ref("system");
const voiceCategory = ref("putonghua");
@@ -41,7 +77,7 @@ function loadCloneVoices() {
}
}
watch(voiceManageModalVisible, (open) => {
watch(dialogVisible, (open) => {
if (open) {
activeTab.value = "system";
voiceCategory.value = "putonghua";
@@ -63,11 +99,21 @@ function stopPreview() {
}
function onClose() {
if (props.standalone) {
dialogVisible.value = false;
} else {
workflow.closeVoiceManageDialog();
}
}
function onSelectVoice(voiceId) {
if (props.standalone) {
currentVoiceId.value = voiceId;
emit("select", voiceId);
dialogVisible.value = false;
} else {
workflow.selectVoice(voiceId);
}
}
function onAddClone() {
@@ -81,9 +127,13 @@ function onEditClone(record) {
function onDeleteClone(record) {
if (!window.confirm(`确认删除音色「${record.title}」?`)) return;
deleteCloneVoice(record.id);
if (selectedVoiceId.value === record.id) {
if (currentVoiceId.value === record.id) {
if (props.standalone) {
currentVoiceId.value = "sys_Cherry";
} else {
workflow.selectVoice("sys_Cherry");
}
}
if (previewPanelKey.value === record.id) {
stopPreview();
}
@@ -144,7 +194,7 @@ function showPreviewPlayer(key) {
<template>
<Dialog
v-model:visible="voiceManageModalVisible"
v-model:visible="dialogVisible"
modal
header="音色管理"
:style="{ width: '900px' }"

8
src/config/ttsModels.js Normal file
View File

@@ -0,0 +1,8 @@
/** 语音合成模型选项(对齐 Electron CosyVoice 版本说明) */
export const TTS_MODEL_OPTIONS = [
{
label: "V1 多语言/方言",
value: "cosyvoice-v3-flash",
hint: "选择他国语言需要文案也对应他国,选择方言需对应匹配的音色或克隆音色本身是方言",
},
];

View File

@@ -50,7 +50,7 @@ const mainChildren = [
name: "voice",
component: placeholder,
component: () => import("../views/VoiceView.vue"),
meta: { title: "声音" },

47
src/services/asrDb.js Normal file
View File

@@ -0,0 +1,47 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
function ensureTauri() {
if (!isTauri()) {
throw new Error("语音识别需在 Tauri 桌面端使用");
}
}
/**
* @typedef {{ id: number, name: string, sourcePath: string, resultText: string, createdAt: string }} AsrHistoryRecord
*/
/** @returns {Promise<AsrHistoryRecord[]>} */
export async function listAsrHistory() {
ensureTauri();
return invoke("list_asr_history");
}
/**
* @param {string} filePath
* @param {string} [model]
* @returns {Promise<AsrHistoryRecord>}
*/
export async function transcribeMediaFile(filePath, model) {
ensureTauri();
return invoke("transcribe_media_file", {
filePath,
model: model || null,
});
}
/**
* @param {number} id
*/
export async function deleteAsrHistory(id) {
ensureTauri();
return invoke("delete_asr_history", { id });
}
/**
* @param {number} id
* @param {string} name
*/
export async function updateAsrHistoryName(id, name) {
ensureTauri();
return invoke("update_asr_history_name", { id, name });
}

View File

@@ -7,7 +7,7 @@ function ensureTauri() {
}
/**
* @typedef {{ id: number, name: string, filePath: string, createdAt: string }} GeneratedAudioRecord
* @typedef {{ id: number, name: string, filePath: string, sourceText: string, createdAt: string }} GeneratedAudioRecord
*/
/** @returns {Promise<GeneratedAudioRecord[]>} */
@@ -19,11 +19,25 @@ export async function listGeneratedAudios() {
/**
* @param {string} name 可为空,空时由后端填入当前时间
* @param {string} filePath
* @param {string} [sourceText]
* @returns {Promise<GeneratedAudioRecord>}
*/
export async function insertGeneratedAudio(name, filePath) {
export async function insertGeneratedAudio(name, filePath, sourceText = "") {
ensureTauri();
return invoke("insert_generated_audio", { name, filePath });
return invoke("insert_generated_audio", {
name,
filePath,
sourceText: sourceText || "",
});
}
/**
* @param {number} id
* @param {string} name
*/
export async function updateGeneratedAudioName(id, name) {
ensureTauri();
return invoke("update_generated_audio_name", { id, name });
}
/**

View File

@@ -0,0 +1,24 @@
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
/**
* @returns {Promise<string | null>}
*/
export async function pickAsrMediaFile() {
if (!isTauri()) {
throw new Error("请在 Tauri 桌面端选择文件");
}
const selected = await open({
multiple: false,
title: "选择音频/视频文件",
filters: [
{
name: "媒体文件",
extensions: ["mp3", "wav", "flac", "aac", "m4a", "mp4", "avi", "mov", "mkv"],
},
],
});
if (selected == null) return null;
if (Array.isArray(selected)) return selected[0] || null;
return String(selected);
}

View File

@@ -0,0 +1,82 @@
import { invoke } from "@tauri-apps/api/core";
import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
import { insertGeneratedAudio } from "./audioDb.js";
const VOICE_GENERATE_SCRIPT = "voice_generate.js";
/**
* @param {{
* text: string,
* selectedVoiceId: string,
* voiceLanguage?: string,
* voiceEmotion?: string,
* speechRate?: number,
* }} opts
*/
export async function synthesizeSpeech(opts) {
const text = String(opts.text || "").trim();
if (!text) {
return { ok: false, message: "请输入合成内容" };
}
if (!opts.selectedVoiceId) {
return { ok: false, message: "请选择音色" };
}
const cfg = await ensureAppConfigReady();
if (!cfg.ok) {
return { ok: false, message: cfg.message };
}
const voice = resolveAliyunVoiceId(opts.selectedVoiceId);
const instruction = buildTtsInstruction(
opts.voiceLanguage || "中文(普通话)",
opts.selectedVoiceId,
opts.voiceEmotion || "自然",
opts.speechRate ?? 1,
);
const languageType = buildTtsLanguageType(opts.voiceLanguage || "中文(普通话)");
try {
const result = await invoke("run_nodejs_script", {
scriptName: VOICE_GENERATE_SCRIPT,
params: {
text,
voice,
instruction,
languageType: languageType || null,
format: "mp3",
},
});
if (!result?.success || !result?.audioPath) {
return {
ok: false,
message: String(result?.error || "语音合成失败,请重试"),
};
}
const title = truncateTitle(text, 32);
const displayName = `${getVoiceDisplayLabel(opts.selectedVoiceId)} · ${formatHistoryTime()}`;
const record = await insertGeneratedAudio(displayName, result.audioPath, text);
return { ok: true, record, title };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err || "未知错误");
return { ok: false, message: `语音合成失败: ${msg}` };
}
}
function truncateTitle(text, max) {
const t = String(text).replace(/\s+/g, " ");
if (t.length <= max) return t;
return `${t.slice(0, max)}`;
}
function formatHistoryTime() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}

View File

@@ -58,7 +58,7 @@ export async function executeGenerateSpeech(store) {
}
const displayName = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
const instruction = buildTtsInstruction(
const record = await insertGeneratedAudio(displayName, result.audioPath, text);
const audioSrc = await localAudioToPlayableUrl(record.filePath);
store.generatedAudioPath = record.filePath;

247
src/stores/voicePage.js Normal file
View File

@@ -0,0 +1,247 @@
import { defineStore, acceptHMRUpdate } from "pinia";
import {
listGeneratedAudios,
deleteGeneratedAudio,
updateGeneratedAudioName,
} from "../services/audioDb.js";
import {
listAsrHistory,
deleteAsrHistory,
updateAsrHistoryName,
transcribeMediaFile,
} from "../services/asrDb.js";
import { synthesizeSpeech } from "../services/soundSynthesize.js";
import { localAudioToPlayableUrl } from "../services/localAudio.js";
import { getMediaDurationSeconds } from "../services/mediaDuration.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
export const useVoicePageStore = defineStore("voicePage", {
state: () => ({
sideTab: "synthesis",
workspaceTab: "workspace",
mobileSidebarOpen: false,
synthesisText: "",
selectedVoiceId: "sys_Cherry",
voiceLanguage: "中文(普通话)",
voiceEmotion: "自然",
speechRate: 1,
ttsModel: "cosyvoice-v3-flash",
synthesizing: false,
asrFilePath: "",
asrRecognizing: false,
asrModel: "qwen3-asr-flash-filetrans",
voiceManageVisible: false,
/** @type {Array<{ id: number, name: string, filePath: string, sourceText: string, createdAt: string, audioSrc?: string, durationSec?: number, _check?: boolean }>} */
synthesisHistory: [],
synthesisLoading: false,
/** @type {Array<{ id: number, name: string, sourcePath: string, resultText: string, createdAt: string, _check?: boolean }>} */
asrHistory: [],
asrLoading: false,
}),
getters: {
selectedVoiceLabel: (state) => getVoiceDisplayLabel(state.selectedVoiceId),
voiceEmotions: () => ["自然", "热情", "沉稳"],
languages: () => [
"中文(普通话)",
"中文(粤语)",
"English",
"日语",
"韩语",
"法语",
"德语",
"西班牙语",
],
checkedSynthesisItems: (state) =>
state.synthesisHistory.filter((i) => i._check),
checkedAsrItems: (state) => state.asrHistory.filter((i) => i._check),
synthesisAllChecked: (state) => {
const list = state.synthesisHistory;
return list.length > 0 && list.every((i) => i._check);
},
asrAllChecked: (state) => {
const list = state.asrHistory;
return list.length > 0 && list.every((i) => i._check);
},
},
actions: {
async refreshSynthesisHistory() {
this.synthesisLoading = true;
try {
const rows = await listGeneratedAudios();
const enriched = await Promise.all(
rows.map(async (item) => {
let audioSrc = "";
let durationSec = 0;
try {
audioSrc = await localAudioToPlayableUrl(item.filePath);
durationSec = await getMediaDurationSeconds(item.filePath);
} catch {
/* ignore */
}
return {
...item,
audioSrc,
durationSec,
_check: false,
};
}),
);
this.synthesisHistory = enriched;
} finally {
this.synthesisLoading = false;
}
},
async refreshAsrHistory() {
this.asrLoading = true;
try {
this.asrHistory = (await listAsrHistory()).map((item) => ({
...item,
_check: false,
}));
} finally {
this.asrLoading = false;
}
},
async startSynthesis() {
this.synthesizing = true;
try {
const result = await synthesizeSpeech({
text: this.synthesisText,
selectedVoiceId: this.selectedVoiceId,
voiceLanguage: this.voiceLanguage,
voiceEmotion: this.voiceEmotion,
speechRate: this.speechRate,
});
if (!result.ok) {
return result;
}
this.synthesisText = "";
await this.refreshSynthesisHistory();
return { ok: true, message: "语音合成成功" };
} finally {
this.synthesizing = false;
}
},
async batchSynthesize(lines) {
const texts = lines.map((l) => String(l.text || "").trim()).filter(Boolean);
if (!texts.length) {
return { ok: false, message: "请添加合成内容" };
}
this.synthesizing = true;
try {
for (const text of texts) {
const result = await synthesizeSpeech({
text,
selectedVoiceId: this.selectedVoiceId,
voiceLanguage: this.voiceLanguage,
voiceEmotion: this.voiceEmotion,
speechRate: this.speechRate,
});
if (!result.ok) {
return result;
}
}
await this.refreshSynthesisHistory();
return { ok: true, message: "批量合成完成" };
} finally {
this.synthesizing = false;
}
},
async startAsr() {
if (!this.asrFilePath) {
return { ok: false, message: "请选择音频/视频文件" };
}
this.asrRecognizing = true;
try {
await transcribeMediaFile(this.asrFilePath, this.asrModel);
this.asrFilePath = "";
await this.refreshAsrHistory();
return { ok: true, message: "语音识别完成" };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, message: msg || "识别失败" };
} finally {
this.asrRecognizing = false;
}
},
async renameSynthesisItem(item, name) {
await updateGeneratedAudioName(item.id, name);
await this.refreshSynthesisHistory();
},
async deleteSynthesisItem(item) {
await deleteGeneratedAudio(item.id);
await this.refreshSynthesisHistory();
},
async renameAsrItem(item, name) {
await updateAsrHistoryName(item.id, name);
await this.refreshAsrHistory();
},
async deleteAsrItem(item) {
await deleteAsrHistory(item.id);
await this.refreshAsrHistory();
},
async deleteCheckedSynthesis() {
const items = this.checkedSynthesisItems;
if (!items.length) return;
for (const item of items) {
await deleteGeneratedAudio(item.id);
}
await this.refreshSynthesisHistory();
},
async deleteCheckedAsr() {
const items = this.checkedAsrItems;
if (!items.length) return;
for (const item of items) {
await deleteAsrHistory(item.id);
}
await this.refreshAsrHistory();
},
toggleSynthesisCheckAll(checked) {
this.synthesisHistory.forEach((i) => {
i._check = checked;
});
},
toggleAsrCheckAll(checked) {
this.asrHistory.forEach((i) => {
i._check = checked;
});
},
async clearCurrentHistory() {
if (this.sideTab === "synthesis") {
for (const item of [...this.synthesisHistory]) {
await deleteGeneratedAudio(item.id);
}
await this.refreshSynthesisHistory();
} else {
for (const item of [...this.asrHistory]) {
await deleteAsrHistory(item.id);
}
await this.refreshAsrHistory();
}
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useVoicePageStore, import.meta.hot));
}

665
src/views/VoiceView.vue Normal file
View File

@@ -0,0 +1,665 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useVoicePageStore } from "../stores/voicePage.js";
import { TTS_MODEL_OPTIONS } from "../config/ttsModels.js";
import VoiceManageDialog from "../components/workflow/VoiceManageDialog.vue";
import BatchTextSynthesizeDialog from "../components/voice/BatchTextSynthesizeDialog.vue";
import { pickAsrMediaFile } from "../services/soundMedia.js";
const voicePage = useVoicePageStore();
const {
sideTab,
workspaceTab,
mobileSidebarOpen,
synthesisText,
selectedVoiceId,
voiceLanguage,
voiceEmotion,
speechRate,
synthesizing,
asrFilePath,
asrRecognizing,
voiceManageVisible,
synthesisHistory,
synthesisLoading,
asrHistory,
asrLoading,
} = storeToRefs(voicePage);
const feedback = ref({ severity: "", message: "" });
const batchVisible = ref(false);
const ttsModelHint = computed(
() => TTS_MODEL_OPTIONS.find((m) => m.value === voicePage.ttsModel)?.hint || "",
);
const synthesisIndeterminate = computed(() => {
const n = voicePage.checkedSynthesisItems.length;
return n > 0 && n < synthesisHistory.value.length;
});
const asrIndeterminate = computed(() => {
const n = voicePage.checkedAsrItems.length;
return n > 0 && n < asrHistory.value.length;
});
const asrFileLabel = computed(() => {
if (!asrFilePath.value) return "选择音频/视频文件";
const name = asrFilePath.value.replace(/\\/g, "/").split("/").pop();
return name || asrFilePath.value;
});
onMounted(async () => {
await voicePage.refreshSynthesisHistory();
await voicePage.refreshAsrHistory();
});
function showFeedback(severity, message) {
feedback.value = { severity, message };
}
async function onSynthesize() {
feedback.value = { severity: "", message: "" };
const result = await voicePage.startSynthesis();
showFeedback(result.ok ? "success" : "error", result.message || "");
}
async function onBatchSubmit(lines) {
feedback.value = { severity: "", message: "" };
const result = await voicePage.batchSynthesize(lines);
showFeedback(result.ok ? "success" : "error", result.message || "");
}
async function onAsr() {
feedback.value = { severity: "", message: "" };
const result = await voicePage.startAsr();
showFeedback(result.ok ? "success" : "error", result.message || "");
}
async function onPickAsrFile() {
try {
const path = await pickAsrMediaFile();
if (path) asrFilePath.value = path;
} catch (err) {
showFeedback("error", err instanceof Error ? err.message : String(err));
}
}
async function onClearHistory() {
if (!window.confirm("确认清空当前列表?此操作不可恢复。")) return;
try {
await voicePage.clearCurrentHistory();
showFeedback("success", "已清空");
} catch (err) {
showFeedback("error", err instanceof Error ? err.message : String(err));
}
}
function formatDuration(sec) {
const s = Math.max(0, Math.round(Number(sec) || 0));
if (s < 60) return `${s}`;
const m = Math.floor(s / 60);
const r = s % 60;
return r ? `${m}${r}` : `${m}分钟`;
}
function formatRelativeTime(iso) {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "";
const diff = Date.now() - t;
const days = Math.floor(diff / 86400000);
if (days > 0) return `${days} 天前`;
const hours = Math.floor(diff / 3600000);
if (hours > 0) return `${hours} 小时前`;
const mins = Math.floor(diff / 60000);
if (mins > 0) return `${mins} 分钟前`;
return "刚刚";
}
function truncateText(text, max = 120) {
const t = String(text || "");
if (t.length <= max) return t;
return `${t.slice(0, max)}`;
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(String(text || ""));
showFeedback("success", "已复制");
} catch {
showFeedback("error", "复制失败");
}
}
function downloadAudio(item) {
if (!item.audioSrc) return;
const a = document.createElement("a");
a.href = item.audioSrc;
a.download = `${item.name || "audio"}.mp3`;
a.click();
}
function downloadText(item) {
const blob = new Blob([item.resultText || ""], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${item.name || "asr"}.txt`;
a.click();
URL.revokeObjectURL(url);
}
const editingId = ref(null);
const editingName = ref("");
function startRename(item) {
editingId.value = item.id;
editingName.value = item.name;
}
function cancelRename() {
editingId.value = null;
editingName.value = "";
}
async function commitRenameSynthesis(item) {
try {
await voicePage.renameSynthesisItem(item, editingName.value);
cancelRename();
} catch (err) {
showFeedback("error", err instanceof Error ? err.message : String(err));
}
}
async function commitRenameAsr(item) {
try {
await voicePage.renameAsrItem(item, editingName.value);
cancelRename();
} catch (err) {
showFeedback("error", err instanceof Error ? err.message : String(err));
}
}
</script>
<template>
<div
class="voice-workbench flex h-full min-h-0 overflow-hidden bg-[#0a0a0a] font-sans text-white"
>
<!-- 移动端顶栏 -->
<div
class="fixed left-0 right-0 top-0 z-50 flex h-14 items-center justify-between border-b border-white/10 bg-[#141414] px-4 md:hidden"
>
<div
class="bg-gradient-to-r from-purple-400 to-pink-500 bg-clip-text text-lg font-bold text-transparent"
>
SOUND:AI
</div>
<button type="button" class="text-white" @click="mobileSidebarOpen = !mobileSidebarOpen">
<i class="pi pi-bars text-xl" />
</button>
</div>
<!-- 左侧栏 -->
<aside
class="fixed inset-y-0 left-0 z-40 flex w-80 flex-col border-r border-white/10 bg-[#141414] transition-transform duration-300 md:relative md:translate-x-0"
:class="mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div class="hidden h-16 shrink-0 items-center border-b border-white/10 px-6 md:flex">
<span class="text-xl font-bold tracking-wider">
SOUND<span class="text-purple-500">:AI</span>
</span>
</div>
<div class="px-6 pb-2 pt-16 md:pt-6">
<div class="flex rounded-lg bg-[#1a1a1a] p-1">
<button
type="button"
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
:class="
sideTab === 'synthesis'
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white'
"
@click="sideTab = 'synthesis'"
>
<i class="pi pi-volume-up text-sm" />
声音合成
</button>
<button
type="button"
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
:class="
sideTab === 'asr'
? 'bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white'
"
@click="sideTab = 'asr'"
>
<i class="pi pi-microphone text-sm" />
语音识别
</button>
</div>
</div>
<div class="px-6 pb-2">
<button
type="button"
class="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-500/40 py-2.5 text-sm font-bold text-purple-300 transition-all hover:border-purple-400 hover:bg-purple-600/20 hover:text-white"
@click="voiceManageVisible = true"
>
<i class="pi pi-sliders-h" />
音色管理
</button>
</div>
<div class="custom-scrollbar flex-1 space-y-6 overflow-y-auto px-6 py-4">
<!-- 声音合成表单 -->
<div
v-if="sideTab === 'synthesis'"
class="rounded-xl border border-[#333] bg-[#141414] p-4"
>
<Textarea
v-model="synthesisText"
rows="4"
auto-resize
class="mb-3 w-full"
placeholder="输入语音内容开始合成"
/>
<div class="mb-3 text-sm font-bold text-gray-200">
<i class="pi pi-cog mr-1 text-slate-400" />
语音合成模型
</div>
<Select
v-model="voicePage.ttsModel"
:options="TTS_MODEL_OPTIONS"
option-label="label"
option-value="value"
size="small"
class="mb-2 w-full"
/>
<p v-if="ttsModelHint" class="mb-3 ml-1 text-xs text-gray-500">{{ ttsModelHint }}</p>
<div class="mb-3 flex items-center gap-2 text-sm text-gray-200">
<i class="pi pi-user text-slate-400" />
<Button
:label="voicePage.selectedVoiceLabel"
size="small"
severity="secondary"
outlined
class="flex-1 truncate"
@click="voiceManageVisible = true"
/>
</div>
<div class="mb-3 grid grid-cols-3 gap-2">
<div>
<label class="mb-1 block text-xs text-gray-500">情绪</label>
<Select
v-model="voiceEmotion"
:options="voicePage.voiceEmotions"
size="small"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-gray-500">语言</label>
<Select
v-model="voiceLanguage"
:options="voicePage.languages"
size="small"
class="w-full"
/>
</div>
<div>
<label class="mb-1 block text-xs text-gray-500">语速</label>
<InputNumber
v-model="speechRate"
:min="0.5"
:max="2"
:step="0.1"
size="small"
class="w-full"
/>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<Button
label="开始合成"
:loading="synthesizing"
@click="onSynthesize"
/>
<Button
label="批量文本合成"
severity="secondary"
outlined
:disabled="synthesizing"
@click="batchVisible = true"
/>
</div>
</div>
<!-- 语音识别表单 -->
<div v-else class="rounded-xl border border-[#333] bg-[#141414] p-4">
<div class="mb-3 text-sm font-bold text-gray-200">
<i class="pi pi-file-audio mr-1 text-slate-400" />
音频/视频文件
</div>
<Button
:label="asrFileLabel"
severity="secondary"
outlined
class="mb-3 w-full truncate"
icon="pi pi-folder-open"
@click="onPickAsrFile"
/>
<Button
label="开始识别"
icon="pi pi-play"
:loading="asrRecognizing"
@click="onAsr"
/>
</div>
</div>
</aside>
<div
v-if="mobileSidebarOpen"
class="fixed inset-0 z-30 bg-black/80 backdrop-blur-sm md:hidden"
@click="mobileSidebarOpen = false"
/>
<!-- 右侧主区 -->
<div class="relative z-0 flex min-w-0 flex-1 flex-col bg-[#0a0a0a] pt-14 md:pt-0">
<div
class="hidden h-16 shrink-0 items-center justify-between border-b border-white/10 px-8 md:flex"
>
<div class="flex items-center gap-6">
<button
type="button"
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors"
:class="
workspaceTab === 'history'
? 'border-purple-500 text-white'
: 'border-transparent text-gray-400 hover:text-white'
"
@click="workspaceTab = 'history'"
>
历史任务
</button>
<button
type="button"
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors"
:class="
workspaceTab === 'workspace'
? 'border-purple-500 text-white'
: 'border-transparent text-gray-400 hover:text-white'
"
@click="workspaceTab = 'workspace'"
>
工作台
</button>
</div>
<Button
icon="pi pi-trash"
severity="secondary"
text
class="text-gray-400 hover:text-red-400"
title="清空当前列表"
@click="onClearHistory"
/>
</div>
<p
v-if="feedback.message"
class="mx-4 mt-2 text-sm md:mx-6"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
>
{{ feedback.message }}
</p>
<div class="custom-scrollbar flex-1 overflow-y-auto p-4 md:p-6">
<!-- 合成历史 -->
<template v-if="sideTab === 'synthesis'">
<div
v-if="synthesisHistory.length"
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4"
>
<Checkbox
:model-value="voicePage.synthesisAllChecked"
:binary="true"
:indeterminate="synthesisIndeterminate"
input-id="syn-all"
@update:model-value="voicePage.toggleSynthesisCheckAll"
/>
<label for="syn-all" class="text-sm">全选</label>
<Button
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="!voicePage.checkedSynthesisItems.length"
@click="voicePage.deleteCheckedSynthesis()"
/>
<span class="ml-auto text-sm text-gray-500">
{{ synthesisHistory.length }}
</span>
</div>
<p v-if="synthesisLoading" class="py-16 text-center text-sm text-gray-500">
加载中...
</p>
<div
v-else-if="!synthesisHistory.length"
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center"
>
<i class="pi pi-volume-up mb-4 text-5xl text-gray-600" />
<h2 class="text-2xl font-bold text-gray-300">准备好创作了吗</h2>
<p class="mt-2 max-w-sm text-gray-500">
在左侧输入文本并选择声音开始您的创作之旅
</p>
</div>
<div v-else class="space-y-4 pb-20">
<div
v-for="item in synthesisHistory"
:key="item.id"
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]"
>
<div class="flex flex-wrap items-center gap-2">
<div
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1"
>
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
<div v-if="editingId !== item.id" class="min-w-0">
<span
class="block max-w-md cursor-pointer truncate text-sm"
:title="item.name"
@click="startRename(item)"
>
{{ item.name }}
</span>
</div>
<div v-else class="flex gap-1">
<InputText v-model="editingName" size="small" />
<Button
icon="pi pi-check"
size="small"
@click="commitRenameSynthesis(item)"
/>
<Button icon="pi pi-times" size="small" severity="secondary" @click="cancelRename" />
</div>
</div>
<span
v-if="item.durationSec"
class="rounded-full bg-white/10 px-2 py-1 text-xs"
>
{{ formatDuration(item.durationSec) }}
</span>
<Tag severity="success" value="成功" class="ml-auto" />
</div>
<div
v-if="item.sourceText"
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300"
>
<span>{{ truncateText(item.sourceText) }}</span>
<div class="mt-2 flex gap-1">
<Button
icon="pi pi-copy"
size="small"
severity="secondary"
text
@click="copyText(item.sourceText)"
/>
</div>
</div>
<div v-if="item.audioSrc" class="mt-3">
<audio :src="item.audioSrc" controls class="h-10 w-full" />
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-500">
{{ formatRelativeTime(item.createdAt) }}
</span>
<div class="flex gap-1">
<Button
icon="pi pi-download"
size="small"
severity="secondary"
outlined
@click="downloadAudio(item)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="
window.confirm('确认删除?') && voicePage.deleteSynthesisItem(item)
"
/>
</div>
</div>
</div>
</div>
</template>
<!-- ASR 历史 -->
<template v-else>
<div
v-if="asrHistory.length"
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4"
>
<Checkbox
:model-value="voicePage.asrAllChecked"
:binary="true"
:indeterminate="asrIndeterminate"
input-id="asr-all"
@update:model-value="voicePage.toggleAsrCheckAll"
/>
<label for="asr-all" class="text-sm">全选</label>
<Button
icon="pi pi-trash"
severity="danger"
outlined
size="small"
:disabled="!voicePage.checkedAsrItems.length"
@click="voicePage.deleteCheckedAsr()"
/>
<span class="ml-auto text-sm text-gray-500"> {{ asrHistory.length }} </span>
</div>
<p v-if="asrLoading" class="py-16 text-center text-sm text-gray-500">加载中...</p>
<div
v-else-if="!asrHistory.length"
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center"
>
<i class="pi pi-microphone mb-4 text-5xl text-gray-600" />
<h2 class="text-2xl font-bold text-gray-300">准备好识别了吗</h2>
<p class="mt-2 max-w-sm text-gray-500">
在左侧上传音频或视频文件开始您的识别之旅
</p>
</div>
<div v-else class="space-y-4 pb-20">
<div
v-for="item in asrHistory"
:key="item.id"
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]"
>
<div class="flex flex-wrap items-center gap-2">
<div
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1"
>
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
<div v-if="editingId !== item.id" class="min-w-0">
<span
class="block max-w-md cursor-pointer truncate text-sm"
@click="startRename(item)"
>
{{ item.name }}
</span>
</div>
<div v-else class="flex gap-1">
<InputText v-model="editingName" size="small" />
<Button icon="pi pi-check" size="small" @click="commitRenameAsr(item)" />
<Button icon="pi pi-times" size="small" severity="secondary" @click="cancelRename" />
</div>
</div>
<Tag severity="success" value="成功" class="ml-auto" />
</div>
<div
v-if="item.resultText"
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300"
>
{{ truncateText(item.resultText, 300) }}
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-500">
{{ formatRelativeTime(item.createdAt) }}
</span>
<div class="flex gap-1">
<Button
icon="pi pi-copy"
size="small"
severity="secondary"
outlined
@click="copyText(item.resultText)"
/>
<Button
icon="pi pi-download"
size="small"
severity="secondary"
outlined
@click="downloadText(item)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
@click="window.confirm('确认删除?') && voicePage.deleteAsrItem(item)"
/>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
<VoiceManageDialog
standalone
v-model:visible="voiceManageVisible"
v-model:selected-voice-id="selectedVoiceId"
/>
<BatchTextSynthesizeDialog v-model:visible="batchVisible" @submit="onBatchSubmit" />
</div>
</template>