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})
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):
}
.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);
}
.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>
<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">
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 = '+ 新建任务';
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
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) {