Files
mediaplayer/sentence_api/static/admin.js
2026-10-25 10:30:22 +08:00

680 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
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.repairGaps.addEventListener("click", () => {
if (state.sentenceVideo) repairGaps(state.sentenceVideo, { reloadSentences: true });
});
elements.repairMissingZh.addEventListener("click", () => {
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) {
try {
const detail = await api(`/api/v1/videos/${video.video_hash}`);
state.sentenceVideo = video;
elements.sentenceTitle.textContent = detail.video.title;
const sentences = detail.boundaries?.sentences || [];
const durationMs = detail.video.duration_ms || 0;
const gaps = collectSentenceGaps(sentences, durationMs);
const missingZh = sentences.filter(translationNeedsRepair).length;
const gapLabel = gaps.length ? ` / ${gaps.length} 段空白待修复` : "";
const zhLabel = missingZh ? ` / ${missingZh} 句未译` : "";
elements.sentenceMeta.textContent = `${sentences.length} 句${gapLabel}${zhLabel} / ${detail.video.video_hash}`;
elements.repairGaps.disabled = gaps.length === 0;
elements.repairMissingZh.disabled = missingZh === 0;
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]));
}
if (!elements.dialog.open) elements.dialog.showModal();
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
}
}
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 || "";
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");
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;
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);
if (sentence.index < totalCount - 1) {
const merge = button("合并下句", async () => {
const preview = `${(textArea.value || sentence.text || "").trim()} + 下一句`;
const confirmed = window.confirm(`确定将第 ${sentence.index + 1} 句与下一句合并成一句吗?\n${preview}`);
if (!confirmed) return;
merge.disabled = true;
try {
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/merge`, {
method: "PUT",
});
merge.textContent = "已合并";
await openSentences(video);
} catch (error) {
elements.serviceState.textContent = `句子合并失败: ${error.message}`;
merge.textContent = error.message;
window.setTimeout(() => { merge.disabled = false; merge.textContent = "合并下句"; }, 1600);
}
});
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, 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 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);