diff --git a/app/tts/page.tsx b/app/tts/page.tsx
index ab61b41..fa76f79 100644
--- a/app/tts/page.tsx
+++ b/app/tts/page.tsx
@@ -1,32 +1,102 @@
-import Link from "next/link";
-import { ArrowRight, WaveIcon } from "@/components/icons";
-import { DevPlaceholder, PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
+"use client";
-export const metadata = { title: "TTS 工作台" };
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { ArrowRight, WaveIcon } from "@/components/icons";
+import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
+
+const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
+
+type Voice = { id: string; provider_voice_id: string; name: string; language?: string | null };
+type Usage = { available: number; used: number; reserved: number; plan: string };
+type Task = { id: string; status: string; text_length: number; voice_id: string; parameters: { format?: string; speed?: number }; error_code?: string | null; audio_available: boolean; created_at: string; finished_at?: string | null };
export default function TtsPage() {
- return (
-
-
-
- Phase 1 界面预览
-
-
当前不连接真实 TTS 服务,不保存文本,不显示虚假的生成成功状态。
+ const [csrf, setCsrf] = useState("");
+ const [loggedIn, setLoggedIn] = useState(false);
+ const [voices, setVoices] = useState
([]);
+ const [usage, setUsage] = useState(null);
+ const [tasks, setTasks] = useState([]);
+ const [text, setText] = useState("");
+ const [voice, setVoice] = useState("");
+ const [speed, setSpeed] = useState("1");
+ const [format, setFormat] = useState("wav");
+ const [activeTask, setActiveTask] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ async function loadWorkspace() {
+ const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" });
+ const csrfBody = await csrfResponse.json();
+ setCsrf(csrfBody.csrf_token ?? "");
+ const meResponse = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
+ if (!meResponse.ok) {
+ setLoggedIn(false);
+ return;
+ }
+ setLoggedIn(true);
+ const [voiceResponse, usageResponse, taskResponse] = await Promise.all([
+ fetch(`${apiBase}/tts/voices`, { credentials: "include" }),
+ fetch(`${apiBase}/account/usage`, { credentials: "include" }),
+ fetch(`${apiBase}/tts/tasks`, { credentials: "include" }),
+ ]);
+ const nextVoices = voiceResponse.ok ? await voiceResponse.json() : [];
+ setVoices(nextVoices);
+ setVoice((current) => current || nextVoices[0]?.provider_voice_id || "");
+ if (usageResponse.ok) setUsage(await usageResponse.json());
+ if (taskResponse.ok) setTasks(await taskResponse.json());
+ }
+
+ useEffect(() => { loadWorkspace().catch(() => setError("暂时无法连接 TTS 工作台")); }, []);
+
+ async function pollTask(taskId: string) {
+ for (let attempt = 0; attempt < 180; attempt += 1) {
+ const response = await fetch(`${apiBase}/tts/tasks/${taskId}`, { credentials: "include" });
+ if (!response.ok) throw new Error("无法读取任务状态");
+ const nextTask: Task = await response.json();
+ setActiveTask(nextTask);
+ if (["succeeded", "failed"].includes(nextTask.status)) return nextTask;
+ await new Promise((resolve) => window.setTimeout(resolve, 1000));
+ }
+ throw new Error("任务等待超时");
+ }
+
+ async function createTask() {
+ setError("");
+ if (!loggedIn) return;
+ if (!voice) return setError("当前没有可用音色,请先配置真实上游音色");
+ if (!text.trim()) return setError("请输入需要转换的文本");
+ setBusy(true);
+ try {
+ const response = await fetch(`${apiBase}/tts/tasks`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Idempotency-Key": crypto.randomUUID() },
+ body: JSON.stringify({ text, voice_id: voice, parameters: { format, speed: Number(speed) } }),
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(body.error?.message ?? "任务创建失败");
+ const result = await pollTask(body.id);
+ if (result.status === "failed") throw new Error(`任务失败:${result.error_code ?? "TASK_FAILED"}`);
+ await loadWorkspace();
+ } catch (taskError) {
+ setError(taskError instanceof Error ? taskError.message : "任务失败");
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return
+
Phase 3
+
{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}
+ {!loggedIn ?
: <>
-
-
+
+
-
-
- 还没有生成记录
完成认证和真实 TTS 接入后,任务状态、播放和下载会出现在这里。
-
-
- );
+ {error ? {error}
: null}
+ {activeTask?.status === "succeeded" ? : null}
+ {tasks.length ? {tasks.map((task) =>
{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}
{task.voice_id} · {task.text_length} 字 · {new Date(task.created_at).toLocaleString()}
{task.audio_available ?
: null}
)}
: 还没有生成记录
提交第一条任务后,状态和音频访问会出现在这里。
}
+ >}
+ ;
}
diff --git a/components/ui.tsx b/components/ui.tsx
index 37fed3e..13607ba 100644
--- a/components/ui.tsx
+++ b/components/ui.tsx
@@ -34,9 +34,9 @@ export function PreviewNotice({ children = "当前为 Phase 1 界面预览,真
return 开发预览:{children}
;
}
-export function StatusCard({ title, description, tone = "neutral" }: { title: string; description: string; tone?: "neutral" | "info" | "warning" | "success" }) {
+export function StatusCard({ title, description, tone = "neutral", children }: { title: string; description: string; tone?: "neutral" | "info" | "warning" | "success"; children?: React.ReactNode }) {
const styles = { neutral: "border-line bg-panel", info: "border-blue/25 bg-blue/8", warning: "border-warning/25 bg-warning/8", success: "border-success/25 bg-success/8" };
- return ;
+ return {title}
{description}
{children}
;
}
export function FeatureList({ items }: { items: string[] }) {