feat(web): 渠道卡片收拢绑定管理 + 删 rail 按钮 + bump 0.28.1

把渠道绑定/对话/管理全部收进「新建任务」下方的卡片,删掉左下角
rail「微信」按钮(精简页面)。后端 /v1/channel_tasks 返回
{ wechat: { bound, task }, wecom: { bound, task } },前端渲染三种卡片:
未绑定(点绑定)/已绑定无对话(占位)/已绑定有对话(点进+⚙管理)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-26 11:19:45 +08:00
parent e66fdd0ffc
commit a6d00b24ff
6 changed files with 127 additions and 66 deletions

View File

@ -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) ### 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 复用。 - 问题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 复用。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.28.0" __version__ = "0.28.1"

View File

@ -1715,49 +1715,60 @@ def create_app() -> FastAPI:
@app.get("/v1/channel_tasks", tags=["tasks"]) @app.get("/v1/channel_tasks", tags=["tasks"])
def list_channel_tasks(user_id: UUID = Depends(require_user)): def list_channel_tasks(user_id: UUID = Depends(require_user)):
"""渠道镜像 task(微信 ClawBot / 企业微信)的常驻对话摘要 —— 前端在左栏「新建任务」 """渠道镜像任务(微信 / 企业微信)的绑定状态 + 常驻对话摘要 —— 前端在左栏「新建任务」
下做成固定卡片每渠道每用户至多一条;未建过(没绑定 / 没说过话) 该键为 null 下做成固定卡片返回 `{ wechat: { bound: bool, task: <task_dict>|null },
返回 `{"wechat": <task_dict>|null, "wecom": <task_dict>|null}`,task_dict /v1/tasks wecom: { bound: bool, task: <task_dict>|null } }`,
列表项同构(复用 _task_dict),前端可直接 selectTask""" bound 状态由 `get_binding` / `get_wecom_userid` 判定;task /v1/tasks 列表项(复用 _task_dict),
有对话则给摘要,无则 null前端据此渲染三种卡片:未绑定(点绑定)已绑定无对话(占位)
已绑定有对话(点进 + 管理)
"""
from core.wechat import service as _wx from core.wechat import service as _wx
snap = _wx.get_binding(user_id) 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]] = { 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), "wecom": _wx.get_wecom_chat_task(user_id),
} }
wanted = [t for t in tids.values() if t is not None] wanted = [t for t in tids.values() if t is not None]
out: dict[str, Optional[dict]] = {"wechat": None, "wecom": None} tasks: dict[str, Optional[dict]] = {"wechat": None, "wecom": None}
if not wanted: if wanted:
return out with session_scope() as s:
with session_scope() as s: rows = {
rows = { r.task_id: r
r.task_id: r for r in s.execute(
for r in s.execute( select(Task).where(
select(Task).where( Task.task_id.in_(wanted),
Task.task_id.in_(wanted), Task.user_id == user_id,
Task.user_id == user_id, Task.deleted_at.is_(None),
Task.deleted_at.is_(None), )
) ).scalars().all()
).scalars().all() }
} msg_counts = dict(
msg_counts = dict( s.execute(
s.execute( select(Message.task_id, func.count())
select(Message.task_id, func.count()) .where(Message.task_id.in_(list(rows.keys())))
.where(Message.task_id.in_(list(rows.keys()))) .group_by(Message.task_id)
.group_by(Message.task_id) ).all()
).all() ) if rows else {}
) if rows else {} usage = _usage_aggregates(s, list(rows.keys()))
usage = _usage_aggregates(s, list(rows.keys())) for kind, tid in tids.items():
for kind, tid in tids.items(): row = rows.get(tid) if tid else None
row = rows.get(tid) if tid else None if row is not None:
if row is not None: tasks[kind] = _task_dict(
out[kind] = _task_dict( row,
row, n_messages=msg_counts.get(row.task_id, 0),
n_messages=msg_counts.get(row.task_id, 0), usage=usage.get(row.task_id),
usage=usage.get(row.task_id), )
) # 按渠道返回 { bound, task }
return out return {
"wechat": {"bound": bound["wechat"], "task": tasks["wechat"]},
"wecom": {"bound": bound["wecom"], "task": tasks["wecom"]},
}
@app.get("/v1/tasks/{task_id}", tags=["tasks"]) @app.get("/v1/tasks/{task_id}", tags=["tasks"])
def get_task(task_id: str, user_id: UUID = Depends(require_user)): def get_task(task_id: str, user_id: UUID = Depends(require_user)):

View File

@ -557,14 +557,16 @@
.badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center; .badge.wx { background: #07C160; color: #fff; display: inline-flex; align-items: center;
gap: 3px; vertical-align: 1px; } gap: 3px; vertical-align: 1px; }
.badge.wx svg { width: 11px; height: 11px; fill: currentColor; display: block; } .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 { padding: 6px 12px 2px; display: flex; gap: 6px; }
#channel-cards:empty { display: none; } #channel-cards:empty { display: none; }
.channel-card { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; padding: 7px 9px; .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; } 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:hover { background: rgba(7,193,96,.12); }
.channel-card.active { border-color: #07C160; background: var(--accent-soft); } .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; .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; } display: inline-flex; align-items: center; justify-content: center; flex: none; }
.channel-card .cc-icon svg { width: 12px; height: 12px; fill: currentColor; display: block; } .channel-card .cc-icon svg { width: 12px; height: 12px; fill: currentColor; display: block; }
@ -1470,10 +1472,6 @@
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 2"></path></svg>
<span>定时</span> <span>定时</span>
</button> </button>
<button id="hd-wechat" title="绑定微信(ClawBot):扫码后在微信里直接和 zcbot 对话">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path></svg>
<span>微信</span>
</button>
</div> </div>
</div> </div>
<div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div> <div id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>

View File

@ -183,9 +183,9 @@ function renderTaskList(tasks, append = false) {
}); });
} }
// 渠道镜像对话卡片(微信 / 企业微信):左栏「新建任务」下方固定入口。这两条是每用户每渠道 // 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互
// 唯一的常驻只读对话,不混进可滚动任务列表(后端 /v1/tasks 已排除)。无对话(没绑定 / 没说过话) // (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: <task_dict>|null },
// → 该渠道无卡片;两个都无 → 容器空,CSS :empty 隐藏整块。点卡片复用 selectTask // 前端据此渲染三种卡片:未绑定(点绑定)、已绑定无对话(占位)、已绑定有对话(点进 + ⚙ 管理)
export async function loadChannelCards() { export async function loadChannelCards() {
const box = $("channel-cards"); const box = $("channel-cards");
if (!box) return; if (!box) return;
@ -194,30 +194,70 @@ export async function loadChannelCards() {
data = await api("GET", "/v1/channel_tasks"); data = await api("GET", "/v1/channel_tasks");
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏(列表仍可用) box.innerHTML = ""; // 拉失败不挡主流程,卡片静默隐藏
return; return;
} }
// CHANNEL_BADGE 的键序(wechat 先、wecom 后)决定卡片顺序,与徽章/锁定文案同一真相源 // CHANNEL_BADGE 的键序决定卡片顺序。每渠道都有一张卡片(占位或对话)。
const cards = Object.keys(CHANNEL_BADGE) const cards = Object.keys(CHANNEL_BADGE).map((kind) => ({ kind, info: data && data[kind] }));
.map((kind) => ({ kind, t: data && data[kind] })) box.innerHTML = cards.map(({ kind, info }) => {
.filter((x) => x.t && x.t.task_id);
if (!cards.length) { box.innerHTML = ""; return; }
box.innerHTML = cards.map(({ kind, t }) => {
const cfg = CHANNEL_BADGE[kind]; const cfg = CHANNEL_BADGE[kind];
const active = state.taskId === t.task_id ? " active" : ""; const bound = info && info.bound;
const name = t.name || cfg.label + "对话"; const t = info && info.task;
const meta = `${t.n_messages || 0} 条 · ${escapeHtml(fmtTimeAgo(t.updated_at))}`; let html = "";
return ` if (!bound) {
<div class="channel-card${active}" data-tid="${t.task_id}" title="${escapeHtml(cfg.title)}"> // 未绑定:占位卡片,点打开弹框绑定
<span class="cc-icon">${WECHAT_ICON}</span> html = `
<span class="cc-body"> <div class="channel-card cc-placeholder" data-kind="${kind}" data-action="bind"
<span class="cc-name">${escapeHtml(name)}</span> title="绑定${cfg.label}后在${cfg.label}里直接和 zcbot 对话">
<span class="cc-meta">${meta}</span> <span class="cc-icon">${WECHAT_ICON}</span>
</span> <span class="cc-body">
</div>`; <span class="cc-name">绑定${cfg.label}</span>
<span class="cc-meta">扫码或手填 userid</span>
</span>
</div>`;
} else if (!t) {
// 已绑定但还没首条消息(无 task):占位卡片,提示发消息后可打开,⚙ 打开弹框管理
html = `
<div class="channel-card cc-placeholder" data-kind="${kind}" data-action="manage"
title="在${cfg.label}发条消息后即可打开对话">
<span class="cc-icon">${WECHAT_ICON}</span>
<span class="cc-body">
<span class="cc-name">${cfg.label}对话</span>
<span class="cc-meta">发消息后可打开 · 管理</span>
</span>
</div>`;
} 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 = `
<div class="channel-card${active}" data-tid="${t.task_id}" data-kind="${kind}"
data-action="select" title="${escapeHtml(cfg.title)}">
<span class="cc-icon">${WECHAT_ICON}</span>
<span class="cc-body">
<span class="cc-name">${escapeHtml(name)}</span>
<span class="cc-meta">${meta}</span>
</span>
</div>`;
}
return html;
}).join(""); }).join("");
// 绑定事件:绑定点打开弹框;已绑定点打开对话(占位点管理);右键/点击 ⚙ 打开弹框管理
box.querySelectorAll(".channel-card").forEach((el) => { 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 切换)
}
}); });
} }

View File

@ -133,7 +133,7 @@ async function bindFlow() {
} }
// ───── 顶层绑定 ───── // ───── 顶层绑定 ─────
$("hd-wechat").onclick = openWechatModal; // 弹框现在由「新建任务」下方的渠道卡片触发(点绑定/管理按钮),不再需要 rail 按钮。
$("wx-close").onclick = closeWechatModal; $("wx-close").onclick = closeWechatModal;
$("wechat-modal").addEventListener("click", (e) => { $("wechat-modal").addEventListener("click", (e) => {
if (e.target.id === "wechat-modal") closeWechatModal(); // 点遮罩关闭 if (e.target.id === "wechat-modal") closeWechatModal(); // 点遮罩关闭