www_site/app/tts/page.tsx

159 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2026-09-08 16:43:56 +00:00
"use client";
import Link from "next/link";
2026-09-08 16:43:56 +00:00
import { useEffect, useState } from "react";
import { ArrowRight, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
2026-09-08 16:43:56 +00:00
import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
2026-09-08 16:43:56 +00:00
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; file_name?: string | null; created_at: string; finished_at?: string | null };
function normalizeText(value: string) {
return value.normalize("NFC").replace(/\r\n?/g, "\n");
}
export default function TtsPage() {
2026-09-08 16:43:56 +00:00
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 [renaming, setRenaming] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameDraft, setRenameDraft] = useState("");
2026-09-08 16:43:56 +00:00
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) {
const taskData: Task[] = await taskResponse.json();
setTasks(taskData);
if (!activeTask && taskData.length > 0 && taskData[0].status === "succeeded") setActiveTask(taskData[0]);
}
2026-09-08 16:43:56 +00:00
}
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: normalizeText(text), voice_id: voice, parameters: { format, speed: Number(speed) } }),
2026-09-08 16:43:56 +00:00
});
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);
}
}
async function renameTask(taskId: string, rawName: string) {
const draft = rawName.trim();
if (renaming) return;
setRenaming(true);
setError("");
try {
const response = await fetch(`${apiBase}/tts/tasks/${taskId}/file-name`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ file_name: draft }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "重命名失败");
setRenameOpen(false);
await loadWorkspace();
} catch (e) {
setError(e instanceof Error ? e.message : "重命名失败");
} finally {
setRenaming(false);
}
}
2026-09-08 16:43:56 +00:00
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"><BrandIcon /> <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">{normalizeText(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"><BrandIcon />{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><span className="field-label"></span><div className="field-input text-muted">1.0xV1 </div></div><div><span className="field-label"></span><div className="field-input text-muted">WAVV1 </div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="info" /></aside>
</div>
2026-09-08 16:43:56 +00:00
{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><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 break-all text-sm text-muted">{activeTask.file_name ?? "音频已就绪"}</p></div><div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center"><div className="min-w-0 flex-1"><audio className="w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></div><div className="flex shrink-0 gap-3"><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}><BrandIcon /></a><button className="button-ghost" onClick={() => { setRenameDraft(activeTask.file_name ?? ""); setRenameOpen(true); }} type="button"><BrandIcon /></button></div></div></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 break-all text-sm text-muted">{task.status === "succeeded" ? (task.file_name ?? "音频已就绪") : `${task.voice_id} · ${task.text_length}`}</p><p className="mt-0.5 text-xs text-subtle">{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>
2026-09-08 16:43:56 +00:00
</>}
{renameOpen && activeTask ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-label="重命名文件">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6">
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted">使</p>
<input
autoFocus
className="field-input mt-4"
maxLength={60}
placeholder="输入新文件名(不含扩展名)"
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") void renameTask(activeTask.id, renameDraft); }}
/>
<div className="mt-5 flex justify-end gap-3">
<button className="button-ghost" onClick={() => setRenameOpen(false)} type="button"><BrandIcon /></button>
<button className="button-primary" disabled={renaming || !csrf} onClick={() => void renameTask(activeTask.id, renameDraft)} type="button"><BrandIcon />{renaming ? "保存中…" : "确认"}</button>
</div>
</div>
</div>
) : null}
2026-09-08 16:43:56 +00:00
</div>;
}