From 70231f9cf3e68bf8790cfd771a0116d94512fda8 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 02:14:26 +0800 Subject: [PATCH] feat: add mobile transfer and drag upload --- chfs-ng/go.mod | 1 + chfs-ng/go.sum | 2 + chfs-ng/internal/api/server.go | 97 +++++++++++++++++++++++++++++ chfs-ng/internal/api/web/index.html | 12 ++-- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/chfs-ng/go.mod b/chfs-ng/go.mod index bef9fda..3668e8e 100644 --- a/chfs-ng/go.mod +++ b/chfs-ng/go.mod @@ -7,6 +7,7 @@ require ( github.com/anthonynsimon/bild v0.14.0 github.com/emersion/go-webdav v0.7.0 github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e ) require golang.org/x/sys v0.3.0 // indirect diff --git a/chfs-ng/go.sum b/chfs-ng/go.sum index ab668a5..6c1864d 100644 --- a/chfs-ng/go.sum +++ b/chfs-ng/go.sum @@ -8,6 +8,8 @@ github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3T github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index ebf9829..fb8b9a9 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -7,6 +7,7 @@ import ( _ "embed" "encoding/json" "fmt" + "github.com/skip2/go-qrcode" "html" "image" _ "image/gif" @@ -85,6 +86,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/chfs/upload2", s.upload2) mux.HandleFunc("/chfs/search", s.search) mux.HandleFunc("/chfs/downloaddir/", s.downloadDir) + mux.HandleFunc("/chfs/downloadzip", s.downloadSelectedZip) + mux.HandleFunc("/chfs/qrcode", s.qrcode) mux.HandleFunc("/chfs/shared", s.shared) mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/preview", s.preview) @@ -1093,6 +1096,100 @@ func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, p string, att http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } +func (s *Server) qrcode(w http.ResponseWriter, r *http.Request) { + u := r.URL.Query().Get("url") + if u == "" || len(u) > 4096 { + writeJSONError(w, http.StatusBadRequest, "invalid url") + return + } + data, err := qrcode.Encode(u, qrcode.Medium, 320) + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "qrcode failed") + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(data) +} + +func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + paths := r.URL.Query()["path"] + if len(paths) == 0 || len(paths) > 100 { + writeJSONError(w, http.StatusBadRequest, "invalid selection") + return + } + seen := map[string]bool{} + for i, raw := range paths { + p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") + if p == "" || unsafePath(p) || seen[p] { + writeJSONError(w, http.StatusBadRequest, "invalid selection") + return + } + seen[p] = true + paths[i] = p + if !a.MaskFor(p).HasRead() { + writeJSONError(w, http.StatusForbidden, "has no privilege") + return + } + if _, err := s.Store.Stat(p); err != nil { + http.NotFound(w, r) + return + } + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) + zw := zip.NewWriter(w) + defer zw.Close() + added := map[string]bool{} + var walk func(string, string) error + walk = func(p, prefix string) error { + fi, err := s.Store.Stat(p) + if err != nil { + return err + } + if fi.IsDir() { + items, err := s.Store.List(p) + if err != nil { + return err + } + for _, it := range items { + child := path.Join(p, it.Name()) + if !a.MaskFor(child).HasRead() { + continue + } + if err := walk(child, path.Join(prefix, it.Name())); err != nil { + return err + } + } + return nil + } + if added[prefix] { + return nil + } + added[prefix] = true + f, err := s.Store.Open(p) + if err != nil { + return err + } + defer f.Close() + h := &zip.FileHeader{Name: prefix, Method: zip.Deflate} + h.SetModTime(fi.ModTime()) + wz, err := zw.CreateHeader(h) + if err != nil { + return err + } + _, err = io.Copy(wz, f) + return err + } + for _, p := range paths { + if err := walk(p, path.Base(p)); err != nil { + + return + } + } +} + func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawPath := strings.TrimPrefix(r.URL.Path, "/chfs/downloaddir/") diff --git a/chfs-ng/internal/api/web/index.html b/chfs-ng/internal/api/web/index.html index e3a5195..63b924b 100644 --- a/chfs-ng/internal/api/web/index.html +++ b/chfs-ng/internal/api/web/index.html @@ -21,7 +21,7 @@ body{background:var(--bg);color:var(--text);transition:background .2s,color .2s} button.ghost{background:var(--surface2);color:var(--text)}button.danger{background:var(--danger)}.meta,.path{color:var(--muted)} .card{box-shadow:var(--shadow)}th{background:var(--surface2);color:var(--muted)}th,td{border-color:var(--border)}.field input,.toolbar input,.field select{background:var(--surface);color:var(--text);border-color:var(--border)} .iconbtn{width:38px;height:38px;padding:0;background:var(--surface2);color:var(--text)}.viewbtn.active{background:var(--primary);color:#fff}.selectbox{width:18px;height:18px;accent-color:var(--primary)} -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.drop-active{border-color:var(--primary)!important;background:var(--surface2)!important}.qr{width:min(320px,80vw);aspect-ratio:1;margin:auto;display:block;border-radius:12px;background:#fff}.drop-hint{color:var(--muted);font-size:13px;margin-top:7px}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)} @@ -31,11 +31,12 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
+
__CHFS_NOTICE__
- +
@@ -65,7 +66,7 @@ function setView(v){localStorage.setItem('chfs-view',v);$("files").parentElement function itemMenu(e,p){e.preventDefault();const f=lastItems.find(x=>(current?current+'/':'')+x.name===p);const canRead=(f?.mask||'').includes('R'),canWrite=(f?.mask||'').includes('W'),canDelete=(f?.mask||'').includes('D');let html='';if(canRead)html+='';if(f?.dir&&canRead)html+='';if(canWrite)html+='';if(canDelete)html+='';if(f?.guestMask?.includes('R'))html+='';if(!html)html='
没有可用操作
';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+'px';$("context").style.top=Math.min(e.clientY,innerHeight-220)+'px';$("context").style.display='block'} function closeContext(){$("context").style.display='none'} addEventListener('click',e=>{if(!e.target.closest('#context')&&!e.target.closest('.tilemenu'))closeContext()}); -function bulkDownload(){if(!selected.size)return;const first=[...selected][0];if(selected.size===1){downloadFile(first);return}location.href='/chfs/downloaddir/'+encodeURI(first.split('/').slice(0,-1).join('/'))} +function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q} async function bulkDelete(){if(!selected.size)return;if(!await confirmBox("批量删除","确定删除已选择的 "+selected.size+" 项?"))return;for(const p of [...selected]){try{await api('/chfs/rmfiles',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath:p})})}catch(e){alert(p+': '+e.message)}}selected.clear();refresh()} async function showRoots(){try{const d=await api("/chfs/roots");const roots=d.roots||[];let html='

选择位置

';for(const r of roots)html+='';if(!roots.length)html+='
没有可进入的共享位置
';html+='
';show(html)}catch(e){alert(e.message)}} function jumpRoot(p){current=p;closeModal();refresh()} @@ -77,7 +78,10 @@ async function renameItem(p){const name=p.split("/").pop();const n=await ask(" async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}} async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}} async function newText(){const n=await ask("新建文本","文件名","new.txt");if(!n)return;const text=await ask("新建文本","文本内容");if(text===null)return;try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});refresh()}catch(e){alert(e.message)}} -$("upload").addEventListener("change",async e=>{const files=[...e.target.files];if(!files.length)return;show('

上传文件

');const box=$("uploadList");for(const file of files){const row=document.createElement("div");row.className="upload-row";row.innerHTML=''+esc(file.name)+''+fmt(file.size)+' · 准备上传
';box.appendChild(row);await uploadOne(file,row)}e.target.value="";refresh()}) +$("upload").addEventListener("change",async e=>{await uploadFiles(e.target.files);e.target.value=""}) +async function uploadFiles(files){const list=[...files];if(!list.length)return;show('

上传文件

');const box=$("uploadList");for(const file of list){const row=document.createElement("div");row.className="upload-row";row.innerHTML=''+esc(file.name)+''+fmt(file.size)+' · 准备上传
';box.appendChild(row);await uploadOne(file,row)}refresh()} +function showMobileTransfer(){const u=new URL(location.href);u.hash='folder='+encodeURIComponent(current);show('

手机传输

用手机扫描二维码打开当前文件夹。登录后即可按你的权限上传、下载和管理文件。
QR Code
')} +function setupDropZone(){const z=$("dropZone"),main=$("fileCard");let depth=0;[document,main].forEach(el=>{el.addEventListener('dragenter',e=>{if(!e.dataTransfer?.types.includes('Files'))return;e.preventDefault();depth++;z.style.display='block';z.classList.add('drop-active')});el.addEventListener('dragover',e=>{if(e.dataTransfer?.types.includes('Files'))e.preventDefault()});el.addEventListener('dragleave',e=>{if(!e.dataTransfer?.types.includes('Files'))return;depth--;if(depth<=0){depth=0;z.classList.remove('drop-active')}});el.addEventListener('drop',async e=>{if(!e.dataTransfer?.files?.length)return;e.preventDefault();depth=0;z.classList.remove('drop-active');z.style.display='none';await uploadFiles(e.dataTransfer.files)})})} function uploadOne(file,row){return new Promise(resolve=>{const xhr=new XMLHttpRequest(),fd=new FormData();fd.append("folder",current);fd.append("file",file);const meta=row.querySelector('.meta'),bar=row.querySelector('i');xhr.open('POST','/chfs/upload');xhr.upload.onprogress=e=>{if(e.lengthComputable){const pct=Math.round(e.loaded/e.total*100);bar.style.width=pct+'%';meta.textContent=fmt(file.size)+' · '+pct+'%'}};xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300){bar.style.width='100%';meta.textContent=fmt(file.size)+' · 完成'}else{meta.textContent='上传失败';bar.style.width='0'}};xhr.onerror=()=>{meta.textContent='网络错误';bar.style.width='0'};xhr.onloadend=resolve;xhr.send(fd)})} $("search").addEventListener("keydown",async e=>{if(e.key!=="Enter")return;const s=e.target.value.trim();if(!s){refresh();return}try{const d=await api("/chfs/search?str="+encodeURIComponent(s));renderSearch(d.files||[])}catch(x){alert(x.message)}}) function renderSearch(items){$("crumb").textContent="搜索结果";const body=$("files");body.innerHTML="";for(const f of items){const row=document.createElement("tr");row.innerHTML=''+(f.dir?"—":fmt(f.size))+''+esc(f.modified||"")+'';body.appendChild(row)}}