"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 }; export default function TtsPage() { 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 ?
前往登录
: <>

01 / Input

输入文本

{text.length} / 当前计划限制