add test module
This commit is contained in:
117
sentence_api/static/admin.css
Normal file
117
sentence_api/static/admin.css
Normal 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; }
|
||||
}
|
||||
96
sentence_api/static/admin.html
Normal file
96
sentence_api/static/admin.html
Normal 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>
|
||||
282
sentence_api/static/admin.js
Normal file
282
sentence_api/static/admin.js
Normal 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);
|
||||
Reference in New Issue
Block a user