107 lines
10 KiB
TypeScript
107 lines
10 KiB
TypeScript
"use client";
|
||
|
||
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 };
|
||
|
||
function normalizeText(value: string) {
|
||
return value.normalize("NFC").replace(/\r\n?/g, "\n");
|
||
}
|
||
|
||
export default function TtsPage() {
|
||
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: normalizeText(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">{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">{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>
|
||
{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>;
|
||
}
|