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:
parent
63ff0222cc
commit
66ed2d8c3a
@ -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.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>
|
<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>
|
</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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user