fix: restore result panel on return, rename via modal on player row, filename with full timestamp, show filenames in history

This commit is contained in:
flym 2026-09-12 16:14:39 +08:00
parent 63ff0222cc
commit 66ed2d8c3a
3 changed files with 46 additions and 17 deletions

View File

@ -27,8 +27,9 @@ export default function TtsPage() {
const [speed, setSpeed] = useState("1"); const [speed, setSpeed] = useState("1");
const [format, setFormat] = useState("wav"); const [format, setFormat] = useState("wav");
const [activeTask, setActiveTask] = useState<Task | null>(null); const [activeTask, setActiveTask] = useState<Task | null>(null);
const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
const [renaming, setRenaming] = useState(false); const [renaming, setRenaming] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameDraft, setRenameDraft] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -51,7 +52,11 @@ export default function TtsPage() {
setVoices(nextVoices); setVoices(nextVoices);
setVoice((current) => current || nextVoices[0]?.provider_voice_id || ""); setVoice((current) => current || nextVoices[0]?.provider_voice_id || "");
if (usageResponse.ok) setUsage(await usageResponse.json()); if (usageResponse.ok) setUsage(await usageResponse.json());
if (taskResponse.ok) setTasks(await taskResponse.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 工作台")); }, []); useEffect(() => { loadWorkspace().catch(() => setError("暂时无法连接 TTS 工作台")); }, []);
@ -93,9 +98,9 @@ export default function TtsPage() {
} }
} }
async function renameTask(taskId: string) { async function renameTask(taskId: string, rawName: string) {
const draft = (nameDrafts[taskId] ?? "").trim(); const draft = rawName.trim();
if (!draft || renaming) return; if (renaming) return;
setRenaming(true); setRenaming(true);
setError(""); setError("");
try { try {
@ -107,7 +112,7 @@ export default function TtsPage() {
}); });
const body = await response.json().catch(() => ({})); const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "重命名失败"); if (!response.ok) throw new Error(body.error?.message ?? "重命名失败");
setNameDrafts((current) => ({ ...current, [taskId]: body.file_name })); setRenameOpen(false);
await loadWorkspace(); await loadWorkspace();
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "重命名失败"); setError(e instanceof Error ? e.message : "重命名失败");
@ -125,8 +130,29 @@ export default function TtsPage() {
<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> <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> </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} {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"><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`}><BrandIcon /> {format.toUpperCase()}</a></div><div className="flex flex-col gap-3 sm:flex-row sm:items-center"><p className="shrink-0 text-sm text-muted sm:w-28"></p><input className="field-input flex-1" maxLength={60} placeholder="留空则自动按 内容_音色_日期 命名" value={nameDrafts[activeTask.id] ?? activeTask.file_name ?? ""} onChange={(event) => setNameDrafts((current) => ({ ...current, [activeTask.id]: event.target.value }))} /><button className="button-ghost" disabled={renaming || !csrf} onClick={() => void renameTask(activeTask.id)} type="button"><BrandIcon />{renaming ? "保存中…" : "重命名"}</button></div></div><audio className="mt-6 w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></section> : 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 text-sm text-muted">{task.voice_id} · {task.text_length} · {new Date(task.created_at).toLocaleString()}{task.file_name ? ` · 文件名:${task.file_name}` : ""}</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> <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>; </div>;
} }

View File

@ -232,12 +232,17 @@ def normalize_tts_text(value: str) -> str:
def task_view(connection: Connection, task: dict) -> dict[str, Any]: def task_view(connection: Connection, task: dict) -> dict[str, Any]:
audio = connection.execute("SELECT expires_at, status FROM audio_files WHERE task_id = %s", (task["id"],)).fetchone() audio = connection.execute("SELECT expires_at, status, mime_type FROM audio_files WHERE task_id = %s", (task["id"],)).fetchone()
available = bool(audio and audio["status"] == "available")
file_name = task["file_name"]
if not file_name and available:
voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone()
file_name = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"])
return { return {
"id": task["id"], "status": task["status"], "text_length": task["text_length"], "id": task["id"], "status": task["status"], "text_length": task["text_length"],
"voice_id": task["provider_voice_id"], "parameters": task["parameters"], "voice_id": task["provider_voice_id"], "parameters": task["parameters"],
"error_code": task["error_code"], "audio_available": bool(audio and audio["status"] == "available"), "error_code": task["error_code"], "audio_available": available,
"audio_expires_at": audio["expires_at"] if audio else None, "file_name": task["file_name"], "created_at": task["created_at"], "audio_expires_at": audio["expires_at"] if audio else None, "file_name": file_name, "created_at": task["created_at"],
"started_at": task["started_at"], "finished_at": task["finished_at"], "started_at": task["started_at"], "finished_at": task["finished_at"],
} }
@ -756,7 +761,7 @@ def tts_download_name(task: dict, voice_name: str | None, mime_type: str) -> str
raw = re.sub(r'[\\/:*?"<>|]', "", raw) raw = re.sub(r'[\\/:*?"<>|]', "", raw)
summary = summarize_text(raw) or "tts" summary = summarize_text(raw) or "tts"
voice = re.sub(r'[\\/:*?"<>|]', "", voice_name or "")[:16] or "voice" voice = re.sub(r'[\\/:*?"<>|]', "", voice_name or "")[:16] or "voice"
date = task["created_at"].strftime("%Y%m%d") date = task["created_at"].strftime("%Y%m%d_%H%M%S")
return f"{summary}_{voice}_{date}.{ext}" return f"{summary}_{voice}_{date}.{ext}"
@ -799,11 +804,9 @@ def sanitize_file_name(value: str) -> str:
def rename_tts_task(task_id: UUID, payload: TtsFileNameRequest, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)): def rename_tts_task(task_id: UUID, payload: TtsFileNameRequest, user: dict = Depends(current_user), connection: Connection = Depends(get_connection)):
task = owned_task(task_id, user, connection) task = owned_task(task_id, user, connection)
name = sanitize_file_name(payload.file_name) name = sanitize_file_name(payload.file_name)
if not name: connection.execute("UPDATE tts_tasks SET file_name = %s WHERE id = %s", (name or None, task["id"]))
raise error("INVALID_FILE_NAME", "文件名不能为空或只包含非法字符", 422)
connection.execute("UPDATE tts_tasks SET file_name = %s WHERE id = %s", (name, task["id"]))
connection.commit() connection.commit()
return {"file_name": name} return {"file_name": name or None}
USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at" USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at"

View File

@ -190,7 +190,7 @@ class TtsSettingsTest(BaseModel):
class TtsFileNameRequest(BaseModel): class TtsFileNameRequest(BaseModel):
file_name: str = Field(min_length=1, max_length=60) file_name: str = Field(default="", max_length=60)
class AdminTaskFilter(BaseModel): class AdminTaskFilter(BaseModel):