feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 两个只读端点:GET /admin/channels/events(列表,8 个过滤条件 + 分页钳制)与 .../events/counters(逐类计数,恒带 approx:true 如实标「近似」)
- 诚实边界:>90 天窗口 ⇒ 400(不静默裁剪);未知 platform/档位 ⇒ 400;**未接线 ⇒ 500 channel_events_unavailable**(绝不回空 200 假装「没有事件」);微信平台的空如实说明「该渠道不提供非消息事件」而不借别平台顶替
- 面板:新 section「渠道事件」(计数卡 + 事件行 + 过滤条 + 30s 静默刷新),空/错误态分开且失败显示错误不显示 0
- 组合根接线(主会话补):wire_frontend.go 的 Deps 注入 EventStore: a.channelEventStore()(未接线时为真 nil ⇒ 保留诚实 500)
- 契约不升版(查询面 v0.67 已登记):就地改「未实现 → 已实现 + 端点路径」,并补登记 5 处原先留白的落地值(camelCase / 通用 bad_request / RBAC owner|admin / data.approx / total 语义)
- 顺带把 §5 三个 CHANNEL_EVENT_* env 键由「尚未实现」改「已接线」(E4)
- 变异实测:去掉 90 天上限 ⇒ status 200 want 400;未接线回空 200 ⇒ status 200 want 500
- 债务登记:events.Filter 无 AccountID 列 ⇒ 服务端暂不能按账号筛(列表仍如实下发 accountId),要加需先改 internal/runtime/events
This commit is contained in:
AskaEth committed 2026-10-05 14:38:37 +08:00
1 parent a0abf0199a
commit c8053b1f36
21 files changed
+1805 -30

No files matched your search

+2
View File
@@ -14,6 +14,7 @@ import { SessionsModule } from "./modules/Sessions";
import { MemoriesModule } from "./modules/Memories";
import { PluginsModule } from "./modules/Plugins";
import { ChannelsModule } from "./modules/Channels";
import { ChannelEventsModule } from "./modules/ChannelEvents";
import { IoTModule } from "./modules/IoT";
import { RagModule } from "./modules/Rag";
import { LlmModule } from "./modules/Llm";
@@ -34,6 +35,7 @@ function renderSection(section: AdminSectionId): React.ReactNode {
case "memories": return <MemoriesModule />;
case "plugins": return <PluginsModule />;
case "channels": return <ChannelsModule />;
case "channel-events": return <ChannelEventsModule />;
case "iot": return <IoTModule />;
case "rag": return <RagModule />;
case "llm": return <LlmModule />;
@@ -0,0 +1,172 @@
// 「渠道事件」页的**真实渲染**测试(design/26 §4.5 的面板段;切片 E6)。
//
// 为什么需要它:本页要回答的是那次无法定案的事故(「上游推过没有?」),因此它的
// 三个态都有产品后果 ——
// ① 有数据:计数与事件行要能一眼读出来(含「计数为近似值」这句真话);
// ② 空:**微信渠道**的空是平台事实,必须换一句说明,而不是「暂无数据」了事;
// ③ 错误:读不到就显示错误,**绝不显示 0**(画成 0 就是「界面与事实无关」,
// 正是本项目面板明令禁止的那一类)。
// 文案对不对只有画出来才算数,因此这里渲染真组件。
//
// 跑法(与 channels/accountRow.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/ChannelEvents.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-events.render.test.cjs \
// && node /tmp/cyrene-channel-events.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelEvent, AdminChannelEventCounter } from "../../lib/types";
import {
ChannelEventCountersBody,
ChannelEventListBody,
NO_EVENT_PLATFORM_HINT,
} from "./channelEventViews";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
const EVENTS: AdminChannelEvent[] = [
{
id: 12,
platform: "qq-obv11",
accountId: "acct-1",
tier: "t2",
category: "group_increase",
platformType: "group_increase",
channelId: "1070898847",
channelType: "group",
groupId: "1070898847",
actorId: "10086",
actorRole: "member",
targetId: "",
selfRelated: false,
messageId: "",
summary: "群成员加入",
createdAt: "2026-10-05T03:04:05.000Z",
updatedAt: "2026-10-05T03:04:05.000Z",
},
{
id: 11,
platform: "qq-obv11",
accountId: "acct-1",
tier: "t3",
category: "poke",
platformType: "notify.poke",
channelId: "1070898847",
channelType: "group",
groupId: "1070898847",
actorId: "10086",
actorRole: "member",
targetId: "9616b27a",
selfRelated: true,
messageId: "",
summary: "有人戳了你一下",
createdAt: "2026-10-05T03:03:05.000Z",
updatedAt: "2026-10-05T03:03:05.000Z",
},
];
const COUNTERS: AdminChannelEventCounter[] = [
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "heartbeat", platformType: "heartbeat", count: 1440, firstSeenAt: "2026-10-01T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "lifecycle", platformType: "lifecycle", count: 2, firstSeenAt: "2026-10-01T00:00:00.000Z", lastSeenAt: "2026-10-05T02:00:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t2", category: "group_increase", platformType: "group_increase", count: 3, firstSeenAt: "2026-10-02T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
{ platform: "qq-obv11", accountId: "acct-1", tier: "t1", category: "unknown", platformType: "notify.mystery", count: 7, firstSeenAt: "2026-10-02T00:00:00.000Z", lastSeenAt: "2026-10-05T03:04:00.000Z" },
];
function countersHtml(props: Partial<Parameters<typeof ChannelEventCountersBody>[0]>): string {
return renderToStaticMarkup(
createElement(ChannelEventCountersBody, {
counters: null,
approx: false,
loading: false,
error: null,
platform: "",
...props,
})
);
}
function listHtml(props: Partial<Parameters<typeof ChannelEventListBody>[0]>): string {
return renderToStaticMarkup(
createElement(ChannelEventListBody, {
events: null,
total: 0,
loading: false,
error: null,
platform: "",
...props,
})
);
}
// ── ① 有数据:四个计数口径 + 合计,事件行的档位 / 摘要 / 当事人 / 时间 ────────────
const dataCounters = countersHtml({ counters: COUNTERS, approx: true });
ok(has(dataCounters, "qq-obv11"), "计数卡必须点名平台");
ok(has(dataCounters, "心跳"), "计数卡必须有「心跳」口径(design/26 §4.5)");
ok(has(dataCounters, "连接"), "计数卡必须有「连接」口径");
ok(has(dataCounters, "请求"), "计数卡必须有「请求」口径");
ok(has(dataCounters, "未知"), "计数卡必须有「未知」口径");
ok(has(dataCounters, "1440"), "心跳计数要如实显示");
ok(has(dataCounters, "合计 1452"), "合计 = 该平台全部落盘计数之和(1440+2+3+7)");
ok(has(dataCounters, "计数为近似值"), "approx=true 时必须如实标注近似语义");
ok(has(dataCounters, "已落盘"), "必须说清读的是已落盘计数表");
const dataList = listHtml({ events: EVENTS, total: 2 });
ok(has(dataList, "群成员加入"), "事件行必须显示脱敏摘要");
ok(has(dataList, "有人戳了你一下"), "第二行摘要也要渲染");
ok(has(dataList, "留存+上行"), "t2 档要有中文档位标签");
ok(has(dataList, "涉及她自己"), "selfRelated 必须在行上标出来");
ok(has(dataList, "10086 → 9616b27a"), "T3 要显示「谁对谁」");
ok(has(dataList, "1070898847"), "群 id 要显示(排障按群定位)");
ok(has(dataList, "共 "), "要给 total 与「本页 N 条」");
ok(has(dataList, "2"), "total 数值要渲染");
// 时间按本地时区渲染,只断言它出现在行上(不带时区假设)。
ok(/10-05 0[0-9]:0[0-9]:0[0-9]|10-0[4-6] /.test(dataList) || has(dataList, "2026"), "事件行必须显示发生时刻");
// ── ② 空态:普通平台 vs 微信渠道(后者是平台事实,必须换文案说明)──────────────
const emptyList = listHtml({ events: [], total: 0 });
ok(has(emptyList, "没有事件落痕"), "空列表要有明确空态");
ok(has(emptyList, "只进计数"), "空态要解释 T1 只进计数(否则会被误读成「没采到」)");
ok(!has(emptyList, "共 "), "空态不该渲染「共 N 条」这类数字行");
const emptyCounters = countersHtml({ counters: [], approx: true });
ok(has(emptyCounters, "暂无计数"), "空计数要有明确空态");
ok(!has(emptyCounters, "合计 0"), "空计数绝不渲染「合计 0」——0 会被读成「上游一帧都没推」");
const weixinCounters = countersHtml({ counters: [], approx: true, platform: "weixin-ilink" });
const weixinList = listHtml({ events: [], total: 0, platform: "weixin-ilink" });
ok(has(weixinCounters, "不提供非消息事件"), "微信渠道的空必须说明「该渠道不提供非消息事件」(§3.4/§8.5)");
ok(has(weixinList, "不提供非消息事件"), "微信列表空态同样要说明");
ok(has(weixinList, NO_EVENT_PLATFORM_HINT), "微信空态用的是同一条平台事实文案");
// ── ③ 错误态:显示错误,绝不显示 0 ───────────────────────────────────────────
const errCounters = countersHtml({ counters: null, error: "读取渠道事件计数失败", approx: true });
ok(has(errCounters, "计数读取失败"), "计数失败必须显示错误");
ok(has(errCounters, "读取渠道事件计数失败"), "要带上后端的错误信息");
ok(!has(errCounters, "合计"), "失败时绝不渲染任何计数(包括 0)");
ok(!has(errCounters, "心跳"), "失败时绝不渲染计数口径");
const errList = listHtml({ events: null, total: 0, error: "读取渠道事件列表失败" });
ok(has(errList, "事件列表读取失败"), "列表失败必须显示错误");
ok(!has(errList, "共 "), "失败时绝不渲染「共 0 条」");
ok(!has(errList, "没有事件落痕"), "失败不得退化成空态(空 ≠ 读不到)");
// 有旧数据时的失败:保留旧行,但必须挂一条醒目的错误说明。
const staleList = listHtml({ events: EVENTS, total: 2, error: "读取渠道事件列表失败" });
ok(has(staleList, "刷新失败"), "带旧数据的失败要显式说明这是上次的结果");
ok(has(staleList, "群成员加入"), "带旧数据的失败仍要保留上一次的结果(不闪空)");
console.log(`channel events panel render checks passed: ${checks}`);
@@ -0,0 +1,222 @@
import { useMemo, useState } from "react";
import { useAsyncData } from "../../lib/useAsyncData";
import { adminChannelEventCounters, adminChannelEvents } from "../../lib/api";
import { useAutoReload } from "../hooks";
import { Notice, PageHeader } from "../components";
import { Button, Surface } from "../../components/primitives";
import { ChannelEventCountersBody, ChannelEventListBody, categoryLabel } from "./channelEventViews";
/**
* 「渠道事件」页(design/26 §4.5 的面板段;切片 E6)。
*
* 它回答的是那次**无法定案**的事故(design/26 §0.2):「上游到底推过没有?是不是
* 到了适配器却被过滤掉了?」—— E1–E4 把每一帧的脱敏结构痕迹落进了事件表与计数表,
* 本页把那份证据画出来。展示层在 `channelEventViews.tsx`(三态可被渲染测试直接钉住)。
*
* 本文件只负责:过滤条 + 取数 + 组装。三条纪律见展示层与下方说明块。
*/
/** 平台白名单:与后端 `parseChannelEventPlatform` 逐字一致(写错就是 400)。 */
export const CHANNEL_EVENT_PLATFORMS: ReadonlyArray<string> = [
"qq-obv11",
"kook",
"telegram",
"weixin-ilink",
"webhook",
];
/** 一页条数。上限 500 由后端钳制,这里取缺省 100 免得一屏塞爆。 */
const PAGE_SIZE = 100;
/** 静默刷新间隔(列表与计数一起)。 */
const RELOAD_MS = 30_000;
/** 毫秒 / 小时 —— 时间窗换算的唯一出处(不散落字面量)。 */
const MS_PER_HOUR = 3600_000;
/** 时间窗档位(都远在 90 天上限内;面板**显式**传窗口,不指望后端给缺省)。 */
export const WINDOWS = [
{ value: "1d", label: "近 1 天", hours: 24 },
{ value: "7d", label: "近 7 天", hours: 168 },
{ value: "30d", label: "近 30 天", hours: 720 },
{ value: "90d", label: "近 90 天", hours: 2160 },
] as const;
type WindowKey = (typeof WINDOWS)[number]["value"];
/** 时间窗起点(UTC RFC3339)。每次拉取时现算,因此「近 7 天」始终跟着现在走。 */
export function windowSince(key: WindowKey): string {
const meta = WINDOWS.find((w) => w.value === key) ?? WINDOWS[1];
return new Date(Date.now() - meta.hours * MS_PER_HOUR).toISOString();
}
const inputCls =
"h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm text-[var(--mu-text-hi)] outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
/** 过滤器条:平台 / 档位 / 类别 / 时间窗 / 会话-群 id。 */
function FilterBar(props: {
platform: string;
tier: string;
category: string;
channelId: string;
windowKey: WindowKey;
categories: readonly string[];
onPlatform: (v: string) => void;
onTier: (v: string) => void;
onCategory: (v: string) => void;
onChannelId: (v: string) => void;
onWindow: (v: WindowKey) => void;
onReload: () => void;
busy: boolean;
}): React.ReactNode {
return (
<div className="flex flex-wrap items-center gap-2">
<select
value={props.platform}
onChange={(e) => props.onPlatform(e.target.value)}
className={inputCls}
title="平台(后端白名单;未知平台会 400,绝不静默放宽成全部)"
>
<option value="">全部平台</option>
{CHANNEL_EVENT_PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select value={props.tier} onChange={(e) => props.onTier(e.target.value)} className={inputCls} title="留存档">
<option value="">全部档位</option>
<option value="t1">t1 · 遥测</option>
<option value="t2">t2 · 留存+上行</option>
<option value="t3">t3 · 交互</option>
</select>
<select
value={props.category}
onChange={(e) => props.onCategory(e.target.value)}
className={inputCls}
title="归一化类别(选项来自已落盘计数,因此永远与数据同步)"
>
<option value="">全部类别</option>
{props.categories.map((c) => (
<option key={c} value={c}>
{categoryLabel(c)} · {c}
</option>
))}
</select>
<select
value={props.windowKey}
onChange={(e) => props.onWindow(e.target.value as WindowKey)}
className={inputCls}
title="时间窗(面板显式传 since,后端不注入缺省窗口;上限 90 天)"
>
{WINDOWS.map((w) => (
<option key={w.value} value={w.value}>
{w.label}
</option>
))}
</select>
<input
value={props.channelId}
onChange={(e) => props.onChannelId(e.target.value)}
placeholder="会话 / 群 id"
className={`${inputCls} w-40`}
/>
<Button variant="soft" size="sm" disabled={props.busy} onClick={props.onReload}>
刷新
</Button>
</div>
);
}
export function ChannelEventsModule(): React.ReactNode {
const [platform, setPlatform] = useState("");
const [tier, setTier] = useState("");
const [category, setCategory] = useState("");
const [channelId, setChannelId] = useState("");
const [windowKey, setWindowKey] = useState<WindowKey>("7d");
const list = useAsyncData(
() =>
adminChannelEvents({
platform,
tier,
category,
channelId,
since: windowSince(windowKey),
limit: PAGE_SIZE,
}),
[platform, tier, category, channelId, windowKey]
);
const counters = useAsyncData(() => adminChannelEventCounters(platform), [platform]);
useAutoReload(list.reload, RELOAD_MS);
useAutoReload(counters.reload, RELOAD_MS);
// 类别选项**来自已落盘的计数**:数据里有什么就能筛什么,不另维护一份会漂移的清单。
// 当前选中的类别即便不在选项里也照样显示(否则会出现「选了却看不见」的空白下拉)。
const categories = useMemo(() => {
const seen = new Set<string>();
for (const c of counters.data?.counters ?? []) if (c.category) seen.add(c.category);
if (category) seen.add(category);
return Array.from(seen).sort();
}, [counters.data, category]);
const reloadAll = (): void => {
list.reload();
counters.reload();
};
return (
<div className="space-y-5">
<PageHeader
title="渠道事件"
subtitle="GET /api/v1/admin/channels/events · 非消息事件留存(design/26 §4.5):到达适配器的每一帧都留痕 —— 推过没有、推了什么,这里查得到"
actions={
<Button variant="soft" disabled={list.loading} onClick={reloadAll}>
刷新
</Button>
}
/>
<Surface className="space-y-3">
<FilterBar
platform={platform}
tier={tier}
category={category}
channelId={channelId}
windowKey={windowKey}
categories={categories}
onPlatform={setPlatform}
onTier={setTier}
onCategory={setCategory}
onChannelId={setChannelId}
onWindow={setWindowKey}
onReload={reloadAll}
busy={list.loading}
/>
<ChannelEventCountersBody
counters={counters.data?.counters ?? null}
approx={counters.data?.approx === true}
loading={counters.loading}
error={counters.error}
platform={platform}
/>
</Surface>
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">事件列表</h3>
<ChannelEventListBody
events={list.data?.events ?? null}
total={list.data?.total ?? 0}
loading={list.loading}
error={list.error}
platform={platform}
/>
</Surface>
<Notice>
只读查询:事件行只有结构化事实(平台 / 类型 / 当事人 id / 固定摘要),
没有正文、没有媒体 URL、没有凭据与 OneBot <span className="font-mono">flag</span>——
那些东西根本没进事件表(design/26 §4.2 红线)。T1 遥测(心跳等)只进计数、
不逐条落行;未接线或读库失败时两个端点诚实回错,本页照实显示,绝不画成 0。
</Notice>
</div>
);
}
@@ -0,0 +1,291 @@
import { EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { Icon } from "../../components/Icon";
import type { AdminChannelEvent, AdminChannelEventCounter } from "../../lib/types";
import { Notice } from "../components";
/**
* 「渠道事件」页的**展示层**(design/26 §4.5 的面板段;切片 E6)。
*
* 与页壳(`ChannelEvents.tsx`,负责取数与过滤)分开,是因为这些组件是**纯展示**:
* 三态完全由 props 决定,因此渲染测试可以逐态直接钉住文案 —— 本页最要紧的三条
* 面板纪律(失败显示错误不显示 0 / 计数如实标近似 / 微信渠道的空是平台事实)都在
* 这里,它们必须能被断言,而不是只能靠肉眼。
*/
/** 微信 iLink 渠道的平台事实:它没有非消息事件信封(design/26 §3.4)。
* 空结果对它**不是故障**,因此换一句说明,而不是「暂无数据」了事。 */
export const WEIXIN_PLATFORM = "weixin-ilink";
/** 「该渠道不提供非消息事件」的说明文案(§3.4 / §8.5 的诚实标注)。 */
export const NO_EVENT_PLATFORM_HINT =
"微信 iLink 渠道不提供非消息事件(没有 notice / request / meta_event 信封)——这里的空是平台事实,不是漏采或故障。";
/** 档位标签(design/26 §2.1 的直觉话术)。未知档位照实显示原值。 */
const TIER_META: Record<string, { label: string; tone: TagTone }> = {
t1: { label: "遥测留存", tone: "neutral" },
t2: { label: "留存+上行", tone: "accent" },
t3: { label: "交互", tone: "ok" },
};
/** 归一化类别 → 中文(只覆盖已登记的类别;未登记的原样显示,绝不丢行)。 */
const CATEGORY_LABELS: Record<string, string> = {
heartbeat: "心跳",
lifecycle: "生命周期",
link: "链路",
message_sent: "自我回声",
join: "成员加入",
friend_add: "加为好友",
leave: "成员离开",
recall: "消息撤回",
admin: "管理员变更",
ban: "禁言",
ban_all: "全员禁言",
upload: "文件上传",
essence: "精华消息",
card: "名片变更",
offline_file: "离线文件",
client_status: "他人在线",
honor: "群荣誉",
title: "头衔",
poke: "戳一戳",
lucky_king: "运气王",
friend_request: "加好友请求",
group_request: "加群请求",
reaction: "表情回应",
button: "按钮点击",
item: "道具",
vote: "投票",
unknown: "未知类型",
decode_error: "解析失败",
};
/** 顶部计数卡的四个口径(design/26 §4.5 第 478 行逐字:心跳 / 连接 / 请求 / 未知)。 */
const COUNTER_GROUPS: ReadonlyArray<{
key: string;
label: string;
categories: readonly string[];
tone: TagTone;
hint: string;
}> = [
{ key: "heartbeat", label: "心跳", categories: ["heartbeat"], tone: "neutral", hint: "上游 meta_event 心跳(对端配了推送才有)" },
{ key: "link", label: "连接", categories: ["link", "lifecycle"], tone: "ok", hint: "连接 / 生命周期(链路当时活没活)" },
{ key: "request", label: "请求", categories: ["friend_request", "group_request"], tone: "warn", hint: "加好友 / 加群请求(默认不自动同意)" },
{ key: "unknown", label: "未知", categories: ["unknown", "decode_error"], tone: "err", hint: "类型未枚举 / 载荷解析失败——「看不懂」也要留痕" },
];
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
/** 本地 `MM-DD HH:mm:ss`;事件是排障证据,秒要留着。 */
export function formatEventTime(iso: string): string {
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return iso || "—";
const d = new Date(ms);
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
/** 类别标签:未登记的类别原样显示(绝不因为不认识就吞掉一行)。 */
export function categoryLabel(category: string): string {
return CATEGORY_LABELS[category] ?? category ?? "";
}
/** 按平台聚合计数:四个口径 + 合计。未登记的类别只进「合计」,绝不并进某一档。 */
export interface CounterGroupRow {
platform: string;
total: number;
groups: { key: string; count: number }[];
}
function groupCounters(counters: readonly AdminChannelEventCounter[]): CounterGroupRow[] {
const byPlatform = new Map<string, CounterGroupRow>();
for (const c of counters) {
let row = byPlatform.get(c.platform);
if (!row) {
row = {
platform: c.platform,
total: 0,
groups: COUNTER_GROUPS.map((g) => ({ key: g.key, count: 0 })),
};
byPlatform.set(c.platform, row);
}
row.total += c.count;
const index = COUNTER_GROUPS.findIndex((g) => g.categories.includes(c.category));
if (index >= 0) row.groups[index].count += c.count;
}
return Array.from(byPlatform.values()).sort((a, b) => (a.platform < b.platform ? -1 : 1));
}
export interface ChannelEventCountersBodyProps {
counters: readonly AdminChannelEventCounter[] | null;
approx: boolean;
loading: boolean;
error: string | null;
platform: string;
}
/** 顶部计数卡(纯展示组件:三态由 props 决定,便于渲染测试直接钉住文案)。 */
export function ChannelEventCountersBody({
counters,
approx,
loading,
error,
platform,
}: ChannelEventCountersBodyProps): React.ReactNode {
// 失败就显示失败:绝不把读不到的计数画成 0(那正是「界面与事实无关」)。
if (error) return <Notice tone="err">计数读取失败:{error}</Notice>;
if (loading && !counters) {
return <div className="px-4 py-6 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
}
const rows = groupCounters(counters ?? []);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag
tone={approx ? "warn" : "neutral"}
title="计数在内存聚合、每 30s 落盘一次(CHANNEL_EVENT_COUNTER_FLUSH),读到的表不含待落盘部分 ⇒ 只少不多"
>
{approx ? "计数为近似值(下界)" : "计数未标注近似语义"}
</Tag>
<span>读的是已落盘计数表;「上游到底推没推」看这里 —— 与事件行的「推过什么」互为补充。</span>
</div>
{rows.length === 0 ? (
<EmptyState
icon={<Icon name="storage" size={28} />}
title="暂无计数"
hint={
platform === WEIXIN_PLATFORM
? NO_EVENT_PLATFORM_HINT
: "该平台在这段时间内没有落盘计数。若入站一直在跑,先确认留存面是否已接线(未接线时计数只在内存里)。"
}
/>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((row) => (
<Surface key={row.platform} gap="sm" className="space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm">{row.platform}</span>
<Tag tone="accent">合计 {row.total}</Tag>
</div>
<div className="grid grid-cols-2 gap-2">
{row.groups.map((g, i) => (
<div key={g.key} className="rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2.5 py-2">
<div className="flex items-center justify-between gap-1">
<span className="text-xs text-[var(--mu-text-lo)]" title={COUNTER_GROUPS[i]?.hint}>
{COUNTER_GROUPS[i]?.label ?? g.key}
</span>
<Tag tone={COUNTER_GROUPS[i]?.tone ?? "neutral"}>{g.count}</Tag>
</div>
</div>
))}
</div>
</Surface>
))}
</div>
)}
</div>
);
}
export interface ChannelEventListBodyProps {
events: readonly AdminChannelEvent[] | null;
total: number;
loading: boolean;
error: string | null;
platform: string;
}
/** 事件列表(倒序)。同样三态分明:失败显示错误,空态带平台说明。 */
export function ChannelEventListBody({
events,
total,
loading,
error,
platform,
}: ChannelEventListBodyProps): React.ReactNode {
if (error && !events) return <Notice tone="err">事件列表读取失败:{error}</Notice>;
if (loading && !events) {
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-text-lo)]">加载中…</div>;
}
const rows = events ?? [];
if (rows.length === 0) {
return (
<EmptyState
icon={<Icon name="storage" size={32} />}
title="没有事件落痕"
hint={
platform === WEIXIN_PLATFORM
? NO_EVENT_PLATFORM_HINT
: "该筛选下没有 T2/T3/未知/链路事件。T1 遥测(心跳等他人在线状态)只进计数、不逐条落行 —— 去上面的计数卡看。"
}
/>
);
}
return (
<div className="space-y-3">
{error ? <Notice tone="err">列表刷新失败,下面显示的是上次成功的结果:{error}</Notice> : null}
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span>
共 <span className="font-mono text-[var(--mu-text-hi)]">{total}</span> 条(本页 {rows.length} 条)
</span>
<span>· 倒序 · 逐条留痕,可查不可改</span>
</div>
<div className="space-y-2">
{rows.map((ev) => (
<ChannelEventRow key={`${ev.platform}-${ev.id}`} event={ev} />
))}
</div>
</div>
);
}
function tierMeta(tier: string): { label: string; tone: TagTone } {
return TIER_META[tier] ?? { label: tier || "未标档", tone: "neutral" };
}
function ChannelEventRow({ event }: { event: AdminChannelEvent }): React.ReactNode {
const tier = tierMeta(event.tier);
const who = event.targetId ? `${event.actorId || "?"} → ${event.targetId}` : event.actorId || "—";
return (
<div className="flex flex-col gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2.5">
<div className="flex flex-wrap items-center gap-2">
<Tag tone={tier.tone} title={`留存档 ${event.tier}`}>
{tier.label}
</Tag>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{event.summary || categoryLabel(event.category) || "(无摘要)"}
</span>
{event.selfRelated ? <Tag tone="warn">涉及她自己</Tag> : null}
<span className="shrink-0 font-mono text-xs text-[var(--mu-text-lo)]">{formatEventTime(event.createdAt)}</span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--mu-text-lo)]">
<span className="font-mono">
{event.platform}
{event.accountId ? `:${event.accountId}` : ""}
</span>
<span title="归一化类别 / 平台原生类型码(排障用)">
<span className="font-mono">{categoryLabel(event.category)}</span>
<span className="font-mono"> ({event.platformType || "—"})</span>
</span>
<span>
行为者 <span className="font-mono">{who}</span> · <span className="font-mono">{event.actorRole || "unknown"}</span>
</span>
{event.groupId ? (
<span>
群 <span className="font-mono">{event.groupId}</span>
</span>
) : null}
{event.channelId && event.channelId !== event.groupId ? (
<span>
会话 <span className="font-mono">{event.channelId}</span>
</span>
) : null}
{event.messageId ? (
<span>
关联消息 <span className="font-mono">{event.messageId}</span>
</span>
) : null}
</div>
</div>
);
}
+2
View File
@@ -18,6 +18,7 @@ export type AdminSectionId =
| "memories"
| "plugins"
| "channels"
| "channel-events"
| "iot"
| "rag"
| "llm"
@@ -53,6 +54,7 @@ const VALID_SECTIONS: ReadonlySet<string> = new Set([
"memories",
"plugins",
"channels",
"channel-events",
"iot",
"rag",
"llm",
+1
View File
@@ -21,6 +21,7 @@ export const ADMIN_SECTIONS: ReadonlyArray<AdminSectionMeta> = [
{ id: "rag", label: "知识库 RAG", icon: "docs", group: "内容" },
{ id: "plugins", label: "插件与能力", icon: "plugin", group: "能力与连接" },
{ id: "channels", label: "渠道管理", icon: "channel", group: "能力与连接" },
{ id: "channel-events", label: "渠道事件", icon: "storage", group: "能力与连接" },
{ id: "iot", label: "IoT 设备", icon: "device", group: "能力与连接" },
{ id: "llm", label: "LLM 模型", icon: "robot", group: "能力与连接" },
{ id: "automations", label: "自动化与提醒", icon: "clock", group: "能力与连接" },