const GAP_REPAIR_MIN_MS = 8000; const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/; const state = { adminKey: localStorage.getItem("oralTrainerAdminKey") || "", videos: [], users: [], retranslateVideo: null, sentenceVideo: null, sentenceDraft: null, sentenceBaseMeta: "", sentencePending: false, sentenceSaving: false, sentenceNeedsProcessing: false, }; const elements = { adminKey: document.querySelector("#admin-key"), saveKey: document.querySelector("#save-key"), refresh: document.querySelector("#refresh"), serviceState: document.querySelector("#service-state"), uploadForm: document.querySelector("#upload-form"), uploadState: document.querySelector("#upload-state"), uploadProgress: document.querySelector("#upload-progress"), videoFile: document.querySelector("#video-file"), videoTitle: document.querySelector("#video-title"), videoRows: document.querySelector("#video-rows"), videoCount: document.querySelector("#video-count"), emptyState: document.querySelector("#empty-state"), dialog: document.querySelector("#sentence-dialog"), sentenceTitle: document.querySelector("#sentence-title"), sentenceMeta: document.querySelector("#sentence-meta"), sentenceRows: document.querySelector("#sentence-rows"), closeDialog: document.querySelector("#close-dialog"), repairGaps: document.querySelector("#repair-gaps"), repairMissingZh: document.querySelector("#repair-missing-zh"), retranslateDialog: document.querySelector("#retranslate-dialog"), retranslateTitle: document.querySelector("#retranslate-title"), retranslateMeta: document.querySelector("#retranslate-meta"), retranslateForm: document.querySelector("#retranslate-form"), retranslateTerms: document.querySelector("#retranslate-terms"), repairTranslations: document.querySelector("#repair-translations"), closeRetranslateDialog: document.querySelector("#close-retranslate-dialog"), userRows: document.querySelector("#user-rows"), userCount: document.querySelector("#user-count"), userEmptyState: document.querySelector("#user-empty-state"), }; elements.adminKey.value = state.adminKey; elements.saveKey.addEventListener("click", () => { state.adminKey = elements.adminKey.value; localStorage.setItem("oralTrainerAdminKey", state.adminKey); elements.serviceState.textContent = "密钥已保存"; }); elements.refresh.addEventListener("click", () => { loadVideos(); loadUsers(); }); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); elements.dialog.addEventListener("close", handleSentenceDialogClose); elements.repairGaps.addEventListener("click", () => { if (state.sentencePending) { elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理"; return; } if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true }); }); elements.repairMissingZh.addEventListener("click", () => { if (state.sentencePending) { elements.serviceState.textContent = "句子修改已暂存,请先关闭对话框完成处理"; return; } if (state.sentenceVideo) repairMissingTranslations(state.sentenceVideo, { reloadSentences: true }); }); elements.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close()); elements.repairTranslations.addEventListener("click", () => { if (state.retranslateVideo) repairMissingTranslations(state.retranslateVideo, { closeRetranslate: true }); }); elements.retranslateForm.addEventListener("submit", submitRetranslate); elements.uploadForm.addEventListener("submit", uploadVideo); elements.videoFile.addEventListener("change", () => { if (!elements.videoFile.files[0]) return; elements.videoTitle.value = titleFromFilename(elements.videoFile.files[0].name); }); async function api(path, options = {}) { const headers = new Headers(options.headers || {}); if (state.adminKey) headers.set("X-Admin-Key", state.adminKey); const response = await fetch(path, { ...options, headers }); if (!response.ok) { let detail = `HTTP ${response.status}`; try { const payload = await response.json(); detail = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail); } catch (_) {} throw new Error(detail); } return response.status === 204 ? null : response.json(); } async function checkHealth() { try { const health = await api("/healthz"); elements.serviceState.textContent = health.moss_configured ? "服务正常 / MOSS 已连接" : "服务正常 / MOSS 未配置"; } catch (error) { elements.serviceState.textContent = `服务异常: ${error.message}`; } } async function loadVideos() { try { const payload = await api("/api/v1/videos"); state.videos = payload.videos; renderVideos(); } catch (error) { elements.serviceState.textContent = `加载失败: ${error.message}`; } } function renderVideos() { elements.videoRows.replaceChildren(); elements.videoCount.textContent = `${state.videos.length} 个视频`; elements.emptyState.hidden = state.videos.length !== 0; for (const video of state.videos) { const row = document.createElement("tr"); row.append( cellWithError(video.title, video.error_message), statusCell(video.status), textCell(formatDuration(video.duration_ms)), textCell(String(video.sentence_count)), textCell(formatBytes(video.size_bytes)), textCell(formatDate(video.updated_at)), actionCell(video), ); elements.videoRows.append(row); } } async function loadUsers() { try { const payload = await api("/api/v1/admin/users"); state.users = payload.users; renderUsers(); } catch (error) { elements.serviceState.textContent = `加载用户失败: ${error.message}`; } } function renderUsers() { elements.userRows.replaceChildren(); elements.userCount.textContent = `${state.users.length} 个用户`; elements.userEmptyState.hidden = state.users.length > 0; state.users.forEach((user) => { const row = document.createElement("tr"); row.append( textCell(user.username), textCell(user.nickname), textCell(user.phone || "—"), textCell(String(user.enrollment_count ?? 0)), textCell(formatDate(user.created_at)), userActionCell(user), ); elements.userRows.append(row); }); } function userActionCell(user) { const cell = document.createElement("td"); cell.classList.add("actions-column"); cell.append( button("删除", async () => { const confirmed = window.confirm( `确定要删除用户「${user.nickname || user.username}」吗?该操作不可恢复。` ); if (!confirmed) return; try { await api(`/api/v1/admin/users/${user.id}`, { method: "DELETE" }); await loadUsers(); } catch (error) { elements.serviceState.textContent = `删除用户失败: ${error.message}`; } }), ); return cell; } function textCell(value) { const cell = document.createElement("td"); cell.textContent = value; return cell; } function cellWithError(title, error) { const cell = textCell(title); if (error) { const message = document.createElement("div"); message.className = "error-message"; message.textContent = error; cell.append(message); } return cell; } function statusCell(status) { const cell = document.createElement("td"); const label = document.createElement("span"); label.className = `status status-${status}`; label.textContent = { uploaded: "已上传", processing: "处理中", ready: "可用", failed: "失败" }[status] || status; cell.append(label); return cell; } function actionCell(video) { const cell = document.createElement("td"); const actions = document.createElement("div"); actions.className = "actions"; const play = document.createElement("a"); play.href = video.stream_url; play.target = "_blank"; play.rel = "noopener"; play.textContent = "播放"; actions.append(play); actions.append(button("改标题", () => renameVideo(video))); actions.append(button("句子", () => openSentences(video))); actions.append(button("修复空白", () => repairGaps(video))); actions.append(button("重新翻译", () => openRetranslate(video))); actions.append(button("重新处理", () => reprocess(video.video_hash))); const remove = button("删除", () => deleteVideo(video)); remove.className = "danger"; actions.append(remove); cell.append(actions); return cell; } function button(label, handler) { const result = document.createElement("button"); result.type = "button"; result.textContent = label; result.addEventListener("click", handler); return result; } async function renameVideo(video) { const title = window.prompt("请输入新的课程标题", video.title || ""); if (title === null) return; const cleanedTitle = title.trim(); if (!cleanedTitle) { elements.serviceState.textContent = "课程标题不能为空"; return; } if (cleanedTitle === video.title) return; try { await api(`/api/v1/admin/videos/${video.video_hash}/title`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: cleanedTitle }), }); elements.serviceState.textContent = "课程标题已更新"; await loadVideos(); } catch (error) { elements.serviceState.textContent = `标题修改失败: ${error.message}`; } } function uploadVideo(event) { event.preventDefault(); const file = document.querySelector("#video-file").files[0]; if (!file) return; const title = document.querySelector("#video-title").value; const language = document.querySelector("#video-language").value; const query = new URLSearchParams({ filename: file.name }); if (title) query.set("title", title); if (language) query.set("language", language); elements.uploadProgress.hidden = false; elements.uploadProgress.value = 0; elements.uploadState.textContent = "上传中"; const request = new XMLHttpRequest(); request.open("PUT", `/api/v1/admin/videos/raw?${query.toString()}`); if (state.adminKey) request.setRequestHeader("X-Admin-Key", state.adminKey); request.setRequestHeader("Content-Type", file.type || "application/octet-stream"); request.upload.addEventListener("progress", (progress) => { if (progress.lengthComputable) elements.uploadProgress.value = progress.loaded / progress.total * 100; }); request.addEventListener("load", async () => { if (request.status >= 200 && request.status < 300) { elements.uploadState.textContent = "上传完成,正在处理"; elements.uploadForm.reset(); await loadVideos(); } else { elements.uploadState.textContent = `上传失败: ${readXhrError(request)}`; } elements.uploadProgress.hidden = true; }); request.addEventListener("error", () => { elements.uploadState.textContent = "上传连接中断"; elements.uploadProgress.hidden = true; }); request.send(file); } function titleFromFilename(filename) { const name = filename.replace(/\\/g, "/").split("/").pop() || ""; const dotIndex = name.lastIndexOf("."); const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name; return stem.slice(0, 200); } function readXhrError(request) { try { return JSON.parse(request.responseText).detail || `HTTP ${request.status}`; } catch (_) { return `HTTP ${request.status}`; } } async function openSentences(video) { if (state.sentenceSaving) { elements.serviceState.textContent = "句子批量处理中,请稍后再打开"; return; } if ( state.sentenceVideo?.video_hash === video.video_hash && Array.isArray(state.sentenceDraft) ) { renderSentenceDraft(video); if (!elements.dialog.open) elements.dialog.showModal(); return; } try { const detail = await api(`/api/v1/videos/${video.video_hash}`); state.sentenceVideo = video; elements.sentenceTitle.textContent = detail.video.title; state.sentenceDraft = (detail.boundaries?.sentences || []).map((sentence) => ({ ...sentence, originStartMs: sentence.start_ms, originEndMs: sentence.end_ms, sourceStartMs: undefined, sourceEndMs: undefined, })); state.sentencePending = false; state.sentenceSaving = false; state.sentenceNeedsProcessing = false; const durationMs = detail.video.duration_ms || 0; const sentences = state.sentenceDraft; const gaps = collectSentenceGaps(sentences, durationMs); const missingZh = sentences.filter(translationNeedsRepair).length; const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : ""; const zhLabel = missingZh ? ` / ${missingZh} 句未译` : ""; state.sentenceBaseMeta = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`; renderSentenceDraft(video); if (!elements.dialog.open) elements.dialog.showModal(); } catch (error) { elements.serviceState.textContent = `句子加载失败: ${error.message}`; } } function renderSentenceDraft(video) { const sentences = state.sentenceDraft || []; const durationMs = state.sentenceVideo?.duration_ms || 0; const gaps = collectSentenceGaps(sentences, durationMs); const missingZh = sentences.filter(translationNeedsRepair).length; elements.sentenceMeta.textContent = [ state.sentenceBaseMeta, state.sentencePending ? "有暂存修改" : "", state.sentenceNeedsProcessing ? "已入库待处理" : "", state.sentenceSaving ? "正在处理" : "", ].filter(Boolean).join(" / "); elements.repairGaps.disabled = gaps.length === 0 || state.sentencePending; elements.repairMissingZh.disabled = missingZh === 0 || state.sentencePending; elements.closeDialog.disabled = state.sentenceSaving; elements.closeDialog.textContent = state.sentenceSaving ? "..." : "X"; elements.sentenceRows.replaceChildren(); let gapIndex = 0; for (const sentence of sentences) { if (gapIndex < gaps.length && gaps[gapIndex].end_ms === sentence.start_ms) { elements.sentenceRows.append(gapRow(gaps[gapIndex])); gapIndex += 1; } elements.sentenceRows.append(sentenceRow(video, sentence, sentences.length)); } if (gapIndex < gaps.length) { elements.sentenceRows.append(gapRow(gaps[gapIndex])); } } async function handleSentenceDialogClose() { if (!state.sentenceDraft || state.sentenceSaving) return; if (!state.sentencePending && !state.sentenceNeedsProcessing) { resetSentenceDraft(); return; } if (state.sentencePending) { const persisted = await saveSentenceDraft({ processTimings: false }); if (!persisted) { if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal(); return; } } const processed = await saveSentenceDraft({ processTimings: true }); if (!processed) { if (state.sentenceVideo && !elements.dialog.open) elements.dialog.showModal(); return; } resetSentenceDraft(); loadVideos(); } async function saveSentenceDraft({ processTimings = false } = {}) { const video = state.sentenceVideo; if (!video) return false; if (!processTimings && !state.sentencePending) return true; if (processTimings && !state.sentencePending && !state.sentenceNeedsProcessing) return true; materializeSentenceDraftSplits(video); state.sentenceSaving = true; renderSentenceDraft(video); try { const body = { process_timings: processTimings, sentences: state.sentenceDraft.map(({ index, originStartMs, originEndMs, ...sentence }) => sentence), }; const saved = await api( `/api/v1/admin/videos/${video.video_hash}/sentences/batch`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ); const previousByRange = new Map( state.sentenceDraft.map((sentence) => [`${sentence.start_ms}:${sentence.end_ms}`, sentence]), ); state.sentenceDraft = saved.map((sentence) => { const previous = previousByRange.get(`${sentence.start_ms}:${sentence.end_ms}`); return { ...sentence, originStartMs: previous?.originStartMs ?? sentence.start_ms, originEndMs: previous?.originEndMs ?? sentence.end_ms, sourceStartMs: previous?.sourceStartMs, sourceEndMs: previous?.sourceEndMs, }; }); state.sentencePending = false; state.sentenceNeedsProcessing = !processTimings; return true; } catch (error) { elements.serviceState.textContent = processTimings ? `句子批量处理失败: ${error.message}` : `句子入库失败: ${error.message}`; return false; } finally { state.sentenceSaving = false; if (state.sentenceDraft) renderSentenceDraft(video); } } function materializeSentenceDraftSplits(video) { for (const sentence of [...(state.sentenceDraft || [])]) { if (/\r?\n/.test(sentence.text || "")) { splitDraftSentence(video, sentence, sentence.text, sentence.language); } } } function resetSentenceDraft() { state.sentenceDraft = null; state.sentencePending = false; state.sentenceSaving = false; state.sentenceNeedsProcessing = false; state.sentenceVideo = null; state.sentenceBaseMeta = ""; } function markSentenceDraftChanged(video) { state.sentencePending = true; renderSentenceDraft(video); } function markSentenceDraftPending() { state.sentencePending = true; elements.sentenceMeta.textContent = [ state.sentenceBaseMeta, "有暂存修改", ].filter(Boolean).join(" / "); elements.repairGaps.disabled = true; elements.repairMissingZh.disabled = true; } function joinDraftSentenceText(left, right, language) { const leftText = (left || "").trim(); const rightText = (right || "").trim(); if (!leftText) return rightText; if (!rightText) return leftText; const lang = (language || "").toLowerCase(); if (lang.startsWith("zh") || lang.startsWith("ja") || lang.startsWith("ko")) { return leftText + rightText; } const leftEnd = leftText.slice(-1); const rightStart = rightText.slice(0, 1); const cjk = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/; if (cjk.test(leftEnd) || cjk.test(rightStart)) return leftText + rightText; if (rightStart && ",.;:!?)]}、,。!?;:”’》〉】".includes(rightStart)) { return leftText + rightText; } return `${leftText} ${rightText}`; } function splitDraftSentence(video, sentence, text, language) { const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const duration = Math.max(1, sentence.end_ms - sentence.start_ms); const weights = lines.map((line) => Math.max(1, line.replace(/\s+/g, "").length)); const totalWeight = Math.max(1, weights.reduce((total, weight) => total + weight, 0)); const sourceStart = sentence.sourceStartMs ?? sentence.originStartMs ?? sentence.start_ms; const sourceEnd = sentence.sourceEndMs ?? sentence.originEndMs ?? sentence.end_ms; const replacements = []; let cursor = sentence.start_ms; let remaining = lines.length; let weightSum = 0; for (const [lineIndex, line] of lines.entries()) { const isLast = lineIndex === lines.length - 1; weightSum += weights[lineIndex]; let end = sentence.end_ms; if (!isLast) { end = sentence.start_ms + Math.round(duration * weightSum / totalWeight); end = Math.min(Math.max(end, cursor + 1), sentence.end_ms - (remaining - 1)); } const sourceDuration = Math.max( 1, (sentence.reference_speech_duration_ms || duration) * weights[lineIndex] / totalWeight, ); replacements.push({ index: sentence.index + lineIndex, start_ms: cursor, end_ms: end, text: line, language: language || sentence.language, zh_text: null, reference_speech_duration_ms: Math.max(1, Math.round(sourceDuration)), sourceStartMs: sourceStart, sourceEndMs: sourceEnd, originStartMs: sourceStart, originEndMs: sourceEnd, }); cursor = end; remaining -= 1; } state.sentenceDraft.splice(sentence.index, 1, ...replacements); renumberSentenceDraft(); markSentenceDraftChanged(video); } function renumberSentenceDraft() { (state.sentenceDraft || []).forEach((sentence, index) => { sentence.index = index; }); } function collectSentenceGaps(sentences, durationMs, minGapMs = GAP_REPAIR_MIN_MS) { const gaps = []; let cursor = 0; for (const sentence of sentences) { if (sentence.start_ms - cursor >= minGapMs) { gaps.push({ start_ms: cursor, end_ms: sentence.start_ms }); } cursor = Math.max(cursor, sentence.end_ms); } const endMs = durationMs || cursor; if (endMs - cursor >= minGapMs) { gaps.push({ start_ms: cursor, end_ms: endMs }); } return gaps; } function gapRow(gap) { const row = document.createElement("tr"); row.className = "gap-row"; row.append( textCell(""), textCell(`${formatDuration(gap.start_ms)} — ${formatDuration(gap.end_ms)}`), textCell(`未识别空白 ${formatDuration(gap.end_ms - gap.start_ms)}`), textCell(""), textCell(""), ); return row; } function translationNeedsRepair(sentence) { const source = (sentence.text || "").trim(); const zh = (sentence.zh_text || "").trim(); if (!source) return false; if (!zh) return true; if (CJK_RE.test(zh)) return false; return !CJK_RE.test(source); } function sentenceRow(video, sentence, totalCount) { const row = document.createElement("tr"); if (translationNeedsRepair(sentence)) row.className = "missing-zh-row"; const textArea = document.createElement("textarea"); textArea.value = sentence.text || ""; textArea.addEventListener("input", () => { sentence.text = textArea.value; sentence.zh_text = null; markSentenceDraftPending(); }); const language = document.createElement("select"); for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) { const option = document.createElement("option"); option.value = value; option.textContent = label; option.selected = value === (sentence.language || ""); language.append(option); } language.addEventListener("change", () => { sentence.language = language.value || null; markSentenceDraftPending(); }); const textContainer = document.createElement("td"); const zhNote = document.createElement("div"); zhNote.className = "zh-text"; zhNote.textContent = translationNeedsRepair(sentence) ? (sentence.zh_text ? `仍是英文:${sentence.zh_text}` : "暂无中文翻译") : sentence.zh_text; textContainer.append(textArea, zhNote); const languageContainer = document.createElement("td"); languageContainer.append(language); const saveContainer = document.createElement("td"); const save = button("保存", async () => { save.disabled = true; save.textContent = "保存中"; try { if (/\r?\n/.test(sentence.text || "")) { splitDraftSentence(video, sentence, sentence.text, language.value || null); } else { sentence.text = textArea.value; sentence.language = language.value || null; sentence.zh_text = null; markSentenceDraftChanged(video); } await saveSentenceDraft({ processTimings: false }); } finally { save.disabled = false; save.textContent = "保存"; } }); saveContainer.append(save); if (sentence.index < totalCount - 1) { const merge = button("合并下句", async () => { const preview = `${(sentence.text || "").trim()} + 下一句`; const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`); if (!confirmed) return; const sourceStart = sentence.start_ms; const sourceEnd = sentence.end_ms; merge.disabled = true; merge.textContent = "入库中"; try { if (state.sentencePending) { const persisted = await saveSentenceDraft({ processTimings: false }); if (!persisted) return; } const targetIndex = state.sentenceDraft.findIndex( (item) => item.start_ms === sourceStart && item.end_ms === sourceEnd, ); if (targetIndex < 0) throw new Error("找不到要合并的句子"); const merged = await api( `/api/v1/admin/videos/${video.video_hash}/sentences/${targetIndex}/merge`, { method: "PUT" }, ); state.sentenceDraft.splice(targetIndex, 2, { ...merged, originStartMs: merged.start_ms, originEndMs: merged.end_ms, sourceStartMs: undefined, sourceEndMs: undefined, }); renumberSentenceDraft(); state.sentencePending = false; state.sentenceNeedsProcessing = true; } catch (error) { elements.serviceState.textContent = `句子合并失败: ${error.message}`; } finally { merge.disabled = false; merge.textContent = "合并下句"; renderSentenceDraft(video); } }); saveContainer.append(merge); } const timeContainer = document.createElement("td"); const startLabel = document.createElement("span"); startLabel.textContent = formatDuration(sentence.start_ms); timeContainer.append(startLabel, " — ", boundaryControl(video, sentence)); row.append( textCell(String(sentence.index + 1)), timeContainer, textContainer, languageContainer, saveContainer, ); return row; } function boundaryControl(video, sentence) { const container = document.createElement("div"); container.className = "boundary-control"; const label = document.createElement("span"); label.className = "boundary-time"; label.textContent = formatDuration(sentence.end_ms); const status = document.createElement("span"); status.className = "boundary-status"; async function adjust(deltaMs) { const next = state.sentenceDraft[sentence.index + 1]; if (!next) return; const nextBoundary = sentence.end_ms + deltaMs; if ( nextBoundary <= sentence.start_ms || nextBoundary >= next.end_ms ) { elements.serviceState.textContent = "分割点必须保留在两句时间范围内"; return; } sentence.end_ms = nextBoundary; next.start_ms = nextBoundary; sentence.sourceStartMs = undefined; sentence.sourceEndMs = undefined; next.sourceStartMs = undefined; next.sourceEndMs = undefined; markSentenceDraftChanged(video); await saveSentenceDraft({ processTimings: false }); } const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100)); const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100)); minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒"; plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒"; container.append(minus, label, plus, status); return container; } async function openRetranslate(video) { state.retranslateVideo = video; elements.retranslateTitle.textContent = `重新翻译:${video.title}`; elements.retranslateMeta.textContent = video.video_hash; elements.retranslateTerms.value = ""; try { const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`); elements.retranslateTerms.value = formatTranslationTerms(payload.terms || []); const missing = payload.missing_count || 0; const status = missing ? `${missing} 句未译` : (payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成")); elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`; elements.repairTranslations.disabled = missing === 0; } catch (error) { elements.serviceState.textContent = `加载翻译信息失败: ${error.message}`; } if (!elements.retranslateDialog.open) elements.retranslateDialog.showModal(); } function formatTranslationTerms(terms) { return (terms || []).map((item) => `${item.source}=${item.zh}`).join("\n"); } function parseTranslationTerms(text) { const terms = []; for (const rawLine of (text || "").split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const match = line.match(/^(.+?)\s*(?:=|=>|:|:)\s*(.+)$/); if (!match) { throw new Error(`无法解析专有名词:${line}`); } const source = match[1].trim(); const zh = match[2].trim(); if (!source || !zh) { throw new Error(`无法解析专有名词:${line}`); } terms.push({ source, zh }); } return terms; } async function submitRetranslate(event) { event.preventDefault(); const video = state.retranslateVideo; if (!video) return; let terms; try { terms = parseTranslationTerms(elements.retranslateTerms.value); } catch (error) { elements.serviceState.textContent = error.message; return; } const submit = elements.retranslateForm.querySelector("button[type='submit']"); submit.disabled = true; try { const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ terms }), }); const status = payload.complete ? "中文字幕已重新翻译" : "已开始重新翻译"; elements.serviceState.textContent = terms.length ? `${status},已应用 ${terms.length} 条专有名词` : status; elements.retranslateDialog.close(); await loadVideos(); } catch (error) { elements.serviceState.textContent = `重新翻译失败: ${error.message}`; } finally { submit.disabled = false; } } async function repairMissingTranslations(video, options = {}) { const confirmed = window.confirm( `将只补译“${video.title}”里缺失或仍是英文的中文,已有中文会保留。继续?`, ); if (!confirmed) return; const buttons = [elements.repairMissingZh, elements.repairTranslations].filter(Boolean); buttons.forEach((button) => { button.disabled = true; }); elements.serviceState.textContent = "正在补译缺失的中文…"; try { const payload = await api(`/api/v1/admin/videos/${video.video_hash}/subtitle-translation/repair`, { method: "POST", }); const attempted = payload.attempted_count || 0; if (!attempted) { elements.serviceState.textContent = "没有需要补译的句子"; } else if (payload.complete) { elements.serviceState.textContent = `补译完成:处理了 ${attempted} 句`; } else { const remaining = payload.missing_count ? `,还剩 ${payload.missing_count} 句` : ""; elements.serviceState.textContent = `已开始补译 ${attempted} 句${remaining},请稍后刷新`; } if (options.closeRetranslate) elements.retranslateDialog.close(); await loadVideos(); if (options.reloadSentences) await openSentences(video); } catch (error) { elements.serviceState.textContent = `补译失败: ${error.message}`; } finally { if (!options.reloadSentences) { buttons.forEach((button) => { button.disabled = false; }); } } } async function reprocess(videoHash) { try { await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" }); await loadVideos(); } catch (error) { elements.serviceState.textContent = `处理启动失败: ${error.message}`; } } async function repairGaps(video, options = {}) { const confirmed = window.confirm( `将对“${video.title}”中间隔超过 8 秒的空白重新识别。现有句子会保留,可能需要几分钟。继续?`, ); if (!confirmed) return; const button = elements.repairGaps; if (button) button.disabled = true; elements.serviceState.textContent = "正在重新识别空白段…"; try { const payload = await api(`/api/v1/admin/videos/${video.video_hash}/repair-gaps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ min_gap_ms: GAP_REPAIR_MIN_MS }), }); if (payload.inserted_count) { elements.serviceState.textContent = `空白修复完成:补了 ${payload.inserted_count} 句(${payload.transcribed_gaps}/${payload.gap_count} 段)`; } else if (payload.gap_count) { elements.serviceState.textContent = `发现 ${payload.gap_count} 段空白,但没有识别到新句子`; } else { elements.serviceState.textContent = "没有发现需要修复的空白段"; } await loadVideos(); if (options.reloadSentences) await openSentences(video); } catch (error) { elements.serviceState.textContent = `空白修复失败: ${error.message}`; } finally { if (button && !options.reloadSentences) button.disabled = false; } } async function deleteVideo(video) { if (!window.confirm(`删除“${video.title}”?`)) return; try { await api(`/api/v1/admin/videos/${video.video_hash}`, { method: "DELETE" }); await loadVideos(); } catch (error) { elements.serviceState.textContent = `删除失败: ${error.message}`; } } function formatDuration(ms) { if (!ms) return "-"; const totalTenths = Math.floor(ms / 100); const hours = Math.floor(totalTenths / 36000); const minutes = Math.floor(totalTenths % 36000 / 600); const seconds = Math.floor(totalTenths % 600 / 10); const tenths = totalTenths % 10; const base = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`; return hours ? `${hours}:${base}` : base; } function formatBytes(bytes) { if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`; return `${(bytes / 1024 ** 3).toFixed(2)} GB`; } function formatDate(value) { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); } checkHealth(); loadVideos(); loadUsers(); window.setInterval(() => { if (!elements.dialog.open) loadVideos(); }, 5000);