159 lines
13 KiB
TypeScript
159 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, useState } from "react";
|
||
import { ArrowRight, WaveIcon } from "@/components/icons";
|
||
import { BrandIcon } from "@/components/brand-icon";
|
||
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; 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() {
|
||
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("");
|
||
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]);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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.0x(V1 已验证范围)</div></div><div><span className="field-label">格式</span><div className="field-input text-muted">WAV(V1 已验证格式)</div></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><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 />下载 {format.toUpperCase()}</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>
|
||
</>}
|
||
{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}
|
||
</div>;
|
||
}
|