improve upload UX: progress bar + client-side size limit
This commit is contained in:
@@ -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=>({"&":"&","<":"<",">":">"}[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>
|
||||
|
||||
Reference in New Issue
Block a user