feat(admin): 渠道状态三态化 + 顶栏后端失联指示 — 不再把「读不到」画成「未连接」
起因:面板把「取数失败(后端不可达)」渲染成红色「未连接」,主人因此把「后端没在跑」误读成「微信适配器挂了」(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:
1 parent
a51aadeef4
commit
37fb3cd265
12 files changed
+782
-192
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user