feat(gateway): 渠道事件查询面 + 面板「渠道事件」页(design/26 E6)
- 两个只读端点: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:
1 parent
a0abf0199a
commit
c8053b1f36
21 files changed
+1805
-30
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "能力与连接" },
|
||||
|
||||
Reference in new issue
Block a user