From: Codebuddy Date: Sun, 16 Aug 2026 23:30:40 +0000 (+0800) Subject: feat: 首页“已上传文件”弹窗(表格/多选/全选删除),上传文件关联所属会话任务 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=f7dfd206c5459021a88a9490267e9b7ebd5cde34;p=codebuddy-web.git feat: 首页“已上传文件”弹窗(表格/多选/全选删除),上传文件关联所属会话任务 --- diff --git a/backend/app.py b/backend/app.py index 69c53a0..915d299 100644 --- a/backend/app.py +++ b/backend/app.py @@ -728,6 +728,30 @@ MAX_UPLOAD_SIZE = 5 * 1024 * 1024 # 5MB(粘贴栏上传上限:图片/视频 _MIME_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp"} +# 上传文件 → 所属会话任务的索引(前端在终端页上传时带上 task_id) +UPLOADS_INDEX = Path(__file__).resolve().parent.parent / "data" / "uploads_index.json" + + +def load_uploads_index() -> dict: + try: + if UPLOADS_INDEX.exists(): + with open(UPLOADS_INDEX, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + pass + return {} + + +def save_uploads_index(idx: dict): + try: + UPLOADS_INDEX.parent.mkdir(parents=True, exist_ok=True) + tmp = str(UPLOADS_INDEX) + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(idx, f, ensure_ascii=False) + os.replace(tmp, UPLOADS_INDEX) + except Exception as e: + logger.warning(f"保存上传索引失败: {e}") + @app.post("/api/upload-image") async def upload_image(request: Request): @@ -759,8 +783,13 @@ async def upload_image(request: Request): fname = f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_{secrets.token_hex(4)}.{ext}" fpath = PASTES_DIR / fname fpath.write_bytes(raw) - logger.info(f"文件上传: {fpath} ({len(raw)} bytes, {mime})") - return {"path": str(fpath), "name": name or fname} + # 记录所属会话任务(前端上传时带 task_id;列表页据此显示“所属会话任务”) + task_id = (body.get("task_id") or "").strip() + idx = load_uploads_index() + idx[fname] = task_id + save_uploads_index(idx) + logger.info(f"文件上传: {fpath} ({len(raw)} bytes, {mime}) task={task_id or '无'}") + return {"path": str(fpath), "name": name or fname, "task_id": task_id} # 已上传文件:列出 / 删除(data/pastes/ 下,供前端“已上传文件”面板使用) @@ -774,6 +803,8 @@ async def list_uploads(request: Request): if not _auth_ok(request): return JSONResponse({"error": "unauthorized"}, status_code=401) try: + tasks = load_tasks() + idx = load_uploads_index() files = [] if PASTES_DIR.exists(): for p in PASTES_DIR.iterdir(): @@ -786,10 +817,15 @@ async def list_uploads(request: Request): kind = "video" elif ext in _ZIP_EXT: kind = "zip" + tid = idx.get(p.name, "") + tname = "" + if tid and tid in tasks: + tname = tasks[tid].get("name", "") or "" st = p.stat() files.append({ "name": p.name, "path": str(p), "size": st.st_size, "mtime": int(st.st_mtime), "kind": kind, + "ext": ext.lstrip("."), "task_id": tid, "task": tname or "—", }) files.sort(key=lambda x: x["mtime"], reverse=True) return {"files": files} @@ -801,8 +837,8 @@ async def list_uploads(request: Request): async def delete_upload(name: str, request: Request): if not _auth_ok(request): return JSONResponse({"error": "unauthorized"}, status_code=401) - # 防路径穿越:只允许纯文件名 - if not name or "/" in name or "\\" in name or name == ".." or name.startswith("."): + # 防路径穿越:只允许纯文件名(同时保护索引文件本身) + if not name or "/" in name or "\\" in name or name == ".." or name.startswith(".") or name.startswith("_"): return JSONResponse({"error": "invalid name"}, status_code=400) try: target = (PASTES_DIR / name).resolve() @@ -811,6 +847,10 @@ async def delete_upload(name: str, request: Request): if not target.exists() or not target.is_file(): return JSONResponse({"error": "not found"}, status_code=404) target.unlink() + idx = load_uploads_index() + if name in idx: + del idx[name] + save_uploads_index(idx) logger.info(f"删除上传文件: {target}") return {"ok": True} except Exception as e: diff --git a/frontend/index.html b/frontend/index.html index 07dab50..069c0e4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -197,6 +197,23 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh #del-modal .del-opt { color:#e0e0e0; font-size:14px; display:flex; align-items:center; gap:8px; cursor:pointer; line-height:1.4; } #del-modal .del-opt input { width:16px; height:16px; flex:none; } #del-modal .del-opt code { color:#4ecca3; font-size:12px; word-break:break-all; } + +/* 已上传文件 modal */ +#uploads-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:300; align-items:center; justify-content:center; } +#uploads-modal .up-dialog { background:#1a1a2e; border:1px solid #74c0fc; border-radius:12px; padding:18px; width:760px; max-width:94vw; max-height:88vh; display:flex; flex-direction:column; } +#uploads-modal .up-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; } +#uploads-modal .up-title { color:#74c0fc; font-size:16px; } +#uploads-modal .up-close { cursor:pointer; color:#ccc; font-size:18px; background:none; border:none; } +#uploads-modal .up-bar { display:flex; gap:8px; align-items:center; margin-bottom:10px; } +#uploads-modal .up-del-sel { background:#ff6b6b; color:#fff; border:none; border-radius:8px; padding:6px 14px; cursor:pointer; font-size:13px; } +#uploads-modal .up-del-sel:disabled { background:rgba(255,107,107,0.3); cursor:not-allowed; } +#uploads-modal .up-table { width:100%; border-collapse:collapse; font-size:13px; overflow:auto; display:block; } +#uploads-modal .up-table th, #uploads-modal .up-table td { text-align:left; padding:8px 10px; border-bottom:1px solid rgba(255,255,255,0.08); } +#uploads-modal .up-table th { color:#74c0fc; position:sticky; top:0; background:#1a1a2e; } +#uploads-modal .up-table td.name { color:#e0e0e0; word-break:break-all; } +#uploads-modal .up-table td.task { color:#4ecca3; } +#uploads-modal .up-empty { color:#888; font-size:13px; padding:20px; text-align:center; } +#uploads-modal .up-row-del { background:none; border:none; color:#ff6b6b; cursor:pointer; font-size:13px; } @@ -247,6 +264,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh +
+
@@ -351,6 +370,36 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
+ + + @@ -814,7 +863,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh const r = await fetch(API + '/api/upload-image', { method: 'POST', headers: Object.assign({}, authHeaders(), { 'Content-Type': 'application/json' }), - body: JSON.stringify({ mime: blob.type, name: name, data: b64 }) + body: JSON.stringify({ mime: blob.type, name: name, data: b64, task_id: TASK_ID || "" }) }); if (!r.ok) { let msg = 'HTTP ' + r.status; @@ -966,6 +1015,93 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh const uploadsRefresh = document.getElementById('uploads-refresh'); if (uploadsRefresh) uploadsRefresh.onclick = refreshUploadsList; + // ========== 首页“已上传文件”弹窗(表格 + 多选/全选删除) ========== + const uploadsModal = document.getElementById('uploads-modal'); + const uploadsTbody = document.getElementById('uploads-tbody'); + const uploadsEmpty = document.getElementById('uploads-empty'); + const uploadsSelectAll = document.getElementById('uploads-select-all'); + const uploadsDelSel = document.getElementById('uploads-del-sel'); + const uploadsCount = document.getElementById('uploads-count'); + + function fmtUploadTime(ts) { + const d = new Date(ts * 1000); + const p = (n) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); + } + function fmtUploadType(f) { + if (f.ext) return '.' + f.ext; + return ({ image: '图片', video: '视频', zip: '压缩包', other: '其他' })[f.kind] || f.kind || ''; + } + function syncUploadsSelState() { + const rows = uploadsTbody.querySelectorAll('tr'); + const checked = uploadsTbody.querySelectorAll('input.up-cb:checked').length; + uploadsDelSel.disabled = checked === 0; + uploadsSelectAll.checked = rows.length > 0 && checked === rows.length; + uploadsCount.textContent = rows.length ? ('共 ' + rows.length + ' 个,已选 ' + checked) : ''; + } + async function refreshUploadsTable() { + try { + const res = await fetch(API + '/api/uploads', { headers: authHeaders() }); + if (!res.ok) { uploadsTbody.innerHTML = ''; uploadsEmpty.style.display = 'block'; uploadsEmpty.textContent = '加载失败'; return; } + const data = await res.json(); + const files = data.files || []; + if (!files.length) { uploadsTbody.innerHTML = ''; uploadsEmpty.style.display = 'block'; uploadsEmpty.textContent = '暂无已上传文件'; syncUploadsSelState(); return; } + uploadsEmpty.style.display = 'none'; + uploadsTbody.innerHTML = files.map(f => + '' + + '' + + '' + escHtml(f.name) + '' + + '' + fmtUploadTime(f.mtime) + '' + + '' + escHtml(f.task) + '' + + '' + escHtml(fmtUploadType(f)) + '' + + '' + + '' + ).join(''); + uploadsTbody.querySelectorAll('.up-cb').forEach(cb => { cb.onchange = syncUploadsSelState; }); + uploadsTbody.querySelectorAll('.up-row-del').forEach(btn => { + btn.onclick = () => deleteUploadFile(btn.dataset.name); + }); + syncUploadsSelState(); + } catch (e) { + uploadsTbody.innerHTML = ''; uploadsEmpty.style.display = 'block'; uploadsEmpty.textContent = '加载失败'; + } + } + async function deleteUploadFile(name) { + if (!confirm('确定删除文件 ' + name + '?')) return; + try { + const res = await fetch(API + '/api/uploads/' + encodeURIComponent(name), { method: 'DELETE', headers: authHeaders() }); + const d = await res.json().catch(() => ({})); + if (!res.ok || !d.ok) throw new Error(d.error || ('HTTP ' + res.status)); + refreshUploadsTable(); + } catch (e) { + alert('删除失败: ' + e.message); + } + } + async function deleteSelectedUploads() { + const names = Array.from(uploadsTbody.querySelectorAll('input.up-cb:checked')).map(cb => cb.dataset.name); + if (!names.length) return; + if (!confirm('确定删除选中的 ' + names.length + ' 个文件?')) return; + let ok = 0, fail = 0; + for (const n of names) { + try { + const res = await fetch(API + '/api/uploads/' + encodeURIComponent(n), { method: 'DELETE', headers: authHeaders() }); + const d = await res.json().catch(() => ({})); + if (res.ok && d.ok) ok++; else fail++; + } catch (_) { fail++; } + } + alert('已删除 ' + ok + ' 个' + (fail ? (',' + fail + ' 个失败') : '')); + refreshUploadsTable(); + } + function openUploadsModal() { uploadsModal.style.display = 'flex'; refreshUploadsTable(); } + document.getElementById('btn-uploads').onclick = openUploadsModal; + document.getElementById('uploads-close').onclick = () => { uploadsModal.style.display = 'none'; }; + uploadsModal.addEventListener('click', (e) => { if (e.target === uploadsModal) uploadsModal.style.display = 'none'; }); + uploadsSelectAll.onchange = () => { + uploadsTbody.querySelectorAll('.up-cb').forEach(cb => { cb.checked = uploadsSelectAll.checked; }); + syncUploadsSelState(); + }; + uploadsDelSel.onclick = deleteSelectedUploads; + // ========== 技能管理 ========== const skillsModal = document.getElementById('skills-modal'); const skillsList = document.getElementById('skills-list');