@@ -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 : 8 px ;
}
/* 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 : 14 px ; 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 : 2 px ; padding : 0 12 px ; border-bottom : 1 px solid var ( - - border ) ; background : var ( - - surface ) }
. tab { height : 38 px ; padding : 0 14 px ; font-size : 13 px ; color : var ( - - muted ) ; border-bottom : 2 px solid transparent }
. tab : hover { color : var ( - - text ) }
. tab . active { color : var ( - - text ) ; border-bottom-color : var ( - - accent ) }
. tab : focus-visible { outline : 2 px solid var ( - - accent ) ; outline-offset : -2 px }
/* ---- API reference view ---- */
. api-view { flex : 1 ; min-height : 0 ; overflow : auto }
. api-body { max-width : 880 px ; margin : 0 auto ; padding : 24 px 28 px 60 px }
. api-body h2 { margin : 0 0 4 px ; font-size : 20 px }
. api-intro { margin : 0 0 20 px ; color : var ( - - muted ) ; font-size : 13 px ; line-height : 1.7 }
. api-intro code { background : var ( - - surface -2 ) ; border : 1 px solid var ( - - border - soft ) ; border-radius : 5 px ; padding : 1 px 6 px ; font-family : var ( - - mono ) ; font-size : 12 px ; color : var ( - - text ) }
. api-section { margin : 24 px 0 }
. api-section h3 { margin : 0 0 10 px ; font-size : 14 px ; color : var ( - - text ) }
. api-section h3 . tag { margin-left : 8 px ; font-size : 10.5 px ; font-family : var ( - - mono ) ; color : var ( - - accent ) ; background : var ( - - accent - soft ) ; border-radius : 4 px ; padding : 1 px 6 px ; vertical-align : middle }
. api-tbl { width : 100 % ; border-collapse : collapse ; font-size : 13 px }
. api-tbl th , . api-tbl td { text-align : left ; padding : 8 px 10 px ; border-bottom : 1 px solid var ( - - border - soft ) ; vertical-align : top }
. api-tbl th { color : var ( - - muted ) ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .06 em ; font-weight : 500 }
. api-tbl td . m { font-family : var ( - - mono ) ; font-size : 11.5 px ; 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 : 12 px }
. code { background : var ( - - surface ) ; border : 1 px solid var ( - - border - soft ) ; border-radius : 8 px ; padding : 14 px 16 px ; overflow : auto ; font-family : var ( - - mono ) ; font-size : 12.5 px ; line-height : 1.6 ; white-space : pre ; color : var ( - - text ) ; margin : 10 px 0 }
. code . c { color : var ( - - muted ) }
/* ---- top bar ---- */
. topbar {
height : 52 px ; display : flex ; align-items : center ; gap : 14 px ;
padding : 0 16 px ; border-bottom : 1 px solid var ( - - border ) ;
background : linear-gradient ( 180 deg , var ( - - tb1 ) , var ( - - tb2 ) ) ;
position : sticky ; top : 0 ; z-index : 20 ;
}
. brand { display : flex ; align-items : center ; gap : 9 px ; font-weight : 600 ; letter-spacing : .2 px ; white-space : nowrap }
. brand . mark {
width : 22 px ; height : 22 px ; border-radius : 6 px ;
background : linear-gradient ( 135 deg , var ( - - accent ) , #7b61ff ) ;
display : grid ; place-items : center ; color : #fff ; font-size : 12 px ; font-weight : 700 ;
font-family : var ( - - mono ) ;
}
. brand small { color : var ( - - muted ) ; font-weight : 400 ; font-size : 12 px ; font-family : var ( - - mono ) }
. health { display : flex ; align-items : center ; gap : 6 px ; font-size : 12 px ; color : var ( - - muted ) ; font-family : var ( - - mono ) }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; background : var ( - - faint ) ; flex : none }
. dot . ok { background : var ( - - ok ) ; box-shadow : 0 0 0 3 px rgba ( 63 , 185 , 80 , .16 ) }
. dot . down { background : var ( - - err ) ; box-shadow : 0 0 0 3 px rgba ( 248 , 81 , 73 , .16 ) }
. dot . live { background : var ( - - warn ) ; box-shadow : 0 0 0 3 px rgba ( 210 , 153 , 34 , .16 ) ; animation : pulse 1.6 s 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 : 20 px }
[ data-theme = "light" ] . modal { background : rgba ( 0 , 0 , 0 , .25 ) }
. modal . hidden { display : none }
. modal-card { background : var ( - - surface ) ; border : 1 px solid var ( - - border ) ; border-radius : 14 px ; padding : 24 px ; width : min ( 380 px , 92 vw ) ; box-shadow : 0 24 px 60 px rgba ( 0 , 0 , 0 , .5 ) }
. modal-card h3 { margin : 0 0 6 px ; font-size : 17 px ; font-weight : 600 }
. modal-msg { margin : 0 0 16 px ; color : var ( - - muted ) ; font-size : 13 px ; line-height : 1.5 }
. modal-msg . err { color : var ( - - err ) }
. modal-field { display : flex ; align-items : center ; gap : 8 px ; background : var ( - - bg ) ; border : 1 px solid var ( - - border ) ; border-radius : 8 px ; padding : 7 px 10 px ; margin-bottom : 16 px }
. modal-field : focus-within { border-color : var ( - - accent ) }
. modal-field svg { width : 15 px ; height : 15 px ; color : var ( - - muted ) ; flex : none }
. modal-field input { flex : 1 ; background : none ; border : none ; outline : none ; font-size : 14 px ; font-family : var ( - - mono ) }
. modal-field input :: placeholder { color : var ( - - faint ) }
. modal-actions { display : flex ; justify-content : flex-end ; gap : 8 px }
. btn {
display : inline-flex ; align-items : center ; gap : 6 px ;
height : 30 px ; padding : 0 12 px ; border-radius : var ( - - r ) ; font-size : 13 px ; font-weight : 500 ;
border : 1 px solid var ( - - border ) ; background : var ( - - surface -2 ) ; color : var ( - - text ) ;
transition : background .12 s , border-color .12 s ;
}
. btn : hover { background : var ( - - surface -3 ) ; border-color : #3a4250 }
. btn : focus-visible { outline : 2 px solid var ( - - accent ) ; outline-offset : 1 px }
. 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 : 14 px ; height : 14 px }
. iconbtn { width : 30 px ; height : 30 px ; padding : 0 ; justify-content : center ; color : var ( - - muted ) }
. iconbtn : hover { color : var ( - - text ) }
. iconbtn svg { width : 16 px ; height : 16 px }
. iconbtn : focus-visible { outline : 2 px solid var ( - - accent ) ; outline-offset : 1 px }
/* ---- layout ---- */
. layout { display : flex ; flex : 1 ; min-height : 0 }
. pane { display : flex ; flex-direction : column ; min-height : 0 }
. list-pane { width : 360 px ; flex : none ; border-right : 1 px solid var ( - - border ) ; background : var ( - - bg ) }
. detail-pane { flex : 1 ; min-width : 0 ; background : var ( - - bg ) }
. pane-head {
height : 42 px ; display : flex ; align-items : center ; gap : 8 px ; padding : 0 14 px ; flex : none ;
border-bottom : 1 px solid var ( - - border - soft ) ; color : var ( - - muted ) ;
font-size : 12 px ; text-transform : uppercase ; letter-spacing : .08 em ;
}
. 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 : 10 px ; align-items : flex-start ; padding : 11 px 14 px ;
border-bottom : 1 px solid var ( - - border - soft ) ; cursor : pointer ; border-left : 2 px solid transparent ;
}
. task : hover { background : var ( - - surface ) }
. task . selected { background : var ( - - surface -2 ) ; border-left-color : var ( - - accent ) }
. task . sdot { margin-top : 5 px ; flex : none }
. task . main { min-width : 0 ; flex : 1 }
. task . name { font-size : 13.5 px ; color : var ( - - text ) ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap }
. task . meta { margin-top : 2 px ; display : flex ; gap : 8 px ; align-items : center ; font-size : 11.5 px ; color : var ( - - muted ) ; font-family : var ( - - mono ) }
. badge { font-size : 10.5 px ; padding : 1 px 6 px ; border-radius : 4 px ; font-family : var ( - - mono ) ; letter-spacing : .04 em }
. 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 : 40 px }
. dropzone {
border : 1.5 px dashed var ( - - border ) ; border-radius : 14 px ; padding : 46 px 30 px ; max-width : 440 px ;
background : var ( - - surface ) ; transition : border-color .15 s , background .15 s ;
}
. dropzone . drag { border-color : var ( - - accent ) ; background : var ( - - accent - soft ) }
. dropzone svg { width : 34 px ; height : 34 px ; color : var ( - - muted ) }
. dropzone h3 { margin : 14 px 0 4 px ; font-size : 16 px }
. dropzone p { margin : 0 ; color : var ( - - muted ) ; font-size : 13 px }
. dropzone . btn { margin-top : 16 px }
. link { color : var ( - - accent ) ; cursor : pointer ; text-decoration : none }
. link : hover { text-decoration : underline }
. dropzone . ext { margin-top : 10 px ; font-family : var ( - - mono ) ; font-size : 11 px ; color : var ( - - faint ) }
. detail-body { padding : 18 px 24 px 60 px ; max-width : 920 px }
. detail-title { display : flex ; align-items : center ; gap : 10 px ; flex-wrap : wrap }
. detail-title h2 { margin : 0 ; font-size : 18 px ; font-weight : 600 ; word-break : break-all }
. detail-title . badge { font-size : 11 px }
. detail-id { margin-top : 4 px ; font-family : var ( - - mono ) ; font-size : 11.5 px ; color : var ( - - faint ) }
. kv { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 120 px , 1 fr ) ) ; gap : 10 px ; margin : 18 px 0 }
. kv . cell { background : var ( - - surface ) ; border : 1 px solid var ( - - border - soft ) ; border-radius : var ( - - r ) ; padding : 9 px 11 px }
. kv . k { font-size : 10.5 px ; text-transform : uppercase ; letter-spacing : .07 em ; color : var ( - - muted ) }
. kv . v { margin-top : 2 px ; font-family : var ( - - mono ) ; font-size : 13 px }
. actions { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin : 18 px 0 4 px }
. section-label { margin : 26 px 0 10 px ; font-size : 11 px ; text-transform : uppercase ; letter-spacing : .08 em ; color : var ( - - muted ) ; display : flex ; align-items : center ; gap : 8 px }
. section-label :: after { content : "" ; flex : 1 ; height : 1 px ; background : var ( - - border - soft ) }
. transcript {
background : var ( - - surface ) ; border : 1 px solid var ( - - border - soft ) ; border-radius : var ( - - r ) ;
padding : 16 px 18 px ; font-size : 15 px ; line-height : 1.7 ; white-space : pre-wrap ; word-break : break-word ;
}
. transcript . empty { color : var ( - - faint ) ; font-style : italic }
. seg {
display : flex ; gap : 14 px ; padding : 11 px 0 ; border-bottom : 1 px solid var ( - - border - soft ) ;
}
. seg : last-child { border-bottom : none }
. seg . ts {
font-family : var ( - - mono ) ; font-size : 11.5 px ; color : var ( - - accent ) ; flex : none ;
white-space : nowrap ; align-self : flex-start ;
display : inline-flex ; align-items : center ; gap : 4 px ;
background : var ( - - surface -2 ) ; border : 1 px solid var ( - - border - soft ) ; border-radius : 6 px ;
padding : 2 px 8 px ; cursor : pointer ;
transition : color .12 s , border-color .12 s , background .12 s ;
}
. seg . ts : hover { border-color : var ( - - accent ) ; color : var ( - - accent ) ; background : var ( - - accent - soft ) }
. seg . ts : focus-visible { outline : 2 px solid var ( - - accent ) ; outline-offset : 1 px }
. seg . ts . copied { color : var ( - - ok ) ; border-color : var ( - - ok ) }
. seg . ts svg { width : 11 px ; height : 11 px ; 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.5 px ; color : var ( - - faint ) ; margin-left : 8 px }
. copy-btn {
height : 24 px ; padding : 0 10 px ; font-size : 11.5 px ; border-radius : 6 px ;
border : 1 px solid var ( - - border ) ; background : var ( - - surface ) ; color : var ( - - muted ) ;
display : inline-flex ; align-items : center ; gap : 5 px ; flex : none ; cursor : pointer ;
transition : color .12 s , border-color .12 s , background .12 s ;
}
. copy-btn : hover { color : var ( - - text ) ; border-color : #3a4250 ; background : var ( - - surface -2 ) }
. copy-btn : focus-visible { outline : 2 px solid var ( - - accent ) ; outline-offset : 1 px }
. copy-btn . copied { color : var ( - - ok ) ; border-color : var ( - - ok ) }
. copy-btn svg { width : 12 px ; height : 12 px }
. seg-copy { margin-left : 6 px ; align-self : flex-start }
. errbox { background : rgba ( 248 , 81 , 73 , .1 ) ; border : 1 px solid rgba ( 248 , 81 , 73 , .3 ) ; border-radius : var ( - - r ) ; padding : 10 px 12 px ; color : var ( - - err ) ; font-size : 13 px ; font-family : var ( - - mono ) ; white-space : pre-wrap ; word-break : break-word }
/* ---- toast ---- */
# toast { position : fixed ; bottom : 18 px ; left : 50 % ; transform : translateX ( -50 % ) ; z-index : 50 ; display : flex ; flex-direction : column ; gap : 8 px ; align-items : center }
. toast { background : var ( - - surface -3 ) ; border : 1 px solid var ( - - border ) ; color : var ( - - text ) ; padding : 8 px 14 px ; border-radius : var ( - - r ) ; font-size : 13 px ; box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , .4 ) ; animation : tin .2 s 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 ( 6 px ) } to { opacity : 1 ; transform : none } }
@ media ( prefers-reduced-motion : reduce ) { . toast { animation : none } }
. spinner { width : 14 px ; height : 14 px ; border : 2 px solid var ( - - border ) ; border-top-color : var ( - - accent ) ; border-radius : 50 % ; animation : spin .7 s linear infinite }
@ keyframes spin { to { transform : rotate ( 360 deg ) } }
@ media ( prefers-reduced-motion : reduce ) { . spinner { animation : none } }
. skeleton { color : var ( - - faint ) ; font-size : 13 px ; display : flex ; align-items : center ; gap : 8 px ; padding : 14 px }
@ media ( max-width : 860px ) {
. layout { flex-direction : column }
. list-pane { width : 100 % ; height : 42 % ; border-right : none ; border-bottom : 1 px 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 >