From b843b50e62cfbf5ae38fcfb205ccf82cb6f05a34 Mon Sep 17 00:00:00 2001 From: Codebuddy Date: Thu, 3 Sep 2026 12:15:51 +0800 Subject: [PATCH] =?utf8?q?feat:=20=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= =?utf8?q?=E6=94=AF=E6=8C=81=E5=88=86=E7=BB=84=EF=BC=88=E6=96=B0=E5=BB=BA/?= =?utf8?q?=E9=87=8D=E5=91=BD=E5=90=8D/=E5=88=A0=E9=99=A4=EF=BC=8C=E4=BB=BB?= =?utf8?q?=E5=8A=A1=E5=8F=AF=E5=9C=A8=E5=88=86=E7=BB=84=E9=97=B4=E7=A7=BB?= =?utf8?q?=E5=8A=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 数据模型(增量、兼容老数据): - tasks.json 顶层新增 groups: [{id, name}];任务新增 group 字段 - group 为空/缺失即归入内置「未分类」;老数据无需迁移 后端: - 新增 GET/POST /api/groups、PUT/DELETE /api/groups/{gid} - 删除分组仅摘掉任务的分组归属(回到未分类),不删除任何任务 - PUT /api/tasks/{tid} 扩展支持 group 字段,实现任务移动(校验 gid 存在) 前端: - 任务列表按分组渲染区块:分组标题行(组名+数量+✎重命名+✕删除)+ 区块内 沿用原 .task-grid,卡片样式与尺寸完全不变 - 内置「未分类」放最后,不可重命名/删除;空分组也显示,方便移入任务 - 分组重命名:点击组名变输入框,Enter 保存 / Esc 取消 - 工具栏新增「+ 新建分组」 - 任务移动:扩展现有 ✎ 编辑面板,新增「分组」下拉(未分类 + 所有分组) - “+ 新建任务”卡片仍仅保留在「未分类」 Co-Authored-By: CodeBuddy --- backend/app.py | 105 ++++++++++++++++- frontend/index.html | 266 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 313 insertions(+), 58 deletions(-) diff --git a/backend/app.py b/backend/app.py index 3505543..5e3884d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -271,6 +271,27 @@ def find_task(data: dict, task_id: str) -> dict | None: return None +# ==================== 分组 ==================== +# 分组独立存于 data["groups"];任务通过 t["group"] 记录所属分组 id, +# 为空/缺失表示归入内置的“未分类”分组。老数据无这些字段时自动兼容。 + +def get_groups(data: dict) -> list: + """返回分组列表;老数据无 groups 字段时返回空列表。""" + return data.get("groups") or [] + + +def find_group(data: dict, group_id: str) -> dict | None: + for g in get_groups(data): + if g["id"] == group_id: + return g + return None + + +def group_name_taken(data: dict, name: str, exclude_id: str = "") -> bool: + """分组名是否已存在(用于新建/重命名去重)。""" + return any(g["id"] != exclude_id and g["name"] == name for g in get_groups(data)) + + def session_exists(session_id: str) -> bool: """session-id 全局唯一,跨所有 project 目录查找。""" pattern = str(CODEBUDDY_PROJECTS_DIR / "*" / f"{session_id}.jsonl") @@ -578,6 +599,80 @@ async def api_models(request: Request): IDLE_THRESHOLD = 2.5 +# ==================== 分组接口 ==================== +@app.get("/api/groups") +async def api_groups(request: Request): + """分组列表(含每个分组的任务数)。""" + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + data = load_tasks() + groups = [] + for g in get_groups(data): + gid = g["id"] + count = sum(1 for t in data.get("tasks", []) if (t.get("group") or "") == gid) + groups.append({"id": gid, "name": g["name"], "count": count}) + return JSONResponse({"ok": True, "groups": groups}) + + +@app.post("/api/groups") +async def api_create_group(request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + body = await request.json() + name = (body.get("name") or "").strip() + if not name: + return JSONResponse({"ok": False, "error": "分组名不能为空"}, status_code=400) + data = load_tasks() + if group_name_taken(data, name): + return JSONResponse({"ok": False, "error": "分组名已存在"}, status_code=409) + data.setdefault("groups", []) + group = {"id": gen_id(), "name": name} + data["groups"].append(group) + save_tasks(data) + logger.info(f"新建分组: name={name}, id={group['id']}") + return JSONResponse({"ok": True, "group": group}) + + +@app.put("/api/groups/{group_id}") +async def api_rename_group(group_id: str, request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + body = await request.json() + name = (body.get("name") or "").strip() + if not name: + return JSONResponse({"ok": False, "error": "分组名不能为空"}, status_code=400) + data = load_tasks() + g = find_group(data, group_id) + if not g: + return JSONResponse({"ok": False, "error": "分组不存在"}, status_code=404) + if group_name_taken(data, name, exclude_id=group_id): + return JSONResponse({"ok": False, "error": "分组名已存在"}, status_code=409) + g["name"] = name + save_tasks(data) + logger.info(f"重命名分组: id={group_id}, name={name}") + return JSONResponse({"ok": True, "group": g}) + + +@app.delete("/api/groups/{group_id}") +async def api_delete_group(group_id: str, request: Request): + """删除分组:仅摘掉任务的分组归属(回到“未分类”),不删除任何任务。""" + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + data = load_tasks() + g = find_group(data, group_id) + if not g: + return JSONResponse({"ok": False, "error": "分组不存在"}, status_code=404) + moved = 0 + for t in data.get("tasks", []): + if (t.get("group") or "") == group_id: + t["group"] = "" + moved += 1 + data["groups"] = [x for x in get_groups(data) if x["id"] != group_id] + save_tasks(data) + logger.info(f"删除分组: id={group_id}, name={g['name']}, 回到未分类的任务数={moved}") + return JSONResponse({"ok": True, "moved": moved}) + + @app.get("/api/tasks") async def api_tasks(request: Request): if not _auth_ok(request): @@ -680,8 +775,16 @@ async def api_update_task(task_id: str, request: Request): return JSONResponse({"ok": False, "error": "描述最多 50 个汉字"}, status_code=400) t["description"] = desc + # 移动到指定分组;空字符串表示移回内置的“未分类” + group = body.get("group") + if group is not None: + group = (group or "").strip() + if group and not find_group(data, group): + return JSONResponse({"ok": False, "error": "分组不存在"}, status_code=404) + t["group"] = group + save_tasks(data) - logger.info(f"更新任务: id={task_id}, name={t.get('name')}, desc_len={len(t.get('description', ''))}") + logger.info(f"更新任务: id={task_id}, name={t.get('name')}, desc_len={len(t.get('description', ''))}, group='{t.get('group', '')}'") return JSONResponse({"ok": True, "task": t}) diff --git a/frontend/index.html b/frontend/index.html index fa2f39e..c8d26a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -215,6 +215,30 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh .task-card .tc-save { background: linear-gradient(135deg, #4ecca3, #38b2ac); color: #1a1a2e; border: none; flex: 1; padding: 9px; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; } .task-card .tc-save:hover { opacity: 0.9; } .task-card .tc-cancel { background: rgba(255,255,255,0.08); color: #ccc; border: 1px solid rgba(255,255,255,0.15); flex: 0 0 56px; padding: 9px; border-radius: 8px; cursor: pointer; font-size: 14px; } +/* 任务分组 */ +.groups-wrap { display: flex; flex-direction: column; gap: 26px; max-width: 1200px; margin: 0 auto; } +.group-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.1); } +.group-head .gh-name { font-size: 16px; font-weight: 600; color: #4ecca3; cursor: pointer; } +.group-head .gh-name:hover { color: #6fd9b4; } +.group-head .gh-count { font-size: 12px; color: #777; } +.group-head .gh-spacer { flex: 1; } +.group-head .gh-btn { + width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; color: #ccc; font-size: 12px; cursor: pointer; +} +.group-head .gh-btn:hover { background: rgba(255,255,255,0.14); color: #fff; } +.group-head .gh-del:hover { background: rgba(255,107,107,0.22); color: #fff; border-color: rgba(255,107,107,0.4); } +.group-head .gh-name-input { + background: #0f0f1e; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; + color: #4ecca3; font-size: 16px; font-weight: 600; padding: 2px 8px; font-family: inherit; width: 200px; +} +.task-card .tc-group-wrap { display: flex; align-items: center; gap: 6px; } +.task-card .tc-group-wrap label { font-size: 12px; color: #888; white-space: nowrap; } +.task-card .tc-group-select { + flex: 1; min-width: 0; background: #0f0f1e; color: #e0e0e0; + border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; padding: 6px 8px; font-size: 13px; +} .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); @@ -288,6 +312,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh

任务清单

+
@@ -321,7 +346,7 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
-
+
@@ -608,79 +633,197 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh } let showAllTasks = false; + let groupsCache = []; // 分组缓存,供任务编辑面板的“分组”下拉使用 + + async function fetchGroups() { + const res = await fetch(API + '/api/groups', { headers: authHeaders() }); + if (!res.ok) return []; + const d = await res.json(); + return d.ok ? (d.groups || []) : []; + } + + // 构建单个任务卡片(样式与原先完全一致) + function makeTaskCard(t) { + const card = document.createElement('div'); + const isPaused = (t.status || 'active') === 'paused'; + // 三态实时状态:前台已登录 / 后台运行中 / 空闲(paused 优先显示已暂停) + const ls = t.live_status || (t.active ? 'connected' : 'idle'); + let statusLine, statusClass; + if (ls === 'connected') { statusLine = '● 前台已登录'; statusClass = 'tc-connected'; } + else if (ls === 'background') { statusLine = '⚙ 后台运行中'; statusClass = 'tc-background'; } + else if (ls === 'background_idle') { statusLine = '⏸ 后台等待中'; statusClass = 'tc-waiting'; } + else if (ls === 'done_detached') { statusLine = '⏳ 后台任务待确认' + (t.exit_code != null ? '(退出码 ' + t.exit_code + ')' : ''); statusClass = 'tc-done'; } + else if (isPaused) { statusLine = '⏸ 已暂停'; statusClass = 'tc-paused'; } + else { statusLine = '○ 空闲'; statusClass = 'tc-idle'; } + card.className = 'task-card' + (isPaused ? ' tc-paused' : '') + ' ' + statusClass; + 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; + } + card.innerHTML = + '
' + escapeHtml(t.name) + '
' + + '
' + escapeHtml(t.cwd) + '
' + + '
' + (t.description ? escapeHtml(t.description) : '添加描述…') + '
' + + '
' + last + (pausedInfo ? ' · ' + pausedInfo : '') + '
' + + '
' + statusLine + '
' + + '' + + '
' + + '' + + (isPaused + ? '' + : '') + + (canDelete ? '' : '') + + '
'; + card.querySelector('.tc-enter').onclick = () => openTask(t.id); + card.querySelector('.tc-edit').onclick = (e) => { e.stopPropagation(); enterEditMode(card, t); }; + 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); + } + return card; + } async function renderTasks() { const tasks = await fetchTasks(); if (!tasks) return; - const grid = document.getElementById('task-grid'); + groupsCache = await fetchGroups(); + const wrap = document.getElementById('task-grid'); // 默认仅列出启用状态的任务 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 = ''; + wrap.innerHTML = ''; if (filtered.length === 0) { - grid.innerHTML = '
' + (showAllTasks ? '没有任务' : '没有启用的任务,可勾选"显示全部任务"查看暂停的') + '
'; + const hint = document.createElement('div'); + hint.className = 'picker-empty'; + hint.textContent = showAllTasks ? '没有任务' : '没有启用的任务,可勾选"显示全部任务"查看暂停的'; + wrap.appendChild(hint); } - filtered.forEach(t => { - const card = document.createElement('div'); - const isPaused = (t.status || 'active') === 'paused'; - // 三态实时状态:前台已登录 / 后台运行中 / 空闲(paused 优先显示已暂停) - const ls = t.live_status || (t.active ? 'connected' : 'idle'); - let statusLine, statusClass; - if (ls === 'connected') { statusLine = '● 前台已登录'; statusClass = 'tc-connected'; } - else if (ls === 'background') { statusLine = '⚙ 后台运行中'; statusClass = 'tc-background'; } - else if (ls === 'background_idle') { statusLine = '⏸ 后台等待中'; statusClass = 'tc-waiting'; } - else if (ls === 'done_detached') { statusLine = '⏳ 后台任务待确认' + (t.exit_code != null ? '(退出码 ' + t.exit_code + ')' : ''); statusClass = 'tc-done'; } - else if (isPaused) { statusLine = '⏸ 已暂停'; statusClass = 'tc-paused'; } - else { statusLine = '○ 空闲'; statusClass = 'tc-idle'; } - card.className = 'task-card' + (isPaused ? ' tc-paused' : '') + ' ' + statusClass; - 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; + // 按分组渲染区块:每个分组一个标题行 + 内部沿用原有 .task-grid(卡片样式不变) + // 空分组也显示,方便随时把任务移进去 + const renderGroupSection = (gid, gname, isBuiltin) => { + const items = filtered.filter(t => (t.group || '') === gid); + const sec = document.createElement('div'); + sec.className = 'group-section'; + + const head = document.createElement('div'); + head.className = 'group-head'; + const nameSpan = document.createElement('span'); + nameSpan.className = 'gh-name'; + nameSpan.textContent = gname; + head.appendChild(nameSpan); + const cnt = document.createElement('span'); + cnt.className = 'gh-count'; + cnt.textContent = items.length + ' 个'; + head.appendChild(cnt); + const spacer = document.createElement('span'); + spacer.className = 'gh-spacer'; + head.appendChild(spacer); + if (!isBuiltin) { + // 自定义分组:可重命名、可删除(内置「未分类」两者都不行) + nameSpan.title = '点击重命名分组'; + nameSpan.onclick = () => startRenameGroup(gid, nameSpan, gname); + const del = document.createElement('button'); + del.className = 'gh-btn gh-del'; + del.textContent = '✕'; + del.title = '删除分组(组内任务不会被删除,将回到「未分类」)'; + del.onclick = () => deleteGroup(gid, gname, items.length); + head.appendChild(del); } - // statusLine 已按 live_status 在上方计算 - card.innerHTML = - '
' + escapeHtml(t.name) + '
' + - '
' + escapeHtml(t.cwd) + '
' + - '
' + (t.description ? escapeHtml(t.description) : '添加描述…') + '
' + - '
' + last + (pausedInfo ? ' · ' + pausedInfo : '') + '
' + - '
' + statusLine + '
' + - '' + - '
' + - '' + - (isPaused - ? '' - : '') + - (canDelete ? '' : '') + - '
'; - card.querySelector('.tc-enter').onclick = () => openTask(t.id); - card.querySelector('.tc-edit').onclick = (e) => { e.stopPropagation(); enterEditMode(card, t); }; - 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); + sec.appendChild(head); + + const grid = document.createElement('div'); + grid.className = 'task-grid'; + items.forEach(t => grid.appendChild(makeTaskCard(t))); + if (isBuiltin) { + // “+ 新建任务”卡片仅保留在「未分类」 + const newCard = document.createElement('div'); + newCard.className = 'task-new'; + newCard.innerHTML = '+ 新建任务'; + newCard.onclick = createTask; + grid.appendChild(newCard); + } else if (items.length === 0) { + const gh = document.createElement('div'); + gh.className = 'picker-empty'; + gh.textContent = '暂无任务,可把其它分组的任务移到这里'; + grid.appendChild(gh); } - grid.appendChild(card); + sec.appendChild(grid); + wrap.appendChild(sec); + }; + + groupsCache.forEach(g => renderGroupSection(g.id, g.name, false)); + // 内置「未分类」放最后,收纳所有未指定分组的任务 + renderGroupSection('', '未分类', true); + } + + // ========== 分组操作 ========== + async function createGroup() { + const name = prompt('输入新分组名称:'); + if (name == null) return; + const n = name.trim(); + if (!n) return; + const res = await fetch(API + '/api/groups', { + method: 'POST', headers: authHeaders(), + body: JSON.stringify({ name: n }), }); + if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '新建分组失败'); return; } + await renderTasks(); + } + + async function deleteGroup(gid, gname, count) { + const msg = count > 0 + ? '删除分组「' + gname + '」?\n\n组内 ' + count + ' 个任务不会被删除,将回到「未分类」。' + : '删除分组「' + gname + '」?'; + if (!confirm(msg)) return; + const res = await fetch(API + '/api/groups/' + gid, { method: 'DELETE', headers: authHeaders() }); + if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '删除分组失败'); return; } + await renderTasks(); + } - // 新建卡片(仅在"显示启用"或"显示全部"时都显示) - const newCard = document.createElement('div'); - newCard.className = 'task-new'; - newCard.innerHTML = '+ 新建任务'; - newCard.onclick = createTask; - grid.appendChild(newCard); + function startRenameGroup(gid, span, oldName) { + if (span.querySelector('input')) return; + const input = document.createElement('input'); + input.className = 'gh-name-input'; + input.value = oldName; + span.innerHTML = ''; + span.appendChild(input); + input.focus(); + input.select(); + let done = false; + const finish = async (save) => { + if (done) return; + done = true; + const v = input.value.trim(); + if (save && v && v !== oldName) { + const res = await fetch(API + '/api/groups/' + gid, { + method: 'PUT', headers: authHeaders(), + body: JSON.stringify({ name: v }), + }); + if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '重命名失败'); } + } + await renderTasks(); + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('blur', () => finish(true)); } + document.getElementById('btn-new-group').onclick = createGroup; + // 显示全部任务切换 document.getElementById('show-all-tasks').onchange = (e) => { showAllTasks = e.target.checked; @@ -699,18 +842,26 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh await renderTasks(); } - // 进入卡片内联编辑模式:编辑名称与描述 + // 进入卡片内联编辑模式:编辑名称、描述与所属分组 function enterEditMode(card, t) { + const curGroup = t.group || ''; + const groupOpts = '' + + groupsCache.map(g => + '').join(''); card.innerHTML = '
' + '
' + '0/50
' + + '
' + + '
' + '
' + '' + '' + '
'; const nameInput = card.querySelector('.tc-name-input'); const descInput = card.querySelector('.tc-desc-wrap input'); + const groupSelect = card.querySelector('.tc-group-select'); const counter = card.querySelector('.tc-counter'); const updateCounter = () => { counter.textContent = descInput.value.length + '/50'; }; updateCounter(); @@ -721,10 +872,11 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh card.querySelector('.tc-save').onclick = async () => { const newName = nameInput.value.trim(); const newDesc = descInput.value; + const newGroup = groupSelect.value; if (!newName) { alert('名称不能为空'); return; } const res = await fetch(API + '/api/tasks/' + t.id, { method: 'PUT', headers: authHeaders(), - body: JSON.stringify({ name: newName, description: newDesc }), + body: JSON.stringify({ name: newName, description: newDesc, group: newGroup }), }); if (!res.ok) { const d = await res.json().catch(()=>({})); alert(d.error || '保存失败'); return; } await renderTasks(); -- 2.43.0