diff --git a/frontend/web/src/admin/AdminApp.tsx b/frontend/web/src/admin/AdminApp.tsx index 59ad6db..be00da3 100644 --- a/frontend/web/src/admin/AdminApp.tsx +++ b/frontend/web/src/admin/AdminApp.tsx @@ -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 = { + 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 = { + 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("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 ( + <> + + + + {meta.label} + + + + + + 实时通道 · {ws.label} + + + + ); +} + function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode { const role = getStoredRole(); const [theme, setTheme] = useState(readTheme); @@ -152,6 +225,7 @@ function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () {role} ) : null} + - - {accountId ? ( - - ) : null} - 连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。 - - + @@ -84,7 +74,7 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack ) : platform === "weixin-ilink" ? ( ) : ( - + )}
@@ -93,3 +83,68 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
); } + +/** 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 ( + +
+ {label} + + {account?.botUsername ? @{account.botUsername} : null} + {account?.botNickname ? {account.botNickname} : null} + {account?.botId ? bot_id:{account.botId} : null} +
+
+ + + {accountId ? ( + + ) : null} + 连接/断开作用于 {platform} adapter,删除作用于账号 {accountId ?? "default"}。 + {unknown ? ( + 读取不到连接状态,连接/断开已禁用;请先确认后端在线再重试。 + ) : null} +
+
+ ); +} diff --git a/frontend/web/src/admin/modules/channels/CreatePage.tsx b/frontend/web/src/admin/modules/channels/CreatePage.tsx index 90fd7be..c0acaa2 100644 --- a/frontend/web/src/admin/modules/channels/CreatePage.tsx +++ b/frontend/web/src/admin/modules/channels/CreatePage.tsx @@ -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 {
- {loadErr ? {loadErr} : null} + {error ? {error} : null} {notice ? {notice} : null}

可用适配器类型

- 全部已知平台,未配置也可创建 + + {listUnknown + ? "读取不到已保存配置,「已配置 / 未配置」暂不可判定,编辑已禁用" + : "全部已知平台,未配置也可创建"} +
{PLATFORM_TYPES.map((p) => { @@ -46,11 +57,19 @@ export function CreatePage(): React.ReactNode {
{PLATFORM_LABEL[p] ?? p} - {has ? "已配置" : "未配置"} + + {listDegraded ? (listUnknown ? "状态未知" : "读取中…") : has ? "已配置" : "未配置"} +

{PLATFORM_GUIDE[p].guide}

-
diff --git a/frontend/web/src/admin/modules/channels/GenericAdapterPanel.tsx b/frontend/web/src/admin/modules/channels/GenericAdapterPanel.tsx index e5ab92b..29cc358 100644 --- a/frontend/web/src/admin/modules/channels/GenericAdapterPanel.tsx +++ b/frontend/web/src/admin/modules/channels/GenericAdapterPanel.tsx @@ -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({ 通用壳 · 平台专属端点未接入
- - - + + +
); } /** 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 (

平台能力

{caps.length === 0 ? ( - 该适配器尚未声明能力列表;平台专属端点未接入,当前仅提供通用操作(连接 / 断开 / 消息日志 / 身份测试)。 + + {recordUnknown + ? "读取失败(与后端失联),无法确认该适配器声明的能力;请先确认后端在线后重试。" + : "该适配器尚未声明能力列表;平台专属端点未接入,当前仅提供通用操作(连接 / 断开 / 消息日志 / 身份测试)。"} + ) : (
{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 (

平台专属功能区(占位)

{adminOps.length === 0 ? ( - 该平台暂无已声明的专属功能模块。频道 / 成员 / 发送测试等平台能力将随后续接入后在此显示。 + + {recordUnknown + ? "读取失败(与后端失联),无法确认该平台的专属功能模块;请先确认后端在线后重试。" + : "该平台暂无已声明的专属功能模块。频道 / 成员 / 发送测试等平台能力将随后续接入后在此显示。"} + ) : ( <>
@@ -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 (

主动推送

{supported ? ( 主动推送(占位,后端待接):该适配器声明了主动推送能力,定时 / 主动消息功能待后端接入。 + ) : recordUnknown ? ( + 读取失败(与后端失联),无法确认该适配器是否支持主动推送;请先确认后端在线后重试。 ) : ( 当前适配器未声明主动推送能力(Proactive),不支持主动 / 定时消息;仅在被对话触发时响应。 )} diff --git a/frontend/web/src/admin/modules/channels/KookPanel.tsx b/frontend/web/src/admin/modules/channels/KookPanel.tsx index bd4128b..b15e3fe 100644 --- a/frontend/web/src/admin/modules/channels/KookPanel.tsx +++ b/frontend/web/src/admin/modules/channels/KookPanel.tsx @@ -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({
- {error ? {error} : null} {notice ? {notice} : null} + {loading ? 加载中… : null} {data ? ( @@ -107,7 +108,7 @@ function KookFriendsCard({

好友 & 申请

{requests.length === 0 && list.length === 0 ? ( - 暂无好友/申请数据(未连接或账号无好友)。 + 暂无好友/申请数据(账号无好友,或该适配器当前未连接 / 未登录)。 ) : null} {requests.length > 0 ? (
@@ -158,7 +159,7 @@ function KookGuildCard({

服务器 / 频道 / 成员

{!guilds || guilds.length === 0 ? ( - 暂无服务器数据(bot 未加入任何服务器或未连接)。 + 暂无服务器数据(bot 未加入任何服务器,或该适配器当前未连接)。 ) : (
{guilds.map((g) => ( diff --git a/frontend/web/src/admin/modules/channels/ListPage.tsx b/frontend/web/src/admin/modules/channels/ListPage.tsx index 29de7a8..b515fe5 100644 --- a/frontend/web/src/admin/modules/channels/ListPage.tsx +++ b/frontend/web/src/admin/modules/channels/ListPage.tsx @@ -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[] = [ - { 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) => ( - - - - - - ), - }, - ]; + 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 (
- {loadErr ? {loadErr} : null} + {error ? {error} : null} {notice ? {notice} : null} - c.name} - /> + {hardFail ? null : ( + c.name} + /> + )} @@ -140,3 +89,82 @@ export function ListPage({ navigate }: { navigate: (n: AdapterNav) => void }): R
); } + +/** 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[] { + return [ + { key: "name", title: "平台", render: (c) => {PLATFORM_LABEL[platformOf(c.name)] ?? c.name} }, + { key: "connected", title: "状态", render: (c) => }, + { key: "accounts", title: "配置账号(点击进入其专属页)", render: (c) => }, + { key: "actions", title: "操作", render: (c) => }, + ]; +} + +/** 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 —; + } + return ( + + {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 ( + + + + {tokenMasked ? "token 已配置" : "token 未填"} + + + {a.id ? ( + + ) : null} + + ); + })} + + ); +} + +/** 连接 / 断开 / 配置. 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 ( + + + + + + ); +} diff --git a/frontend/web/src/admin/modules/channels/OverviewPage.tsx b/frontend/web/src/admin/modules/channels/OverviewPage.tsx index 059f89a..1bf3c06 100644 --- a/frontend/web/src/admin/modules/channels/OverviewPage.tsx +++ b/frontend/web/src/admin/modules/channels/OverviewPage.tsx @@ -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 (
+ + } label="适配器类型" value={PLATFORM_TYPES.length} sub="可用平台类型" tone="accent" /> - } label="已连接平台" value={`${connected}/${configured.length}`} sub="已配置平台中连接数" tone="ok" /> - } label="配置账号" value={accountCount} sub="当前全部账号" tone="neutral" /> + } + label="已连接平台" + value={countsUnknown ? "—" : `${connected}/${configured.length}`} + sub={countsSub("已配置平台中连接数")} + tone={countsUnknown ? "warn" : "ok"} + /> + } + label="配置账号" + value={countsUnknown ? "—" : accountCount} + sub={countsSub("当前全部账号")} + tone="neutral" + /> } label="最近消息" value={logs.data?.length ?? "—"} sub="最近事件数" tone="warn" /> - {chans.error && !chans.data ? {chans.error} : null} - - -
-

最近事件(下钻到平台 / 配置)

- - - - -
- {logs.loading ? ( - 加载中… - ) : logs.error ? ( - {logs.error} - ) : !logs.data || logs.data.length === 0 ? ( - 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 - ) : ( -
- {logs.data.map((m) => ( - navigate({ kind: "detail", adapter })} /> - ))} -
- )} -
+ navigate({ kind: "detail", adapter })} + onOpenAll={() => navigate({ kind: "page", page: "log" })} + /> {configured.length ? ( - -
-

已配置平台

- - - -
-
- {configured.map((c) => { - const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id); - return ( - - ); - })} -
-
+ navigate({ kind: "detail", adapter })} + onOpenList={() => navigate({ kind: "page", page: "list" })} + /> ) : null}
); } +/** 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 ( + +
+

已配置平台

+ + + +
+
+ {configured.map((c) => { + const first = adapterNameOf(platformOf(c.name), c.accounts?.[0]?.id); + return ( + + ); + })} +
+
+ ); +} + +/** 最近事件卡: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 ( + +
+

最近事件(下钻到平台 / 配置)

+ + + + +
+ {loading ? ( + 加载中… + ) : error && !data ? ( + + ) : ( + <> + + {!data || data.length === 0 ? ( + 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : ( +
+ {data.map((m) => ( + + ))} +
+ )} + + )} +
+ ); +} + /** 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"; diff --git a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx index 36d4e5d..9814e39 100644 --- a/frontend/web/src/admin/modules/channels/WeixinLogin.tsx +++ b/frontend/web/src/admin/modules/channels/WeixinLogin.tsx @@ -62,6 +62,9 @@ export function WeixinLogin({ const [botId, setBotId] = useState(""); const [busy, setBusy] = useState(null); const [notice, setNotice] = useState(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(null); const [hidden, setHidden] = useState(false); const pollRef = useRef | null>(null); const statusRef = useRef("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({ ) : null} + {statusErr ? ( +
+ 读取失败 + + 读取失败(与后端失联),无法确认该账号的微信登录状态,下方「已登录 / 已扫码」标记暂不可信:{statusErr} + + +
+ ) : null} + {status === "confirmed" && !showQR ? ( 该账号已绑定 bot_token,无需再次扫码。二维码 / token 均不在此回显。 ) : null} diff --git a/frontend/web/src/admin/modules/channels/channelLogList.tsx b/frontend/web/src/admin/modules/channels/channelLogList.tsx index aeb13c6..379d108 100644 --- a/frontend/web/src/admin/modules/channels/channelLogList.tsx +++ b/frontend/web/src/admin/modules/channels/channelLogList.tsx @@ -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({
{loading ? ( 加载中… - ) : error ? ( - {error} - ) : !msgs || msgs.length === 0 ? ( - 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : error && !data && msgs.length === 0 ? ( + ) : ( - msgs.map((m) => ) + <> + + {msgs.length === 0 ? ( + 暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。 + ) : ( + msgs.map((m) => ) + )} + )}
diff --git a/frontend/web/src/admin/modules/channels/channelStatus.test.ts b/frontend/web/src/admin/modules/channels/channelStatus.test.ts new file mode 100644 index 0000000..259bd50 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelStatus.test.ts @@ -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(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`); diff --git a/frontend/web/src/admin/modules/channels/channelStatus.ts b/frontend/web/src/admin/modules/channels/channelStatus.ts new file mode 100644 index 0000000..2cd9aa4 --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelStatus.ts @@ -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, + }; +} diff --git a/frontend/web/src/admin/modules/channels/channelStatusView.tsx b/frontend/web/src/admin/modules/channels/channelStatusView.tsx new file mode 100644 index 0000000..de3108c --- /dev/null +++ b/frontend/web/src/admin/modules/channels/channelStatusView.tsx @@ -0,0 +1,65 @@ +import { Button, Tag } from "../../../components/primitives"; +import type { ChannelStatus, ChannelStatusKind } from "./channelStatus"; + +const dot: Record = { + 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 ( + + + + {status.label} + + {status.stale ? 数据可能过期 : null} + {showHint && status.hint ? {status.hint} : null} + + ); +} + +/** 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 ( +
+ {stale ? "数据可能过期" : "读取失败"} + + {stale + ? `${what}本次刷新失败(与后端失联):${error}` + : `${what}读取失败(与后端失联):${error}`} + + +
+ ); +}