651 lines
36 KiB
HTML
651 lines
36 KiB
HTML
<!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://<host>:8080</code><br>
|
||
鉴权(除 <code>/healthz</code> 外):<code>X-Api-Key</code> · <code>Authorization: Bearer <pw></code> · <code>?token=<pw></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/<id>
|
||
curl -H "X-Api-Key: $PW" http://localhost:8080/api/tasks/<id>/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=>({"&":"&","<":"<",">":">"}[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>
|