Files
mediaplayer/sentence_api/static/admin.js

539 lines
19 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 state = {
adminKey: localStorage.getItem("oralTrainerAdminKey") || "",
videos: [],
users: [],
retranslateVideo: 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"),
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"),
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.closeRetranslateDialog.addEventListener("click", () => elements.retranslateDialog.close());
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("重新翻译", () => 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}`);
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, sentences.length));
}
if (!elements.dialog.open) elements.dialog.showModal();
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
}
}
function sentenceRow(video, sentence, totalCount) {
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");
const zhNote = document.createElement("div");
zhNote.className = "zh-text";
zhNote.textContent = 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 status = payload.complete ? "中文字幕已就绪" : (payload.status === "running" ? "正在翻译" : "尚未翻译完成");
elements.retranslateMeta.textContent = `${status} / ${video.video_hash}`;
} 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 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();
loadUsers();
window.setInterval(() => {
if (!elements.dialog.open) loadVideos();
}, 5000);