feat(admin): 渠道状态三态化 + 顶栏后端失联指示 — 不再把「读不到」画成「未连接」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

起因:面板把「取数失败(后端不可达)」渲染成红色「未连接」,主人因此把「后端没在跑」误读成「微信适配器挂了」(AdapterDetailPage 的 record?.connected ?? false)。
- 新增 channels/channelStatus.ts(纯逻辑三态:ready/pending/unknown + 记录级 connected/disconnected/unconfigured/unknown/pending;局部类型,未动 lib/types.ts)
- 新增 channelStatusView.tsx:ChannelStatusTag(三态 Tag + 「数据可能过期」)+ ChannelLoadError(失败/过期横幅 + 重试)
- 覆盖 13 处渲染点(channels/ 全目录 + AdminApp):未知态一律灰色「状态未知」+ 原因,绝不落红色「未连接」;未知态禁用连接/断开与「新建/编辑配置」(避免 prefill 空列表误建/覆盖);读取失败不再冒充「尚未保存任何渠道配置」「暂无消息」「未声明能力」等事实性谎言
- 顺带修:WeixinLogin 首次 login-status 读取失败被 .catch 静默吞成「未登录」,现如实报「读取失败(与后端失联)」
- AdminApp 顶栏新增 useBackendHealth + BackendStatusTag:HTTP /api/v1/health 探活(15s 循环),**探测成功前不显示「在线」**;WS 状态作为独立 Tag 展示,两者不混算
- 验收:npm run build 绿(177 模块)+ 新增 45 断言 + 既有 54 断言回归 + renderToStaticMarkup 真渲染冒烟(unknown 渲染不含「未连接」也不含 err 色;未探测成功不显示在线)
- ListPage 主函数 125→71 行(改前已超 80 行红线,行为等价)
This commit is contained in:
AskaEth committed 2026-10-03 19:16:22 +08:00
1 parent a51aadeef4
commit 37fb3cd265
12 files changed
+782 -192

No files matched your search

+77 -3
View File
@@ -1,9 +1,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken, getStoredRole } from "../lib/api";
import { getAuthToken, getStoredRole, health } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { WsStatus } from "../lib/types";
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
import { navigateAdmin, type AdminSectionId } from "./router";
import { Button, Tag } from "../components/primitives";
import { Button, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon";
import { DashboardModule } from "./modules/Dashboard";
import { PersonasModule } from "./modules/Personas";
@@ -117,6 +119,77 @@ function AdminMobileNav({ open, active, onClose }: { open: boolean; active: Admi
);
}
/** Poll interval for the backend liveness probe (GET /api/v1/health). */
const HEALTH_POLL_MS = 15_000;
type BackendState = "checking" | "online" | "offline";
const BACKEND_META: Record<BackendState, { label: string; tone: TagTone; dot: string }> = {
checking: { label: "后端检测中", tone: "neutral", dot: "mu-blink" },
online: { label: "后端在线", tone: "ok", dot: "bg-[var(--mu-ok)]" },
offline: { label: "后端失联", tone: "err", dot: "bg-[var(--mu-err)]" },
};
const WS_META: Record<WsStatus, { label: string; tone: TagTone; dot: string }> = {
idle: { label: "未连接", tone: "neutral", dot: "bg-[var(--mu-text-lo)]" },
connecting: { label: "连接中", tone: "warn", dot: "mu-blink" },
connected: { label: "实时", tone: "ok", dot: "bg-[var(--mu-ok)]" },
reconnecting: { label: "重连中", tone: "warn", dot: "mu-blink" },
disconnected: { label: "已断开", tone: "err", dot: "bg-[var(--mu-err)]" },
expired: { label: "登录过期", tone: "err", dot: "bg-[var(--mu-err)]" },
};
/** Real liveness probe — the tag must never claim 在线 before /health has actually
* answered, so it starts as "检测中" (which is a state of its own, not a guess).
* The realtime channel is a separate signal from the store: it can be down while
* REST is fine (and vice versa), so it is shown as its own tag. */
function useBackendHealth(): BackendState {
const [state, setState] = useState<BackendState>("checking");
useEffect(() => {
let cancelled = false;
const probe = () => {
health()
.then(() => {
if (!cancelled) setState("online");
})
.catch(() => {
if (!cancelled) setState("offline");
});
};
probe();
const id = window.setInterval(probe, HEALTH_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
return state;
}
/** 后端在线 / 失联 + 实时通道状态, both from real signals. */
function BackendStatusTag(): React.ReactNode {
const backend = useBackendHealth();
const wsStatus = useAppStore((s) => s.wsStatus);
const meta = BACKEND_META[backend];
const ws = WS_META[wsStatus];
return (
<>
<span className="inline-flex shrink-0" title={`GET /api/v1/health · 每 ${HEALTH_POLL_MS / 1000}s 探测一次`}>
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</Tag>
</span>
<span className="hidden shrink-0 sm:inline-flex" title="实时通道(/ws)状态">
<Tag tone={ws.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${ws.dot}`} />
实时通道 · {ws.label}
</Tag>
</span>
</>
);
}
function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
const role = getStoredRole();
const [theme, setTheme] = useState<ThemeMode>(readTheme);
@@ -152,6 +225,7 @@ function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: ()
{role}
</Tag>
) : null}
<BackendStatusTag />
<button
onClick={toggleTheme}
aria-label="切换主题"
@@ -1,4 +1,5 @@
import { adminListChannels } from "../../../lib/api";
import type { AdminChannelAccount } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAutoReload } from "../../hooks";
import { Notice, PageHeader } from "../../components";
@@ -10,6 +11,8 @@ import { KookPanel } from "./KookPanel";
import { WeixinLogin } from "./WeixinLogin";
import { GenericAdapterPanel } from "./GenericAdapterPanel";
import { platformOf } from "./channelUtils";
import { channelStatus, type ChannelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { useChannelActions } from "./useChannelActions";
/** One configured account's own page (design/17 §2). This round: realtime log +
@@ -20,12 +23,13 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
const accountId = sep >= 0 ? adapter.slice(sep + 1) : undefined;
const label = PLATFORM_LABEL[platform] ?? platform;
const { data, loading, error: loadErr, reload } = useAsyncData(adminListChannels, []);
const { data, 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 status = channelStatus(data, loadErr, record);
const statusUnknown = status.kind === "unknown";
const account = record?.accounts?.find((a) =>
accountId === undefined ? (!a.id || a.id === "default") : a.id === accountId,
);
@@ -48,34 +52,20 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
}
/>
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
<ChannelLoadError error={loadErr} reload={reload} stale={data != null} what="适配器状态" />
{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}
{account?.botId ? <Tag tone="neutral">bot_id:{account.botId}</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>
<AdapterStatusCard
label={label}
platform={platform}
accountId={accountId}
account={account}
status={status}
busy={busy}
toggle={toggle}
del={del}
/>
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
@@ -84,7 +74,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
) : platform === "weixin-ilink" ? (
<WeixinLogin platform={platform} accountId={accountId} />
) : (
<GenericAdapterPanel platform={platform} accountId={accountId} record={record} />
<GenericAdapterPanel platform={platform} accountId={accountId} record={record} recordUnknown={statusUnknown} />
)}
<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">
@@ -93,3 +83,68 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
</div>
);
}
/** Status + actions card. Extracted so the page body stays small; it owns the
* tri-state tag and the connect/disconnect gating that follows from it. */
function AdapterStatusCard({
label,
platform,
accountId,
account,
status,
busy,
toggle,
del,
}: {
label: string;
platform: string;
accountId?: string;
account?: AdminChannelAccount;
status: ChannelStatus;
busy: string | null;
toggle: (name: string, connect: boolean) => void;
del: (platform: string, id: string) => void;
}): React.ReactNode {
const unknown = status.kind === "unknown";
const unknownTitle = unknown ? "连接状态未知,已禁用;请先确认后端在线" : undefined;
return (
<Surface gap="sm" className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{label}</Tag>
<ChannelStatusTag status={status} />
{account?.botUsername ? <Tag tone="neutral">@{account.botUsername}</Tag> : null}
{account?.botNickname ? <Tag tone="neutral">{account.botNickname}</Tag> : null}
{account?.botId ? <Tag tone="neutral">bot_id:{account.botId}</Tag> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="soft"
disabled={busy !== null || !status.canConnect}
title={unknownTitle}
onClick={() => toggle(platform, true)}
>
连接
</Button>
<Button
size="sm"
variant="ghost"
disabled={busy !== null || !status.canDisconnect}
title={unknownTitle}
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>
{unknown ? (
<span className="text-xs text-[var(--mu-warn)]">读取不到连接状态,连接/断开已禁用;请先确认后端在线再重试。</span>
) : null}
</div>
</Surface>
);
}
@@ -7,6 +7,8 @@ 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 { channelListState } from "./channelStatus";
import { ChannelLoadError } from "./channelStatusView";
import { useChannelActions } from "./useChannelActions";
/** Page ② — available platform types (PLATFORM_TYPES); clicking one opens the
@@ -19,6 +21,11 @@ export function CreatePage(): React.ReactNode {
const records = data ?? [];
const configuredSet = new Set(configuredPlatforms(records).map((c) => platformOf(c.name)));
const listState = channelListState(data, loadErr);
// While the saved configs are unread (or still loading), "未配置" would be a claim
// we cannot back — and the form prefills from a list we do not have.
const listDegraded = listState !== "ready";
const listUnknown = listState === "unknown";
const openConfig = (p: string) => {
setError(null);
@@ -30,14 +37,18 @@ export function CreatePage(): React.ReactNode {
<div className="space-y-5">
<PageHeader title="添加 / 新建适配器" subtitle="选择平台类型创建配置,或编辑已保存配置(写入 channels.json + 热更新)" />
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
<ChannelLoadError error={loadErr} reload={reload} stale={data != null} what="已保存的配置" />
{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>
<span className="text-xs text-[var(--mu-text-lo)]">
{listUnknown
? "读取不到已保存配置,「已配置 / 未配置」暂不可判定,编辑已禁用"
: "全部已知平台,未配置也可创建"}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLATFORM_TYPES.map((p) => {
@@ -46,11 +57,19 @@ export function CreatePage(): React.ReactNode {
<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>
<Tag tone={listDegraded ? "neutral" : has ? "ok" : "neutral"}>
{listDegraded ? (listUnknown ? "状态未知" : "读取中…") : 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)}>
<Button
size="sm"
variant="soft"
disabled={busy !== null || loading || listDegraded}
title={listDegraded ? "读取不到已保存配置,已禁用,避免覆盖或重复创建" : undefined}
onClick={() => openConfig(p)}
>
{has ? "编辑配置" : "新建配置"}
</Button>
</div>
@@ -16,10 +16,14 @@ export function GenericAdapterPanel({
platform,
accountId,
record,
recordUnknown = false,
}: {
platform: string;
accountId?: string;
record?: AdminChannelRecord;
/** The channel list could not be read (backend unreachable): capability/account
* emptiness is then unverified, so never present it as "declares nothing". */
recordUnknown?: boolean;
}): React.ReactNode {
const label = PLATFORM_LABEL[platform] ?? platform;
const account = record?.accounts?.find((a) =>
@@ -39,20 +43,24 @@ export function GenericAdapterPanel({
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">通用壳 · 平台专属端点未接入</span>
</div>
<CapabilityBadges caps={caps} />
<FeaturePlaceholder caps={caps} />
<ProactiveNote caps={caps} />
<CapabilityBadges caps={caps} recordUnknown={recordUnknown} />
<FeaturePlaceholder caps={caps} recordUnknown={recordUnknown} />
<ProactiveNote caps={caps} recordUnknown={recordUnknown} />
</div>
);
}
/** Capability badges: what this adapter declares it supports. Empty → honest note. */
function CapabilityBadges({ caps }: { caps: readonly string[] }): React.ReactNode {
function CapabilityBadges({ caps, recordUnknown }: { caps: readonly string[]; recordUnknown: boolean }): React.ReactNode {
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">平台能力</h3>
{caps.length === 0 ? (
<Notice>该适配器尚未声明能力列表;平台专属端点未接入,当前仅提供通用操作(连接 / 断开 / 消息日志 / 身份测试)。</Notice>
<Notice tone={recordUnknown ? "err" : "neutral"}>
{recordUnknown
? "读取失败(与后端失联),无法确认该适配器声明的能力;请先确认后端在线后重试。"
: "该适配器尚未声明能力列表;平台专属端点未接入,当前仅提供通用操作(连接 / 断开 / 消息日志 / 身份测试)。"}
</Notice>
) : (
<div className="flex flex-wrap gap-2">
{caps.map((c) => {
@@ -72,13 +80,17 @@ function CapabilityBadges({ caps }: { caps: readonly string[] }): React.ReactNod
/** Placeholder for future platform-specific feature areas (friends/channels/members/
* test-send). Only the admin-operations the adapter actually declares surface here,
* each marked as not yet wired. */
function FeaturePlaceholder({ caps }: { caps: readonly string[] }): React.ReactNode {
function FeaturePlaceholder({ caps, recordUnknown }: { caps: readonly string[]; recordUnknown: boolean }): React.ReactNode {
const adminOps = adminOpsOf(caps);
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">平台专属功能区(占位)</h3>
{adminOps.length === 0 ? (
<Notice>该平台暂无已声明的专属功能模块。频道 / 成员 / 发送测试等平台能力将随后续接入后在此显示。</Notice>
<Notice tone={recordUnknown ? "err" : "neutral"}>
{recordUnknown
? "读取失败(与后端失联),无法确认该平台的专属功能模块;请先确认后端在线后重试。"
: "该平台暂无已声明的专属功能模块。频道 / 成员 / 发送测试等平台能力将随后续接入后在此显示。"}
</Notice>
) : (
<>
<div className="flex flex-wrap gap-2">
@@ -96,13 +108,15 @@ function FeaturePlaceholder({ caps }: { caps: readonly string[] }): React.ReactN
}
/** Proactive-push note: declared → placeholder; otherwise an honest "not supported". */
function ProactiveNote({ caps }: { caps: readonly string[] }): React.ReactNode {
function ProactiveNote({ caps, recordUnknown }: { caps: readonly string[]; recordUnknown: boolean }): React.ReactNode {
const supported = hasCapability(caps, "Proactive");
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">主动推送</h3>
{supported ? (
<Notice tone="warn">主动推送(占位,后端待接):该适配器声明了主动推送能力,定时 / 主动消息功能待后端接入。</Notice>
) : recordUnknown ? (
<Notice tone="err">读取失败(与后端失联),无法确认该适配器是否支持主动推送;请先确认后端在线后重试。</Notice>
) : (
<Notice>当前适配器未声明主动推送能力(Proactive),不支持主动 / 定时消息;仅在被对话触发时响应。</Notice>
)}
@@ -15,6 +15,7 @@ import { useAsyncData } from "../../../lib/useAsyncData";
import { useAuditStore } from "../../audit";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { ChannelLoadError } from "./channelStatusView";
/** KOOK per-adapter panel (design/17 §2): friends/requests, servers/channels/
* members, private chats, and a test-send card. It renders only the elements the
@@ -72,8 +73,8 @@ export function KookPanel({
</span>
</div>
{error ? <Notice tone="err">{error}</Notice> : null}
{notice ? <Notice tone={notice.startsWith("已") || notice.includes("成功") ? "ok" : "err"}>{notice}</Notice> : null}
<ChannelLoadError error={error} reload={reload} stale={data != null} what="KOOK 平台数据" />
{loading ? <Notice>加载中…</Notice> : null}
{data ? (
@@ -107,7 +108,7 @@ function KookFriendsCard({
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">好友 & 申请</h3>
{requests.length === 0 && list.length === 0 ? (
<Notice>暂无好友/申请数据(未连接或账号无好友)。</Notice>
<Notice>暂无好友/申请数据(账号无好友,或该适配器当前未连接 / 未登录)。</Notice>
) : null}
{requests.length > 0 ? (
<div className="flex flex-wrap gap-2">
@@ -158,7 +159,7 @@ function KookGuildCard({
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">服务器 / 频道 / 成员</h3>
{!guilds || guilds.length === 0 ? (
<Notice>暂无服务器数据(bot 未加入任何服务器或未连接)。</Notice>
<Notice>暂无服务器数据(bot 未加入任何服务器,或该适配器当前未连接)。</Notice>
) : (
<div className="flex flex-wrap gap-2">
{guilds.map((g) => (
@@ -10,6 +10,8 @@ import { ChannelConfigForm } from "../channelConfigForm";
import { ChannelLogPanel } from "./channelLogPanel";
import { ChannelIdentityTest } from "./channelIdentityTest";
import { adapterNameOf, buildCfgTarget, configuredPlatforms, platformOf, type CfgTarget } from "./channelUtils";
import { channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import { useChannelActions } from "./useChannelActions";
import type { AdapterNav } from "./useAdapterNav";
@@ -24,6 +26,8 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
const records = data ?? [];
const configured = configuredPlatforms(records);
// Never read as "no configs saved" when we in fact could not read at all.
const hardFail = loadErr != null && data == null;
const openConfig = (name: string) => {
setError(null);
@@ -31,91 +35,36 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
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>
),
},
];
const ctx: ListCtx = {
data,
error: loadErr,
busy,
openConfig,
toggle,
del,
openDetail: (a) => navigate({ kind: "detail", adapter: a }),
openLog: (a) => setLogFor({ adapter: a }),
};
const columns = columnsOf(ctx);
return (
<div className="space-y-5">
<PageHeader title="活动适配器列表" subtitle="当前已配置账号的平台 · 点击某个配置进入其专属页(配置名 / 实时日志 / 功能操作)" />
{loadErr ? <Notice tone="err">{loadErr}</Notice> : null}
<ChannelLoadError error={loadErr} reload={reload} stale={data != null} what="适配器列表" />
{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}
/>
{hardFail ? null : (
<AdminTable
columns={columns}
rows={loading ? null : configured}
loading={loading}
error={null}
emptyTitle="尚未保存任何渠道配置——请在上方「新建适配器」选择平台类型创建"
rowKey={(c) => c.name}
/>
)}
<ChannelIdentityTest disabled={busy !== null} />
@@ -140,3 +89,82 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
</div>
);
}
/** Everything a row cell needs from the page, bundled so each cell is its own
* small component instead of one giant inline render tree. */
interface ListCtx {
data: AdminChannelRecord[] | null;
error: string | null;
busy: string | null;
openConfig: (name: string) => void;
toggle: (name: string, connect: boolean) => void;
del: (platform: string, id: string) => void;
openDetail: (adapter: string) => void;
openLog: (adapter: string) => void;
}
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} /> },
];
}
/** One configured account per entry: enter its detail page, log, delete. */
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>;
}
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 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={() => 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 ? "token 已配置" : "token 未填"}
</Tag>
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.openLog(adapter)}>
消息日志
</Button>
{a.id ? (
<Button size="sm" variant="ghost" disabled={ctx.busy !== null} onClick={() => ctx.del(platformOf(record.name), a.id)}>
删除
</Button>
) : null}
</span>
);
})}
</span>
);
}
/** 连接 / 断开 / 配置. Gated by the row's tri-state so an unread state cannot be
* acted on as if it read "未连接". */
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>
);
}
@@ -1,5 +1,5 @@
import { adminListChannels, adminChannelLogOverview } from "../../../lib/api";
import type { AdminChannelMessage } from "../../../lib/types";
import type { AdminChannelMessage, AdminChannelRecord } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAutoReload } from "../../hooks";
import { CardGrid, Notice, PageHeader, StatCard } from "../../components";
@@ -7,6 +7,8 @@ import { Button, Surface, Tag } from "../../../components/primitives";
import { Icon } from "../../../components/Icon";
import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
import { channelListState, channelStatus } from "./channelStatus";
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
import type { AdapterNav } from "./useAdapterNav";
/** Page ① — aggregate all adapters: connection state, message volume, health.
@@ -21,83 +23,168 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
const configured = configuredPlatforms(records);
const accountCount = configuredAccountCount(records);
const connected = configured.filter((c) => c.connected).length;
// A list we could not read must not be summarised as "0 已连接 / 0 账号".
const listState = channelListState(chans.data, chans.error);
const countsUnknown = listState !== "ready";
const countsStale = !countsUnknown && chans.error != null;
const countsSub = (fresh: string): string =>
countsUnknown
? listState === "unknown"
? "读取失败(与后端失联),数值未知"
: "读取中…"
: countsStale
? `${fresh}(数据可能过期)`
: fresh;
return (
<div className="space-y-5">
<PageHeader title="适配器活动总览" subtitle="聚合所有渠道适配器 · 连接状态 / 收发消息量 / 健康度 / 最近事件" />
<ChannelLoadError error={chans.error} reload={chans.reload} stale={chans.data != null} what="适配器列表" />
<CardGrid>
<StatCard icon={<Icon name="plugin" size={18} />} label="适配器类型" value={PLATFORM_TYPES.length} sub="可用平台类型" tone="accent" />
<StatCard icon={<Icon name="channel" size={18} />} label="已连接平台" value={`${connected}/${configured.length}`} sub="已配置平台中连接数" tone="ok" />
<StatCard icon={<Icon name="device" size={18} />} label="配置账号" value={accountCount} sub="当前全部账号" tone="neutral" />
<StatCard
icon={<Icon name="channel" size={18} />}
label="已连接平台"
value={countsUnknown ? "—" : `${connected}/${configured.length}`}
sub={countsSub("已配置平台中连接数")}
tone={countsUnknown ? "warn" : "ok"}
/>
<StatCard
icon={<Icon name="device" size={18} />}
label="配置账号"
value={countsUnknown ? "—" : accountCount}
sub={countsSub("当前全部账号")}
tone="neutral"
/>
<StatCard icon={<Icon name="chat" size={18} />} label="最近消息" value={logs.data?.length ?? "—"} sub="最近事件数" tone="warn" />
</CardGrid>
{chans.error && !chans.data ? <Notice tone="err">{chans.error}</Notice> : null}
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">最近事件(下钻到平台 / 配置)</h3>
<span className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => navigate({ kind: "page", page: "log" })}>
全部日志
</Button>
<Button size="sm" variant="ghost" onClick={logs.reload}>
刷新
</Button>
</span>
</div>
{logs.loading ? (
<Notice>加载中…</Notice>
) : logs.error ? (
<Notice tone="err">{logs.error}</Notice>
) : !logs.data || logs.data.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
<div className="space-y-2">
{logs.data.map((m) => (
<RecentEvent key={m.id} m={m} onDrill={(adapter) => navigate({ kind: "detail", adapter })} />
))}
</div>
)}
</Surface>
<RecentEvents
data={logs.data}
loading={logs.loading}
error={logs.error}
reload={logs.reload}
onDrill={(adapter) => navigate({ kind: "detail", adapter })}
onOpenAll={() => navigate({ kind: "page", page: "log" })}
/>
{configured.length ? (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">已配置平台</h3>
<span className="ml-auto">
<Button size="sm" variant="soft" onClick={() => navigate({ kind: "page", page: "list" })}>
活动适配器列表
</Button>
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{configured.map((c) => {
const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id);
return (
<button
key={c.name}
onClick={() => navigate({ kind: "detail", adapter: first })}
className="flex flex-col gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] p-3 text-left transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98]"
>
<span className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</Tag>
<Tag tone={c.connected ? "ok" : "err"}>{c.connected ? "已连接" : "未连接"}</Tag>
</span>
<span className="text-xs text-[var(--mu-text-lo)]">
{c.accounts?.length ?? 0} 个账号 · 点击进入该平台/配置专属页
</span>
</button>
);
})}
</div>
</Surface>
<ConfiguredPlatformsCard
configured={configured}
data={chans.data}
error={chans.error}
onOpenDetail={(adapter) => navigate({ kind: "detail", adapter })}
onOpenList={() => navigate({ kind: "page", page: "list" })}
/>
) : null}
</div>
);
}
/** The "已配置平台" grid, extracted so the page body stays small. Each card keeps
* the same tri-state status tag as the list/detail pages. */
function ConfiguredPlatformsCard({
configured,
data,
error,
onOpenDetail,
onOpenList,
}: {
configured: AdminChannelRecord[];
data: AdminChannelRecord[] | null;
error: string | null;
onOpenDetail: (adapter: string) => void;
onOpenList: () => void;
}): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">已配置平台</h3>
<span className="ml-auto">
<Button size="sm" variant="soft" onClick={onOpenList}>
活动适配器列表
</Button>
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{configured.map((c) => {
const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id);
return (
<button
key={c.name}
onClick={() => onOpenDetail(first)}
className="flex flex-col gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] p-3 text-left transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98]"
>
<span className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{PLATFORM_LABEL[platformOf(c.name)] ?? c.name}</Tag>
<ChannelStatusTag status={channelStatus(data, error, c)} showHint={false} />
</span>
<span className="text-xs text-[var(--mu-text-lo)]">
{c.accounts?.length ?? 0} 个账号 · 点击进入该平台/配置专属页
</span>
</button>
);
})}
</div>
</Surface>
);
}
/** 最近事件卡:its own honest failure/stale handling — a failed refresh warns while
* keeping the previous events, and a first-read failure shows the retry instead of
* the "暂无消息" empty state. */
function RecentEvents({
data,
loading,
error,
reload,
onDrill,
onOpenAll,
}: {
data: AdminChannelMessage[] | null;
loading: boolean;
error: string | null;
reload: () => void;
onDrill: (adapter: string) => void;
onOpenAll: () => void;
}): React.ReactNode {
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">最近事件(下钻到平台 / 配置)</h3>
<span className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={onOpenAll}>
全部日志
</Button>
<Button size="sm" variant="ghost" onClick={reload}>
刷新
</Button>
</span>
</div>
{loading ? (
<Notice>加载中…</Notice>
) : error && !data ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" />
) : (
<>
<ChannelLoadError error={error} reload={reload} stale what="消息日志" />
{!data || data.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
<div className="space-y-2">
{data.map((m) => (
<RecentEvent key={m.id} m={m} onDrill={onDrill} />
))}
</div>
)}
</>
)}
</Surface>
);
}
/** One recent event card; clicking a message with an adapter drills into it. */
function RecentEvent({ m, onDrill }: { m: AdminChannelMessage; onDrill: (adapter: string) => void }): React.ReactNode {
const isAssistant = m.role === "assistant";
@@ -62,6 +62,9 @@ export function WeixinLogin({
const [botId, setBotId] = useState<string>("");
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// A failed status read must not masquerade as "未登录/idle" — that is exactly the
// "could not read" → "no state" conflation this panel used to have.
const [statusErr, setStatusErr] = useState<string | null>(null);
const [hidden, setHidden] = useState<boolean>(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const statusRef = useRef<string>("idle");
@@ -92,10 +95,13 @@ export function WeixinLogin({
const fetchStatus = () => {
return adminWeixinLoginStatus(platform, accountId)
.then(applyStatus)
.then((r) => {
setStatusErr(null);
applyStatus(r);
})
.catch((e: unknown) => {
log("channel.weixin.login.status", "error");
setNotice(e instanceof Error ? e.message : "查询登录状态失败");
setStatusErr(e instanceof Error ? e.message : "查询登录状态失败");
});
};
@@ -136,7 +142,16 @@ export function WeixinLogin({
}, []);
useEffect(() => {
adminWeixinLoginStatus(platform, accountId).then(applyStatus).catch(() => setStatus("idle"));
adminWeixinLoginStatus(platform, accountId)
.then((r) => {
setStatusErr(null);
applyStatus(r);
})
.catch((e: unknown) => {
// Keep the panel in its neutral state but say so out loud: the absence of a
// status is a failed read, not evidence the account is logged out.
setStatusErr(e instanceof Error ? e.message : "查询登录状态失败");
});
return stopPolling;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platform, accountId]);
@@ -219,6 +234,18 @@ export function WeixinLogin({
</Notice>
) : null}
{statusErr ? (
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<Tag tone="err">读取失败</Tag>
<span className="min-w-0 flex-1">
读取失败(与后端失联),无法确认该账号的微信登录状态,下方「已登录 / 已扫码」标记暂不可信:{statusErr}
</span>
<Button size="sm" variant="ghost" onClick={fetchStatus}>
重试
</Button>
</div>
) : null}
{status === "confirmed" && !showQR ? (
<Notice>该账号已绑定 bot_token,无需再次扫码。二维码 / token 均不在此回显。</Notice>
) : null}
@@ -7,6 +7,7 @@ import { Notice } from "../../components";
import { useAutoReload } from "../../hooks";
import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView";
/** One message line in the adapter log. */
export function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
@@ -78,12 +79,17 @@ export function ChannelLogList({
<div className="space-y-2 pt-3">
{loading ? (
<Notice>加载中…</Notice>
) : error ? (
<Notice tone="err">{error}</Notice>
) : !msgs || msgs.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : error && !data && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" />
) : (
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
<>
<ChannelLoadError error={error} reload={reload} stale={data != null} what="消息日志" />
{msgs.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
)}
</>
)}
</div>
</div>
@@ -0,0 +1,111 @@
// Logic test for the honest adapter connection state (channelStatus.ts). The bug
// this guards against: the admin panel rendered "we could not read the state" as a
// red「未连接」, so the owner read "backend not running" as "the WeChat adapter is
// down". The project has no frontend test runner, so this is a dependency-free
// plain-node check that drives the real pure function and throws on the first
// failure. Run it with the esbuild that already ships with vite (no new
// dependency, no package.json change):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelStatus.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-channel-status.test.mjs \
// && node /tmp/cyrene-channel-status.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import type { AdminChannelRecord } from "../../../lib/types";
import { channelListState, channelStatus } from "./channelStatus";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(
JSON.stringify(got) === JSON.stringify(want),
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`,
);
}
const kook = (connected: boolean): AdminChannelRecord => ({ name: "kook", connected });
const weixin = (connected: boolean): AdminChannelRecord => ({ name: "weixin-ilink", connected });
// ── Case 1: first read failed (backend unreachable, no data yet) → 未知 ─────────
const unknown = channelStatus(null, "网络请求失败", kook(true));
same(unknown.kind, "unknown", "a failed first read is unknown, not disconnected");
same(unknown.label, "状态未知", "a failed first read is labelled 状态未知");
same(unknown.tone, "neutral", "unknown is gray — never the red 未连接 tone");
ok(unknown.tone !== "err", "unknown must NOT render with the error tone");
ok(unknown.hint !== null && unknown.hint.includes("网络请求失败"), "unknown carries the failure reason");
same(unknown.stale, false, "unknown is not a stale-but-known value");
same(unknown.canConnect, false, "connect is disabled while the state is unknown");
same(unknown.canDisconnect, false, "disconnect is disabled while the state is unknown");
// The record being absent (data == null) must not leak into a "disconnected" read,
// even though the caller may pass an explicit record.
same(channelStatus(null, "读取失败").kind, "unknown", "unknown wins even without a record");
// ── Case 2: data on screen + the silent refresh failed → keep it, flag stale ────
const staleConnected = channelStatus([kook(true)], "网络请求失败", kook(true));
same(staleConnected.kind, "connected", "a stale read keeps the last known 已连接 value");
same(staleConnected.label, "已连接", "a stale read keeps the last known label");
same(staleConnected.tone, "ok", "a stale read keeps the last known tone");
same(staleConnected.stale, true, "a stale read is flagged stale");
ok(staleConnected.hint !== null && staleConnected.hint.includes("网络请求失败"), "stale surfaces the refresh failure");
same(staleConnected.canConnect, false, "stale 已连接 keeps connect disabled");
same(staleConnected.canDisconnect, true, "stale 已连接 keeps disconnect enabled");
const staleDisconnected = channelStatus([kook(false)], "读取失败", kook(false));
same(staleDisconnected.kind, "disconnected", "a stale read keeps the last known 未连接 value");
same(staleDisconnected.stale, true, "a stale 未连接 is flagged stale");
same(staleDisconnected.canConnect, true, "stale 未连接 keeps connect enabled");
// ── Case 3: a healthy read of a connected adapter → 已连接 ─────────────────────
const connected = channelStatus([kook(true)], null, kook(true));
same(connected.kind, "connected", "a healthy connected read is connected");
same(connected.label, "已连接", "a healthy connected read is labelled 已连接");
same(connected.tone, "ok", "a healthy connected read is green");
same(connected.hint, null, "a healthy read has no hint");
same(connected.stale, false, "a healthy read is not stale");
same(connected.canConnect, false, "a connected adapter cannot be connected again");
same(connected.canDisconnect, true, "a connected adapter can be disconnected");
// ── Case 4: a healthy read of a disconnected adapter → 未连接 ───────────────────
const disconnected = channelStatus([weixin(false)], null, weixin(false));
same(disconnected.kind, "disconnected", "a healthy disconnected read is disconnected");
same(disconnected.label, "未连接", "a healthy disconnected read is labelled 未连接");
same(disconnected.tone, "err", "a healthy disconnected read is red");
same(disconnected.hint, null, "a healthy disconnected read has no hint");
same(disconnected.stale, false, "a healthy disconnected read is not stale");
same(disconnected.canConnect, true, "a disconnected adapter can be connected");
same(disconnected.canDisconnect, false, "a disconnected adapter cannot be disconnected");
// ── Extra: first load, and a loaded list that lacks the record ─────────────────
const pending = channelStatus(null, null, undefined);
same(pending.kind, "pending", "data==null with no error yet is pending, not 未连接");
same(pending.label, "状态读取中", "pending is labelled 状态读取中");
same(pending.canConnect, false, "pending disables connect");
const unconfigured = channelStatus([weixin(false)], null, undefined);
same(unconfigured.kind, "unconfigured", "a loaded list without the record is 未配置, not 未连接");
same(unconfigured.label, "未配置", "a loaded list without the record is labelled 未配置");
same(unconfigured.canConnect, false, "an unconfigured platform cannot be connected");
same(unconfigured.stale, false, "an unconfigured platform read fresh is not stale");
same(
channelStatus([weixin(false)], "读取失败", undefined).stale,
true,
"an unconfigured platform read from stale data is flagged stale",
);
// ── List-level tri-state ───────────────────────────────────────────────────────
same(channelListState([], null), "ready", "an empty but read list is ready");
same(channelListState([weixin(true)], null), "ready", "a read list is ready");
same(channelListState(null, null), "pending", "a list never read and not failed is pending");
same(channelListState(null, "读取失败"), "unknown", "a list never read with an error is unknown");
console.log(`channel status logic test: ${checks} checks passed`);
@@ -0,0 +1,103 @@
import type { AdminChannelRecord } from "../../../lib/types";
/** Honest connection state for one adapter. The whole point of the third state is
* that "we could not read the state" is NOT evidence that the adapter is down:
* rendering a failed read as red「未连接」makes the owner misread a stopped
* backend as a broken IM adapter. Pure logic (no React) so it is unit-testable
* as plain node — see channelStatus.test.ts. */
export type ChannelStatusKind =
| "connected"
| "disconnected"
| "unconfigured"
| "unknown"
| "pending";
export type ChannelStatusTone = "ok" | "err" | "warn" | "neutral";
export interface ChannelStatus {
kind: ChannelStatusKind;
label: string;
tone: ChannelStatusTone;
/** Failure reason / staleness note; null when nothing is wrong. */
hint: string | null;
/** A last-known value is on screen but the latest refresh failed. */
stale: boolean;
canConnect: boolean;
canDisconnect: boolean;
}
/** How much of the channel list we actually know right now. */
export type ChannelListState = "ready" | "pending" | "unknown";
/** List-level tri-state: a list we never read plus a recorded error is *unknown*,
* never "empty". `data == null` with no error is the first load still running. */
export function channelListState(
data: readonly AdminChannelRecord[] | null,
error: string | null,
): ChannelListState {
if (data != null) return "ready";
return error != null ? "unknown" : "pending";
}
/** Tri-state for one record: 已连接 / 未连接 / 状态未知(读取失败).
*
* - `data == null && error != null` → unknown: gray, carries the failure reason,
* and both actions are disabled (acting on an unread state is a misclick).
* - `data == null && error == null` → pending: still loading, never "未连接".
* - `data != null && error != null` → last known value kept, flagged stale.
* - otherwise the real `record.connected`.
* A loaded list without the record is not a connection state at all → "未配置". */
export function channelStatus(
data: readonly AdminChannelRecord[] | null,
error: string | null,
record?: AdminChannelRecord,
): ChannelStatus {
const state = channelListState(data, error);
if (state === "unknown") {
return {
kind: "unknown",
label: "状态未知",
tone: "neutral",
hint: `读取失败(与后端失联),连接状态无法确认:${error}`,
stale: false,
canConnect: false,
canDisconnect: false,
};
}
if (state === "pending") {
return {
kind: "pending",
label: "状态读取中",
tone: "neutral",
hint: null,
stale: false,
canConnect: false,
canDisconnect: false,
};
}
if (!record) {
return {
kind: "unconfigured",
label: "未配置",
tone: "neutral",
hint: "渠道列表中找不到该适配器记录(可能已删除或尚未保存)",
stale: error != null,
canConnect: false,
canDisconnect: false,
};
}
const connected = record.connected === true;
return {
kind: connected ? "connected" : "disconnected",
label: connected ? "已连接" : "未连接",
tone: connected ? "ok" : "err",
hint: error != null ? `本次刷新失败,数据可能过期:${error}` : null,
stale: error != null,
canConnect: !connected,
canDisconnect: connected,
};
}
@@ -0,0 +1,65 @@
import { Button, Tag } from "../../../components/primitives";
import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
const dot: Record<ChannelStatusKind, string> = {
connected: "bg-[var(--mu-ok)]",
disconnected: "bg-[var(--mu-err)]",
unconfigured: "bg-[var(--mu-text-lo)]",
unknown: "bg-[var(--mu-text-lo)]",
pending: "bg-[var(--mu-text-lo)] mu-blink",
};
/** The one honest place an adapter's connection state is rendered, so list page,
* detail page and overview can never drift apart: 已连接 / 未连接 / 状态未知.
* `showHint` off renders the compact form for dense surfaces (table row, card) —
* the surrounding banner already carries the failure reason there. */
export function ChannelStatusTag({
status,
showHint = true,
className = "",
}: {
status: ChannelStatus;
showHint?: boolean;
className?: string;
}): React.ReactNode {
return (
<span className={`inline-flex flex-wrap items-center gap-2 ${className}`}>
<Tag tone={status.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${dot[status.kind]}`} />
{status.label}
</Tag>
{status.stale ? <Tag tone="warn">数据可能过期</Tag> : null}
{showHint && status.hint ? <span className="text-xs text-[var(--mu-text-lo)]">{status.hint}</span> : null}
</span>
);
}
/** Read-failure banner with a retry. `stale` means an older copy is still on screen,
* so it warns about it instead of hiding the data behind an error. Failure is
* named as "与后端失联" so a stopped gateway is never read as a broken adapter. */
export function ChannelLoadError({
error,
reload,
stale = false,
what = "渠道数据",
}: {
error: string | null;
reload: () => void;
stale?: boolean;
what?: string;
}): React.ReactNode {
if (!error) return null;
return (
<div className="flex flex-wrap items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 px-3 py-2 text-xs text-[var(--mu-text-mid)]">
<Tag tone={stale ? "warn" : "err"}>{stale ? "数据可能过期" : "读取失败"}</Tag>
<span className="min-w-0 flex-1">
{stale
? `${what}本次刷新失败(与后端失联):${error}`
: `${what}读取失败(与后端失联):${error}`}
</span>
<Button size="sm" variant="ghost" onClick={reload}>
重试
</Button>
</div>
);
}