diff --git a/src-tauri/src/commands/fs_util.rs b/src-tauri/src/commands/fs_util.rs
new file mode 100644
index 0000000..535fb14
--- /dev/null
+++ b/src-tauri/src/commands/fs_util.rs
@@ -0,0 +1,46 @@
+//! 本地文件读取(供前端播放 Node 生成的临时音频等)
+
+use base64::{engine::general_purpose::STANDARD, Engine as _};
+use std::path::{Component, Path, PathBuf};
+
+fn normalize_path(path: &str) -> PathBuf {
+ Path::new(path)
+ .components()
+ .filter(|c| !matches!(c, Component::ParentDir))
+ .collect()
+}
+
+/// 仅允许读取临时目录下的试听/流水线产物,避免任意路径读取。
+fn is_allowed_local_media(path: &Path) -> bool {
+ let lower = path.to_string_lossy().to_lowercase();
+ if lower.contains("aiclient-voice-preview-cache")
+ || lower.contains("aiclient-node-pipeline")
+ {
+ return true;
+ }
+ if let Ok(temp) = std::env::temp_dir().canonicalize() {
+ if let Ok(canonical) = path.canonicalize() {
+ return canonical.starts_with(&temp);
+ }
+ }
+ false
+}
+
+/// 读取本地文件为 base64(用于 `
+
+
+
+
+ {{ previewError }}
+
diff --git a/src/services/voicePreview.js b/src/services/voicePreview.js
new file mode 100644
index 0000000..e6661a4
--- /dev/null
+++ b/src/services/voicePreview.js
@@ -0,0 +1,85 @@
+import { invoke, isTauri } from "@tauri-apps/api/core";
+import { ensureAppConfigReady } from "../config/videoPipeline.js";
+import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
+
+const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
+
+/** @type {Map} cacheKey → data:audio URL */
+const previewUrlCache = new Map();
+
+function mimeFromPath(filePath) {
+ const lower = String(filePath || "").toLowerCase();
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
+ if (lower.endsWith(".ogg")) return "audio/ogg";
+ return "audio/wav";
+}
+
+/**
+ * 将 Node 写入的本地音频转为可在 WebView 播放的 URL(避免 asset.localhost 不可用)
+ * @param {string} filePath
+ */
+async function localAudioToPlayableUrl(filePath) {
+ if (!isTauri()) {
+ throw new Error("请在 Tauri 桌面端使用试听功能");
+ }
+ const base64 = await invoke("read_local_file_base64", { path: filePath });
+ const mime = mimeFromPath(filePath);
+ return `data:${mime};base64,${base64}`;
+}
+
+/**
+ * 音色试听
+ * @param {{ listKey: string, title: string, aliyunVoiceId?: string, selectedVoiceId?: string }} opts
+ */
+export async function previewVoice(opts) {
+ const cfg = await ensureAppConfigReady();
+ if (!cfg.ok) {
+ return { ok: false, message: cfg.message };
+ }
+
+ const voice = String(
+ opts.aliyunVoiceId ||
+ resolveAliyunVoiceId(opts.selectedVoiceId || ""),
+ ).trim();
+ if (!voice) {
+ return { ok: false, message: "音色 ID 无效" };
+ }
+
+ const cacheKey = opts.listKey || "";
+ const memCached = cacheKey ? previewUrlCache.get(cacheKey) : null;
+ if (memCached) {
+ return { ok: true, audioSrc: memCached, cached: true };
+ }
+
+ try {
+ const result = await invoke("run_nodejs_script", {
+ scriptName: VOICE_PREVIEW_SCRIPT,
+ params: {
+ voice,
+ title: opts.title || "",
+ cacheKey: cacheKey || undefined,
+ },
+ });
+
+ if (!result?.success || !result?.audioPath) {
+ return {
+ ok: false,
+ message: String(result?.error || "试听失败,请重试"),
+ };
+ }
+
+ const audioSrc = await localAudioToPlayableUrl(result.audioPath);
+ if (cacheKey) {
+ previewUrlCache.set(cacheKey, audioSrc);
+ }
+ return {
+ ok: true,
+ audioSrc,
+ audioPath: result.audioPath,
+ cached: Boolean(result.cached),
+ };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err || "未知错误");
+ return { ok: false, message: `试听失败: ${msg}` };
+ }
+}