From 63ff0222cc30ba366d6496d92b9e091fb716127e Mon Sep 17 00:00:00 2001 From: flym Date: Sat, 12 Sep 2026 07:37:57 +0800 Subject: [PATCH] feat: shorter auto TTS filenames (4 CJK/8 latin), user rename support --- app/tts/page.tsx | 31 ++++++++++-- services/api/app/main.py | 48 +++++++++++++++++-- services/api/app/schemas.py | 5 ++ services/api/migrations/008_tts_file_name.sql | 1 + 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 services/api/migrations/008_tts_file_name.sql diff --git a/app/tts/page.tsx b/app/tts/page.tsx index 77fa9e3..abdd6c2 100644 --- a/app/tts/page.tsx +++ b/app/tts/page.tsx @@ -10,7 +10,7 @@ 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 }; +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"); @@ -27,6 +27,8 @@ export default function TtsPage() { const [speed, setSpeed] = useState("1"); const [format, setFormat] = useState("wav"); const [activeTask, setActiveTask] = useState(null); + const [nameDrafts, setNameDrafts] = useState>({}); + const [renaming, setRenaming] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -91,6 +93,29 @@ export default function TtsPage() { } } + async function renameTask(taskId: string) { + const draft = (nameDrafts[taskId] ?? "").trim(); + if (!draft || 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 ?? "重命名失败"); + setNameDrafts((current) => ({ ...current, [taskId]: body.file_name })); + await loadWorkspace(); + } catch (e) { + setError(e instanceof Error ? e.message : "重命名失败"); + } finally { + setRenaming(false); + } + } + return
Phase 3
{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}
@@ -100,8 +125,8 @@ export default function TtsPage() {
{error ?

{error}

: null} - {activeTask?.status === "succeeded" ?

03 / Result

生成完成

任务 {activeTask.id}

下载 {format.toUpperCase()}
: null} -

04 / History

生成历史

{tasks.length} 条记录
{tasks.length ?
{tasks.map((task) =>

{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}

{task.voice_id} · {task.text_length} 字 · {new Date(task.created_at).toLocaleString()}

{task.audio_available ? : null}
)}
:

还没有生成记录

提交第一条任务后,状态和音频访问会出现在这里。

}
+ {activeTask?.status === "succeeded" ?

03 / Result

生成完成

任务 {activeTask.id}

下载 {format.toUpperCase()}

自定义文件名

setNameDrafts((current) => ({ ...current, [activeTask.id]: event.target.value }))} />
: null} +

04 / History

生成历史

{tasks.length} 条记录
{tasks.length ?
{tasks.map((task) =>

{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}

{task.voice_id} · {task.text_length} 字 · {new Date(task.created_at).toLocaleString()}{task.file_name ? ` · 文件名:${task.file_name}` : ""}

{task.audio_available ? : null}
)}
:

还没有生成记录

提交第一条任务后,状态和音频访问会出现在这里。

}
} ; } diff --git a/services/api/app/main.py b/services/api/app/main.py index 9010570..42ecbfd 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -34,6 +34,7 @@ from .schemas import ( RegisterRequest, RegisterResponse, StatusRequest, + TtsFileNameRequest, TtsSettingsTest, TtsSettingsUpdate, UserPublic, @@ -236,7 +237,7 @@ def task_view(connection: Connection, task: dict) -> dict[str, Any]: "id": task["id"], "status": task["status"], "text_length": task["text_length"], "voice_id": task["provider_voice_id"], "parameters": task["parameters"], "error_code": task["error_code"], "audio_available": bool(audio and audio["status"] == "available"), - "audio_expires_at": audio["expires_at"] if audio else None, "created_at": task["created_at"], + "audio_expires_at": audio["expires_at"] if audio else None, "file_name": task["file_name"], "created_at": task["created_at"], "started_at": task["started_at"], "finished_at": task["finished_at"], } @@ -730,12 +731,30 @@ def get_tts_task(task_id: UUID, user: dict = Depends(current_user), connection: return task_view(connection, owned_task(task_id, user, connection)) +def summarize_text(raw: str) -> str: + cjk = 0 + latin = 0 + out: list[str] = [] + for ch in raw: + if "\u4e00" <= ch <= "\u9fff": + if cjk >= 4: + break + cjk += 1 + out.append(ch) + elif ch.isalnum(): + if latin >= 8: + break + latin += 1 + out.append(ch) + return "".join(out) + + def tts_download_name(task: dict, voice_name: str | None, mime_type: str) -> str: ext = "mp3" if mime_type == "audio/mpeg" else "wav" raw = unicodedata.normalize("NFC", task["text"] or "") raw = re.sub(r"\s+", "", raw) raw = re.sub(r'[\\/:*?"<>|]', "", raw) - summary = raw[:16] or "tts" + summary = summarize_text(raw) or "tts" voice = re.sub(r'[\\/:*?"<>|]', "", voice_name or "")[:16] or "voice" date = task["created_at"].strftime("%Y%m%d") return f"{summary}_{voice}_{date}.{ext}" @@ -751,8 +770,12 @@ def audio_response(task_id: UUID, user: dict, connection: Connection, download: raise error("AUDIO_NOT_AVAILABLE", "音频文件不可用", 404) filename = None if download: - voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone() - filename = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"]) + ext = "mp3" if audio["mime_type"] == "audio/mpeg" else "wav" + if task["file_name"]: + filename = f"{task['file_name']}.{ext}" + else: + voice = connection.execute("SELECT name FROM tts_voices WHERE provider_voice_id = %s", (task["provider_voice_id"],)).fetchone() + filename = tts_download_name(task, voice["name"] if voice else None, audio["mime_type"]) return FileResponse(path, media_type=audio["mime_type"], filename=filename) @@ -766,6 +789,23 @@ def download_tts_audio(task_id: UUID, user: dict = Depends(current_user), connec return audio_response(task_id, user, connection, True) +def sanitize_file_name(value: str) -> str: + name = re.sub(r'[\\/:*?"<>|]', "", value) + name = re.sub(r"\s+", " ", name).strip() + return name[:60] + + +@app.patch("/api/v1/tts/tasks/{task_id}/file-name", dependencies=[Depends(require_csrf)]) +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) + name = sanitize_file_name(payload.file_name) + if not name: + raise error("INVALID_FILE_NAME", "文件名不能为空或只包含非法字符", 422) + connection.execute("UPDATE tts_tasks SET file_name = %s WHERE id = %s", (name, task["id"])) + connection.commit() + return {"file_name": name} + + USER_COLUMNS = "id, username, email, phone, role, plan, status, email_verified, phone_verified, created_at, last_login_at, updated_at" diff --git a/services/api/app/schemas.py b/services/api/app/schemas.py index cafcaa5..56188f1 100644 --- a/services/api/app/schemas.py +++ b/services/api/app/schemas.py @@ -189,6 +189,10 @@ class TtsSettingsTest(BaseModel): text: str | None = Field(default=None, max_length=50) +class TtsFileNameRequest(BaseModel): + file_name: str = Field(min_length=1, max_length=60) + + class AdminTaskFilter(BaseModel): user_id: UUID | None = None status: Literal["queued", "running", "succeeded", "failed"] | None = None @@ -244,6 +248,7 @@ class TtsTaskPublic(BaseModel): error_code: str | None = None audio_available: bool = False audio_expires_at: datetime | None = None + file_name: str | None = None created_at: datetime started_at: datetime | None = None finished_at: datetime | None = None diff --git a/services/api/migrations/008_tts_file_name.sql b/services/api/migrations/008_tts_file_name.sql new file mode 100644 index 0000000..8976f02 --- /dev/null +++ b/services/api/migrations/008_tts_file_name.sql @@ -0,0 +1 @@ +ALTER TABLE tts_tasks ADD COLUMN IF NOT EXISTS file_name TEXT;