根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。 改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。 线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。 凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。 验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
69 lines
2.6 KiB
TypeScript
69 lines
2.6 KiB
TypeScript
import { Button, Tag } from "../../../components/primitives";
|
|
import type { ChannelStatus, ChannelStatusKind } from "./channelStatus";
|
|
|
|
const dot: Record<ChannelStatusKind, string> = {
|
|
connected: "bg-[var(--mu-ok)]",
|
|
connecting: "bg-[var(--mu-warn)] mu-blink",
|
|
ready: "bg-[var(--mu-text-lo)]",
|
|
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) — there the hint (e.g. the retry reason behind「连接中」) rides
|
|
* along as a hover title instead of taking a line of its own. */
|
|
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} title={status.hint ?? undefined}>
|
|
<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>
|
|
);
|
|
}
|