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:
parent
e66fdd0ffc
commit
a6d00b24ff
12
PROGRESS.md
12
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 复用。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.28.0"
|
||||
__version__ = "0.28.1"
|
||||
|
|
|
|||
83
web/app.py
83
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": <task_dict>|null, "wecom": <task_dict>|null}`,task_dict 与 /v1/tasks
|
||||
列表项同构(复用 _task_dict),前端可直接 selectTask。"""
|
||||
"""渠道镜像任务(微信 / 企业微信)的绑定状态 + 常驻对话摘要 —— 前端在左栏「新建任务」
|
||||
下做成固定卡片。返回 `{ wechat: { bound: bool, task: <task_dict>|null },
|
||||
wecom: { bound: bool, task: <task_dict>|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)):
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<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>
|
||||
</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 id="split-left" class="splitter" role="separator" aria-orientation="vertical" title="拖拽调整任务栏宽度"></div>
|
||||
|
|
|
|||
|
|
@ -183,9 +183,9 @@ function renderTaskList(tasks, append = false) {
|
|||
});
|
||||
}
|
||||
|
||||
// 渠道镜像对话卡片(微信 / 企业微信):左栏「新建任务」下方固定入口。这两条是每用户每渠道
|
||||
// 唯一的常驻只读对话,不混进可滚动任务列表(后端 /v1/tasks 已排除)。无对话(没绑定 / 没说过话)
|
||||
// → 该渠道无卡片;两个都无 → 容器空,CSS :empty 隐藏整块。点卡片复用 selectTask。
|
||||
// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互
|
||||
// (绑定 / 对话 / 管理)。后端 /v1/channel_tasks 返回 { bound: bool, task: <task_dict>|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 `
|
||||
<div class="channel-card${active}" data-tid="${t.task_id}" 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>`;
|
||||
const bound = info && info.bound;
|
||||
const t = info && info.task;
|
||||
let html = "";
|
||||
if (!bound) {
|
||||
// 未绑定:占位卡片,点打开弹框绑定
|
||||
html = `
|
||||
<div class="channel-card cc-placeholder" data-kind="${kind}" data-action="bind"
|
||||
title="绑定${cfg.label}后在${cfg.label}里直接和 zcbot 对话">
|
||||
<span class="cc-icon">${WECHAT_ICON}</span>
|
||||
<span class="cc-body">
|
||||
<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("");
|
||||
// 绑定事件:绑定点打开弹框;已绑定点打开对话(占位点管理);右键/点击 ⚙ 打开弹框管理
|
||||
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 切换)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(); // 点遮罩关闭
|
||||
|
|
|
|||
Loading…
Reference in New Issue