add full source: transcribe pipeline, HTTP API, OpenAI endpoint, web UI, Docker

This commit is contained in:
2026-09-09 16:04:10 +08:00
parent 757861f1dd
commit 7010da8855
19 changed files with 2918 additions and 0 deletions
+650
View File
@@ -0,0 +1,650 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>record-asr · 转写控制台</title>
<style>
:root{
--bg:#0d1117; --surface:#161b22; --surface-2:#1c2330; --surface-3:#222b3a;
--border:#2a313c; --border-soft:#222732;
--text:#e6edf3; --muted:#7d8590; --faint:#545b66;
--accent:#2f81f7; --accent-dim:#1f6feb; --accent-soft:rgba(47,129,247,.14);
--ok:#3fb950; --warn:#d29922; --err:#f85149; --skip:#6e7681;
--tb1:#161b22; --tb2:#131820;
--mono:ui-monospace,'SF Mono','Cascadia Code',Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;
--r:8px;
}
/* Light (white) theme — default; toggle stored in localStorage as asr_theme. */
[data-theme="light"]{
--bg:#f6f8fa; --surface:#ffffff; --surface-2:#eef2f6; --surface-3:#e2e8ee;
--border:#d4dbe2; --border-soft:#e4e9ee;
--text:#1f2328; --muted:#57606a; --faint:#8b949e;
--accent:#0969da; --accent-dim:#0550ae; --accent-soft:rgba(9,105,218,.12);
--ok:#1a7f37; --warn:#9a6700; --err:#cf222e; --skip:#6e7681;
--tb1:#ffffff; --tb2:#eef2f6;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:14px; line-height:1.5;
-webkit-font-smoothing:antialiased;
display:flex; flex-direction:column;
}
::selection{background:var(--accent-soft)}
button{font:inherit;color:inherit;cursor:pointer;border:none;background:none}
input{font:inherit;color:inherit}
/* ---- tab bar ---- */
.tabbar{flex:none;display:flex;gap:2px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--surface)}
.tab{height:38px;padding:0 14px;font-size:13px;color:var(--muted);border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}
.tab.active{color:var(--text);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
/* ---- API reference view ---- */
.api-view{flex:1;min-height:0;overflow:auto}
.api-body{max-width:880px;margin:0 auto;padding:24px 28px 60px}
.api-body h2{margin:0 0 4px;font-size:20px}
.api-intro{margin:0 0 20px;color:var(--muted);font-size:13px;line-height:1.7}
.api-intro code{background:var(--surface-2);border:1px solid var(--border-soft);border-radius:5px;padding:1px 6px;font-family:var(--mono);font-size:12px;color:var(--text)}
.api-section{margin:24px 0}
.api-section h3{margin:0 0 10px;font-size:14px;color:var(--text)}
.api-section h3 .tag{margin-left:8px;font-size:10.5px;font-family:var(--mono);color:var(--accent);background:var(--accent-soft);border-radius:4px;padding:1px 6px;vertical-align:middle}
.api-tbl{width:100%;border-collapse:collapse;font-size:13px}
.api-tbl th,.api-tbl td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border-soft);vertical-align:top}
.api-tbl th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em;font-weight:500}
.api-tbl td.m{font-family:var(--mono);font-size:11.5px;white-space:nowrap}
.api-tbl td.m.post{color:var(--accent)} .api-tbl td.m.get{color:var(--ok)} .api-tbl td.m.del{color:var(--err)}
.api-tbl code{font-family:var(--mono);font-size:12px}
.code{background:var(--surface);border:1px solid var(--border-soft);border-radius:8px;padding:14px 16px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.6;white-space:pre;color:var(--text);margin:10px 0}
.code .c{color:var(--muted)}
/* ---- top bar ---- */
.topbar{
height:52px;display:flex;align-items:center;gap:14px;
padding:0 16px;border-bottom:1px solid var(--border);
background:linear-gradient(180deg,var(--tb1),var(--tb2));
position:sticky;top:0;z-index:20;
}
.brand{display:flex;align-items:center;gap:9px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
.brand .mark{
width:22px;height:22px;border-radius:6px;
background:linear-gradient(135deg,var(--accent),#7b61ff);
display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
font-family:var(--mono);
}
.brand small{color:var(--muted);font-weight:400;font-size:12px;font-family:var(--mono)}
.health{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);font-family:var(--mono)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,185,80,.16)}
.dot.down{background:var(--err);box-shadow:0 0 0 3px rgba(248,81,73,.16)}
.dot.live{background:var(--warn);box-shadow:0 0 0 3px rgba(210,153,34,.16);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.dot.live{animation:none}}
.spacer{flex:1}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:grid;place-items:center;z-index:60;padding:20px}
[data-theme="light"] .modal{background:rgba(0,0,0,.25)}
.modal.hidden{display:none}
.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px;width:min(380px,92vw);box-shadow:0 24px 60px rgba(0,0,0,.5)}
.modal-card h3{margin:0 0 6px;font-size:17px;font-weight:600}
.modal-msg{margin:0 0 16px;color:var(--muted);font-size:13px;line-height:1.5}
.modal-msg.err{color:var(--err)}
.modal-field{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:7px 10px;margin-bottom:16px}
.modal-field:focus-within{border-color:var(--accent)}
.modal-field svg{width:15px;height:15px;color:var(--muted);flex:none}
.modal-field input{flex:1;background:none;border:none;outline:none;font-size:14px;font-family:var(--mono)}
.modal-field input::placeholder{color:var(--faint)}
.modal-actions{display:flex;justify-content:flex-end;gap:8px}
.btn{
display:inline-flex;align-items:center;gap:6px;
height:30px;padding:0 12px;border-radius:var(--r);font-size:13px;font-weight:500;
border:1px solid var(--border);background:var(--surface-2);color:var(--text);
transition:background .12s,border-color .12s;
}
.btn:hover{background:var(--surface-3);border-color:#3a4250}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-dim)}
.btn.danger:hover{background:rgba(248,81,73,.14);border-color:var(--err);color:var(--err)}
.btn.ghost{background:none;border-color:transparent}
.btn.ghost:hover{background:var(--surface-2)}
.btn svg{width:14px;height:14px}
.iconbtn{width:30px;height:30px;padding:0;justify-content:center;color:var(--muted)}
.iconbtn:hover{color:var(--text)}
.iconbtn svg{width:16px;height:16px}
.iconbtn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
/* ---- layout ---- */
.layout{display:flex;flex:1;min-height:0}
.pane{display:flex;flex-direction:column;min-height:0}
.list-pane{width:360px;flex:none;border-right:1px solid var(--border);background:var(--bg)}
.detail-pane{flex:1;min-width:0;background:var(--bg)}
.pane-head{
height:42px;display:flex;align-items:center;gap:8px;padding:0 14px;flex:none;
border-bottom:1px solid var(--border-soft);color:var(--muted);
font-size:12px;text-transform:uppercase;letter-spacing:.08em;
}
.pane-head .count{margin-left:auto;font-family:var(--mono);color:var(--faint)}
.scroll{overflow:auto;flex:1;min-height:0}
/* ---- task list rows ---- */
.task{
display:flex;gap:10px;align-items:flex-start;padding:11px 14px;
border-bottom:1px solid var(--border-soft);cursor:pointer;border-left:2px solid transparent;
}
.task:hover{background:var(--surface)}
.task.selected{background:var(--surface-2);border-left-color:var(--accent)}
.task .sdot{margin-top:5px;flex:none}
.task .main{min-width:0;flex:1}
.task .name{font-size:13.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.task .meta{margin-top:2px;display:flex;gap:8px;align-items:center;font-size:11.5px;color:var(--muted);font-family:var(--mono)}
.badge{font-size:10.5px;padding:1px 6px;border-radius:4px;font-family:var(--mono);letter-spacing:.04em}
.b-pending{background:rgba(210,153,34,.14);color:var(--warn)}
.b-processing{background:rgba(47,129,247,.16);color:var(--accent)}
.b-completed{background:rgba(63,185,80,.14);color:var(--ok)}
.b-failed{background:rgba(248,81,73,.14);color:var(--err)}
.b-skipped{background:rgba(110,118,129,.16);color:var(--skip)}
/* ---- detail ---- */
.detail-empty{height:100%;display:grid;place-items:center;text-align:center;padding:40px}
.dropzone{
border:1.5px dashed var(--border);border-radius:14px;padding:46px 30px;max-width:440px;
background:var(--surface);transition:border-color .15s,background .15s;
}
.dropzone.drag{border-color:var(--accent);background:var(--accent-soft)}
.dropzone svg{width:34px;height:34px;color:var(--muted)}
.dropzone h3{margin:14px 0 4px;font-size:16px}
.dropzone p{margin:0;color:var(--muted);font-size:13px}
.dropzone .btn{margin-top:16px}
.link{color:var(--accent);cursor:pointer;text-decoration:none}
.link:hover{text-decoration:underline}
.dropzone .ext{margin-top:10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
.detail-body{padding:18px 24px 60px;max-width:920px}
.detail-title{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.detail-title h2{margin:0;font-size:18px;font-weight:600;word-break:break-all}
.detail-title .badge{font-size:11px}
.detail-id{margin-top:4px;font-family:var(--mono);font-size:11.5px;color:var(--faint)}
.kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:18px 0}
.kv .cell{background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--r);padding:9px 11px}
.kv .k{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
.kv .v{margin-top:2px;font-family:var(--mono);font-size:13px}
.actions{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0 4px}
.section-label{margin:26px 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:8px}
.section-label::after{content:"";flex:1;height:1px;background:var(--border-soft)}
.transcript{
background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--r);
padding:16px 18px;font-size:15px;line-height:1.7;white-space:pre-wrap;word-break:break-word;
}
.transcript.empty{color:var(--faint);font-style:italic}
.seg{
display:flex;gap:14px;padding:11px 0;border-bottom:1px solid var(--border-soft);
}
.seg:last-child{border-bottom:none}
.seg .ts{
font-family:var(--mono);font-size:11.5px;color:var(--accent);flex:none;
white-space:nowrap;align-self:flex-start;
display:inline-flex;align-items:center;gap:4px;
background:var(--surface-2);border:1px solid var(--border-soft);border-radius:6px;
padding:2px 8px;cursor:pointer;
transition:color .12s,border-color .12s,background .12s;
}
.seg .ts:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.seg .ts:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.seg .ts.copied{color:var(--ok);border-color:var(--ok)}
.seg .ts svg{width:11px;height:11px;opacity:.9}
.seg .ts:hover svg{opacity:1}
.seg .stext{flex:1;line-height:1.55}
.seg .slang{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-left:8px}
.copy-btn{
height:24px;padding:0 10px;font-size:11.5px;border-radius:6px;
border:1px solid var(--border);background:var(--surface);color:var(--muted);
display:inline-flex;align-items:center;gap:5px;flex:none;cursor:pointer;
transition:color .12s,border-color .12s,background .12s;
}
.copy-btn:hover{color:var(--text);border-color:#3a4250;background:var(--surface-2)}
.copy-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.copy-btn.copied{color:var(--ok);border-color:var(--ok)}
.copy-btn svg{width:12px;height:12px}
.seg-copy{margin-left:6px;align-self:flex-start}
.errbox{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);border-radius:var(--r);padding:10px 12px;color:var(--err);font-size:13px;font-family:var(--mono);white-space:pre-wrap;word-break:break-word}
/* ---- toast ---- */
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:50;display:flex;flex-direction:column;gap:8px;align-items:center}
.toast{background:var(--surface-3);border:1px solid var(--border);color:var(--text);padding:8px 14px;border-radius:var(--r);font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:tin .2s ease}
.toast.err{border-color:var(--err);color:var(--err)}
.toast.ok{border-color:var(--ok);color:var(--ok)}
@keyframes tin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media(prefers-reduced-motion:reduce){.toast{animation:none}}
.spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.spinner{animation:none}}
.skeleton{color:var(--faint);font-size:13px;display:flex;align-items:center;gap:8px;padding:14px}
@media(max-width:860px){
.layout{flex-direction:column}
.list-pane{width:100%;height:42%;border-right:none;border-bottom:1px solid var(--border)}
}
.hidden{display:none!important}
</style>
<script>
// Apply saved theme before first paint to avoid a flash of the wrong theme.
(function(){
var t = localStorage.getItem("asr_theme");
document.documentElement.dataset.theme = (t === "dark") ? "dark" : "light";
})();
</script>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark">A</span>record-asr <small>SenseVoice · silero VAD</small>
</div>
<div class="health" id="health"><span class="dot" id="hdot"></span><span id="htext">checking…</span></div>
<div class="spacer"></div>
<button class="btn iconbtn hidden" id="logoutBtn" title="退出登录" aria-label="退出登录"></button>
<button class="btn iconbtn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
<button class="btn primary" id="uploadBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M5 11l7-7 7 7M5 20h14"/></svg>
上传
</button>
</header>
<nav class="tabbar">
<button class="tab active" data-view="console" type="button">转写控制台</button>
<button class="tab" data-view="api" type="button">API</button>
</nav>
<main class="layout" id="consoleView">
<section class="pane list-pane">
<div class="pane-head">任务队列 <span class="count" id="listCount"></span></div>
<div class="scroll" id="list"><!-- rows --></div>
</section>
<section class="pane detail-pane">
<div class="scroll" id="detail"><!-- detail or empty --></div>
</section>
</main>
<section class="api-view hidden" id="apiView">
<div class="api-body">
<h2>API 参考</h2>
<p class="api-intro">
Base URL:<code id="apiBase">http://&lt;host&gt;:8080</code><br>
鉴权(除 <code>/healthz</code> 外):<code>X-Api-Key</code> · <code>Authorization: Bearer &lt;pw&gt;</code> · <code>?token=&lt;pw&gt;</code><br>
支持格式:<code>.wav .mp3 .flac .ogg .m4a .aac .opus .aiff</code> 及视频 <code>.mp4 .mkv .mov .avi .ts …</code>(自动提取音轨)
</p>
<div class="api-section">
<h3>任务 API <span class="tag">异步:上传 → 轮询 → 取结果</span></h3>
<table class="api-tbl">
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks</code></td><td>上传音频/视频(multipart: <code>file</code>)→ 202 + 任务</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks</code></td><td>任务列表(新→旧)</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks/{id}</code></td><td>任务详情(完成时含 <code>result</code>)</td></tr>
<tr><td class="m del">DELETE</td><td><code>/api/tasks/{id}</code></td><td>删除任务及其音频 → 204</td></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks/{id}/retry</code></td><td>失败/跳过后重新排队 → 202</td></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks/{id}/skip</code></td><td>放弃失败任务,队列继续 → 202</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks/{id}/transcript</code></td><td>仅转写结果(完成后)</td></tr>
</table>
</div>
<div class="api-section">
<h3>OpenAI 兼容 <span class="tag">同步</span></h3>
<table class="api-tbl">
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
<tr><td class="m post">POST</td><td><code>/v1/audio/transcriptions</code></td><td>同步转写,返回结果(无需轮询)。字段:<code>file</code>(必填)、<code>response_format</code>=<code>json</code>(默认)|<code>text</code>|<code>verbose_json</code>|<code>srt</code>|<code>vtt</code>、<code>model</code>/<code>language</code>(兼容接受,识别自动检测语种)</td></tr>
</table>
<pre class="code"><span class="c"># 默认 json:{"text":"..."}</span>
curl -H "Authorization: Bearer $PW" -F "[email protected]" \
http://localhost:8080/v1/audio/transcriptions
<span class="c"># 字幕</span>
curl -H "Authorization: Bearer $PW" -F "[email protected]" -F "response_format=srt" \
http://localhost:8080/v1/audio/transcriptions</pre>
</div>
<div class="api-section">
<h3>示例</h3>
<pre class="code"><span class="c"># 上传任务(异步)</span>
curl -H "X-Api-Key: $PW" -F "[email protected]" http://localhost:8080/api/tasks
<span class="c"># 轮询 + 取结果</span>
curl -H "X-Api-Key: $PW" http://localhost:8080/api/tasks/&lt;id&gt;
curl -H "X-Api-Key: $PW" http://localhost:8080/api/tasks/&lt;id&gt;/transcript
<span class="c"># 健康检查(免鉴权)</span>
curl http://localhost:8080/healthz</pre>
</div>
</div>
</section>
<input id="file" type="file" accept=".wav,.mp3,.flac,.ogg,.oga,.m4a,.m4b,.aac,.opus,.wma,.webm,.aiff,.aif,.mp4,.m4v,.mkv,.mov,.avi,.ts,.m2ts,.mts,.flv,.3gp,.mpg,.mpeg" multiple class="hidden">
<div id="toast"></div>
<!-- Password modal: shown only when auth is needed (no saved password, or a
401). Once verified it hides and never clutters the top bar. -->
<div id="authModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="authTitle">
<div class="modal-card">
<h3 id="authTitle">需要访问密码</h3>
<p id="authMsg" class="modal-msg">请输入 ASR_PASSWORD 设置的访问密码。验证后本次会话不再提示。</p>
<div class="modal-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input id="authPw" type="password" placeholder="访问密码" autocomplete="off" spellcheck="false">
</div>
<div class="modal-actions">
<button class="btn" id="authCancel" type="button">取消</button>
<button class="btn primary" id="authOk" type="button">验证</button>
</div>
</div>
</div>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,p={})=>{const e=document.createElement(t);for(const k in p)k==="class"?e.className=p[k]:k==="text"?e.textContent=p[k]:e.setAttribute(k,p[k]);return e};
const fmtTime = iso=>{ if(!iso) return "—"; const d=new Date(iso); return isNaN(d)?iso : d.toLocaleTimeString("zh",{hour12:false})+" "+d.toLocaleDateString("zh",{month:"2-digit",day:"2-digit"})};
const fmtDur = s=> s==null?"—": (s<60?s.toFixed(1)+"s" : Math.floor(s/60)+"m"+String(Math.round(s%60)).padStart(2,"0")+"s");
const escapeHtml = s=>(s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false };
// --- password modal (shown only when verification is needed) ---
const authModal=$("#authModal"), authPw=$("#authPw"), authMsg=$("#authMsg"), authOk=$("#authOk"), authCancel=$("#authCancel");
function openAuthModal(msg, isErr){
if(msg){ authMsg.textContent=msg; authMsg.classList.toggle("err",!!isErr); }
state.authing=true;
authModal.classList.remove("hidden");
authPw.value=state.pw||"";
setTimeout(()=>{ authPw.focus(); authPw.select(); }, 0);
}
function closeAuthModal(){ authModal.classList.add("hidden"); state.authing=false; }
async function verifyPassword(pw){
try{
const r = await fetch("/api/tasks", {headers:{"X-Api-Key":pw}});
if(r.status===401) return {ok:false, msg:"密码不正确,请重新输入"};
if(!r.ok) return {ok:false, msg:"服务暂时不可用 (HTTP "+r.status+")"};
return {ok:true};
}catch(e){ return {ok:false, msg:"无法连接服务"} };
}
async function submitAuth(){
const v=authPw.value.trim();
if(!v){ authPw.focus(); return; }
authOk.disabled=true; const old=authOk.textContent; authOk.innerHTML='<span class="spinner"></span> 验证中';
const res=await verifyPassword(v);
authOk.disabled=false; authOk.textContent=old;
if(res.ok){ state.pw=v; localStorage.setItem("asr_pw",v); closeAuthModal(); refresh(); }
else { authMsg.textContent=res.msg; authMsg.classList.add("err"); authPw.focus(); authPw.select(); }
}
authOk.addEventListener("click", submitAuth);
authPw.addEventListener("keydown", e=>{ if(e.key==="Enter"){e.preventDefault(); submitAuth();} });
authPw.addEventListener("input", ()=>{ authMsg.classList.remove("err"); });
authCancel.addEventListener("click", ()=> closeAuthModal());
authModal.addEventListener("click", e=>{ if(e.target===authModal) closeAuthModal(); });
document.addEventListener("keydown", e=>{ if(e.key==="Escape" && !authModal.classList.contains("hidden")) closeAuthModal(); });
// A hook for empty states to re-open the modal.
function askForPassword(msg){ openAuthModal(msg||"请输入访问密码。", false); }
// Theme toggle (light/dark). Default light; choice persisted in localStorage.
const SUN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8l1.8-1.8M18 6l1.8-1.8"/></svg>';
const MOON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const themeBtn=$("#themeBtn");
function applyTheme(t){ document.documentElement.dataset.theme=t; themeBtn.innerHTML = t==="dark"?SUN:MOON; themeBtn.setAttribute("title", t==="dark"?"切换到亮色":"切换到暗色"); }
applyTheme(document.documentElement.dataset.theme || "light");
themeBtn.addEventListener("click", ()=>{ const next = document.documentElement.dataset.theme==="dark"?"light":"dark"; localStorage.setItem("asr_theme",next); applyTheme(next); });
// Logout: clear saved password, return to the no-auth state + prompt modal.
const LOGOUT_ICON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>';
const logoutBtn=$("#logoutBtn"); logoutBtn.innerHTML=LOGOUT_ICON;
function logout(){
state.pw=""; localStorage.removeItem("asr_pw");
state.tasks=[]; state.sel=null;
renderList(); renderDetail();
openAuthModal("已退出。请重新输入访问密码。", false);
}
logoutBtn.addEventListener("click", logout);
async function api(path, opts={}){
const h = {...(opts.headers||{})};
if(state.pw) h["X-Api-Key"]=state.pw;
const r = await fetch(path, {...opts, headers:h});
if(r.status===401){
// Saved password is wrong/missing: clear it and ask via the modal.
state.pw=""; localStorage.removeItem("asr_pw");
openAuthModal("密码不正确或服务已重启,请重新输入。", true);
throw new ApiError("unauthorized");
}
if(r.status===204) return null;
let body=null; try{body=await r.json()}catch(e){}
if(!r.ok){ const m = body&&body.error ? body.error : (`HTTP ${r.status}`); throw new ApiError(m); }
return body;
}
class ApiError extends Error{constructor(m){super(m);this.name="ApiError"}}
function toast(msg,type){ const t=el("div",{class:"toast "+(type||"")}); t.textContent=msg; $("#toast").appendChild(t); setTimeout(()=>t.remove(),3200); }
// Copy to clipboard with a fallback for non-secure contexts (http via IP,
// where navigator.clipboard is unavailable). "done" flashes the button label.
function copyText(text,btn){
// Preserve the icon: only the .cb-label span is swapped for the "已复制 ✓"
// feedback, so the copy glyph stays after the first click.
const done=()=>{ if(btn){ const lbl=btn.querySelector(".cb-label"); const o=btn.dataset.label||(lbl?lbl.textContent:btn.textContent); if(lbl){ lbl.textContent="已复制 ✓"; } else { btn.textContent="已复制 ✓"; } btn.classList.add("copied"); setTimeout(()=>{ if(lbl){ lbl.textContent=o; } else { btn.textContent=o; } btn.classList.remove("copied"); },1400);} toast("已复制","ok"); };
const fail=(e)=>{ toast("复制失败"+(e&&e.message?": "+e.message:""),"err"); };
if(navigator.clipboard && window.isSecureContext && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(done).catch(()=>fallbackCopy(text,done,fail));
} else { fallbackCopy(text,done,fail); }
}
function fallbackCopy(text,done,fail){
try{
const ta=document.createElement("textarea"); ta.value=text;
ta.style.position="fixed"; ta.style.top="-1000px"; ta.style.opacity="0";
document.body.appendChild(ta); ta.focus(); ta.select();
const ok=document.execCommand("copy"); document.body.removeChild(ta);
ok ? done() : fail(new Error("execCommand 失败"));
}catch(e){ fail(e); }
}
const COPY_ICON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
function mkCopyBtn(label, getText){
const b=el("button",{class:"copy-btn",type:"button"});
b.dataset.label=label;
b.innerHTML=COPY_ICON+'<span class="cb-label">'+label+'</span>';
b.onclick=()=>copyText(getText(), b);
return b;
}
async function loadHealth(){
try{
const h = await api("/healthz");
$("#htext").textContent = h.models?"模型就绪":"模型加载中";
$("#hdot").className = "dot "+(h.models?"ok":"down");
}catch(e){ $("#htext").textContent="服务不可达"; $("#hdot").className="dot down"; }
}
async function loadTasks(){
if(!state.pw){ renderList(); return; }
try{
const d = await api("/api/tasks");
state.tasks = d.tasks||[];
renderList(); renderDetail();
}catch(e){ if(!(e instanceof ApiError && /密码/.test(e.message))) toast(e.message,"err"); renderList(); }
}
function statusDot(s){ return s==="completed"?"ok":s==="failed"?"down":(s==="pending"||s==="processing")?"live":"dot"; }
function renderList(){
const c = $("#list"); c.innerHTML=""; $("#listCount").textContent=state.tasks.length?state.tasks.length:"";
logoutBtn.classList.toggle("hidden", !state.pw);
if(!state.pw){ const e=el("div",{class:"skeleton"}); e.innerHTML='🔒 需要访问密码 — <a href="#" class="link">点击输入</a>'; e.querySelector(".link").onclick=(ev)=>{ev.preventDefault(); askForPassword();}; c.appendChild(e); return; }
if(!state.tasks.length){
const e=el("div",{class:"skeleton"}); e.innerHTML='<span class="spinner"></span> 等待上传音频…'; c.appendChild(e); return;
}
state.tasks.forEach(t=>{
const row=el("div",{class:"task"+(t.id===state.sel?" selected":"")});
row.onclick=()=>{state.sel=t.id; renderList(); renderDetail();};
row.appendChild(el("span",{class:"dot "+statusDot(t.status),id:"x"})).className="sdot dot "+statusDot(t.status);
const m=el("div",{class:"main"});
m.appendChild(el("div",{class:"name",text:t.original_name||t.id}));
const meta=el("div",{class:"meta"});
meta.appendChild(el("span",{class:"badge b-"+t.status,text:t.status}));
meta.appendChild(el("span",{text:"· "+fmtTime(t.created_at)}));
if(t.attempts>1) meta.appendChild(el("span",{text:"· ×"+t.attempts}));
m.appendChild(meta);
row.appendChild(m);
c.appendChild(row);
});
}
function renderDetail(){
const c=$("#detail"); c.innerHTML="";
if(!state.pw){
const w=el("div",{class:"detail-empty"});
const dz=el("div",{class:"dropzone"});
dz.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><h3>需要访问密码</h3><p>本服务已启用密码保护。验证后本次会话不再提示。</p>';
const b=el("button",{class:"btn primary",text:"输入密码"}); b.onclick=()=>askForPassword();
dz.appendChild(b);
w.appendChild(dz); c.appendChild(w);
return;
}
if(!state.sel){ renderDropzone(c); return; }
const t = state.tasks.find(x=>x.id===state.sel);
if(!t){ renderDropzone(c); return; }
const body=el("div",{class:"detail-body"});
const title=el("div",{class:"detail-title"});
title.appendChild(el("h2",{text:t.original_name||t.id}));
title.appendChild(el("span",{class:"badge b-"+t.status,text:t.status}));
body.appendChild(title);
body.appendChild(el("div",{class:"detail-id",text:"id: "+t.id}));
const kv=el("div",{class:"kv"});
cell(kv,"创建",fmtTime(t.created_at));
cell(kv,"更新",fmtTime(t.updated_at));
cell(kv,"时长",t.result?fmtDur(t.result.duration):"—");
cell(kv,"尝试",t.attempts+"/"+(t.max_retries+1));
cell(kv,"语种",t.result&&t.result.lang?t.result.lang.replace(/[<>|]/g,""):"—");
cell(kv,"情绪",t.result&&t.result.emotion?t.result.emotion.replace(/[<>|]/g,""):"—");
body.appendChild(kv);
const act=el("div",{class:"actions"});
if(t.status==="failed"||t.status==="skipped"||t.status==="completed"){
act.appendChild(btn("重试","retry",()=>doAction(t.id,"retry")));
}
if(t.status==="failed"||t.status==="processing"||t.status==="pending"){
act.appendChild(btn("放弃 (继续队列)","skip ghost",()=>doAction(t.id,"skip")));
}
act.appendChild(btn("删除任务","danger",()=>{ if(confirm("删除该任务及其音频?")) doAction(t.id,"delete"); }));
body.appendChild(act);
if(t.error){
const lbl=el("div",{class:"section-label",text:"错误信息"});
body.appendChild(lbl);
body.appendChild(el("div",{class:"errbox",text:t.error}));
}
const r=t.result;
if(r){
const sl=el("div",{class:"section-label"}); sl.appendChild(document.createTextNode("转写全文"));
sl.appendChild(mkCopyBtn("复制全文",()=>r.text||""));
body.appendChild(sl);
body.appendChild(el("div",{class:"transcript"+(r.text?"":" empty"),text:r.text||"(无识别内容)"}));
if(r.segments&&r.segments.length){
body.appendChild(el("div",{class:"section-label",text:"分段 ("+r.segments.length+")"}));
const segs=el("div",{class:"transcript"});
r.segments.forEach((s,i)=>{
const row=el("div",{class:"seg"});
const tsLabel="["+(s.start?secs(s.start):"--")+" → "+(s.end!=null?secs(s.end):"--")+"]";
const ts=mkCopyBtn(tsLabel,()=>tsLabel);
ts.classList.add("ts"); ts.setAttribute("title","复制时间轴");
ts.innerHTML=COPY_ICON+'<span class="cb-label">'+tsLabel+'</span>';
const tx=el("div",{class:"stext"});
tx.append(document.createTextNode(s.text||""));
if(s.lang){ const L=el("span",{class:"slang",text:s.lang.replace(/[<>|]/g,"")}); tx.appendChild(L); }
const cb=mkCopyBtn("复制",()=>s.text||""); cb.classList.add("seg-copy");
row.appendChild(ts); row.appendChild(tx); row.appendChild(cb); segs.appendChild(row);
});
body.appendChild(segs);
}
} else if(t.status==="processing"||t.status==="pending"){
body.appendChild(el("div",{class:"section-label"}));
const sp=el("div",{class:"skeleton"}); sp.innerHTML='<span class="spinner"></span> 正在转写,请稍候…'; body.appendChild(sp);
}
c.appendChild(body);
}
function cell(parent,k,v){ const c=el("div",{class:"cell"}); c.appendChild(el("div",{class:"k",text:k})); c.appendChild(el("div",{class:"v",text:String(v)})); parent.appendChild(c); }
function btn(label,cls,fn){ const b=el("button",{class:"btn "+cls,text:label}); b.onclick=fn; return b; }
function secs(x){ const m=Math.floor(x/60),s=(x%60).toFixed(1); return m+":"+s.padStart(4,"0"); }
function emptyState(title,sub){ const w=el("div",{class:"detail-empty"}); const d=el("div",{class:"dropzone"}); d.innerHTML=`<h3 style="margin:0">${title}</h3><p style="margin:6px 0 0">${sub}</p>`; w.appendChild(d); return w; }
function renderDropzone(c){
const w=el("div",{class:"detail-empty"});
const dz=el("div",{class:"dropzone"});
dz.innerHTML=`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4M5 11l7-7 7 7M4 20h16"/></svg><h3>拖拽音频或视频到这里,或点击上传</h3><p>音频直接转写;视频会自动提取音轨。约 1~3 秒出结果。</p><div class="ext">音频 .wav/.mp3/.flac/.ogg/.m4a/.aac/.opus · 视频 .mp4/.mkv/.mov/.avi/.ts…</div>`;
dz.onclick=()=>$("#file").click();
dz.addEventListener("dragover",e=>{e.preventDefault();dz.classList.add("drag")});
dz.addEventListener("dragleave",()=>dz.classList.remove("drag"));
dz.addEventListener("drop",e=>{e.preventDefault();dz.classList.remove("drag"); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
w.appendChild(dz); c.appendChild(w);
}
async function uploadFiles(files){
if(!state.pw){ askForPassword("请先输入访问密码后再上传。"); return; }
if(!files||!files.length) return;
for(const f of files){
const fd=new FormData(); fd.append("file",f);
toast("上传中: "+f.name);
try{
const t=await api("/api/tasks",{method:"POST",body:fd});
toast("已加入队列: "+(t.original_name||t.id),"ok");
state.sel=t.id;
}catch(e){ toast(e.message,"err"); }
}
await loadTasks();
}
$("#file").addEventListener("change",e=>{ if(e.target.files.length) uploadFiles(e.target.files); e.target.value=""; });
$("#uploadBtn").addEventListener("click",()=>$("#file").click());
// Tab switching: console <-> API reference.
document.querySelectorAll(".tab").forEach(t=>t.addEventListener("click",()=>{
document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
t.classList.add("active");
const v=t.dataset.view;
$("#consoleView").classList.toggle("hidden", v!=="console");
$("#apiView").classList.toggle("hidden", v!=="api");
}));
$("#apiBase").textContent = location.origin;
// global drop
window.addEventListener("dragover",e=>e.preventDefault());
window.addEventListener("drop",e=>{e.preventDefault(); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
async function doAction(id,action){
const path = action==="delete" ? `/api/tasks/${id}` : `/api/tasks/${id}/${action}`;
const method = action==="delete"?"DELETE":"POST";
try{
await api(path,{method});
toast(action==="delete"?"已删除":action==="retry"?"已重新排队":"已放弃","ok");
if(action==="delete"){ if(state.sel===id) state.sel=null; }
await loadTasks();
}catch(e){ toast(e.message,"err"); }
}
async function refresh(){ await Promise.all([loadHealth(), loadTasks()]); }
// If a saved password exists, try it silently; a 401 will reopen the modal.
// Otherwise prompt for it up front via the modal.
if(state.pw){ refresh(); } else { loadHealth(); openAuthModal("请输入访问密码。验证后本次会话不再提示。"); }
// Background refresh — skip task fetches while the password modal is open to
// avoid spamming 401s; health still updates.
setInterval(()=>{ if(state.authing){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500);
</script>
</body>
</html>