924 lines
33 KiB
JavaScript
924 lines
33 KiB
JavaScript
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);
|