const state = { adminKey: localStorage.getItem("oralTrainerAdminKey") || "", videos: [], }; 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"), 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"), }; 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); elements.closeDialog.addEventListener("click", () => elements.dialog.close()); elements.uploadForm.addEventListener("submit", uploadVideo); 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); } } 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("句子", () => openSentences(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; } 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 readXhrError(request) { try { return JSON.parse(request.responseText).detail || `HTTP ${request.status}`; } catch (_) { return `HTTP ${request.status}`; } } async function openSentences(video) { try { const detail = await api(`/api/v1/videos/${video.video_hash}`); elements.sentenceTitle.textContent = detail.video.title; const sentences = detail.boundaries?.sentences || []; elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`; elements.sentenceRows.replaceChildren(); for (const sentence of sentences) { elements.sentenceRows.append(sentenceRow(video, sentence)); } if (!elements.dialog.open) elements.dialog.showModal(); } catch (error) { elements.serviceState.textContent = `句子加载失败: ${error.message}`; } } function sentenceRow(video, sentence) { const row = document.createElement("tr"); const textArea = document.createElement("textarea"); textArea.value = sentence.text || ""; 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); } const textContainer = document.createElement("td"); textContainer.append(textArea); const languageContainer = document.createElement("td"); languageContainer.append(language); const saveContainer = document.createElement("td"); const save = button("保存", async () => { save.disabled = true; try { const body = JSON.stringify({ text: textArea.value, language: language.value || null }); if (/\r?\n/.test(textArea.value)) { await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/split`, { method: "PUT", headers: { "Content-Type": "application/json" }, body, }); save.textContent = "已拆分"; await openSentences(video); } else { await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body, }); save.textContent = "已保存"; } } catch (error) { elements.serviceState.textContent = `句子保存失败: ${error.message}`; save.textContent = error.message; } finally { window.setTimeout(() => { save.disabled = false; save.textContent = "保存"; }, 1600); } }); saveContainer.append(save); 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, changedButton) { const buttons = [minus, plus]; buttons.forEach((item) => { item.disabled = true; }); status.textContent = ""; try { await api( `/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/boundary`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delta_ms: deltaMs }), }, ); await openSentences(video); } catch (error) { elements.serviceState.textContent = `分割点调整失败: ${error.message}`; status.textContent = "失败"; buttons.forEach((item) => { item.disabled = false; }); window.setTimeout(() => { status.textContent = ""; }, 1800); } if (changedButton) { window.setTimeout(() => { changedButton.textContent = deltaMs < 0 ? "−0.1" : "+0.1"; }, 900); } } const minus = button("−0.1", (event) => adjust(event.shiftKey ? -10 : -100, minus)); const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus)); minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒"; plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒"; container.append(minus, label, plus, status); return container; } 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 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(); window.setInterval(() => { if (!elements.dialog.open) loadVideos(); }, 5000);