feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)

缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
This commit is contained in:
AskaEth committed 2026-10-05 10:37:56 +08:00
1 parent c4ea02a887
commit 42279b7124
21 files changed
+1719 -164

No files matched your search

@@ -0,0 +1,139 @@
import type { AdminChannelAccount } from "../../../lib/types";
import { Button, Tag } from "../../../components/primitives";
import { authKeyOf } from "../channelFieldRules";
import { adapterNameOf } from "./channelUtils";
import { accountActionGate, shortAccountID, type ChannelStatus } from "./channelStatus";
/** 一条渠道账号在活动适配器列表里的行。
*
* 为什么每个账号必须自成一行:旧面板每平台只有一组「连接/断开」,点下去打的是
* 后端**自己挑**的一条账号 —— 主人有两条 qq-obv11 时,他点「连接」看到「已连接」,
* 实际连的却是那条 mode=server、listen_addr 为空的历史条目(新账号永远轮不到)。
* 逐账号按钮把「连的是哪一条」变成用户的选择,`accountId` 随请求发出;
* 按钮旁的小字进一步说明本次会作用在哪个账号上。 */
export function AccountRow({
platform,
account,
status,
busy,
onConnect,
onDisconnect,
onConfig,
onDelete,
onDeleteEmpty,
onLog,
onDetail,
}: {
platform: string;
account: AdminChannelAccount;
status: ChannelStatus;
busy: string | null;
onConnect: () => void;
onDisconnect: () => void;
onConfig: () => void;
onDelete: () => void;
onDeleteEmpty: () => void;
onLog: () => void;
onDetail: () => void;
}): React.ReactNode {
const id = account.id?.trim() ?? "";
const noID = id === "";
const adapter = adapterNameOf(platform, id);
const short = shortAccountID(id);
const label = account.botNickname || (account.botUsername ? `@${account.botUsername}` : short);
const authKey = authKeyOf(platform);
const authLabel = authKey === "bot_token" ? "token" : authKey;
const raw = account.config?.[authKey];
const authSet = typeof raw === "string" && raw.trim() !== "";
const gate = accountActionGate(status, account.connected);
const state = connectionTag(account.connected);
const blocker = status.kind === "unknown" ? "读取不到连接状态(后端失联),已禁用" : undefined;
return (
<span
className="flex w-full flex-wrap items-center gap-2 rounded-[var(--mu-radius-md)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] px-2 py-1.5"
data-account-id={id}
>
<button
onClick={onDetail}
className="rounded-[var(--mu-radius-sm)] transition-transform duration-200 ease-[var(--mu-spring)] active:scale-95"
title={`进入 ${adapter} 专属页`}
>
<Tag tone="accent">{label}</Tag>
</button>
{noID ? null : (
<span className="text-xs text-[var(--mu-text-lo)]" title={`账号 id:${id}`}>
id {short}
</span>
)}
<Tag tone={account.connected === true ? "ok" : account.connected === false ? "err" : "neutral"}>{state}</Tag>
<Tag tone={authSet ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{authSet ? `${authLabel} 已配置` : `${authLabel} 未填`}
</Tag>
<Button
size="sm"
variant="soft"
disabled={busy !== null || !gate.canConnect}
title={blocker}
onClick={onConnect}
>
连接
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy !== null || !gate.canDisconnect}
title={blocker ?? (account.connected === undefined ? "该账号没有已注册的适配器,无从断开" : undefined)}
onClick={onDisconnect}
>
断开
</Button>
<span className="text-xs text-[var(--mu-text-lo)]">{accountActionHint(platform, id, account.connected)}</span>
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onLog}>
消息日志
</Button>
{noID ? (
<>
{/* 空 id 条目:面板既改不动(保存会被后端当成新账号另开一条)也删不掉
(按 id 删除没有 id 可给),所以这里显著标注 + 给显式删除入口。 */}
<Tag tone="warn" title="后端只按 id 定位账号:这条没有 id,保存配置会另开一条新账号,按 id 也删不掉">
无 id 的历史条目
</Tag>
<Button size="sm" variant="danger" disabled={busy !== null} onClick={onDeleteEmpty} title="删除该平台全部无 id 条目(后端 ?empty=1)">
删除无 id 条目
</Button>
</>
) : (
<>
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onConfig}>
配置
</Button>
<Button size="sm" variant="danger" disabled={busy !== null} onClick={onDelete}>
删除
</Button>
</>
)}
</span>
);
}
/** 账号级连接状态的呈现:已连接 / 未连接 / 状态未知(后端没报告该账号的活适配器)。 */
function connectionTag(connected: boolean | undefined): string {
if (connected === true) return "已连接";
if (connected === false) return "未连接";
return "状态未知";
}
/** 「本次会作用在哪一条账号」的小字。这是修掉「连了谁」这一困惑的关键文案:
* 用户点之前就该看见自己点的是哪一条;空 id 条目没有 id 可念,就念它的后果
* (它就是该平台的裸适配器)。 */
export function accountActionHint(platform: string, id: string, connected?: boolean): string {
if (id === "") {
return connected === true
? `断开无 id 条目(裸适配器 ${platform})`
: `将在无 id 条目(裸适配器 ${platform})上连接`;
}
return connected === true ? `断开账号 ${shortAccountID(id)}` : `将在账号 ${shortAccountID(id)} 上连接`;
}
@@ -12,7 +12,7 @@ import { KookPanel } from "./KookPanel";
import { WeixinLogin } from "./WeixinLogin";
import { GenericAdapterPanel } from "./GenericAdapterPanel";
import { platformOf } from "./channelUtils";
import { channelStatus, type ChannelStatus } from "./channelStatus";
import { channelStatus, shortAccountID, type ChannelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { useChannelActions } from "./useChannelActions";
@@ -27,7 +27,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
const { data, error: loadErr, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const { busy, error, notice, toggle, del } = useChannelActions(reload);
const { busy, error, notice, toggle, del, delEmpty } = useChannelActions(reload);
const record = (data ?? []).find((c) => platformOf(c.name) === platform);
const status = channelStatus(data, loadErr, record);
@@ -67,6 +67,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
busy={busy}
toggle={toggle}
del={del}
delEmpty={delEmpty}
/>
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
@@ -104,6 +105,7 @@ function AdapterStatusCard({
busy,
toggle,
del,
delEmpty,
}: {
label: string;
platform: string;
@@ -111,11 +113,24 @@ function AdapterStatusCard({
account?: AdminChannelAccount;
status: ChannelStatus;
busy: string | null;
toggle: (name: string, connect: boolean) => void;
toggle: (name: string, connect: boolean, accountId?: string) => void;
del: (platform: string, id: string) => void;
delEmpty: (platform: string) => void;
}): React.ReactNode {
const unknown = status.kind === "unknown";
const unknownTitle = unknown ? "连接状态未知,已禁用;请先确认后端在线" : undefined;
// 本页是**某一条账号**的专属页,因此连接/断开必须指名它(旧代码只发平台名,
// 多账号平台上会打到后端自己挑的那条)。accountId 为空 = 该平台的裸适配器,
// 也就是那条无 id 条目本身。
const target = accountId ?? "";
const emptyIDEntry = target === "" && account !== undefined && (account.id ?? "") === "";
// 「本次会作用在哪一条」在专属页上同样要写清:旧代码只发平台名,多账号平台上
// 连接/断开打到的是后端自己挑的那条,与用户当前看的这条可能不是同一条。
const scope = accountId
? `账号 ${shortAccountID(accountId)}`
: emptyIDEntry
? `无 id 条目(裸适配器 ${platform})`
: "该平台的默认账号";
return (
<Surface gap="sm" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
@@ -131,7 +146,7 @@ function AdapterStatusCard({
variant="soft"
disabled={busy !== null || !status.canConnect}
title={unknownTitle}
onClick={() => toggle(platform, true)}
onClick={() => toggle(platform, true, accountId)}
>
连接
</Button>
@@ -140,7 +155,7 @@ function AdapterStatusCard({
variant="ghost"
disabled={busy !== null || !status.canDisconnect}
title={unknownTitle}
onClick={() => toggle(platform, false)}
onClick={() => toggle(platform, false, accountId)}
>
断开
</Button>
@@ -148,8 +163,12 @@ function AdapterStatusCard({
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => del(platform, accountId)}>
删除账号
</Button>
) : emptyIDEntry ? (
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => delEmpty(platform)}>
删除无 id 条目
</Button>
) : null}
<span className="text-xs text-[var(--mu-text-lo)]">连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。</span>
<span className="text-xs text-[var(--mu-text-lo)]">连接/断开只作用于{scope};删除只作用于它自己。</span>
{unknown ? (
<span className="text-xs text-[var(--mu-warn)]">读取不到连接状态,连接/断开已禁用;请先确认后端在线再重试。</span>
) : null}
@@ -4,13 +4,20 @@ import type { AdminChannelRecord } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAutoReload } from "../../hooks";
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../../components";
import { Button, Tag } from "../../../components/primitives";
import { Button } from "../../../components/primitives";
import { PLATFORM_LABEL } from "../channelFields";
import { authKeyOf } from "../channelFieldRules";
import { ChannelConfigForm } from "../channelConfigForm";
import { ChannelLogPanel } from "./channelLogPanel";
import { ChannelIdentityTest } from "./channelIdentityTest";
import { adapterNameOf, buildCfgTarget, configuredPlatforms, platformOf, type CfgTarget } from "./channelUtils";
import { AccountRow } from "./AccountRow";
import {
adapterNameOf,
buildCfgTarget,
configuredPlatforms,
platformConnectHint,
platformOf,
type CfgTarget,
} from "./channelUtils";
import { channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { useChannelActions } from "./useChannelActions";
@@ -21,7 +28,7 @@ import type { AdapterNav } from "./useAdapterNav";
export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): React.ReactNode {
const { data, loading, error: loadErr, reload } = useAsyncData(adminListChannels, []);
useAutoReload(reload, 10000);
const { busy, setBusy, error, setError, notice, setNotice, toggle, del } = useChannelActions(reload);
const { busy, setBusy, error, setError, notice, setNotice, toggle, del, delEmpty } = useChannelActions(reload);
const [cfg, setCfg] = useState<CfgTarget | null>(null);
const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null);
@@ -43,6 +50,7 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
openConfig,
toggle,
del,
delEmpty,
openDetail: (a) => navigate({ kind: "detail", adapter: a }),
openLog: (a) => setLogFor({ adapter: a }),
};
@@ -99,8 +107,10 @@ interface ListCtx {
busy: string | null;
/** `accountId` 指定要编辑的账号(多账号平台逐条编辑);缺省 = 该平台第一条。 */
openConfig: (name: string, accountId?: string) => void;
toggle: (name: string, connect: boolean) => void;
/** `accountId` 给出时只作用于该账号;缺省 = 平台级(后端按规则挑一条)。 */
toggle: (name: string, connect: boolean, accountId?: string) => void;
del: (platform: string, id: string) => void;
delEmpty: (platform: string) => void;
openDetail: (adapter: string) => void;
openLog: (adapter: string) => void;
}
@@ -109,72 +119,60 @@ function columnsOf(ctx: ListCtx): AdminColumn<AdminChannelRecord>[] {
return [
{ key: "name", title: "平台", render: (c) => <span className="font-medium">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</span> },
{ key: "connected", title: "状态", render: (c) => <ChannelStatusTag status={channelStatus(ctx.data, ctx.error, c)} showHint={false} /> },
{ key: "accounts", title: "配置账号(点击进入其专属页)", render: (c) => <AccountsCell record={c} ctx={ctx} /> },
{ key: "actions", title: "操作", render: (c) => <ActionsCell record={c} ctx={ctx} /> },
{ key: "accounts", title: "配置账号(逐条连接/断开/配置)", render: (c) => <AccountsCell record={c} ctx={ctx} /> },
{ key: "actions", title: "平台级操作", render: (c) => <ActionsCell record={c} ctx={ctx} /> },
];
}
/** One configured account per entry: enter its detail page, log, delete. */
/** One row per configured account: it carries its own connect/disconnect (with the
* accountId), so「连接」never silently lands on another account. */
function AccountsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCtx }): React.ReactNode {
if (!record.accounts || record.accounts.length === 0) {
return <span className="text-xs text-[var(--mu-text-lo)]">—</span>;
}
// 「凭据是否已配」必须看该平台**真正读取**的键:qq-obv11 读 access_token、webhook 读
// secret,用 bot_token 去判断会让配好的渠道永远显示「未填」。
const authKey = authKeyOf(platformOf(record.name));
const authLabel = authKey === "bot_token" ? "token" : authKey;
const platform = platformOf(record.name);
const status = channelStatus(ctx.data, ctx.error, record);
return (
<span className="flex flex-wrap items-center gap-2">
{record.accounts.map((a) => {
const adapter = adapterNameOf(platformOf(record.name), a.id);
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
const raw = a.config?.[authKey];
const tokenMasked = typeof raw === "string" && raw.trim() !== "";
return (
<span key={a.id || "default"} className="flex flex-wrap items-center gap-1">
<button
onClick={() => ctx.openDetail(adapter)}
className="rounded-[var(--mu-radius-sm)] transition-transform duration-200 ease-[var(--mu-spring)] active:scale-95"
title={`进入 ${adapter} 专属页`}
>
<Tag tone="accent">{label}</Tag>
</button>
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
{tokenMasked ? `${authLabel} 已配置` : `${authLabel} 未填`}
</Tag>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.openLog(adapter)}>
消息日志
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !a.id} onClick={() => ctx.openConfig(record.name, a.id)}>
配置
</Button>
{a.id ? (
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.del(platformOf(record.name), a.id)}>
删除
</Button>
) : null}
</span>
);
})}
<span className="flex w-full flex-col gap-1.5">
{record.accounts.map((a, idx) => (
<AccountRow
key={a.id || `__no_id_${idx}__`}
platform={platform}
account={a}
status={status}
busy={ctx.busy}
onConnect={() => ctx.toggle(record.name, true, a.id)}
onDisconnect={() => ctx.toggle(record.name, false, a.id)}
onConfig={() => ctx.openConfig(record.name, a.id)}
onDelete={() => ctx.del(platform, a.id)}
onDeleteEmpty={() => ctx.delEmpty(platform)}
onLog={() => ctx.openLog(adapterNameOf(platform, a.id))}
onDetail={() => ctx.openDetail(adapterNameOf(platform, a.id))}
/>
))}
</span>
);
}
/** 连接 / 断开 / 配置. Gated by the row's tri-state so an unread state cannot be
* acted on as if it read "未连接". */
/** 平台级 连接 / 断开 / 配置。这些按钮**不带** accountId,目标由后端规则决定
* (精确同名适配器优先,否则该平台第一条账号),因此下方小字必须说清它会作用在
* 哪一条 —— 否则主人会把「已连接」误读成「连上了我想连的那条」。 */
function ActionsCell({ record, ctx }: { record: AdminChannelRecord; ctx: ListCtx }): React.ReactNode {
const st = channelStatus(ctx.data, ctx.error, record);
return (
<span className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="soft" disabled={ctx.busy !== null} onClick={() => ctx.openConfig(record.name)}>
配置
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !st.canConnect} onClick={() => ctx.toggle(record.name, true)}>
连接
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !st.canDisconnect} onClick={() => ctx.toggle(record.name, false)}>
断开
</Button>
<span className="flex flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="soft" disabled={ctx.busy !== null} onClick={() => ctx.openConfig(record.name)}>
配置
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !st.canConnect} onClick={() => ctx.toggle(record.name, true)}>
平台级连接
</Button>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null || !st.canDisconnect} onClick={() => ctx.toggle(record.name, false)}>
平台级断开
</Button>
</span>
<span className="text-xs text-[var(--mu-text-lo)]">{platformConnectHint(record)}</span>
</span>
);
}
@@ -0,0 +1,137 @@
// 渠道列表「每账号一行」的**真实渲染**测试。
//
// 为什么需要它:这次的产品缺口在渲染层的表现是「每平台只有一组连接/断开」——
// 主人点了「连接」,界面回「已连接」,但连的是后端挑的另一条账号。修好之后,
// 每行必须自带连接/断开 + 「本次会作用在哪一条账号」的小字,空 id 的历史条目
// 必须有显著标注和删除入口。这些是**画出来才算数**的事,纯逻辑测不了。
//
// 跑法(同 channelConfigForm.render.test.ts):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/accountRow.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-account-row.test.cjs \
// && node /tmp/cyrene-channel-account-row.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelAccount, AdminChannelRecord } from "../../../lib/types";
import { AccountRow } from "./AccountRow";
import { platformConnectHint } from "./channelUtils";
import type { ChannelStatus } from "./channelStatus";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** 平台列表已读好的常态(未知/读取失败的门控在 channelActions.test.ts 里测)。 */
const READY: ChannelStatus = {
kind: "disconnected",
label: "未连接",
tone: "err",
hint: null,
stale: false,
canConnect: true,
canDisconnect: false,
};
function renderRow(account: AdminChannelAccount, platform = "qq-obv11"): string {
const noop = (): void => {};
return renderToStaticMarkup(
createElement(AccountRow, {
platform,
account,
status: READY,
busy: null,
onConnect: noop,
onDisconnect: noop,
onConfig: noop,
onDelete: noop,
onDeleteEmpty: noop,
onLog: noop,
onDetail: noop,
}),
);
}
/** 被禁用按钮的标记。注意 Button 的 className 里本来就有 `disabled:opacity-50`
* 这类 Tailwind 片段,所以只能用 React 实际渲染出的 `disabled=""` 属性来判定。 */
const DISABLED = 'disabled=""';
/** 取出渲染出的某个按钮元素(用于断言它是否被禁用)。 */
function buttonHTML(html: string, label: string): string {
const end = html.indexOf(`>${label}</button>`);
if (end < 0) throw new Error(`FAILED: 按钮「${label}」没有渲染出来`);
return html.slice(html.lastIndexOf("<button", end), end);
}
// ── ① 有 id 的账号:自带连接/断开 + 说清作用在哪个账号 ─────────────────────────
{
const html = renderRow({ id: "9616b27a5c1d", config: { mode: "client", remote_url: "ws://x/y" } });
for (const label of ["连接", "断开", "配置", "删除", "消息日志"]) {
ok(has(html, `>${label}</button>`), `账号行渲染「${label}」`);
}
ok(has(html, "将在账号 9616b27a… 上连接"), "「连接」旁的小字点名本次会连哪一条账号");
ok(has(html, "状态未知"), "后端未报告该账号的活适配器 ⇒ 显示「状态未知」而不是「未连接」");
ok(!has(html, "无 id 的历史条目"), "有 id 的普通行不做空 id 标注");
ok(!has(html, "删除无 id 条目"), "有 id 的普通行不给「删无 id 条目」入口");
// 未知状态:给「连接」,不给「断开」(断一条状态未知的账号是盲操作)。
ok(!has(buttonHTML(html, "连接"), DISABLED), "状态未知仍可点「连接」");
ok(has(buttonHTML(html, "断开"), DISABLED), "状态未知时「断开」被禁用");
ok(has(html, "data-account-id=\"9616b27a5c1d\""), "行上带账号 id,便于排查");
}
// ── ② 已连接 / 未连接:按钮门控与提示随该账号自己的状态走 ──────────────────────
{
const up = renderRow({ id: "9616b27a", config: {}, connected: true });
ok(has(up, "已连接"), "connected=true 显示「已连接」");
ok(has(up, "断开账号 9616b27a"), "已连接时小字说的是「断开账号 X」");
ok(has(buttonHTML(up, "连接"), DISABLED), "已连接时「连接」被禁用");
ok(!has(buttonHTML(up, "断开"), DISABLED), "已连接时「断开」可点");
const down = renderRow({ id: "9616b27a", config: {}, connected: false });
ok(has(down, "未连接"), "connected=false 显示「未连接」");
ok(has(down, "将在账号 9616b27a 上连接"), "未连接时小字说的是「将在账号 X 上连接」");
ok(!has(buttonHTML(down, "连接"), DISABLED), "确知未连接时「连接」可点");
ok(has(buttonHTML(down, "断开"), DISABLED), "确知未连接时「断开」被禁用");
}
// ── ③ 空 id 的历史条目:显著标注 + 显式删除入口(按 id 的操作一律不给) ────────
{
const html = renderRow({ id: "", config: { mode: "server", listen_addr: "" } });
ok(has(html, "无 id 的历史条目"), "空 id 行显著标注");
ok(has(html, "后端只按 id 定位账号"), "标注里给出悬停说明(为什么它是个问题)");
ok(has(html, ">删除无 id 条目</button>"), "空 id 行提供删除入口(走后端 ?empty=1)");
ok(!has(html, ">配置</button>"), "空 id 行不给「配置」(保存会另开一条,越改越多)");
ok(!has(html, ">删除</button>"), "空 id 行不给「按 id 删除」(没有 id 可给)");
ok(has(html, "将在无 id 条目(裸适配器 qq-obv11)上连接"), "空 id 行的小字明说它是裸适配器,不渲染成空串");
ok(has(html, "data-account-id=\"\""), "空 id 行的 data 属性为空,可被测试/排查定位");
}
// ── ④ 平台级按钮的小字:说清它会作用在哪一条账号 ──────────────────────────────
{
const one = platformConnectHint({ name: "qq-obv11", connected: false, accounts: [{ id: "9616b27a", config: {} }] });
ok(has(one, "平台级 = 账号 9616b27a"), "单账号平台点名平台级连接就是这一条");
const two = platformConnectHint({
name: "qq-obv11",
connected: false,
accounts: [{ id: "aaaa1111", config: {} }, { id: "9616b27a", config: {} }],
} as AdminChannelRecord);
ok(has(two, "多账号(2 条)"), "多账号平台说明平台级按钮的目标不确定");
ok(has(two, "请用每行「连接」点名账号"), "多账号平台把用户导向逐账号按钮");
const none = platformConnectHint({ name: "qq-obv11", connected: false, accounts: [] });
ok(has(none, "平台级连接会失败"), "没有账号时明说平台级连接会失败");
}
console.log(`channel account row render test: ${checks} checks passed`);
@@ -0,0 +1,214 @@
// 「accountId 到底有没有随请求发出去」的**真实请求**测试。
//
// 为什么必须存在:这次的原始缺口就是「按平台连、且连的是第一条」——面板渲染得再好看,
// 只要请求体里没有 accountId,后端就只能自己挑一条账号。所以这里不看组件、不靠记忆,
// 而是 stub 掉 globalThis.fetch,直接读**真正发出的** URL 与请求体。
//
// 同时覆盖 `createChannelActions`(面板按钮实际调用的那层):验证逐账号按钮确实把
// accountId 传到了 API 层,以及显式 `?empty=1` 删除是唯一表达「删无 id 条目」的方式。
//
// 跑法(与 channelConfigForm.render.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelActions.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-actions.test.cjs \
// && node /tmp/cyrene-channel-actions.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
// 导入、不 import node:*。
import {
adminChannelClose,
adminChannelOpen,
adminDeleteChannelAccount,
adminDeleteEmptyChannelAccounts,
channelActionBody,
} from "../../../lib/api/adminChannels";
import { createChannelActions } from "./useChannelActions";
import { accountActionGate, shortAccountID, type ChannelStatus } from "./channelStatus";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq(got: unknown, want: unknown, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
}
// ── 请求捕获装置 ─────────────────────────────────────────────────────────────
// request() 会读 localStorage(token/apiKey)与 fetch,因此先把这两个全局装好;
// 模块在 import 时并不触碰它们(只在调用时读),所以静态 import 是安全的。
interface Captured {
url: string;
method: string;
body: string;
}
const captured: Captured[] = [];
function installStubs(): void {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: (url: string, init?: RequestInit) => {
captured.push({
url: String(url),
method: String(init?.method ?? "GET"),
body: typeof init?.body === "string" ? init.body : "",
});
// 只回一个满足 request() 读取面的最小响应对象(不依赖 fetch 的 Response 实现)。
const payload = { data: { name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true, removed: 2 } };
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(payload),
} as unknown as Response);
},
});
}
installStubs();
function lastCall(): Captured {
const call = captured[captured.length - 1];
if (!call) throw new Error("FAILED: 没有任何请求被发出");
return call;
}
/** 从捕获到的请求体里取出 accountId(缺省时返回 undefined)。 */
function accountIdOf(body: string): string | undefined {
const parsed = JSON.parse(body) as { accountId?: string };
return parsed.accountId;
}
// ── ① 纯构造:缺省时逐字维持旧契约 `{name}`,给出时才带 accountId ──────────────
{
eq(JSON.stringify(channelActionBody("qq-obv11")), '{"name":"qq-obv11"}', "缺省 accountId 的请求体逐字不变");
eq(JSON.stringify(channelActionBody("qq-obv11", "")), '{"name":"qq-obv11"}', "空 accountId 等同于缺省");
eq(JSON.stringify(channelActionBody("qq-obv11", " ")), '{"name":"qq-obv11"}', "纯空白 accountId 等同于缺省");
eq(accountIdOf(JSON.stringify(channelActionBody("qq-obv11", " 9616b27a "))), "9616b27a", "accountId 去除首尾空白后发出");
eq(channelActionBody("qq-obv11", "default").accountId, "default", "default 是显式的默认账号定位器,必须原样发出");
}
// ── ② 真实请求:open/close 带上 accountId,且 URL 未变 ─────────────────────────
{
captured.length = 0;
void adminChannelOpen("qq-obv11", "9616b27a").catch(() => {});
const open = lastCall();
eq(open.url, "/api/v1/admin/channels/open", "open 打到既有路径(契约未变)");
eq(open.method, "POST", "open 是 POST");
eq(accountIdOf(open.body), "9616b27a", "open 请求体带上 accountId(否则后端只能自己挑一条)");
void adminChannelClose("qq-obv11", "9616b27a").catch(() => {});
const close = lastCall();
eq(close.url, "/api/v1/admin/channels/close", "close 打到既有路径");
eq(accountIdOf(close.body), "9616b27a", "close 请求体带上 accountId");
// 平台级(缺省)调用仍是旧请求体:老后端/老客户端零影响。
void adminChannelOpen("qq-obv11").catch(() => {});
eq(lastCall().body, '{"name":"qq-obv11"}', "平台级 open 的请求体不带 accountId");
}
// ── ③ 真实请求:删除只有两种显式写法 ──────────────────────────────────────────
{
captured.length = 0;
void adminDeleteChannelAccount("qq-obv11", "9616b27a").catch(() => {});
eq(lastCall().url, "/api/v1/admin/channels/qq-obv11/config?id=9616b27a", "按 id 删除走 ?id=");
captured.length = 0;
void adminDeleteEmptyChannelAccounts("qq-obv11").catch(() => {});
const delEmpty = lastCall();
eq(delEmpty.url, "/api/v1/admin/channels/qq-obv11/config?empty=1", "删无 id 条目必须显式 ?empty=1");
ok(!delEmpty.url.includes("id="), "删无 id 条目**不能**靠「id 为空」表达");
}
// ── ④ 面板按钮真正调用的那一层:createChannelActions 必须把 accountId 传下去 ──
{
const notices: string[] = [];
const errors: string[] = [];
let reloads = 0;
const actions = createChannelActions({
log: () => {},
setBusy: () => {},
setError: (e) => {
if (e != null) errors.push(e);
},
setNotice: (n) => notices.push(n ?? ""),
reload: () => {
reloads += 1;
},
});
captured.length = 0;
actions.toggle("qq-obv11", true, "9616b27a");
eq(accountIdOf(lastCall().body), "9616b27a", "逐账号「连接」按钮把 accountId 传到了 API 层");
captured.length = 0;
actions.toggle("qq-obv11", false, "9616b27a");
eq(accountIdOf(lastCall().body), "9616b27a", "逐账号「断开」按钮把 accountId 传到了 API 层");
captured.length = 0;
actions.toggle("qq-obv11", true);
eq(lastCall().body, '{"name":"qq-obv11"}', "平台级按钮不带 accountId(后端按自己的规则挑一条)");
captured.length = 0;
actions.delEmpty("qq-obv11");
eq(lastCall().url, "/api/v1/admin/channels/qq-obv11/config?empty=1", "「删除无 id 条目」走显式 ?empty=1");
// 异步收尾(fetch 的回调在微任务里):等一拍后核对提示文案里回显了**实际被驱动的
// 适配器**与删除条数 —— 多账号平台上「已连接」不写清是哪一个就等于没说。
setTimeout(() => {
ok(
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
`连接成功提示必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
);
ok(
notices.some((n) => n.includes("已删除 2 条无 id 条目")),
`删除提示必须报出实际条数,got ${JSON.stringify(notices)}`,
);
eq(errors.length, 0, "这些成功路径不该产生错误提示");
eq(reloads, 4, "每次成功动作后都刷新列表");
console.log(`channel actions test: ${checks} checks passed`);
}, 20);
}
// ── ⑤ 每账号门控:状态未知只给「连接」,绝不给「断开」 ─────────────────────────
{
const ready: ChannelStatus = {
kind: "disconnected",
label: "未连接",
tone: "err",
hint: null,
stale: false,
canConnect: true,
canDisconnect: false,
};
const unknownList: ChannelStatus = { ...ready, kind: "unknown", canConnect: false, canDisconnect: false };
eq(accountActionGate(ready, true).canConnect, false, "已连接的账号不再给「连接」");
eq(accountActionGate(ready, true).canDisconnect, true, "已连接的账号给「断开」");
eq(accountActionGate(ready, false).canDisconnect, false, "确知未连接的账号不给「断开」");
eq(accountActionGate(ready, undefined).canConnect, true, "状态未知的账号仍可「连接」(点它正是把它变活)");
eq(accountActionGate(ready, undefined).canDisconnect, false, "状态未知的账号不给「断开」(断它是盲操作,后端也会拒)");
eq(accountActionGate(unknownList, true).canDisconnect, false, "列表都读不到时两个动作都禁用");
eq(accountActionGate(unknownList, undefined).canConnect, false, "列表都读不到时连「连接」也禁用");
eq(shortAccountID("9616b27a5c1d"), "9616b27a…", "长 id 截断显示");
eq(shortAccountID("conf0"), "conf0", "短 id 原样显示");
eq(shortAccountID(""), "(无 id)", "空 id 明说「无 id」而不是渲染成空串");
eq(shortAccountID(undefined), "(无 id)", "缺省 id 同样明说");
}
@@ -101,3 +101,31 @@ export function channelStatus(
canDisconnect: connected,
};
}
/** 每个账号一行连接/断开时的门控(多账号平台逐条操作用)。
*
* `connected` 是该账号**自己**的实时状态(`AdminChannelAccount.connected`):
* - `true` → 已连接:只给「断开」;
* - `false` → 确知未连接:只给「连接」;
* - 缺省 → 后端没有该账号的活适配器 ⇒ 状态**未知**:只给「连接」(点它正是把它
* 变成活的),**不给「断开」** —— 断一条状态未知的账号是盲操作,
* 后端也会以 404/409 拒绝,界面不该给一个注定失败的按钮。
*
* 平台级状态(列表读取失败/读取中/记录不在)优先:读都读不到时两个动作都禁用,
* 与 channelStatus 的平台级门控同一口径。 */
export function accountActionGate(
status: ChannelStatus,
connected?: boolean,
): { canConnect: boolean; canDisconnect: boolean } {
if (!status.canConnect && !status.canDisconnect) return { canConnect: false, canDisconnect: false };
if (connected === true) return { canConnect: false, canDisconnect: true };
return { canConnect: true, canDisconnect: false };
}
/** 账号 id 的短显示(面板里一行里塞得下):空 id 是「无 id 的历史条目」这一事实本身,
* 必须原样说出来,不能渲染成空串让主人以为「这行没问题」。 */
export function shortAccountID(id: string | undefined, keep = 8): string {
const t = (id ?? "").trim();
if (!t) return "(无 id)";
return t.length <= keep ? t : `${t.slice(0, keep)}…`;
}
@@ -2,6 +2,7 @@ import type { AdminChannelRecord } from "../../../lib/types";
import { PLATFORM_FIELDS } from "../channelFields";
import { defaultSelection } from "../channelFieldRules";
import { channelPersonaOf } from "./channelPersona";
import { shortAccountID } from "./channelStatus";
import type { ChannelConfigInitial } from "../channelConfigForm";
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
@@ -140,6 +141,24 @@ export function limitRequestBody(
return { ok: true, body };
}
/** 平台级「连接」按钮下方的小字:说清**它本次会作用在哪一条账号**。
*
* 这不是装饰:平台级按钮不带 accountId,后端只能自己挑一条 —— 单账号时它等价于
* 「就是这一条」,多账号时却是一个由后端规则决定的、用户无法预判的目标。把这些
* 如实写出来,主人才不会以为平台级「连接」点的是他心里那条。
*
* 0 条账号时更要明说:那次调用注定失败(没有可驱动的账号)。 */
export function platformConnectHint(record: AdminChannelRecord): string {
const accts = record.accounts ?? [];
if (accts.length === 0) return "该平台还没有账号:平台级连接会失败,请先新建账号";
if (accts.length === 1) {
// 后端对「无 accountId」的解析优先级是:精确同名适配器 → 该平台第一条账号。
// 只要这条账号的适配器存在,两者就是同一条,因此可以直接点名。
return `平台级 = 账号 ${shortAccountID(accts[0].id)}(无 accountId 时的唯一一条)`;
}
return `多账号(${accts.length} 条):平台级按钮由后端规则挑一条,不保证是你想的这条 → 请用每行「连接」点名账号`;
}
/** The set of platforms that have at least one configured account. */
export function configuredPlatforms(records: AdminChannelRecord[]): AdminChannelRecord[] {
return records.filter((c) => (c.accounts?.length ?? 0) > 0);
@@ -1,11 +1,97 @@
import { useCallback, useState } from "react";
import { useMemo, useState } from "react";
import {
adminChannelOpen,
adminChannelClose,
adminChannelOpen,
adminDeleteChannelAccount,
adminDeleteEmptyChannelAccounts,
} from "../../../lib/api";
import { useAuditStore } from "../../audit";
/** 渠道动作执行器需要的最小依赖:状态写入口 + 审计 + 刷新。
* 抽成显式依赖是为了让「请求里到底带没带 accountId」能在 node 侧被断言
* (见 channelActions.test.ts)—— 把这段逻辑埋在 React hook 里就测不到,
* 而「accountId 被丢掉」正是这次要修的产品缺口本身。 */
export interface ChannelActionDeps {
log: (action: string, result: "ok" | "error") => void;
setBusy: (busy: string | null) => void;
setError: (error: string | null) => void;
setNotice: (notice: string | null) => void;
reload: () => void;
}
/** 与 React 解耦的渠道动作集合(连接/断开/删除账号/删除无 id 条目)。 */
export interface ChannelActions {
/** 连接/断开。`accountId` 给出时**只**作用于该账号(多账号平台逐条操作);
* 缺省时维持平台级旧行为(后端按自己的规则挑一条)。 */
toggle: (name: string, connect: boolean, accountId?: string) => void;
del: (platform: string, id: string) => void;
/** 删除该平台**无 id 的历史条目**(显式 `?empty=1`)。 */
delEmpty: (platform: string) => void;
}
export function createChannelActions(deps: ChannelActionDeps): ChannelActions {
const { log, setBusy, setError, setNotice, reload } = deps;
const toggle = (name: string, connect: boolean, accountId?: string) => {
setBusy(name);
setError(null);
const call = connect ? adminChannelOpen(name, accountId) : adminChannelClose(name, accountId);
call
.then((res) => {
log(connect ? "channel.open" : "channel.close", "ok");
// 回显**实际被驱动**的适配器:多账号平台上请求里的平台名说明不了「连的是哪一条」,
// 后端把解析结果放在 resolved 里,这里如实说出来。
const who = res?.resolved ?? name;
setNotice(`${connect ? "已连接" : "已断开"} ${who}`);
reload();
})
.catch((e: unknown) => {
log(connect ? "channel.open" : "channel.close", "error");
setError(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
})
.finally(() => setBusy(null));
};
const del = (platform: string, id: string) => {
setBusy("__del__" + id);
setError(null);
setNotice(null);
adminDeleteChannelAccount(platform, id)
.then(() => {
log("channel.delete", "ok");
setNotice(`已删除账号 ${id} 并热更新 ${platform}`);
reload();
})
.catch((e: unknown) => {
log("channel.delete", "error");
setError(e instanceof Error ? e.message : "删除渠道账号失败");
})
.finally(() => setBusy(null));
};
const delEmpty = (platform: string) => {
setBusy("__delEmpty__" + platform);
setError(null);
setNotice(null);
adminDeleteEmptyChannelAccounts(platform)
.then((res) => {
log("channel.delete", "ok");
// 删了几条必须说出来:无 id 条目彼此不可区分,「删掉无 id 条目」是批量动作,
// 条数对调用方是唯一能核对的东西。
const n = res?.removed ?? 0;
setNotice(`已删除 ${n} 条无 id 条目并热更新 ${platform}`);
reload();
})
.catch((e: unknown) => {
log("channel.delete", "error");
setError(e instanceof Error ? e.message : "删除无 id 条目失败");
})
.finally(() => setBusy(null));
};
return { toggle, del, delEmpty };
}
/** Shared connect/disconnect/delete actions + busy/error/notice state, reused by
* the list page and each adapter detail page. `reload` re-fetches the channel
* list after a mutation. */
@@ -15,44 +101,10 @@ export function useChannelActions(reload: () => void) {
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const toggle = useCallback(
(name: string, connect: boolean) => {
setBusy(name);
setError(null);
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
call
.then(() => {
log(connect ? "channel.open" : "channel.close", "ok");
reload();
})
.catch((e: unknown) => {
log(connect ? "channel.open" : "channel.close", "error");
setError(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
})
.finally(() => setBusy(null));
},
[reload, log],
const actions = useMemo(
() => createChannelActions({ log, setBusy, setError, setNotice, reload }),
[log, reload],
);
const del = useCallback(
(platform: string, id: string) => {
setBusy("__del__" + id);
setError(null);
setNotice(null);
adminDeleteChannelAccount(platform, id)
.then(() => {
log("channel.delete", "ok");
setNotice(`已删除账号 ${id} 并热更新 ${platform}`);
reload();
})
.catch((e: unknown) => {
log("channel.delete", "error");
setError(e instanceof Error ? e.message : "删除渠道账号失败");
})
.finally(() => setBusy(null));
},
[reload, log],
);
return { busy, setBusy, error, setError, notice, setNotice, toggle, del };
return { busy, setBusy, error, setError, notice, setNotice, ...actions };
}