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

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

No files matched your search

+77 -3
View File
@@ -1,9 +1,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken, getStoredRole } from "../lib/api";
import { getAuthToken, getStoredRole, health } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import type { WsStatus } from "../lib/types";
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
import { navigateAdmin, type AdminSectionId } from "./router";
import { Button, Tag } from "../components/primitives";
import { Button, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon";
import { DashboardModule } from "./modules/Dashboard";
import { PersonasModule } from "./modules/Personas";
@@ -117,6 +119,77 @@ function AdminMobileNav({ open, active, onClose }: { open: boolean; active: Admi
);
}
/** Poll interval for the backend liveness probe (GET /api/v1/health). */
const HEALTH_POLL_MS = 15_000;
type BackendState = "checking" | "online" | "offline";
const BACKEND_META: Record<BackendState, { label: string; tone: TagTone; dot: string }> = {
checking: { label: "后端检测中", tone: "neutral", dot: "mu-blink" },
online: { label: "后端在线", tone: "ok", dot: "bg-[var(--mu-ok)]" },
offline: { label: "后端失联", tone: "err", dot: "bg-[var(--mu-err)]" },
};
const WS_META: Record<WsStatus, { label: string; tone: TagTone; dot: string }> = {
idle: { label: "未连接", tone: "neutral", dot: "bg-[var(--mu-text-lo)]" },
connecting: { label: "连接中", tone: "warn", dot: "mu-blink" },
connected: { label: "实时", tone: "ok", dot: "bg-[var(--mu-ok)]" },
reconnecting: { label: "重连中", tone: "warn", dot: "mu-blink" },
disconnected: { label: "已断开", tone: "err", dot: "bg-[var(--mu-err)]" },
expired: { label: "登录过期", tone: "err", dot: "bg-[var(--mu-err)]" },
};
/** Real liveness probe — the tag must never claim 在线 before /health has actually
* answered, so it starts as "检测中" (which is a state of its own, not a guess).
* The realtime channel is a separate signal from the store: it can be down while
* REST is fine (and vice versa), so it is shown as its own tag. */
function useBackendHealth(): BackendState {
const [state, setState] = useState<BackendState>("checking");
useEffect(() => {
let cancelled = false;
const probe = () => {
health()
.then(() => {
if (!cancelled) setState("online");
})
.catch(() => {
if (!cancelled) setState("offline");
});
};
probe();
const id = window.setInterval(probe, HEALTH_POLL_MS);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
return state;
}
/** 后端在线 / 失联 + 实时通道状态, both from real signals. */
function BackendStatusTag(): React.ReactNode {
const backend = useBackendHealth();
const wsStatus = useAppStore((s) => s.wsStatus);
const meta = BACKEND_META[backend];
const ws = WS_META[wsStatus];
return (
<>
<span className="inline-flex shrink-0" title={`GET /api/v1/health · 每 ${HEALTH_POLL_MS / 1000}s 探测一次`}>
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</Tag>
</span>
<span className="hidden shrink-0 sm:inline-flex" title="实时通道(/ws)状态">
<Tag tone={ws.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${ws.dot}`} />
实时通道 · {ws.label}
</Tag>
</span>
</>
);
}
function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: () => void }): React.ReactNode {
const role = getStoredRole();
const [theme, setTheme] = useState<ThemeMode>(readTheme);
@@ -152,6 +225,7 @@ function AdminTopBar({ section, onMenu }: { section: AdminSectionId; onMenu?: ()
{role}
</Tag>
) : null}
<BackendStatusTag />
<button
onClick={toggleTheme}
aria-label="切换主题"