add test module

This commit is contained in:
2026-08-16 15:39:52 +08:00
parent d0310620fc
commit 6e4d93cea6
46 changed files with 3880 additions and 206 deletions

View File

@@ -0,0 +1,117 @@
:root {
color-scheme: light;
font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif;
color: #17202a;
background: #f4f6f7;
letter-spacing: 0;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, select { font: inherit; letter-spacing: 0; }
button { cursor: pointer; }
.topbar {
min-height: 72px;
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
color: #fff;
background: #20262c;
border-bottom: 3px solid #1f8a70;
}
h1, h2 { margin: 0; letter-spacing: 0; }
h1 { font-size: 21px; line-height: 1.35; }
h2 { font-size: 17px; }
.service-state, .section-heading span { font-size: 13px; color: #66727d; }
.topbar .service-state { color: #c8d0d7; }
.admin-auth { display: flex; align-items: center; gap: 8px; }
.admin-auth label { font-size: 13px; color: #d5dadd; }
input, select {
min-height: 36px;
padding: 7px 10px;
border: 1px solid #b8c0c7;
border-radius: 4px;
background: #fff;
}
button {
min-height: 36px;
padding: 7px 13px;
border: 1px solid #aab4bc;
border-radius: 4px;
color: #26323c;
background: #fff;
}
button:hover { background: #edf1f3; }
button:disabled { cursor: default; opacity: .55; }
.primary { color: #fff; border-color: #176b57; background: #1f8a70; }
.primary:hover { background: #176b57; }
.danger { color: #a12622; border-color: #d4aaa8; }
.icon-button { width: 36px; padding: 0; font-weight: 700; }
main { width: min(1480px, 100%); margin: 0 auto; }
section { padding: 24px 28px; }
.upload-band { background: #fff; border-bottom: 1px solid #dce1e5; }
.video-band { min-height: 400px; }
.section-heading {
min-height: 30px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
#upload-form {
display: grid;
grid-template-columns: minmax(260px, 2fr) minmax(180px, 1fr) 150px auto;
align-items: end;
gap: 12px;
}
#upload-form label { display: grid; gap: 5px; font-size: 13px; color: #4f5b64; }
#upload-progress { width: 100%; height: 8px; margin-top: 14px; accent-color: #1f8a70; }
.table-scroll { overflow: auto; border: 1px solid #d7dde1; background: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e3e7ea; vertical-align: middle; }
th { position: sticky; top: 0; z-index: 1; color: #44515b; background: #edf1f3; font-weight: 600; }
tr:last-child td { border-bottom: 0; }
.actions-column { min-width: 280px; }
.actions { display: flex; gap: 6px; white-space: nowrap; }
.actions button, .actions a { min-height: 30px; padding: 5px 9px; font-size: 12px; }
.actions a { display: inline-flex; align-items: center; border: 1px solid #aab4bc; border-radius: 4px; color: #26323c; text-decoration: none; }
.status { display: inline-block; min-width: 64px; font-weight: 600; }
.status-ready { color: #167356; }
.status-failed { color: #b12d28; }
.status-processing, .status-uploaded { color: #a45d08; }
.error-message { max-width: 320px; margin-top: 3px; color: #a12622; font-size: 12px; white-space: normal; }
.empty-state { padding: 70px 20px; text-align: center; color: #6f7a82; }
dialog {
width: min(1200px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
padding: 0;
border: 1px solid #909ba3;
border-radius: 6px;
box-shadow: 0 18px 60px rgb(0 0 0 / 25%);
}
dialog::backdrop { background: rgb(20 25 28 / 55%); }
.dialog-heading { padding: 16px 18px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #dce1e5; }
.dialog-heading span { font-size: 12px; color: #68747d; }
.sentence-scroll { max-height: calc(100vh - 120px); overflow: auto; }
.sentence-scroll textarea { width: min(600px, 45vw); min-width: 260px; min-height: 58px; resize: vertical; padding: 7px; border: 1px solid #b8c0c7; border-radius: 3px; }
.sentence-scroll select { min-width: 90px; }
@media (max-width: 820px) {
.topbar { align-items: stretch; flex-direction: column; padding: 14px 16px; }
.admin-auth { flex-wrap: wrap; }
.admin-auth input { flex: 1 1 150px; }
section { padding: 20px 16px; }
#upload-form { grid-template-columns: 1fr; }
.actions-column { min-width: 250px; }
}

View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>口语训练视频管理</title>
<link rel="stylesheet" href="/static/admin.css">
</head>
<body>
<header class="topbar">
<div>
<h1>口语训练视频管理</h1>
<span id="service-state" class="service-state">检查服务</span>
</div>
<div class="admin-auth">
<label for="admin-key">管理密钥</label>
<input id="admin-key" type="password" autocomplete="current-password">
<button id="save-key" type="button">保存</button>
<button id="refresh" type="button">刷新</button>
</div>
</header>
<main>
<section class="upload-band" aria-labelledby="upload-heading">
<div class="section-heading">
<h2 id="upload-heading">上传视频</h2>
<span id="upload-state"></span>
</div>
<form id="upload-form">
<label class="file-field">
<span>视频文件</span>
<input id="video-file" name="file" type="file" accept="video/*" required>
</label>
<label>
<span>标题</span>
<input id="video-title" name="title" type="text" maxlength="200">
</label>
<label>
<span>语言</span>
<select id="video-language" name="language">
<option value="">自动识别</option>
<option value="en">英语</option>
<option value="zh">中文</option>
</select>
</label>
<button class="primary" type="submit">上传并处理</button>
</form>
<progress id="upload-progress" value="0" max="100" hidden></progress>
</section>
<section class="video-band" aria-labelledby="videos-heading">
<div class="section-heading">
<h2 id="videos-heading">视频库</h2>
<span id="video-count">0 个视频</span>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>标题</th>
<th>状态</th>
<th>时长</th>
<th>句子</th>
<th>大小</th>
<th>更新时间</th>
<th class="actions-column">操作</th>
</tr>
</thead>
<tbody id="video-rows"></tbody>
</table>
</div>
<div id="empty-state" class="empty-state" hidden>暂无视频</div>
</section>
</main>
<dialog id="sentence-dialog">
<div class="dialog-heading">
<div>
<h2 id="sentence-title">句子文本</h2>
<span id="sentence-meta"></span>
</div>
<button id="close-dialog" class="icon-button" type="button" aria-label="关闭">X</button>
</div>
<div class="sentence-scroll">
<table>
<thead>
<tr><th>#</th><th>时间</th><th>文本</th><th>语言</th><th>操作</th></tr>
</thead>
<tbody id="sentence-rows"></tbody>
</table>
</div>
</dialog>
<script src="/static/admin.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,282 @@
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.video_hash, sentence));
}
elements.dialog.showModal();
} catch (error) {
elements.serviceState.textContent = `句子加载失败: ${error.message}`;
}
}
function sentenceRow(videoHash, 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 {
await api(`/api/v1/admin/videos/${videoHash}/sentences/${sentence.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: textArea.value, language: language.value || null }),
});
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 total = Math.floor(ms / 1000);
const hours = Math.floor(total / 3600);
const minutes = Math.floor(total % 3600 / 60);
const seconds = total % 60;
return hours ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` : `${minutes}:${String(seconds).padStart(2, "0")}`;
}
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);