diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index ce4168e..19d2fd5 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -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(null); - const [err, setErr] = useState(null); - const [note, setNote] = useState(null); - const [cfg, setCfg] = useState(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(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 = {}; - 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[] = [ - { key: "name", title: "渠道", render: (c) => {PLATFORM_LABEL[platformOf(c.name)] ?? c.name} }, - { - key: "connected", - title: "状态", - render: (c) => ( - - - {c.connected ? "已连接" : "未连接"} - - ), - }, - { - key: "accounts", - title: "已配账号", - render: (c) => - c.accounts && c.accounts.length ? ( - - {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 ( - - {label} - - {tokenMasked ? "token 已配置" : "token 未填"} - - - {a.id ? ( - - ) : null} - - ); - })} - - ) : ( - — - ), - }, - { - key: "actions", - title: "操作", - render: (c) => ( - - - - - - ), - }, - ]; - - return ( -
- - - - - } - /> - - {err ? {err} : null} - {note ? {note} : null} - - {/* ① 可用适配器类型(新建):全部已知平台,未配置也可创建 */} -
-
-

可用适配器类型(新建)

- 选择平台类型创建配置,或管理下方已保存配置 -
-
- {PLATFORM_TYPES.map((p) => { - const has = configuredSet.has(p); - return ( - -
- {PLATFORM_LABEL[p] ?? p} - {has ? "已配置" : "未配置"} -
-

{PLATFORM_GUIDE[p].guide}

-
- -
-
- ); - })} -
-
- - {/* ② 已保存配置:仅显示已配置账号的平台 */} -
-
-

已保存配置

- 仅显示已配置账号的平台(含连接/断开/删除) -
- - c.name} - /> - -
- - {cfg ? ( - { - setNote(msg); - setErr(null); - reload(); - }} - onError={(msg) => setErr(msg)} - onClose={() => setCfg(null)} - /> - ) : null} - - {logFor ? ( - setLogFor(null)} /> - ) : null} - - -

身份识别测试(结构性判定 · 绝非提示词)

-

- 输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。 -

-
- 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)]" - /> - 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)]" - /> - - -
- {verdict ? ( -
-
角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}
-
判定:{verdict.verdict}
-
- ) : null} -
- - 账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。 -
- ); -} - -/** 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 ( -
-
e.stopPropagation()}> -
- {platform ? {PLATFORM_LABEL[platform] ?? platform} : 总览} - {adapter ? {adapter} : null} -

{isOverview ? "适配器总览日志" : "消息日志"}

- GET /api/v1/admin/channels/messages - - -
-
- {loading ? ( - 加载中… - ) : error ? ( - {error} - ) : !msgs || msgs.length === 0 ? ( - 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 - ) : ( - msgs.map((m) => ) - )} -
-
-
- ); -} - -/** One message line in the adapter log. */ -function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode { - const isAssistant = m.role === "assistant"; - return ( -
-
- {isAssistant ? "她 / 昔涟" : "用户"} - {m.senderName ? {m.senderName} : null} - {showAdapter && m.adapterId ? {m.adapterId} : null} - {m.deviceId ? {m.deviceId} : null} - {new Date(m.createdAt).toLocaleString()} -
-
{m.content}
-
- ); -} +/** + * 渠道管理模块入口(design/17)。原单页聚合列表已重构为「子侧边栏 + 4 页 + 每配置专属页」, + * 拆分到 `./channels/` 目录(<500 行约束)。此文件仅保留导出入口,避免改动 AdminApp 的引用路径。 + */ +export { ChannelsModule } from "./channels/ChannelsPage"; diff --git a/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx new file mode 100644 index 0000000..3993f66 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/AdapterDetailPage.tsx @@ -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 ( +
+ + 返回列表 + + } + /> + + {loadErr ? {loadErr} : null} + {error ? {error} : null} + {notice ? {notice} : null} + + +
+ {label} + {connected ? "已连接" : "未连接"} + {account?.botUsername ? @{account.botUsername} : null} + {account?.botNickname ? {account.botNickname} : null} + {loading ? 加载中… : null} +
+
+ + + {accountId ? ( + + ) : null} + 连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。 +
+
+ + + +
+ +
+
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/ChannelsPage.tsx b/frontend/web/src/admin/modules/channels/ChannelsPage.tsx new file mode 100644 index 0000000..3693f37 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/ChannelsPage.tsx @@ -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 navigate({ kind: "page", page: "list" })} />; + } + switch (nav.page) { + case "create": return ; + case "list": return ; + case "log": return ; + default: return ; + } +} + +/** 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 ( +
+ +
+
+ +

{pageTitle(nav)}

+
+ {renderPage(nav, navigate)} +
+ { + navigate(n); + setDrawerOpen(false); + }} + onClose={() => setDrawerOpen(false)} + /> +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/CreatePage.tsx b/frontend/web/src/admin/modules/channels/CreatePage.tsx new file mode 100644 index 0000000..90fd7be --- /dev/null +++ b/frontend/web/src/admin/modules/channels/CreatePage.tsx @@ -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(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 ( +
+ + + {loadErr ? {loadErr} : null} + {error ? {error} : null} + {notice ? {notice} : null} + +
+
+

可用适配器类型

+ 全部已知平台,未配置也可创建 +
+
+ {PLATFORM_TYPES.map((p) => { + const has = configuredSet.has(p); + return ( + +
+ {PLATFORM_LABEL[p] ?? p} + {has ? "已配置" : "未配置"} +
+

{PLATFORM_GUIDE[p].guide}

+
+ +
+
+ ); + })} +
+
+ + {cfg ? ( + { + setNotice(msg); + setError(null); + reload(); + }} + onError={(msg) => setError(msg)} + onClose={() => setCfg(null)} + /> + ) : null} + + 账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/GlobalLogPage.tsx b/frontend/web/src/admin/modules/channels/GlobalLogPage.tsx new file mode 100644 index 0000000..30874b3 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/GlobalLogPage.tsx @@ -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 ( +
+ + + 总览日志聚合所有已配置 adapter(adapter_id);按平台 / 单个配置过滤在对应专属页查看。 +
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/ListPage.tsx b/frontend/web/src/admin/modules/channels/ListPage.tsx new file mode 100644 index 0000000..29de7a8 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/ListPage.tsx @@ -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(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[] = [ + { key: "name", title: "平台", render: (c) => {PLATFORM_LABEL[platformOf(c.name)] ?? c.name} }, + { + key: "connected", + title: "状态", + render: (c) => ( + + + {c.connected ? "已连接" : "未连接"} + + ), + }, + { + key: "accounts", + title: "配置账号(点击进入其专属页)", + render: (c) => + c.accounts && c.accounts.length ? ( + + {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 ( + + + + {tokenMasked ? "token 已配置" : "token 未填"} + + + {a.id ? ( + + ) : null} + + ); + })} + + ) : ( + — + ), + }, + { + key: "actions", + title: "操作", + render: (c) => ( + + + + + + ), + }, + ]; + + return ( +
+ + + {loadErr ? {loadErr} : null} + {error ? {error} : null} + {notice ? {notice} : null} + + c.name} + /> + + + + {cfg ? ( + { + setNotice(msg); + setError(null); + reload(); + }} + onError={(msg) => setError(msg)} + onClose={() => setCfg(null)} + /> + ) : null} + + {logFor ? setLogFor(null)} /> : null} +
+ ); +}