296 lines
10 KiB
JavaScript
296 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));
|
|
}
|
|
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) {
|
|
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);
|