feat(integrations): 微信要先扫码配对,改成 iLink 扫码登录 + 长轮询
CI / backend (push) Canceled after 0s

上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。
现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 →
后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。

后端
- wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage,
  bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文
- wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题
- wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复;
  message_id 去重;errcode -14 转 expired 让用户重新扫
- api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息
- 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子)

Web
- 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息

验证
- pytest 149 passed(新增 test_wechat_bot 9 例)
- ruff check / ruff format --check 干净;tsc 0;vite build ✓
- web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码)

教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
This commit is contained in:
Steven
2026-11-05 23:03:18 +08:00
parent d21394a5ac
commit 8436c0edbf
18 changed files with 1836 additions and 1060 deletions
+36 -54
View File
@@ -1,9 +1,8 @@
/**
* 微信接入的端到端验证:页面上建通道 → 把"微信"发来的那条 POST 打到回调地址
* → 任务真出现在任务列表里。
* 微信接入的端到端验证:页面上点「扫码连接微信」→ 真拿一张二维码回来 → 画在页面上。
*
* 这里不发真正的微信消息(那得有人拿手机发),而是照着 clawbot 的形状
* 直接 POST 一次——它就是这个接口唯一的调用方。
* 不扫这张码(那得有人拿手机扫),所以只验到"二维码出来了、通道在等扫码"为止;
* 扫码之后的落库与轮询由 `backend/tests/test_wechat_bot.py` 覆盖。
*/
import { readFileSync } from "node:fs";
import { chromium } from "playwright-core";
@@ -64,67 +63,50 @@ async function main() {
localStorage.setItem("dadao.access", access);
localStorage.setItem("dadao.refresh", refresh);
},
[token, session.tokens.refresh_token],
[session.tokens.access_token, session.tokens.refresh_token],
);
await page.goto(`${WEB}/w/${ws.id}/integrations`, { waitUntil: "networkidle" });
check("接入页能打开", await page.getByRole("heading", { name: "接入" }).isVisible());
check("接入页打开", await page.getByText("怎么用").isVisible());
// 建一条新通道:令牌只在创建后那一屏出现
await page.getByRole("button", { name: "新建通道" }).click();
const urlBox = page.getByTestId("webhook-url");
await urlBox.waitFor({ state: "visible" });
const url = (await urlBox.innerText()).trim();
check("创建后拿到回调地址", /\/integrations\/clawbot\/[\w-]{20,}/.test(url), url.slice(0, 48) + "…");
const button = page.getByRole("button", { name: "扫码连接微信" });
if (await button.isVisible()) {
await button.click();
} else {
// 之前连过:卡片上给的是「重新扫码」
await page.getByRole("button", { name: "重新扫码" }).click();
}
// 冒充 clawbot 推一条消息
const push = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
msg_id: `e2e-${Date.now()}`,
from: "e2e",
text: `微信来的任务 ${Date.now()}\n从手机发的,带正文`,
}),
});
const pushed = await push.json();
check("回调地址收到消息并建了任务", pushed.code === 0 && pushed.data.action === "task");
const title = pushed.data.reply.match(/《(.+?)》/)?.[1] || "";
const card = page.getByTestId("wechat-qr");
await card.waitFor({ state: "visible", timeout: 20_000 });
const img = card.locator("img");
const src = await img.getAttribute("src");
check(
"二维码是页面里画出来的 SVG",
Boolean(src?.startsWith("data:image/svg+xml;base64,")),
src?.slice(0, 32),
);
check(
"提示用微信扫码",
(await card.innerText()).includes("用微信扫这张码"),
(await card.innerText()).split("\n")[0],
);
// 再推一次同一条:不该多出第二条
const again = await (
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
})
).json();
const dup = await (
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ msg_id: "dup", text: "重复消息" }),
})
).json();
check("同一条消息不会建两条", again.code === 0 && dup.data.duplicate === true);
const channels = await (await fetch(`${API}/workspaces/${ws.id}/integrations`, { headers })).json();
const channel = channels.data.items[0];
check("后端记下了这条通道", Boolean(channel), JSON.stringify(channel?.status));
check("还没扫码,状态是 pending", channel?.status === "pending", channel?.status);
check("没有登录态就不该在轮询", channel?.polling === false, String(channel?.polling));
// 页面上的「最近消息」要能看到刚才那几条
await page.getByRole("button", { name: "最近消息" }).first().click();
await page.getByText("建了任务").first().waitFor({ state: "visible" });
check("页面能看到收到的消息", await page.getByText("建了任务").first().isVisible());
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: false });
check("控制台没有报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
// 任务真的进了任务列表
const items = await (await fetch(`${API}/workspaces/${ws.id}/items`, { headers })).json();
const found = items.data.items.find((i) => i.title === title);
check("任务出现在工作区里", Boolean(found), title);
check("控制台零报错", consoleErrors.length === 0, consoleErrors.slice(0, 2).join(" | "));
await page.screenshot({ path: "e2e/artifacts/integrations.png", fullPage: true });
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log(`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`);
console.log(
`\n${failed.length === 0 ? "ALL PASS" : "FAILED"} — pass ${results.length - failed.length}, fail ${failed.length}`,
);
if (failed.length > 0) process.exit(1);
}
+14 -2
View File
@@ -225,17 +225,29 @@ export interface AppNotification {
created_at: string;
}
/** 外部接入通道:微信机器人靠这条令牌往工作区里发消息。 */
/** 微信通道:`status` 是登录态的四种情形,`connected` 只是它的近视写法。 */
export interface BotChannel {
id: string;
kind: string;
name: string;
status: "active" | "disabled";
created_by: string | null;
status: "pending" | "active" | "disabled" | "expired";
connected: boolean;
polling: boolean;
ilink_user_id: string | null;
last_seen_at: string | null;
last_error: string | null;
created_at: string;
}
/** 取二维码的返回:`image` 是 data URL,前端直接塞进 `<img>`。 */
export interface WechatQr {
channel_id: string;
qrcode_id: string;
payload: string;
image: string;
expires_in: number;
}
/** 收到的消息留痕:`action` 说明它变成了什么。 */
export interface BotMessage {
id: string;
+242 -221
View File
@@ -1,18 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { AppShell } from "../components/AppShell";
import { Chat, Link2, Plus, Trash } from "../components/icons";
import { Chat } from "../components/icons";
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows } from "../components/ui";
import { useAuth } from "../App";
import { api, ApiError } from "../lib/client";
import { copyText } from "../lib/clipboard";
import type { BotChannel, BotMessage } from "../lib/types";
/** 真发出去的那一步:把令牌拼成地址。 */
function webhookUrl(origin: string, token: string) {
return `${origin.replace(/\/$/, "")}/api/v1/integrations/clawbot/${token}`;
}
import type { BotChannel, BotMessage, WechatQr } from "../lib/types";
function ago(iso: string | null): string {
if (!iso) return "还没收到过消息";
@@ -33,15 +27,42 @@ const ACTION_TEXT: Record<string, string> = {
error: "出错",
};
/** 扫码的几种回话,说给用户听的话。 */
const QR_TEXT: Record<string, string> = {
waiting: "用微信扫这张码,扫完在手机上点确认。",
scaned: "已经扫到了,在手机上点一下「确认登录」。",
confirmed: "配好了,现在可以直接在微信里发消息。",
expired: "二维码过期了,重新拿一张。",
error: "问登录结果时出错了,稍等再试。",
};
function ChannelStatus({ channel }: { channel: BotChannel }) {
if (channel.status === "active") {
return (
<span className="chip bg-primary-50 text-primary-700">
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-primary-600" />
接收中
</span>
);
}
if (channel.status === "expired") {
return <span className="chip bg-rust-50 text-rust-500">登录已失效</span>;
}
if (channel.status === "disabled") {
return <span className="chip bg-paper-sunken text-ink-500">已停用</span>;
}
return <span className="chip bg-paper-sunken text-ink-500">待扫码</span>;
}
/**
* 接入页:把微信(clawbot)接进这个工作区。
*
* 令牌是通道的钥匙,**只在创建/重生成时出现一次**——旁边配一个复制按钮,
* 因为用户要做的就是把它粘进 clawbot 的后台。
* 走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
* 页面这一侧只做两件事:把二维码画出来、反复问「扫了没」。
*/
export function IntegrationsPage() {
const { wsId = "" } = useParams();
const { user, workspaces } = useAuth();
const { workspaces } = useAuth();
const ws = workspaces.find((w) => w.id === wsId);
const canManage = ws?.role === "owner" || ws?.role === "admin";
@@ -49,10 +70,12 @@ export function IntegrationsPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
/** 刚建好或刚换过的令牌:只活在这一屏里,刷新就没了(库里只有 sha256)。 */
const [fresh, setFresh] = useState<{ id: string; token: string; url: string } | null>(null);
const [copied, setCopied] = useState<"url" | "token" | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [qr, setQr] = useState<WechatQr | null>(null);
const [qrStatus, setQrStatus] = useState<string>("waiting");
const [messages, setMessages] = useState<Record<string, BotMessage[]>>({});
/** 关掉轮询:确认成功、过期、或者用户离开这一屏。 */
const stopPolling = useRef(false);
const load = useCallback(async () => {
setLoading(true);
@@ -61,7 +84,7 @@ export function IntegrationsPage() {
const data = await api.get<{ items: BotChannel[] }>(`/workspaces/${wsId}/integrations`);
setChannels(data.items);
} catch (err) {
setError(err instanceof ApiError ? err.message : "读取接入配置失败");
setError(err instanceof ApiError ? err.message : "读不到接入配置");
} finally {
setLoading(false);
}
@@ -71,99 +94,95 @@ export function IntegrationsPage() {
void load();
}, [load]);
async function create() {
setBusy("create");
const channel = channels[0] ?? null;
const startQr = useCallback(async () => {
setBusy("qr");
setError(null);
setNotice(null);
setMessages({});
stopPolling.current = false;
try {
const data = await api.post<{ channel: BotChannel; token: string }>(
`/workspaces/${wsId}/integrations`,
{ name: "微信" },
);
setFresh({
id: data.channel.id,
token: data.token,
url: webhookUrl(window.location.origin, data.token),
});
await load();
const data = await api.post<WechatQr>(`/workspaces/${wsId}/integrations/wechat/qr`, {});
setQr(data);
setQrStatus("waiting");
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建通道失败");
setError(err instanceof ApiError ? err.message : "取二维码失败,稍后再试");
setQr(null);
} finally {
setBusy(null);
}
}
}, [wsId]);
async function toggle(channel: BotChannel) {
setBusy(channel.id);
try {
await api.patch<{ channel: BotChannel }>(
`/workspaces/${wsId}/integrations/${channel.id}`,
{ status: channel.status === "active" ? "disabled" : "active" },
);
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "改状态失败");
} finally {
setBusy(null);
}
}
// 每 2 秒问一次扫码进度;确认之后换成"已连接"并停手。
useEffect(() => {
if (!qr || stopPolling.current) return;
let alive = true;
const timer = window.setTimeout(async () => {
try {
const data = await api.get<{ status: string; channel?: BotChannel }>(
`/workspaces/${wsId}/integrations/wechat/qr/${qr.qrcode_id}`,
);
if (!alive) return;
setQrStatus(data.status);
if (data.status === "confirmed") {
stopPolling.current = true;
setQr(null);
setNotice("微信已经连上,可以直接在对话里发「任务:…」或「知识:…」。");
void load();
} else if (data.status === "expired") {
stopPolling.current = true;
}
} catch {
if (alive) setQrStatus("error");
}
}, 2000);
return () => {
alive = false;
window.clearTimeout(timer);
};
}, [qr, qrStatus, wsId, load]);
async function rotate(channel: BotChannel) {
setBusy(channel.id);
try {
const data = await api.post<{ token: string }>(
`/workspaces/${wsId}/integrations/${channel.id}/rotate`,
{},
);
setFresh({
id: channel.id,
token: data.token,
url: webhookUrl(window.location.origin, data.token),
});
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "换令牌失败");
} finally {
setBusy(null);
}
}
const disconnect = useCallback(
async (id: string) => {
setBusy(id);
setError(null);
try {
await api.del(`/workspaces/${wsId}/integrations/${id}?confirm=true`);
setQr(null);
stopPolling.current = true;
setNotice("已断开,微信里再发消息就不会进来了。");
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "断开失败");
} finally {
setBusy(null);
}
},
[wsId, load],
);
async function remove(channel: BotChannel) {
if (!window.confirm(`删掉「${channel.name}」这条通道?微信那边再发消息就进不来了。`)) return;
setBusy(channel.id);
try {
await api.del(`/workspaces/${wsId}/integrations/${channel.id}`);
if (fresh?.id === channel.id) setFresh(null);
await load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "删除失败");
} finally {
setBusy(null);
}
}
async function peek(channel: BotChannel) {
setBusy(channel.id);
try {
const data = await api.get<{ items: BotMessage[] }>(
`/workspaces/${wsId}/integrations/${channel.id}/messages?limit=5`,
);
setMessages((prev) => ({ ...prev, [channel.id]: data.items }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "读取消息失败");
} finally {
setBusy(null);
}
}
async function copy(kind: "url" | "token", text: string) {
const ok = await copyText(text);
if (!ok) {
setError("浏览器不让直接写剪贴板,请手动选中复制");
return;
}
setCopied(kind);
window.setTimeout(() => setCopied(null), 1600);
}
const showMessages = useCallback(
async (id: string) => {
if (messages[id]) {
setMessages((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
return;
}
try {
const data = await api.get<{ items: BotMessage[] }>(
`/workspaces/${wsId}/integrations/${id}/messages?limit=5`,
);
setMessages((prev) => ({ ...prev, [id]: data.items }));
} catch (err) {
setError(err instanceof ApiError ? err.message : "读不到消息记录");
}
},
[wsId, messages],
);
return (
<AppShell wsId={wsId} active="integrations">
@@ -172,8 +191,8 @@ export function IntegrationsPage() {
desc="微信里发一句话,这里就多一条任务或知识。不用装 App,也不用切浏览器。"
actions={
canManage ? (
<Button variant="primary" onClick={() => void create()} disabled={busy === "create"}>
<Plus size={14} /> 新建通道
<Button variant="primary" onClick={() => void startQr()} disabled={busy === "qr"}>
{busy === "qr" ? "正在取二维码…" : qr ? "换一张二维码" : "扫码连接微信"}
</Button>
) : null
}
@@ -184,140 +203,142 @@ export function IntegrationsPage() {
<InlineAlert tone="error">{error}</InlineAlert>
</div>
) : null}
<div className="mb-5 rounded-xl border border-line-200 bg-paper-50 px-4 py-3 text-sm text-ink-600">
<p className="font-medium text-ink-800">怎么用</p>
<ol className="mt-1.5 list-decimal space-y-1 pl-5 text-sm leading-6">
<li>在下面建一条通道,把「回调地址」粘进 clawbot 的后台。</li>
<li>在微信里给机器人发消息:直接说就是任务,「知识:」开头记成知识草稿。</li>
<li>首行当标题,其余当正文;标题里带 <code className="text-ink-800">#标签</code> 会一起加上。</li>
</ol>
</div>
{fresh ? (
<div className="mb-5 rounded-xl border border-primary-200 bg-primary-50/60 px-4 py-4">
<p className="text-sm font-medium text-primary-800">通道已就绪,这两样只在现在显示一次</p>
<div className="mt-3 space-y-2">
<div>
<p className="text-2xs uppercase tracking-wide text-ink-500">回调地址</p>
<div className="mt-1 flex items-start gap-2">
<code
data-testid="webhook-url"
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
>
{fresh.url}
</code>
<Button size="sm" onClick={() => void copy("url", fresh.url)}>
<Link2 size={13} /> {copied === "url" ? "已复制" : "复制"}
</Button>
</div>
</div>
<div>
<p className="text-2xs uppercase tracking-wide text-ink-500">令牌</p>
<div className="mt-1 flex items-start gap-2">
<code
data-testid="webhook-token"
className="min-w-0 flex-1 break-all rounded-lg bg-white px-3 py-2 text-xs text-ink-800"
>
{fresh.token}
</code>
<Button size="sm" onClick={() => void copy("token", fresh.token)}>
{copied === "token" ? "已复制" : "复制"}
</Button>
</div>
</div>
</div>
<p className="mt-3 text-xs text-ink-500">
令牌等同于钥匙:库里只存它的哈希,页面刷新就再也看不到。丢了就「重新生成」,旧地址立刻失效。
</p>
{notice ? (
<div className="mb-4">
<InlineAlert tone="success">{notice}</InlineAlert>
</div>
) : null}
<section className="card mb-4 p-5">
<h2 className="text-sm font-semibold text-ink-900">怎么用</h2>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm leading-6 text-ink-600">
<li>点右上「扫码连接微信」,用手机微信扫出现的二维码并在手机上确认。</li>
<li>连上之后直接给这个微信机器人发消息:默认记成任务,「知识:」开头记成知识草稿。</li>
<li>第一行当标题,其余当正文;标题或正文里带 #标签 会一起加上。</li>
</ol>
</section>
{loading ? (
<SkeletonRows rows={3} />
) : channels.length === 0 ? (
<EmptyState
icon={<Chat size={18} />}
title="还没有接进来"
hint={
canManage
? "先建一条通道,再把地址填到 clawbot 里;之后微信里发的话就会出现在任务和知识里。"
: "这个工作区还没有配接入通道。需要所有者或管理员来建。"
}
/>
<div className="card">
<SkeletonRows rows={2} />
</div>
) : (
<ul className="space-y-3">
{channels.map((channel) => (
<li key={channel.id} className="rounded-xl border border-line-200 bg-white px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-primary-50 text-primary-700">
<Chat size={16} />
</span>
<>
{qr ? (
<section className="card mb-4 p-5" data-testid="wechat-qr">
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center">
<img
src={qr.image}
alt="微信扫码登录二维码"
className="h-44 w-44 rounded-xl border border-line-200 bg-white p-2"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-ink-900">{channel.name}</p>
<p className="text-xs text-ink-500">{ago(channel.last_seen_at)}</p>
<h2 className="text-sm font-semibold text-ink-900">
{QR_TEXT[qrStatus] ?? "等扫码结果…"}
</h2>
<p className="mt-1 text-xs leading-5 text-ink-500">
二维码 5 分钟内有效。扫完手机上会问「是否允许登录」,点确认就配好了。
</p>
{qrStatus === "expired" || qrStatus === "error" ? (
<Button className="mt-3" variant="subtle" onClick={() => void startQr()}>
重新取二维码
</Button>
) : null}
</div>
<span
className={`ml-1 rounded-full px-2 py-px text-2xs ${
channel.status === "active"
? "bg-primary-50 text-primary-700"
: "bg-paper-100 text-ink-500"
}`}
>
{channel.status === "active" ? "接收中" : "已停用"}
</span>
{canManage ? (
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Button size="sm" onClick={() => void peek(channel)} disabled={busy === channel.id}>
最近消息
</Button>
<Button size="sm" onClick={() => void toggle(channel)} disabled={busy === channel.id}>
{channel.status === "active" ? "停用" : "启用"}
</Button>
<Button size="sm" onClick={() => void rotate(channel)} disabled={busy === channel.id}>
重新生成令牌
</Button>
<Button size="sm" onClick={() => void remove(channel)} disabled={busy === channel.id}>
<Trash size={13} />
</Button>
</div>
</section>
) : null}
{channel ? (
<section className="card p-5" data-testid="wechat-channel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary-50 text-primary-700">
<Chat size={15} />
</span>
<span className="text-sm font-semibold text-ink-900">{channel.name || "微信"}</span>
<ChannelStatus channel={channel} />
{channel.polling ? <span className="text-xs text-ink-400">正在接收</span> : null}
</div>
) : null}
<p className="mt-1 text-xs text-ink-500">
{channel.status === "active"
? ago(channel.last_seen_at)
: "还没连上微信,扫码之后才收得到消息"}
{channel.ilink_user_id ? ` · 微信用户 ${channel.ilink_user_id}` : ""}
</p>
{channel.last_error ? (
<p className="mt-1 text-xs text-rust-500">{channel.last_error}</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="subtle" size="sm" onClick={() => void showMessages(channel.id)}>
{messages[channel.id] ? "收起消息" : "最近消息"}
</Button>
{canManage ? (
<>
<Button
variant="subtle"
size="sm"
onClick={() => void startQr()}
disabled={busy === "qr"}
>
重新扫码
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void disconnect(channel.id)}
disabled={busy === channel.id}
>
断开
</Button>
</>
) : null}
</div>
</div>
{channel.last_error ? (
<p className="mt-2 text-xs text-clay-600">上次出错:{channel.last_error}</p>
) : null}
{messages[channel.id] ? (
messages[channel.id].length === 0 ? (
<p className="mt-3 text-xs text-ink-500">还没有收到过消息。</p>
) : (
<ul className="mt-3 space-y-1.5 border-t border-line-100 pt-2.5">
{messages[channel.id].map((m) => (
<li key={m.id} className="flex gap-2 text-xs text-ink-600">
<span className="shrink-0 rounded bg-paper-100 px-1.5 py-px text-2xs text-ink-500">
<div className="mt-3 divide-y divide-line-100 border-t border-line-100">
{messages[channel.id].length === 0 ? (
<p className="py-3 text-xs text-ink-500">还没收到过消息。</p>
) : (
messages[channel.id].map((m) => (
<div key={m.id} className="flex items-start justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="truncate text-xs text-ink-700">{m.text || "(没有文字)"}</p>
<p className="mt-0.5 text-[11px] text-ink-400">{m.sender}</p>
</div>
<span className="shrink-0 text-[11px] text-ink-500">
{ACTION_TEXT[m.action] ?? m.action}
</span>
<span className="min-w-0 flex-1 truncate">{m.text || "(空)"}</span>
<span className="shrink-0 text-2xs text-ink-400">
{new Date(m.created_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</li>
))}
</ul>
)
</div>
))
)}
</div>
) : null}
</li>
))}
</ul>
)}
</section>
) : (
<div className="card">
<EmptyState
icon={<Chat size={18} />}
title="还没连微信"
hint={
canManage
? "点右上「扫码连接微信」,用手机扫一下就行。"
: "只有工作区管理员能连微信,让他扫一下。"
}
/>
</div>
)}
{!canManage && user ? (
<p className="mt-4 text-xs text-ink-500">接入通道只有所有者和管理员能改。</p>
) : null}
{!canManage ? (
<p className="mt-3 text-xs text-ink-500">
你不是这个工作区的管理员,能看到接入状态,但不能扫码或断开。
</p>
) : null}
</>
)}
</AppShell>
);
}