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

297 lines
10 KiB
JavaScript

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);
row.append(
textCell(String(sentence.index + 1)),
textCell(`${formatDuration(sentence.start_ms)} - ${formatDuration(sentence.end_ms)}`),
textContainer,
languageContainer,
saveContainer,
);
return row;
}
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);