_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):
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/ 下,供前端“已上传文件”面板使用)
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():
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}
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()
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:
#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>
<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>
</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>
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;
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');