feat: connect Phase 3 TTS workspace

This commit is contained in:
flym 2026-09-09 00:43:56 +08:00
parent f5ddf761e4
commit d82693b4fa
2 changed files with 98 additions and 28 deletions

View File

@ -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 (
<div className="container-shell py-16 sm:py-24">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end">
<SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="独立的 TTS 工作台布局预览。生成、历史、回放和下载将在认证、上游适配与额度服务完成后启用。" tone="cyan" />
<DevPlaceholder>Phase 1 </DevPlaceholder>
</div>
<div className="mt-10"><PreviewNotice> TTS </PreviewNotice></div>
const [csrf, setCsrf] = useState("");
const [loggedIn, setLoggedIn] = useState(false);
const [voices, setVoices] = useState<Voice[]>([]);
const [usage, setUsage] = useState<Usage | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [text, setText] = useState("");
const [voice, setVoice] = useState("");
const [speed, setSpeed] = useState("1");
const [format, setFormat] = useState("wav");
const [activeTask, setActiveTask] = useState<Task | null>(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 <div className="container-shell py-16 sm:py-24">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end"><SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="TTS 任务现在由业务 API 持久化并归属当前用户;生成、历史、回放和下载使用真实服务状态。" tone="cyan" /><span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">Phase 3</span></div>
<div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div>
{!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"> <ArrowRight /></Link></StatusCard></div> : <>
<div className="mt-8 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title">
<div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div><span className="text-xs text-subtle">0 / </span></div>
<textarea className="field-input mt-6 min-h-64 resize-y leading-7" disabled placeholder="登录后输入需要转换的文本。" />
<div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted"></p><Link className="button-primary" href="/login"> <ArrowRight /></Link></div>
</section>
<aside className="space-y-6">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2><div className="mt-6 space-y-5"><div><label className="field-label" htmlFor="voice"></label><select className="field-input" disabled id="voice"><option></option></select></div><div><label className="field-label" htmlFor="format"></label><select className="field-input" disabled id="format"><option></option></select></div></div></section>
<StatusCard title="额度" description="登录后显示当前周期额度、已用与冻结。" tone="info" />
</aside>
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title"><div className="flex items-center justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div><span className="text-xs text-subtle">{text.length} / </span></div><textarea className="field-input mt-6 min-h-64 resize-y leading-7" onChange={(event) => setText(event.target.value)} placeholder="输入需要转换的文本。" value={text} /><div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted">{usage?.available.toLocaleString() ?? "加载中"}</p><button className="button-primary" disabled={busy || !csrf} onClick={createTask} type="button">{busy ? "生成中…" : "生成语音"} <ArrowRight /></button></div></section>
<aside className="space-y-6"><section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2><div className="mt-6 space-y-5"><div><label className="field-label" htmlFor="voice"></label><select className="field-input" id="voice" onChange={(event) => setVoice(event.target.value)} value={voice}><option value=""></option>{voices.map((item) => <option key={item.id} value={item.provider_voice_id}>{item.name}{item.language ? ` · ${item.language}` : ""}</option>)}</select></div><div><label className="field-label" htmlFor="speed"></label><select className="field-input" id="speed" onChange={(event) => setSpeed(event.target.value)} value={speed}><option value="0.5">0.5x</option><option value="0.8">0.8x</option><option value="1">1.0x</option><option value="1.2">1.2x</option><option value="1.5">1.5x</option><option value="2">2.0x</option></select></div><div><label className="field-label" htmlFor="format"></label><select className="field-input" id="format" onChange={(event) => setFormat(event.target.value)} value={format}><option value="wav">WAV</option><option value="mp3">MP3</option></select></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
</div>
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title">
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">03 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted"></span></div>
<div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted"> TTS </p></div>
</section>
</div>
);
{error ? <p className="mt-6 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><div className="flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">03 / Result</p><h2 className="mt-2 text-xl font-semibold"></h2><p className="mt-2 text-sm text-muted"> {activeTask.id}</p></div><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}> {format.toUpperCase()}</a></div><audio className="mt-6 w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></section> : null}
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">04 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted">{tasks.length} </span></div>{tasks.length ? <div className="mt-6 divide-y divide-line">{tasks.map((task) => <div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" key={task.id}><div><p className="font-semibold text-copy">{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}</p><p className="mt-1 text-sm text-muted">{task.voice_id} · {task.text_length} · {new Date(task.created_at).toLocaleString()}</p></div>{task.audio_available ? <div className="flex gap-3"><a className="text-sm text-cyan hover:text-copy" href={`${apiBase}/tts/tasks/${task.id}/audio`}></a><a className="text-sm text-cyan hover:text-copy" download href={`${apiBase}/tts/tasks/${task.id}/download`}></a></div> : null}</div>)}</div> : <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted">访</p></div>}</section>
</>}
</div>;
}

View File

@ -34,9 +34,9 @@ export function PreviewNotice({ children = "当前为 Phase 1 界面预览,真
return <div className="rounded-xl border border-warning/25 bg-warning/8 px-4 py-3 text-sm leading-6 text-warning" role="status"><span className="font-semibold"></span>{children}</div>;
}
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 <div className={`rounded-xl border p-4 ${styles[tone]}`}><p className="font-semibold text-copy">{title}</p><p className="mt-2 text-sm leading-6 text-muted">{description}</p></div>;
return <div className={`rounded-xl border p-4 ${styles[tone]}`}><p className="font-semibold text-copy">{title}</p><p className="mt-2 text-sm leading-6 text-muted">{description}</p>{children}</div>;
}
export function FeatureList({ items }: { items: string[] }) {