Files
record-asr/internal/server/ui/index.html
T

692 lines
39 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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)}
.upload-list{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--surface);border:1px solid var(--border);border-radius:12px;margin-bottom:16px}
.upload-item{display:flex;flex-direction:column;gap:6px}
.upload-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.upload-name{font-size:13px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.upload-pct{font-size:12px;font-family:var(--mono);color:var(--accent);flex:none}
.upload-bar{height:6px;background:var(--border);border-radius:3px;overflow:hidden}
.upload-bar>div{height:100%;width:0%;background:var(--accent);border-radius:3px;transition:width .15s ease}
.upload-status{font-size:11px;color:var(--muted)}
.upload-item.done .upload-status{color:var(--accent)}
.upload-item.done .upload-bar>div{background:var(--ok,#22c55e)}
.upload-item.err .upload-status{color:var(--err)}
.upload-item.err .upload-bar>div{background:var(--err)}
.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, uploading:false, maxUpload:0 };
// --- 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); }
// fmtSize formats a byte count as a human-readable string.
function fmtSize(b){ if(b<1024) return b+"B"; if(b<1048576) return (b/1024).toFixed(1)+"KB"; if(b<1073741824) return (b/1048576).toFixed(1)+"MB"; return (b/1073741824).toFixed(2)+"GB"; }
// 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");
state.maxUpload = h.max_upload_bytes || 0;
$("#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;
state.uploading=true;
const detail=$("#detail");
let list=$("#uploadList");
if(!list) list=el("div",{class:"upload-list",id:"uploadList"});
detail.prepend(list);
for(const f of files){
if(state.maxUpload && f.size>state.maxUpload){ toast("文件过大: "+f.name+"("+fmtSize(f.size)+"),上限 "+fmtSize(state.maxUpload),"err"); continue; }
const item=el("div",{class:"upload-item"});
item.innerHTML=`<div class="upload-head"><span class="upload-name"></span><span class="upload-pct">0%</span></div><div class="upload-bar"><div></div></div><div class="upload-status">上传中…</div>`;
item.querySelector(".upload-name").textContent=f.name;
list.appendChild(item);
const bar=item.querySelector(".upload-bar>div"), pct=item.querySelector(".upload-pct"), st=item.querySelector(".upload-status");
const fd=new FormData(); fd.append("file",f);
try{
const t=await new Promise((res,rej)=>{
const x=new XMLHttpRequest();
x.open("POST","/api/tasks");
x.setRequestHeader("X-Api-Key",state.pw);
x.upload.onprogress=e=>{ if(e.lengthComputable){ const p=Math.round(e.loaded/e.total*100); bar.style.width=p+"%"; pct.textContent=p+"%"; } };
x.onload=()=>{
if(x.status>=200&&x.status<300){ try{res(JSON.parse(x.responseText));}catch(e){res(null);} }
else if(x.status===401){ state.pw=""; localStorage.removeItem("asr_pw"); openAuthModal("密码不正确或服务已重启,请重新输入。",true); rej(new Error("unauthorized")); }
else { let m="HTTP "+x.status; try{m=(JSON.parse(x.responseText).error)||m;}catch(e){} rej(new Error(m)); }
};
x.onerror=()=>rej(new Error("网络错误,上传失败"));
x.send(fd);
});
bar.style.width="100%"; pct.textContent="100%";
st.textContent="已入队,转写排队中…"; item.classList.add("done");
toast("已加入队列: "+(t.original_name||t.id),"ok");
state.sel=t.id;
}catch(e){ st.textContent="失败: "+e.message; item.classList.add("err"); toast(e.message,"err"); }
}
// Let the user see the finished state briefly, then refresh the queue.
setTimeout(()=>{ state.uploading=false; loadTasks(); }, 600);
}
$("#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||state.uploading){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500);
</script>
</body>
</html>