feat(channels): 适配器面板重构 P1——子侧边栏+4页+每配置专属页(design/17)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
Aska committed 2026-09-07 18:01:11 +08:00
1 parent 5a0c1e2fd1
commit 2d7a377d74
6 files changed
+399 -398

No files matched your search

@@ -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>
);
}