358 lines
13 KiB
JavaScript
358 lines
13 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"),
|
||
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"),
|
||
};
|
||
|
||
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);
|
||
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);
|
||
}
|
||
}
|
||
|
||
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 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));
|
||
}
|
||
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);
|