]> acesimba.cloud Git - codebuddy-web.git/commitdiff
feat: 首页“已上传文件”弹窗(表格/多选/全选删除),上传文件关联所属会话任务
authorCodebuddy <codebuddy@localhost>
Sun, 16 Aug 2026 23:30:40 +0000 (07:30 +0800)
committerCodebuddy <codebuddy@localhost>
Sun, 16 Aug 2026 23:30:40 +0000 (07:30 +0800)
backend/app.py
frontend/index.html

index 69c53a026f13a5a5ddf1d8beb3081a8313202256..915d299f98c43ffec7dcd76850496166f007fd38 100644 (file)
@@ -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:
index 07dab50b2b2b8a26ab382da4a664fe25b04ba6f1..069c0e4353d93cd1bab99d034e4a2436c68973a6 100644 (file)
@@ -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; }
 </style>
 </head>
 <body>
@@ -247,6 +264,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
           <button class="ss-btn ss-primary" id="ss-load-confirm">确定加载</button>
         </div>
       </div>
+      <hr style="border:none; border-top:1px solid rgba(255,255,255,0.12); margin:14px 0;">
+      <button class="ss-btn ss-primary" id="btn-uploads" style="width:100%;">📂 已上传文件</button>
     </aside>
     <div class="picker-body">
       <div class="task-grid" id="task-grid"></div>
@@ -351,6 +370,36 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   </div>
 </div>
 
+<!-- 已上传文件 modal(列表页“已上传文件”按钮触发) -->
+<div id="uploads-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:300; align-items:center; justify-content:center;">
+  <div class="up-dialog">
+    <div class="up-head">
+      <div class="up-title">📂 已上传文件</div>
+      <button class="up-close" id="uploads-close">✕</button>
+    </div>
+    <div class="up-bar">
+      <button class="up-del-sel" id="uploads-del-sel" disabled>删除所选</button>
+      <span id="uploads-count" style="color:#888; font-size:12px; margin-left:auto;"></span>
+    </div>
+    <div style="overflow:auto; flex:1; min-height:0;">
+      <table class="up-table" id="uploads-table">
+        <thead>
+          <tr>
+            <th style="width:36px;"><input type="checkbox" id="uploads-select-all"></th>
+            <th>文件名</th>
+            <th>上传时间</th>
+            <th>所属会话任务</th>
+            <th>文件类型</th>
+            <th style="width:60px;">操作</th>
+          </tr>
+        </thead>
+        <tbody id="uploads-tbody"></tbody>
+      </table>
+      <div class="up-empty" id="uploads-empty" style="display:none;">暂无已上传文件</div>
+    </div>
+  </div>
+</div>
+
 <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
@@ -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 =>
+        '<tr>'
+        + '<td><input type="checkbox" class="up-cb" data-name="' + escHtml(f.name) + '"></td>'
+        + '<td class="name">' + escHtml(f.name) + '</td>'
+        + '<td>' + fmtUploadTime(f.mtime) + '</td>'
+        + '<td class="task">' + escHtml(f.task) + '</td>'
+        + '<td>' + escHtml(fmtUploadType(f)) + '</td>'
+        + '<td><button class="up-row-del" data-name="' + escHtml(f.name) + '">删除</button></td>'
+        + '</tr>'
+      ).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');