]> acesimba.cloud Git - codebuddy-web.git/commitdiff
任务清单:暂停/恢复 + 受限删除
authorCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:57:20 +0000 (16:57 +0800)
committerCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 08:57:20 +0000 (16:57 +0800)
后端 (backend/app.py):
- GET /api/tasks 返回 status (active/paused) 和 paused_at 字段
- POST /api/tasks/{id}/pause: 标记暂停,记录 paused_at
- POST /api/tasks/{id}/resume: 恢复启用,清除 paused_at
- DELETE /api/tasks/{id}: 仅允许删除已暂停超 24 小时的任务

前端 (frontend/index.html):
- 任务卡片:删除按钮改为暂停(⏸)/恢复(▶)按钮
- 默认仅列出启用状态任务,勾选"显示全部任务"查看暂停的
- 暂停超 24 小时的任务才显示删除按钮(✕)
- 删除确认 modal:3 次确认,每次需等待 10 秒才能点击
- 暂停状态任务卡片半透明显示,标注已暂停时长
- ESC/点击遮罩/取消按钮 关闭删除 modal

backend/app.py
frontend/index.html

index e863092a02fa2136e9e564c8f03ecf9e6edd3751..c9c14aee97de3ec8f3a39eb066b684bfc19431cb 100644 (file)
@@ -195,6 +195,8 @@ async def api_tasks(request: Request):
     for t in data.get("tasks", []):
         tt = dict(t)
         tt["active"] = t["id"] in ACTIVE_TASKS
+        # 兼容旧数据:无 status 字段视为 active
+        tt.setdefault("status", "active")
         tasks.append(tt)
     return JSONResponse({"ok": True, "tasks": tasks})
 
@@ -240,16 +242,69 @@ async def api_delete_task(task_id: str, request: Request):
     if not _auth_ok(request):
         return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
     if task_id in ACTIVE_TASKS:
-        return JSONResponse({"ok": False, "error": "任务正在运行,请先关闭其终端窗口"}, status_code=409)
+        return JSONResponse({"ok": False, "error": "任务正在运行,请先暂停"}, status_code=409)
     data = load_tasks()
-    before = len(data["tasks"])
-    data["tasks"] = [t for t in data["tasks"] if t["id"] != task_id]
-    if len(data["tasks"]) == before:
+    t = find_task(data, task_id)
+    if not t:
         return JSONResponse({"ok": False, "error": "任务不存在"}, status_code=404)
+    # 仅允许删除已暂停超过 24 小时的任务
+    if t.get("status") != "paused":
+        return JSONResponse({"ok": False, "error": "仅可删除已暂停的任务"}, status_code=409)
+    paused_at = t.get("paused_at")
+    if not paused_at:
+        return JSONResponse({"ok": False, "error": "任务暂停时间未知"}, status_code=409)
+    try:
+        paused_dt = datetime.datetime.fromisoformat(paused_at)
+        elapsed = datetime.datetime.now() - paused_dt
+        if elapsed.total_seconds() < 24 * 3600:
+            hours_left = 24 - elapsed.total_seconds() / 3600
+            return JSONResponse({"ok": False, "error": f"暂停不足 24 小时,还需 {hours_left:.1f} 小时"}, status_code=409)
+    except (ValueError, TypeError):
+        return JSONResponse({"ok": False, "error": "暂停时间格式错误"}, status_code=409)
+    data["tasks"] = [x for x in data["tasks"] if x["id"] != task_id]
     save_tasks(data)
+    logger.info(f"删除任务: id={task_id}, name={t.get('name')}")
     return JSONResponse({"ok": True})
 
 
+@app.post("/api/tasks/{task_id}/pause")
+async def api_pause_task(task_id: str, request: Request):
+    """暂停任务:仅标记状态,不删除任何数据。运行中的任务需先关闭终端窗口。"""
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
+    if task_id in ACTIVE_TASKS:
+        return JSONResponse({"ok": False, "error": "任务正在运行,请先关闭其终端窗口"}, status_code=409)
+    data = load_tasks()
+    t = find_task(data, task_id)
+    if not t:
+        return JSONResponse({"ok": False, "error": "任务不存在"}, status_code=404)
+    if t.get("status") == "paused":
+        return JSONResponse({"ok": False, "error": "任务已是暂停状态"}, status_code=409)
+    t["status"] = "paused"
+    t["paused_at"] = now_iso()
+    save_tasks(data)
+    logger.info(f"暂停任务: id={task_id}, name={t.get('name')}")
+    return JSONResponse({"ok": True, "task": t})
+
+
+@app.post("/api/tasks/{task_id}/resume")
+async def api_resume_task(task_id: str, request: Request):
+    """恢复任务:从暂停状态切回启用。"""
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
+    data = load_tasks()
+    t = find_task(data, task_id)
+    if not t:
+        return JSONResponse({"ok": False, "error": "任务不存在"}, status_code=404)
+    if t.get("status", "active") == "active":
+        return JSONResponse({"ok": False, "error": "任务已是启用状态"}, status_code=409)
+    t["status"] = "active"
+    t.pop("paused_at", None)
+    save_tasks(data)
+    logger.info(f"恢复任务: id={task_id}, name={t.get('name')}")
+    return JSONResponse({"ok": True, "task": t})
+
+
 @app.post("/api/kill/{pid}")
 async def api_kill(pid: int, request: Request):
     if not _auth_ok(request):
index 285e8ba0dd2920fedbb39b333c7f0b961553245c..18a3ebc64e23073b8211abc8a0bde28059395425 100644 (file)
@@ -112,10 +112,16 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
 }
 .task-card .tc-enter { background: linear-gradient(135deg, #4ecca3, #38b2ac); color: #1a1a2e; }
 .task-card .tc-enter:hover { opacity: 0.9; }
+.task-card .tc-pause { background: rgba(255,230,109,0.12); color: #ffe66d; border: 1px solid rgba(255,230,109,0.25); flex: 0 0 56px; }
+.task-card .tc-pause:hover { background: rgba(255,230,109,0.22); }
+.task-card .tc-resume { background: rgba(78,204,163,0.12); color: #4ecca3; border: 1px solid rgba(78,204,163,0.25); flex: 0 0 56px; }
+.task-card .tc-resume:hover { background: rgba(78,204,163,0.22); }
 .task-card .tc-del { background: rgba(255,107,107,0.12); color: #ff8787; border: 1px solid rgba(255,107,107,0.25); flex: 0 0 56px; }
 .task-card .tc-del:hover { background: rgba(255,107,107,0.22); }
+.task-card.tc-paused { opacity: 0.6; border-color: rgba(255,230,109,0.3); }
+.task-card .tc-status { font-size: 12px; }
 .task-card.tc-active { border-color: #ffe66d; }
-.task-card.tc-active .tc-status { color: #ffe66d; font-size: 12px; }
+.task-card.tc-active .tc-status { color: #ffe66d; }
 .task-new {
   display: flex; align-items: center; justify-content: center; gap: 8px;
   background: rgba(78,204,163,0.06); border: 2px dashed rgba(78,204,163,0.3);
@@ -124,6 +130,21 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
 }
 .task-new:hover { border-color: #4ecca3; background: rgba(78,204,163,0.12); }
 .picker-empty { color: #666; text-align: center; padding: 60px; font-size: 15px; }
+.picker-toggle { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #aaa; cursor: pointer; user-select: none; }
+.picker-toggle input { cursor: pointer; }
+
+/* 删除确认 modal */
+#del-modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:200; align-items:center; justify-content:center; }
+#del-modal .del-dialog { background:#1a1a2e; border:1px solid #ff6b6b; border-radius:12px; padding:24px; width:440px; max-width:90vw; text-align:center; }
+#del-modal .del-title { color:#ff6b6b; font-size:18px; margin-bottom:12px; }
+#del-modal .del-msg { color:#e0e0e0; font-size:14px; margin-bottom:8px; line-height:1.6; }
+#del-modal .del-warn { color:#ffe66d; font-size:13px; margin-bottom:16px; }
+#del-modal .del-count { color:#4ecca3; font-size:13px; margin-bottom:16px; }
+#del-modal .del-actions { display:flex; gap:10px; justify-content:center; }
+#del-modal .del-btn { padding:10px 24px; border-radius:8px; border:none; cursor:pointer; font-size:14px; font-weight:600; }
+#del-modal .del-cancel { background:rgba(255,255,255,0.08); color:#ccc; }
+#del-modal .del-confirm { background:#ff6b6b; color:#fff; }
+#del-modal .del-confirm:disabled { background:rgba(255,107,107,0.3); cursor:not-allowed; }
 </style>
 </head>
 <body>
@@ -144,13 +165,30 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
 <div id="picker-page">
   <div class="picker-header">
     <h2>任务清单 <small id="picker-subtitle"></small></h2>
-    <button class="action-btn" id="btn-logout-picker">退出登录</button>
+    <div style="display:flex; gap:16px; align-items:center;">
+      <label class="picker-toggle"><input type="checkbox" id="show-all-tasks"> 显示全部任务</label>
+      <button class="action-btn" id="btn-logout-picker">退出登录</button>
+    </div>
   </div>
   <div class="picker-body">
     <div class="task-grid" id="task-grid"></div>
   </div>
 </div>
 
+<!-- 删除确认 modal -->
+<div id="del-modal">
+  <div class="del-dialog">
+    <div class="del-title">⚠ 删除任务</div>
+    <div class="del-msg" id="del-msg"></div>
+    <div class="del-warn">此操作仅删除任务记录,不删除工作目录与历史会话。</div>
+    <div class="del-count" id="del-count"></div>
+    <div class="del-actions">
+      <button class="del-btn del-cancel" id="del-cancel">取消</button>
+      <button class="del-btn del-confirm" id="del-confirm" disabled>确认删除</button>
+    </div>
+  </div>
+</div>
+
 <!-- 终端页 -->
 <div id="terminal-page" style="display:none; height:100vh; flex-direction:column;">
   <div class="top-bar">
@@ -286,35 +324,61 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     await renderTasks();
   }
 
+  let showAllTasks = false;
+
   async function renderTasks() {
     const tasks = await fetchTasks();
     if (!tasks) return;
     const grid = document.getElementById('task-grid');
-    document.getElementById('picker-subtitle').textContent = '共 ' + tasks.length + ' 个任务';
+    // 默认仅列出启用状态的任务
+    const filtered = showAllTasks ? tasks : tasks.filter(t => (t.status || 'active') === 'active');
+    const pausedCount = tasks.filter(t => t.status === 'paused').length;
+    document.getElementById('picker-subtitle').textContent =
+      '共 ' + filtered.length + ' 个' + (showAllTasks ? '(全部 ' + tasks.length + ',暂停 ' + pausedCount + ')' : '启用');
     grid.innerHTML = '';
 
-    if (tasks.length === 0) {
-      grid.innerHTML = '<div class="picker-empty">还没有任务,点击下方卡片新建</div>';
+    if (filtered.length === 0) {
+      grid.innerHTML = '<div class="picker-empty">' + (showAllTasks ? '没有任务' : '没有启用的任务,可勾选"显示全部任务"查看暂停的') + '</div>';
     }
 
-    tasks.forEach(t => {
+    filtered.forEach(t => {
       const card = document.createElement('div');
-      card.className = 'task-card';
+      const isPaused = (t.status || 'active') === 'paused';
+      card.className = 'task-card' + (isPaused ? ' tc-paused' : '') + (t.active ? ' tc-active' : '');
       const last = t.last_used ? '上次使用 ' + t.last_used.replace('T', ' ') : '';
+      // 计算暂停时长(用于判断是否可删除)
+      let pausedInfo = '';
+      let canDelete = false;
+      if (isPaused && t.paused_at) {
+        const pausedMs = new Date(t.paused_at).getTime();
+        const elapsedH = (Date.now() - pausedMs) / 3600000;
+        pausedInfo = '已暂停 ' + elapsedH.toFixed(1) + ' 小时';
+        canDelete = elapsedH >= 24;
+      }
+      const statusLine = t.active ? '● 运行中' : (isPaused ? '⏸ 已暂停' : '○ 空闲');
       card.innerHTML =
         '<div class="tc-name">' + escapeHtml(t.name) + '</div>' +
         '<div class="tc-cwd">' + escapeHtml(t.cwd) + '</div>' +
-        '<div class="tc-time">' + last + '</div>' +
+        '<div class="tc-time">' + last + (pausedInfo ? ' · ' + pausedInfo : '') + '</div>' +
+        '<div class="tc-status">' + statusLine + '</div>' +
         '<div class="tc-actions">' +
           '<button class="tc-btn tc-enter">进入</button>' +
-          '<button class="tc-btn tc-del" title="删除任务">✕</button>' +
+          (isPaused
+            ? '<button class="tc-btn tc-resume" title="恢复任务">▶</button>'
+            : '<button class="tc-btn tc-pause" title="暂停任务">⏸</button>') +
+          (canDelete ? '<button class="tc-btn tc-del" title="删除任务">✕</button>' : '') +
         '</div>';
       card.querySelector('.tc-enter').onclick = () => openTask(t.id);
-      card.querySelector('.tc-del').onclick = () => deleteTask(t.id, t.name);
+      if (isPaused) {
+        card.querySelector('.tc-resume').onclick = () => resumeTask(t.id);
+        if (canDelete) card.querySelector('.tc-del').onclick = () => startDeleteTask(t);
+      } else {
+        card.querySelector('.tc-pause').onclick = () => pauseTask(t.id);
+      }
       grid.appendChild(card);
     });
 
-    // 新建卡片
+    // 新建卡片(仅在"显示启用"或"显示全部"时都显示)
     const newCard = document.createElement('div');
     newCard.className = 'task-new';
     newCard.innerHTML = '+ 新建任务';
@@ -322,6 +386,24 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     grid.appendChild(newCard);
   }
 
+  // 显示全部任务切换
+  document.getElementById('show-all-tasks').onchange = (e) => {
+    showAllTasks = e.target.checked;
+    renderTasks();
+  };
+
+  async function pauseTask(id) {
+    const res = await fetch(API + '/api/tasks/' + id + '/pause', { method: 'POST', headers: authHeaders() });
+    if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '暂停失败'); return; }
+    await renderTasks();
+  }
+
+  async function resumeTask(id) {
+    const res = await fetch(API + '/api/tasks/' + id + '/resume', { method: 'POST', headers: authHeaders() });
+    if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '恢复失败'); return; }
+    await renderTasks();
+  }
+
   function escapeHtml(s) {
     return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
   }
@@ -337,10 +419,69 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color:
     else { const d = await res.json().catch(()=>({})); alert(d.error || '创建失败'); }
   }
 
-  async function deleteTask(id, name) {
-    if (!confirm('删除任务 "' + name + '"?\\n(仅删除任务记录,不删除工作目录与历史会话)')) return;
-    await fetch(API + '/api/tasks/' + id, { method: 'DELETE', headers: authHeaders() });
+  // 删除任务:3 次确认,每次需等待 10 秒才能点击确认
+  const delModal = document.getElementById('del-modal');
+  const delMsg = document.getElementById('del-msg');
+  const delCount = document.getElementById('del-count');
+  const delConfirm = document.getElementById('del-confirm');
+  const delCancel = document.getElementById('del-cancel');
+  let delState = null;  // {task, step, timer, countdown}
+
+  function startDeleteTask(task) {
+    delState = { task: task, step: 0, timer: null, countdown: 10 };
+    delMsg.textContent = '即将删除任务 "' + task.name + '"';
+    delModal.style.display = 'flex';
+    armDelConfirmStep();
+  }
+
+  function armDelConfirmStep() {
+    const step = delState.step;
+    delState.countdown = 10;
+    delConfirm.disabled = true;
+    delCount.textContent = '第 ' + (step + 1) + '/3 次确认 · 请等待 10 秒...';
+    if (delState.timer) clearInterval(delState.timer);
+    delState.timer = setInterval(() => {
+      delState.countdown--;
+      if (delState.countdown > 0) {
+        delCount.textContent = '第 ' + (step + 1) + '/3 次确认 · 请等待 ' + delState.countdown + ' 秒';
+      } else {
+        clearInterval(delState.timer);
+        delState.timer = null;
+        if (step < 2) {
+          delCount.textContent = '第 ' + (step + 1) + '/3 次确认 · 可点击继续';
+        } else {
+          delCount.textContent = '第 3/3 次确认 · 可点击最终删除';
+        }
+        delConfirm.disabled = false;
+      }
+    }, 1000);
+  }
+
+  delConfirm.onclick = async () => {
+    if (delConfirm.disabled) return;
+    delState.step++;
+    if (delState.step < 3) {
+      // 进入下一步,重新等待 10 秒
+      armDelConfirmStep();
+      return;
+    }
+    // 第 3 次确认:执行删除
+    const task = delState.task;
+    delConfirm.disabled = true;
+    delCount.textContent = '正在删除...';
+    const res = await fetch(API + '/api/tasks/' + task.id, { method: 'DELETE', headers: authHeaders() });
+    closeDelModal();
+    if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '删除失败'); }
     await renderTasks();
+  };
+
+  delCancel.onclick = closeDelModal;
+  delModal.addEventListener('click', (e) => { if (e.target === delModal) closeDelModal(); });
+
+  function closeDelModal() {
+    delModal.style.display = 'none';
+    if (delState && delState.timer) { clearInterval(delState.timer); delState.timer = null; }
+    delState = null;
   }
 
   function openTask(id) {