Files
mediaplayer/sentence_api/static/admin.js
2026-08-30 11:07:50 +08:00

345 lines
12 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: [],
};
const elements = {
adminKey: document.querySelector("#admin-key"),
saveKey: document.querySelector("#save-key"),
refresh: document.querySelector("#refresh"),
serviceState: document.querySelector("#service-state"),
uploadForm: document.querySelector("#upload-form"),
uploadState: document.querySelector("#upload-state"),
uploadProgress: document.querySelector("#upload-progress"),
videoRows: document.querySelector("#video-rows"),
videoCount: document.querySelector("#video-count"),
emptyState: document.querySelector("#empty-state"),
dialog: document.querySelector("#sentence-dialog"),
sentenceTitle: document.querySelector("#sentence-title"),
sentenceMeta: document.querySelector("#sentence-meta"),
sentenceRows: document.querySelector("#sentence-rows"),
closeDialog: document.querySelector("#close-dialog"),
};
elements.adminKey.value = state.adminKey;
elements.saveKey.addEventListener("click", () => {
state.adminKey = elements.adminKey.value;
localStorage.setItem("oralTrainerAdminKey", state.adminKey);
elements.serviceState.textContent = "密钥已保存";
});
elements.refresh.addEventListener("click", loadVideos);
elements.closeDialog.addEventListener("click", () => elements.dialog.close());
elements.uploadForm.addEventListener("submit", uploadVideo);
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.adminKey) headers.set("X-Admin-Key", state.adminKey);
const response = await fetch(path, { ...options, headers });
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const payload = await response.json();
detail = typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail);
} catch (_) {}
throw new Error(detail);
}
return response.status === 204 ? null : response.json();
}
async function checkHealth() {
try {
const health = await api("/healthz");
elements.serviceState.textContent = health.moss_configured ? "服务正常 / MOSS 已连接" : "服务正常 / MOSS 未配置";
} catch (error) {
elements.serviceState.textContent = `服务异常: ${error.message}`;
}
}
async function loadVideos() {
try {
const payload = await api("/api/v1/videos");
state.videos = payload.videos;
renderVideos();
} catch (error) {
elements.serviceState.textContent = `加载失败: ${error.message}`;
}
}
function renderVideos() {
elements.videoRows.replaceChildren();
elements.videoCount.textContent = `${state.videos.length} 个视频`;
elements.emptyState.hidden = state.videos.length !== 0;
for (const video of state.videos) {
const row = document.createElement("tr");
row.append(
cellWithError(video.title, video.error_message),
statusCell(video.status),
textCell(formatDuration(video.duration_ms)),
textCell(String(video.sentence_count)),
textCell(formatBytes(video.size_bytes)),
textCell(formatDate(video.updated_at)),
actionCell(video),
);
elements.videoRows.append(row);
}
}
function textCell(value) {
const cell = document.createElement("td");
cell.textContent = value;
return cell;
}
function cellWithError(title, error) {
const cell = textCell(title);
if (error) {
const message = document.createElement("div");
message.className = "error-message";
message.textContent = error;
cell.append(message);
}
return cell;
}
function statusCell(status) {
const cell = document.createElement("td");
const label = document.createElement("span");
label.className = `status status-${status}`;
label.textContent = { uploaded: "已上传", processing: "处理中", ready: "可用", failed: "失败" }[status] || status;
cell.append(label);
return cell;
}
function actionCell(video) {
const cell = document.createElement("td");
const actions = document.createElement("div");
actions.className = "actions";
const play = document.createElement("a");
play.href = video.stream_url;
play.target = "_blank";
play.rel = "noopener";
play.textContent = "播放";
actions.append(play);
actions.append(button("句子", () => openSentences(video)));
actions.append(button("重新处理", () => reprocess(video.video_hash)));
const remove = button("删除", () => deleteVideo(video));
remove.className = "danger";
actions.append(remove);
cell.append(actions);
return cell;
}
function button(label, handler) {
const result = document.createElement("button");
result.type = "button";
result.textContent = label;
result.addEventListener("click", handler);
return result;
}
function uploadVideo(event) {
event.preventDefault();
const file = document.querySelector("#video-file").files[0];
if (!file) return;
const title = document.querySelector("#video-title").value;
const language = document.querySelector("#video-language").value;
const query = new URLSearchParams({ filename: file.name });
if (title) query.set("title", title);
if (language) query.set("language", language);
elements.uploadProgress.hidden = false;
elements.uploadProgress.value = 0;
elements.uploadState.textContent = "上传中";
const request = new XMLHttpRequest();
request.open("PUT", `/api/v1/admin/videos/raw?${query.toString()}`);
if (state.adminKey) request.setRequestHeader("X-Admin-Key", state.adminKey);
request.setRequestHeader("Content-Type", file.type || "application/octet-stream");
request.upload.addEventListener("progress", (progress) => {
if (progress.lengthComputable) elements.uploadProgress.value = progress.loaded / progress.total * 100;
});
request.addEventListener("load", async () => {
if (request.status >= 200 && request.status < 300) {
elements.uploadState.textContent = "上传完成,正在处理";
elements.uploadForm.reset();
await loadVideos();
} else {
elements.uploadState.textContent = `上传失败: ${readXhrError(request)}`;
}
elements.uploadProgress.hidden = true;
});
request.addEventListener("error", () => {
elements.uploadState.textContent = "上传连接中断";
elements.uploadProgress.hidden = true;
});
request.send(file);
}
function readXhrError(request) {
try { return JSON.parse(request.responseText).detail || `HTTP ${request.status}`; }
catch (_) { return `HTTP ${request.status}`; }
}
async function openSentences(video) {
try {
const detail = await api(`/api/v1/videos/${video.video_hash}`);
elements.sentenceTitle.textContent = detail.video.title;
const sentences = detail.boundaries?.sentences || [];
elements.sentenceMeta.textContent = `${sentences.length} 句 / ${detail.video.video_hash}`;
elements.sentenceRows.replaceChildren();
for (const sentence of sentences) {
elements.sentenceRows.append(sentenceRow(video, sentence));
}
if (!elements.dialog.open) elements.dialog.showModal();
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
}
}
function sentenceRow(video, sentence) {
const row = document.createElement("tr");
const textArea = document.createElement("textarea");
textArea.value = sentence.text || "";
const language = document.createElement("select");
for (const [value, label] of [["", "自动"], ["en", "英语"], ["zh", "中文"]]) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
option.selected = value === (sentence.language || "");
language.append(option);
}
const textContainer = document.createElement("td");
textContainer.append(textArea);
const languageContainer = document.createElement("td");
languageContainer.append(language);
const saveContainer = document.createElement("td");
const save = button("保存", async () => {
save.disabled = true;
try {
const body = JSON.stringify({ text: textArea.value, language: language.value || null });
if (/\r?\n/.test(textArea.value)) {
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/split`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
save.textContent = "已拆分";
await openSentences(video);
} else {
await api(`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body,
});
save.textContent = "已保存";
}
} catch (error) {
elements.serviceState.textContent = `句子保存失败: ${error.message}`;
save.textContent = error.message;
} finally {
window.setTimeout(() => { save.disabled = false; save.textContent = "保存"; }, 1600);
}
});
saveContainer.append(save);
const timeContainer = document.createElement("td");
const startLabel = document.createElement("span");
startLabel.textContent = formatDuration(sentence.start_ms);
timeContainer.append(startLabel, " — ", boundaryControl(video, sentence));
row.append(
textCell(String(sentence.index + 1)),
timeContainer,
textContainer,
languageContainer,
saveContainer,
);
return row;
}
function boundaryControl(video, sentence) {
const container = document.createElement("div");
container.className = "boundary-control";
const label = document.createElement("span");
label.className = "boundary-time";
label.textContent = formatDuration(sentence.end_ms);
const status = document.createElement("span");
status.className = "boundary-status";
async function adjust(deltaMs, changedButton) {
const buttons = [minus, plus];
buttons.forEach((item) => { item.disabled = true; });
status.textContent = "";
try {
await api(
`/api/v1/admin/videos/${video.video_hash}/sentences/${sentence.index}/boundary`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delta_ms: deltaMs }),
},
);
await openSentences(video);
} catch (error) {
elements.serviceState.textContent = `分割点调整失败: ${error.message}`;
status.textContent = "失败";
buttons.forEach((item) => { item.disabled = false; });
window.setTimeout(() => { status.textContent = ""; }, 1800);
}
if (changedButton) {
window.setTimeout(() => {
changedButton.textContent = deltaMs < 0 ? "0.1" : "+0.1";
}, 900);
}
}
const minus = button("0.1", (event) => adjust(event.shiftKey ? -10 : -100, minus));
const plus = button("+0.1", (event) => adjust(event.shiftKey ? 10 : 100, plus));
minus.title = "结束点提前 0.1 秒;按住 Shift 微调 0.01 秒";
plus.title = "结束点延后 0.1 秒;按住 Shift 微调 0.01 秒";
container.append(minus, label, plus, status);
return container;
}
async function reprocess(videoHash) {
try {
await api(`/api/v1/admin/videos/${videoHash}/process`, { method: "POST" });
await loadVideos();
} catch (error) {
elements.serviceState.textContent = `处理启动失败: ${error.message}`;
}
}
async function deleteVideo(video) {
if (!window.confirm(`删除“${video.title}”?`)) return;
try {
await api(`/api/v1/admin/videos/${video.video_hash}`, { method: "DELETE" });
await loadVideos();
} catch (error) {
elements.serviceState.textContent = `删除失败: ${error.message}`;
}
}
function formatDuration(ms) {
if (!ms) return "-";
const totalTenths = Math.floor(ms / 100);
const hours = Math.floor(totalTenths / 36000);
const minutes = Math.floor(totalTenths % 36000 / 600);
const seconds = Math.floor(totalTenths % 600 / 10);
const tenths = totalTenths % 10;
const base = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${tenths}`;
return hours ? `${hours}:${base}` : base;
}
function formatBytes(bytes) {
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
function formatDate(value) {
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
}
checkHealth();
loadVideos();
window.setInterval(() => {
if (!elements.dialog.open) loadVideos();
}, 5000);