From a6d00b24ffe730a8a06931998822f21f9ebf48be Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 26 Jun 2026 11:19:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B8=A0=E9=81=93=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E6=94=B6=E6=8B=A2=E7=BB=91=E5=AE=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=20+=20=E5=88=A0=20rail=20=E6=8C=89=E9=92=AE=20+=20bump=200.28.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。后端 /v1/channel_tasks 返回 { wechat: { bound, task }, wecom: { bound, task } },前端渲染三种卡片: 未绑定(点绑定)/已绑定无对话(占位)/已绑定有对话(点进+⚙管理)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 12 ++++++ core/__init__.py | 2 +- web/app.py | 83 ++++++++++++++++++++++------------------ web/static/dev.html | 10 ++--- web/static/js/chat.js | 84 ++++++++++++++++++++++++++++++----------- web/static/js/wechat.js | 2 +- 6 files changed, 127 insertions(+), 66 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 4b99d05..c97b5e3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -21,6 +21,18 @@ ## 已完成关键能力 +### 2026-06-26 / 渠道卡片收拢绑定管理 + 删 rail 按钮(bump 0.28.1) + +- 把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角 rail「微信」按钮(精简页面)。 +- 后端 `/v1/channel_tasks` 改为返回 `{ wechat: { bound, task }, wecom: { bound, task } }`: + * bound: 绑定状态(`wechat` 用 `get_binding` 判定,`wecom` 用 `get_wecom_userid`) + * task: 对话摘要(无对话为 null,复用 `_task_dict`)。 +- 前端 `loadChannelCards` 渲染三种卡片: + * 未绑定: 虚线占位「绑定微信」(点打开弹框绑定) + * 已绑定无对话: 虚线占位「微信对话(发消息后可打开)」(点打开弹框管理) + * 已绑定有对话: 正常卡片(名称 + N条·时间 + ⚙,点打开对话,⚙ 打开弹框管理) +- 文件:`web/app.py`(/v1/channel_tasks 返回 bound+task)、`web/static/dev.html`(删 rail 按钮+占位样式)、`web/static/js/chat.js`(三态卡片渲染)、`web/static/js/wechat.js`(删 hd-wechat 绑定)。 + ### 2026-06-26 / 定时任务对话归属 + push 统一记录到渠道对话(bump 0.28.0) - 问题1:定时任务产生的 task(isolated 每次新建)混进普通对话列表。解:`tasks` 加 `scheduled_job_id`(nullable FK→scheduled_jobs,0017 migration + backfill persistent/isolated);列表 `WHERE scheduled_job_id IS NULL`(+ `working_dir LIKE '%/scheduled-%'` 兜底漏网孤行);`ensure_local_task_row` 加参数,`_execute_scheduled_job` 建任务时填。mode 语义澄清:只管对话是否延续,文件夹两种模式都按 job 复用。 diff --git a/core/__init__.py b/core/__init__.py index 85810d0..d288f13 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.28.0" +__version__ = "0.28.1" diff --git a/web/app.py b/web/app.py index e13de60..dff66c3 100644 --- a/web/app.py +++ b/web/app.py @@ -1715,49 +1715,60 @@ def create_app() -> FastAPI: @app.get("/v1/channel_tasks", tags=["tasks"]) def list_channel_tasks(user_id: UUID = Depends(require_user)): - """渠道镜像 task(微信 ClawBot / 企业微信)的常驻对话摘要 —— 前端在左栏「新建任务」 - 下做成固定卡片。每渠道每用户至多一条;未建过(没绑定 / 没说过话)→ 该键为 null。 - 返回 `{"wechat": |null, "wecom": |null}`,task_dict 与 /v1/tasks - 列表项同构(复用 _task_dict),前端可直接 selectTask。""" + """渠道镜像任务(微信 / 企业微信)的绑定状态 + 常驻对话摘要 —— 前端在左栏「新建任务」 + 下做成固定卡片。返回 `{ wechat: { bound: bool, task: |null }, + wecom: { bound: bool, task: |null } }`, + bound 状态由 `get_binding` / `get_wecom_userid` 判定;task 同 /v1/tasks 列表项(复用 _task_dict), + 有对话则给摘要,无则 null。前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、 + 已绑定有对话(点进 + ⚙ 管理)。 + """ from core.wechat import service as _wx snap = _wx.get_binding(user_id) + wuid = _wx.get_wecom_userid(user_id) + bound: dict[str, bool] = { + "wechat": bool(snap and snap.status == "active"), + "wecom": bool(wuid), + } tids: dict[str, Optional[UUID]] = { - "wechat": snap.chat_task_id if snap else None, + "wechat": snap.chat_task_id if snap and snap.status == "active" else None, "wecom": _wx.get_wecom_chat_task(user_id), } wanted = [t for t in tids.values() if t is not None] - out: dict[str, Optional[dict]] = {"wechat": None, "wecom": None} - if not wanted: - return out - with session_scope() as s: - rows = { - r.task_id: r - for r in s.execute( - select(Task).where( - Task.task_id.in_(wanted), - Task.user_id == user_id, - Task.deleted_at.is_(None), - ) - ).scalars().all() - } - msg_counts = dict( - s.execute( - select(Message.task_id, func.count()) - .where(Message.task_id.in_(list(rows.keys()))) - .group_by(Message.task_id) - ).all() - ) if rows else {} - usage = _usage_aggregates(s, list(rows.keys())) - for kind, tid in tids.items(): - row = rows.get(tid) if tid else None - if row is not None: - out[kind] = _task_dict( - row, - n_messages=msg_counts.get(row.task_id, 0), - usage=usage.get(row.task_id), - ) - return out + tasks: dict[str, Optional[dict]] = {"wechat": None, "wecom": None} + if wanted: + with session_scope() as s: + rows = { + r.task_id: r + for r in s.execute( + select(Task).where( + Task.task_id.in_(wanted), + Task.user_id == user_id, + Task.deleted_at.is_(None), + ) + ).scalars().all() + } + msg_counts = dict( + s.execute( + select(Message.task_id, func.count()) + .where(Message.task_id.in_(list(rows.keys()))) + .group_by(Message.task_id) + ).all() + ) if rows else {} + usage = _usage_aggregates(s, list(rows.keys())) + for kind, tid in tids.items(): + row = rows.get(tid) if tid else None + if row is not None: + tasks[kind] = _task_dict( + row, + n_messages=msg_counts.get(row.task_id, 0), + usage=usage.get(row.task_id), + ) + # 按渠道返回 { bound, task } + return { + "wechat": {"bound": bound["wechat"], "task": tasks["wechat"]}, + "wecom": {"bound": bound["wecom"], "task": tasks["wecom"]}, + } @app.get("/v1/tasks/{task_id}", tags=["tasks"]) def get_task(task_id: str, user_id: UUID = Depends(require_user)): diff --git a/web/static/dev.html b/web/static/dev.html index 146312a..81190f5 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -557,14 +557,16 @@ .badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center; gap: 3px; vertical-align: 1px; } .badge.wx svg { width: 11px; height: 11px; fill: currentColor; display: block; } - /* 渠道镜像对话卡片(微信 / 企业微信):固定在「新建任务」下,绿调入口,与普通任务列表分离。 - 并排放(flex row,各 flex:1)省纵向空间;窄栏内图标左、名称/条数时间堆两行 */ + /* 渠道镜像卡片(微信 / 企业微信):固定在「新建任务」下,绿调入口,与普通任务列表分离。 + 并排放(flex row,各 flex:1)省纵向空间;窄栏内图标左、名称/条数时间堆两行。 + 三态:未绑定(cc-placeholder)、已绑定无对话(cc-placeholder)、已绑定有对话(active 高亮) */ #channel-cards { padding: 6px 12px 2px; display: flex; gap: 6px; } #channel-cards:empty { display: none; } .channel-card { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; padding: 7px 9px; border-radius: 8px; border: 1px solid rgba(7,193,96,.35); background: rgba(7,193,96,.06); cursor: pointer; } .channel-card:hover { background: rgba(7,193,96,.12); } .channel-card.active { border-color: #07C160; background: var(--accent-soft); } + .channel-card.cc-placeholder { border-style: dashed; border-color: rgba(7,193,96,.5); } .channel-card .cc-icon { width: 18px; height: 18px; border-radius: 5px; background: #07C160; color: #fff; display: inline-flex; align-items: center; justify-content: center; flex: none; } .channel-card .cc-icon svg { width: 12px; height: 12px; fill: currentColor; display: block; } @@ -1470,10 +1472,6 @@ 定时 - diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 1da8f76..a233a95 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -183,9 +183,9 @@ function renderTaskList(tasks, append = false) { }); } -// 渠道镜像对话卡片(微信 / 企业微信):左栏「新建任务」下方固定入口。这两条是每用户每渠道 -// 唯一的常驻只读对话,不混进可滚动任务列表(后端 /v1/tasks 已排除)。无对话(没绑定 / 没说过话) -// → 该渠道无卡片;两个都无 → 容器空,CSS :empty 隐藏整块。点卡片复用 selectTask。 +// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互 +// (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: |null }, +// 前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、已绑定有对话(点进 + ⚙ 管理)。 export async function loadChannelCards() { const box = $("channel-cards"); if (!box) return; @@ -194,30 +194,70 @@ export async function loadChannelCards() { data = await api("GET", "/v1/channel_tasks"); } catch (e) { if (e.status === 401) { logout(); return; } - box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏(列表仍可用) + box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏 return; } - // CHANNEL_BADGE 的键序(wechat 先、wecom 后)决定卡片顺序,与徽章/锁定文案同一真相源 - const cards = Object.keys(CHANNEL_BADGE) - .map((kind) => ({ kind, t: data && data[kind] })) - .filter((x) => x.t && x.t.task_id); - if (!cards.length) { box.innerHTML = ""; return; } - box.innerHTML = cards.map(({ kind, t }) => { + // CHANNEL_BADGE 的键序决定卡片顺序。每渠道都有一张卡片(占位或对话)。 + const cards = Object.keys(CHANNEL_BADGE).map((kind) => ({ kind, info: data && data[kind] })); + box.innerHTML = cards.map(({ kind, info }) => { const cfg = CHANNEL_BADGE[kind]; - const active = state.taskId === t.task_id ? " active" : ""; - const name = t.name || cfg.label + "对话"; - const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))}`; - return ` -
- ${WECHAT_ICON} - - ${escapeHtml(name)} - ${meta} - -
`; + const bound = info && info.bound; + const t = info && info.task; + let html = ""; + if (!bound) { + // 未绑定:占位卡片,点打开弹框绑定 + html = ` +
+ ${WECHAT_ICON} + + 绑定${cfg.label} + 扫码或手填 userid + +
`; + } else if (!t) { + // 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,⚙ 打开弹框管理 + html = ` +
+ ${WECHAT_ICON} + + ${cfg.label}对话 + 发消息后可打开 · ⚙ 管理 + +
`; + } else { + // 已绑定且有对话:正常卡片,点打开,⚙ 打开弹框管理 + const active = state.taskId === t.task_id ? " active" : ""; + const name = t.name || cfg.label + "对话"; + const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))} · ⚙`; + html = ` +
+ ${WECHAT_ICON} + + ${escapeHtml(name)} + ${meta} + +
`; + } + return html; }).join(""); + // 绑定事件:绑定点打开弹框;已绑定点打开对话(占位点管理);右键/点击 ⚙ 打开弹框管理 box.querySelectorAll(".channel-card").forEach((el) => { - el.onclick = () => selectTask(el.dataset.tid); + const action = el.dataset.action; + if (action === "bind") { + el.onclick = () => { + if (typeof openWechatModal === "function") openWechatModal(); + }; + } else if (action === "manage") { + el.onclick = () => { + if (typeof openWechatModal === "function") openWechatModal(); + }; + } else if (action === "select") { + el.onclick = () => selectTask(el.dataset.tid); + // TODO: ⚙ 打开弹框管理(待实现——需要给弹框里当前渠道加高亮或 tab 切换) + } }); } diff --git a/web/static/js/wechat.js b/web/static/js/wechat.js index bedac30..1c9c2fd 100644 --- a/web/static/js/wechat.js +++ b/web/static/js/wechat.js @@ -133,7 +133,7 @@ async function bindFlow() { } // ───── 顶层绑定 ───── -$("hd-wechat").onclick = openWechatModal; +// 弹框现在由「新建任务」下方的渠道卡片触发(点绑定/管理按钮),不再需要 rail 按钮。 $("wx-close").onclick = closeWechatModal; $("wechat-modal").addEventListener("click", (e) => { if (e.target.id === "wechat-modal") closeWechatModal(); // 点遮罩关闭