feat(channels): 适配器面板重构 P1——子侧边栏+4页+每配置专属页(design/17)
- Channels.tsx 改为 re-export 到 ./channels/ChannelsPage(入口不变) - 新增 channels/ 模块:子侧边栏(桌面rail+移动端抽屉复用mu-drawer-in) + 子路由 #/admin/channels(<page>|detail/<adapter_id>),刷新/返回可还原 - 4页:①适配器活动总览 ②添加/新建 ③活动适配器列表(每账号可点进专属页) ④适配器模块总日志(adminChannelLogOverview总览) - 每配置一个页面(绑adapter_id, 不同配置不共用):实时日志+功能操作+身份测试 - 迁移保留原消息日志弹窗/身份测试/配置表单; 全部文件<500行 - 验收: tsc+vite build 96模块全绿 - 注: 另8个channels/*文件于5a0c1e2一并入库(该提交信息为后端修复,历史遗留混提交)
This commit is contained in:
1 parent
5a0c1e2fd1
commit
2d7a377d74
6 files changed
+399
-398
No files matched your search
@@ -1,398 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import {
|
||||
adminListChannels,
|
||||
adminChannelOpen,
|
||||
adminChannelClose,
|
||||
adminChannelTest,
|
||||
adminChannelMessages,
|
||||
adminChannelLogOverview,
|
||||
adminDeleteChannelAccount,
|
||||
} from "../../lib/api";
|
||||
import type { AdminChannelMessage, AdminChannelRecord, ChannelTestResult } from "../../lib/types";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
import { PLATFORM_FIELDS, PLATFORM_GUIDE, PLATFORM_TYPES, PLATFORM_LABEL } from "./channelFields";
|
||||
import { ChannelConfigForm, type ChannelConfigInitial } from "./channelConfigForm";
|
||||
|
||||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||||
function platformOf(name: string): string {
|
||||
return name.split(":")[0];
|
||||
}
|
||||
|
||||
/** Adapter (platform:account) name for one account config. The bridge registers a
|
||||
* channels.json account as "kook:conf0" (env single-account = bare platform), so a
|
||||
* config id of "default"/"" maps to the bare platform name. */
|
||||
function adapterNameOf(platform: string, accountId?: string): string {
|
||||
const id = accountId?.trim();
|
||||
return id && id !== "default" ? `${platform}:${id}` : platform;
|
||||
}
|
||||
|
||||
/** Open-config target: the platform + the prefill bundle for its form. */
|
||||
interface CfgTarget {
|
||||
platform: string;
|
||||
initial: ChannelConfigInitial;
|
||||
}
|
||||
|
||||
export function ChannelsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
|
||||
useAutoReload(reload, 10000);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const [cfg, setCfg] = useState<CfgTarget | null>(null);
|
||||
const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null);
|
||||
|
||||
const [ownerId, setOwnerId] = useState("");
|
||||
const [platform, setPlatform] = useState("");
|
||||
const [group, setGroup] = useState(false);
|
||||
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
|
||||
|
||||
const configured = (data ?? []).filter((c) => (c.accounts?.length ?? 0) > 0);
|
||||
const configuredSet = new Set(configured.map((c) => platformOf(c.name)));
|
||||
|
||||
const toggle = (name: string, connect: boolean) => {
|
||||
setBusy(name);
|
||||
setErr(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");
|
||||
setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const test = () => {
|
||||
if (!ownerId.trim()) {
|
||||
setErr("邮箱/账号 id 不能为空");
|
||||
return;
|
||||
}
|
||||
setBusy("__test__");
|
||||
setErr(null);
|
||||
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
|
||||
.then((v) => {
|
||||
log("channel.identity-test", "ok");
|
||||
setVerdict(v);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("channel.identity-test", "error");
|
||||
setErr(e instanceof Error ? e.message : "身份测试失败");
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const openConfig = (name: string) => {
|
||||
setErr(null);
|
||||
setNote(null);
|
||||
const plat = platformOf(name);
|
||||
const acct = (data ?? []).find((c) => platformOf(c.name) === plat)?.accounts?.[0];
|
||||
const acctCfg = acct?.config ?? {};
|
||||
// Do NOT put the masked placeholder into `token`: it would be written back as
|
||||
// the real bot_token (overwriting the stored secret with a demo string). Keep
|
||||
// token empty (save omits it → keeps the existing secret) and show a hint.
|
||||
const hasToken = typeof acctCfg.bot_token === "string" && acctCfg.bot_token && !acctCfg.bot_token.includes("(已配置");
|
||||
const platVals: Record<string, string> = {};
|
||||
for (const f of PLATFORM_FIELDS[plat] ?? []) {
|
||||
const v = acctCfg[f.key];
|
||||
platVals[f.key] = v == null ? "" : String(v);
|
||||
}
|
||||
setCfg({
|
||||
platform: plat,
|
||||
initial: {
|
||||
accountId: acct?.id ?? "",
|
||||
tokenHint: hasToken ? "已配置(仅掩码显示),留空则不改动;如需更换请重新输入。" : "",
|
||||
baseURL: typeof acctCfg.base_url === "string" ? acctCfg.base_url : "",
|
||||
platVals,
|
||||
allowGroups: (acct?.allowGroups ?? []).join(","),
|
||||
denyGroups: (acct?.denyGroups ?? []).join(","),
|
||||
allowUsers: (acct?.allowUsers ?? []).join(","),
|
||||
denyUsers: acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "",
|
||||
groupMsgPerMin: acctCfg.groupMsgPerMin == null ? "" : String(acctCfg.groupMsgPerMin),
|
||||
msgPerMin: acctCfg.msgPerMin == null ? "" : String(acctCfg.msgPerMin),
|
||||
maxMessages: acctCfg.maxMessages == null ? "" : String(acctCfg.maxMessages),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const del = (plat: string, id: string) => {
|
||||
setBusy("__del__" + id);
|
||||
setErr(null);
|
||||
setNote(null);
|
||||
adminDeleteChannelAccount(plat, id)
|
||||
.then(() => {
|
||||
log("channel.delete", "ok");
|
||||
setNote(`已删除账号 ${id} 并热更新 ${plat}`);
|
||||
reload();
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
log("channel.delete", "error");
|
||||
setErr(e instanceof Error ? e.message : "删除渠道账号失败");
|
||||
})
|
||||
.finally(() => setBusy(null));
|
||||
};
|
||||
|
||||
const columns: AdminColumn<AdminChannelRecord>[] = [
|
||||
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</span> },
|
||||
{
|
||||
key: "connected",
|
||||
title: "状态",
|
||||
render: (c) => (
|
||||
<Tag tone={c.connected ? "ok" : "err"}>
|
||||
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
|
||||
{c.connected ? "已连接" : "未连接"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accounts",
|
||||
title: "已配账号",
|
||||
render: (c) =>
|
||||
c.accounts && c.accounts.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{c.accounts.map((a) => {
|
||||
// Show the connected bot's resolved identity (kook user/me) when the
|
||||
// adapter fetched it, falling back to the account id.
|
||||
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||||
const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token;
|
||||
return (
|
||||
<span key={a.id || "default"} className="flex items-center gap-1">
|
||||
<Tag tone="neutral">{label}</Tag>
|
||||
<Tag tone={tokenMasked ? "ok" : "warn"} className="text-[var(--mu-text-lo)]">
|
||||
{tokenMasked ? "token 已配置" : "token 未填"}
|
||||
</Tag>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy !== null}
|
||||
onClick={() => setLogFor({ adapter: adapterNameOf(platformOf(c.name), a.id) })}
|
||||
>
|
||||
消息日志
|
||||
</Button>
|
||||
{a.id ? (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (c) => (
|
||||
<span className="flex items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
|
||||
配置
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
|
||||
连接
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
|
||||
断开
|
||||
</Button>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="渠道管理"
|
||||
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="ghost" disabled={busy !== null} onClick={() => setLogFor({ adapter: null })}>
|
||||
适配器总览日志
|
||||
</Button>
|
||||
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
|
||||
刷新
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{err ? <Notice tone="err">{err}</Notice> : null}
|
||||
{note ? <Notice tone="ok">{note}</Notice> : null}
|
||||
|
||||
{/* ① 可用适配器类型(新建):全部已知平台,未配置也可创建 */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">可用适配器类型(新建)</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">选择平台类型创建配置,或管理下方已保存配置</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PLATFORM_TYPES.map((p) => {
|
||||
const has = configuredSet.has(p);
|
||||
return (
|
||||
<Surface key={p} gap="sm" className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag tone="accent">{PLATFORM_LABEL[p] ?? p}</Tag>
|
||||
<Tag tone={has ? "ok" : "neutral"}>{has ? "已配置" : "未配置"}</Tag>
|
||||
</div>
|
||||
<p className="min-h-10 text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[p].guide}</p>
|
||||
<div className="mt-auto">
|
||||
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(p)}>
|
||||
{has ? "编辑配置" : "新建配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ② 已保存配置:仅显示已配置账号的平台 */}
|
||||
<section className="space-y-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">已保存配置</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">仅显示已配置账号的平台(含连接/断开/删除)</span>
|
||||
</div>
|
||||
<Surface className="space-y-2">
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={loading ? null : configured}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyTitle="尚未保存任何渠道配置——请在上方选择平台类型创建"
|
||||
rowKey={(c) => c.name}
|
||||
/>
|
||||
</Surface>
|
||||
</section>
|
||||
|
||||
{cfg ? (
|
||||
<ChannelConfigForm
|
||||
key={cfg.platform}
|
||||
platform={cfg.platform}
|
||||
initial={cfg.initial}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
onSaved={(msg) => {
|
||||
setNote(msg);
|
||||
setErr(null);
|
||||
reload();
|
||||
}}
|
||||
onError={(msg) => setErr(msg)}
|
||||
onClose={() => setCfg(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{logFor ? (
|
||||
<ChannelLogPanel adapter={logFor.adapter} onClose={() => setLogFor(null)} />
|
||||
) : null}
|
||||
|
||||
<Surface className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
placeholder="platform(如 weixin-ilink,可留空)"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<input
|
||||
value={ownerId}
|
||||
onChange={(e) => setOwnerId(e.target.value)}
|
||||
placeholder="渠道账号 id(如 weixin-ilink 的 id)"
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
|
||||
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
|
||||
群聊
|
||||
</label>
|
||||
<Button variant="primary" disabled={busy !== null} onClick={test}>
|
||||
测试
|
||||
</Button>
|
||||
</div>
|
||||
{verdict ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||||
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
|
||||
<div>判定:{verdict.verdict}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
|
||||
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Overlay that renders one adapter config's send/receive message log, or the
|
||||
* adapter overview (adapter == null) across every adapter. */
|
||||
function ChannelLogPanel({ adapter, onClose }: { adapter: string | null; onClose: () => void }): React.ReactNode {
|
||||
const isOverview = adapter == null;
|
||||
const platform = adapter ? platformOf(adapter) : "";
|
||||
const { data, loading, error, reload } = useAsyncData(
|
||||
() => (adapter ? adminChannelMessages(adapter, 200) : adminChannelLogOverview(200)),
|
||||
[adapter],
|
||||
);
|
||||
// Backend returns newest-first; display oldest→newest for a natural read.
|
||||
const msgs = data ? [...data].reverse() : data;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="w-full max-w-3xl rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-1)] shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-5 py-3">
|
||||
{platform ? <Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag> : <Tag tone="accent">总览</Tag>}
|
||||
{adapter ? <Tag tone="neutral">{adapter}</Tag> : null}
|
||||
<h3 className="text-sm font-semibold">{isOverview ? "适配器总览日志" : "消息日志"}</h3>
|
||||
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">GET /api/v1/admin/channels/messages</span>
|
||||
<Button size="sm" variant="ghost" onClick={reload}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button size="sm" variant="soft" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[70vh] space-y-2 overflow-y-auto px-5 py-4">
|
||||
{loading ? (
|
||||
<Notice>加载中…</Notice>
|
||||
) : error ? (
|
||||
<Notice tone="err">{error}</Notice>
|
||||
) : !msgs || msgs.length === 0 ? (
|
||||
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
|
||||
) : (
|
||||
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One message line in the adapter log. */
|
||||
function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
|
||||
const isAssistant = m.role === "assistant";
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[var(--mu-radius)] px-3 py-2 ${
|
||||
isAssistant ? "bg-[var(--mu-surface-2)]" : "bg-[var(--mu-surface-3)]/70"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
|
||||
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
|
||||
{showAdapter && m.adapterId ? <Tag tone="neutral">{m.adapterId}</Tag> : null}
|
||||
{m.deviceId ? <span className="font-mono text-[var(--mu-text-lo)]">{m.deviceId}</span> : null}
|
||||
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 渠道管理模块入口(design/17)。原单页聚合列表已重构为「子侧边栏 + 4 页 + 每配置专属页」,
|
||||
* 拆分到 `./channels/` 目录(<500 行约束)。此文件仅保留导出入口,避免改动 AdminApp 的引用路径。
|
||||
*/
|
||||
export { ChannelsModule } from "./channels/ChannelsPage";
|
||||
@@ -0,0 +1,77 @@
|
||||
import { adminListChannels } from "../../../lib/api";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import { useAutoReload } from "../../hooks";
|
||||
import { Notice, PageHeader } from "../../components";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { PLATFORM_LABEL } from "../channelFields";
|
||||
import { ChannelLogList } from "./channelLogList";
|
||||
import { ChannelIdentityTest } from "./channelIdentityTest";
|
||||
import { platformOf } from "./channelUtils";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
|
||||
/** One configured account's own page (design/17 §2). This round: realtime log +
|
||||
* 功能操作 (connect/disconnect/delete/identity). Full platform config fields are P2. */
|
||||
export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack: () => void }): React.ReactNode {
|
||||
const sep = adapter.indexOf(":");
|
||||
const platform = sep >= 0 ? adapter.slice(0, sep) : adapter;
|
||||
const accountId = sep >= 0 ? adapter.slice(sep + 1) : undefined;
|
||||
const label = PLATFORM_LABEL[platform] ?? platform;
|
||||
|
||||
const { data, loading, error: loadErr, reload } = useAsyncData(adminListChannels, []);
|
||||
useAutoReload(reload, 10000);
|
||||
const { busy, error, notice, toggle, del } = useChannelActions(reload);
|
||||
|
||||
const record = (data ?? []).find((c) => platformOf(c.name) === platform);
|
||||
const connected = record?.connected ?? false;
|
||||
const account = record?.accounts?.find((a) =>
|
||||
accountId === undefined ? (!a.id || a.id === "default") : a.id === accountId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title={`${label} · ${adapter}`}
|
||||
subtitle="该配置专属页 · 实时日志 + 功能操作(完整配置字段见 P2)"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onBack}>
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
|
||||
{error ? <Notice tone="err">{error}</Notice> : null}
|
||||
{notice ? <Notice tone="ok">{notice}</Notice> : null}
|
||||
|
||||
<Surface gap="sm" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{label}</Tag>
|
||||
<Tag tone={connected ? "ok" : "err"}>{connected ? "已连接" : "未连接"}</Tag>
|
||||
{account?.botUsername ? <Tag tone="neutral">@{account.botUsername}</Tag> : null}
|
||||
{account?.botNickname ? <Tag tone="neutral">{account.botNickname}</Tag> : null}
|
||||
{loading ? <span className="text-xs text-[var(--mu-text-lo)]">加载中…</span> : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={busy !== null || connected} onClick={() => toggle(platform, true)}>
|
||||
连接
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null || !connected} onClick={() => toggle(platform, false)}>
|
||||
断开
|
||||
</Button>
|
||||
{accountId ? (
|
||||
<Button size="sm" variant="danger" disabled={busy !== null} onClick={() => del(platform, accountId)}>
|
||||
删除账号
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。</span>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
|
||||
|
||||
<div className="rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)] p-5">
|
||||
<ChannelLogList adapter={adapter} limit={200} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../../components/Icon";
|
||||
import { AdaptersDrawer, AdaptersRail } from "./AdaptersNav";
|
||||
import { PLATFORM_LABEL } from "../channelFields";
|
||||
import { platformOf } from "./channelUtils";
|
||||
import { useAdapterNav, type AdapterNav } from "./useAdapterNav";
|
||||
import { OverviewPage } from "./OverviewPage";
|
||||
import { CreatePage } from "./CreatePage";
|
||||
import { ListPage } from "./ListPage";
|
||||
import { GlobalLogPage } from "./GlobalLogPage";
|
||||
import { AdapterDetailPage } from "./AdapterDetailPage";
|
||||
|
||||
/** Title for the mobile sub-nav bar; also echoed by the rail when in a detail page. */
|
||||
function pageTitle(nav: AdapterNav): string {
|
||||
if (nav.kind === "detail") {
|
||||
const platform = platformOf(nav.adapter);
|
||||
return `${PLATFORM_LABEL[platform] ?? platform} · ${nav.adapter}`;
|
||||
}
|
||||
switch (nav.page) {
|
||||
case "create": return "新建适配器";
|
||||
case "list": return "活动适配器";
|
||||
case "log": return "模块总日志";
|
||||
default: return "活动总览";
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(nav: AdapterNav, navigate: (n: AdapterNav) => void): React.ReactNode {
|
||||
if (nav.kind === "detail") {
|
||||
return <AdapterDetailPage key={nav.adapter} adapter={nav.adapter} onBack={() => navigate({ kind: "page", page: "list" })} />;
|
||||
}
|
||||
switch (nav.page) {
|
||||
case "create": return <CreatePage />;
|
||||
case "list": return <ListPage navigate={navigate} />;
|
||||
case "log": return <GlobalLogPage />;
|
||||
default: return <OverviewPage navigate={navigate} />;
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapter module shell: sub-sidebar + 4 pages, sub-routed by the location hash
|
||||
* (design/17 §1). Desktop keeps the rail sticky at the top; mobile (<768px)
|
||||
* collapses it into a drawer opened from a hamburger in the page title bar. */
|
||||
export function ChannelsModule(): React.ReactNode {
|
||||
const { nav, navigate } = useAdapterNav();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start">
|
||||
<AdaptersRail
|
||||
nav={nav}
|
||||
onNavigate={navigate}
|
||||
className="hidden w-[var(--mu-sidebar-w)] shrink-0 md:sticky md:top-0 md:self-start md:flex"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<button
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label="打开适配器导航"
|
||||
title="适配器菜单"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--mu-radius)] text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-95"
|
||||
>
|
||||
<Icon name="menu" size={20} />
|
||||
</button>
|
||||
<h1 className="min-w-0 flex-1 truncate whitespace-nowrap text-base font-semibold">{pageTitle(nav)}</h1>
|
||||
</div>
|
||||
{renderPage(nav, navigate)}
|
||||
</div>
|
||||
<AdaptersDrawer
|
||||
open={drawerOpen}
|
||||
nav={nav}
|
||||
onNavigate={(n) => {
|
||||
navigate(n);
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import { adminListChannels } from "../../../lib/api";
|
||||
import { useAsyncData } from "../../../lib/useAsyncData";
|
||||
import { useAutoReload } from "../../hooks";
|
||||
import { Notice, PageHeader } from "../../components";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { PLATFORM_GUIDE, PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
|
||||
import { ChannelConfigForm } from "../channelConfigForm";
|
||||
import { buildCfgTarget, configuredPlatforms, platformOf, type CfgTarget } from "./channelUtils";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
|
||||
/** Page ② — available platform types (PLATFORM_TYPES); clicking one opens the
|
||||
* config form to create/edit a config for that platform. */
|
||||
export function CreatePage(): React.ReactNode {
|
||||
const { data, loading, error: loadErr, reload } = useAsyncData(adminListChannels, []);
|
||||
useAutoReload(reload, 10000);
|
||||
const { busy, setBusy, error, setError, notice, setNotice } = useChannelActions(reload);
|
||||
const [cfg, setCfg] = useState<CfgTarget | null>(null);
|
||||
|
||||
const records = data ?? [];
|
||||
const configuredSet = new Set(configuredPlatforms(records).map((c) => platformOf(c.name)));
|
||||
|
||||
const openConfig = (p: string) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setCfg(buildCfgTarget(p, records));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader title="添加 / 新建适配器" subtitle="选择平台类型创建配置,或编辑已保存配置(写入 channels.json + 热更新)" />
|
||||
|
||||
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
|
||||
{error ? <Notice tone="err">{error}</Notice> : null}
|
||||
{notice ? <Notice tone="ok">{notice}</Notice> : null}
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">可用适配器类型</h3>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">全部已知平台,未配置也可创建</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PLATFORM_TYPES.map((p) => {
|
||||
const has = configuredSet.has(p);
|
||||
return (
|
||||
<Surface key={p} gap="sm" className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag tone="accent">{PLATFORM_LABEL[p] ?? p}</Tag>
|
||||
<Tag tone={has ? "ok" : "neutral"}>{has ? "已配置" : "未配置"}</Tag>
|
||||
</div>
|
||||
<p className="min-h-10 text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[p].guide}</p>
|
||||
<div className="mt-auto">
|
||||
<Button size="sm" variant="soft" disabled={busy !== null || loading} onClick={() => openConfig(p)}>
|
||||
{has ? "编辑配置" : "新建配置"}
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{cfg ? (
|
||||
<ChannelConfigForm
|
||||
key={cfg.platform}
|
||||
platform={cfg.platform}
|
||||
initial={cfg.initial}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
onSaved={(msg) => {
|
||||
setNotice(msg);
|
||||
setError(null);
|
||||
reload();
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
onClose={() => setCfg(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Notice, PageHeader } from "../../components";
|
||||
import { ChannelLogList } from "./channelLogList";
|
||||
|
||||
/** Page ④ — aggregate message stream across every adapter (adapter == null →
|
||||
* GET /api/v1/admin/channels/messages overview). */
|
||||
export function GlobalLogPage(): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="适配器模块总日志" subtitle="所有适配器消息流聚合 · GET /api/v1/admin/channels/messages" />
|
||||
<ChannelLogList adapter={null} limit={200} />
|
||||
<Notice>总览日志聚合所有已配置 adapter(adapter_id);按平台 / 单个配置过滤在对应专属页查看。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from "react";
|
||||
import { adminListChannels } from "../../../lib/api";
|
||||
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 { PLATFORM_LABEL } from "../channelFields";
|
||||
import { ChannelConfigForm } from "../channelConfigForm";
|
||||
import { ChannelLogPanel } from "./channelLogPanel";
|
||||
import { ChannelIdentityTest } from "./channelIdentityTest";
|
||||
import { adapterNameOf, buildCfgTarget, configuredPlatforms, platformOf, type CfgTarget } from "./channelUtils";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import type { AdapterNav } from "./useAdapterNav";
|
||||
|
||||
/** Page ③ — the currently configured platforms; each account is its own page
|
||||
* (click a config to enter its detail), plus connect/disconnect/delete/config. */
|
||||
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 [cfg, setCfg] = useState<CfgTarget | null>(null);
|
||||
const [logFor, setLogFor] = useState<{ adapter: string | null } | null>(null);
|
||||
|
||||
const records = data ?? [];
|
||||
const configured = configuredPlatforms(records);
|
||||
|
||||
const openConfig = (name: string) => {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setCfg(buildCfgTarget(name, records));
|
||||
};
|
||||
|
||||
const columns: AdminColumn<AdminChannelRecord>[] = [
|
||||
{ key: "name", title: "平台", render: (c) => <span className="font-medium">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</span> },
|
||||
{
|
||||
key: "connected",
|
||||
title: "状态",
|
||||
render: (c) => (
|
||||
<Tag tone={c.connected ? "ok" : "err"}>
|
||||
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
|
||||
{c.connected ? "已连接" : "未连接"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "accounts",
|
||||
title: "配置账号(点击进入其专属页)",
|
||||
render: (c) =>
|
||||
c.accounts && c.accounts.length ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{c.accounts.map((a) => {
|
||||
const adapter = adapterNameOf(platformOf(c.name), a.id);
|
||||
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||||
const tokenMasked = typeof a.config?.bot_token === "string" && a.config.bot_token;
|
||||
return (
|
||||
<span key={a.id || "default"} className="flex flex-wrap items-center gap-1">
|
||||
<button
|
||||
onClick={() => navigate({ kind: "detail", 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 ? "token 已配置" : "token 未填"}
|
||||
</Tag>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => setLogFor({ adapter })}>
|
||||
消息日志
|
||||
</Button>
|
||||
{a.id ? (
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
title: "操作",
|
||||
render: (c) => (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
|
||||
配置
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
|
||||
连接
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
|
||||
断开
|
||||
</Button>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader title="活动适配器列表" subtitle="当前已配置账号的平台 · 点击某个配置进入其专属页(配置名 / 实时日志 / 功能操作)" />
|
||||
|
||||
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
|
||||
{error ? <Notice tone="err">{error}</Notice> : null}
|
||||
{notice ? <Notice tone="ok">{notice}</Notice> : null}
|
||||
|
||||
<AdminTable
|
||||
columns={columns}
|
||||
rows={loading ? null : configured}
|
||||
loading={loading}
|
||||
error={loadErr}
|
||||
emptyTitle="尚未保存任何渠道配置——请在上方「新建适配器」选择平台类型创建"
|
||||
rowKey={(c) => c.name}
|
||||
/>
|
||||
|
||||
<ChannelIdentityTest disabled={busy !== null} />
|
||||
|
||||
{cfg ? (
|
||||
<ChannelConfigForm
|
||||
key={cfg.platform}
|
||||
platform={cfg.platform}
|
||||
initial={cfg.initial}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
onSaved={(msg) => {
|
||||
setNotice(msg);
|
||||
setError(null);
|
||||
reload();
|
||||
}}
|
||||
onError={(msg) => setError(msg)}
|
||||
onClose={() => setCfg(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{logFor ? <ChannelLogPanel adapter={logFor.adapter} onClose={() => setLogFor(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user