improve upload UX: progress bar + client-side size limit

This commit is contained in:
2026-09-09 17:11:26 +08:00
parent 7010da8855
commit ab6bf8b370
2 changed files with 51 additions and 9 deletions
+4 -3
View File
@@ -19,9 +19,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
// handleHealthz reports liveness and model readiness (public).
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"models": s.manager != nil && s.manager.Ready(),
"version": s.modelVersion(),
"status": "ok",
"models": s.manager != nil && s.manager.Ready(),
"version": s.modelVersion(),
"max_upload_bytes": s.cfg.MaxUploadBytes,
})
}
+47 -6
View File
@@ -165,6 +165,18 @@
.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}
@@ -356,7 +368,7 @@ const fmtTime = iso=>{ if(!iso) return "—"; const d=new Date(iso); return isNa
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 };
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");
@@ -432,6 +444,9 @@ 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){
@@ -464,6 +479,7 @@ function mkCopyBtn(label, getText){
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"; }
@@ -599,16 +615,41 @@ function renderDropzone(c){
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);
toast("上传中: "+f.name);
try{
const t=await api("/api/tasks",{method:"POST",body:fd});
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){ toast(e.message,"err"); }
}catch(e){ st.textContent="失败: "+e.message; item.classList.add("err"); toast(e.message,"err"); }
}
await loadTasks();
// 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());
@@ -644,7 +685,7 @@ async function refresh(){ await Promise.all([loadHealth(), loadTasks()]); }
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);
setInterval(()=>{ if(state.authing||state.uploading){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500);
</script>
</body>
</html>