Files
mediaplayer/sentence_api/static/admin.js
2026-10-29 13:25:39 +08:00

931 lines
33 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,
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) {
return;
}
}
const processed = await saveSentenceDraft({ processTimings: true });
if (!processed) {
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,
sourceStartMs,
sourceEndMs,
...sentence
}) => ({
...sentence,
...(sourceStartMs !== undefined && sourceEndMs !== undefined ? {
source_start_ms: sourceStartMs,
source_end_ms: sourceEndMs,
} : {}),
}),
),
};
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);