fix(admin): 日志类列表统一「最新在上」+ 顶部前插不顶跑阅读位置
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 审计 15 个日志/事件面后统一口径:改 3 个(渠道总日志 / 每账号消息日志弹窗 / 适配器专属页实时日志),7 个本已合规未动,聊天页**刻意保持正序**(本质是读对话,正序+自动滚底才自然)
- 排序口径唯一出处 channelLogOrder.ts(历史+WS 追帧按 id 去重、时间倒序)+ 展示层不倒序(与 channelEventViews 同分工);三面共用 useChannelMessageLog 取数
- 新增 logScroll.ts:贴顶跟着最新走、已下读按新增高度补偿(**新条目不把正在看的内容顶跑**);窗口化容器的滚动锚定一并接上
- 后端无需改(三处消息日志读取本就是 created_at DESC,已核)
- 诚实边界:滚动锚定的 DOM 那半只有纯函数有自动化证据(项目无 jsdom/Playwright,渲染测试跑 react-dom/server、effect 不执行)⇒ 已如实标注
- 变异实测:排序改回旧→新 / 取消补偿 / 渲染层再倒一次 / 改坏共享文案 —— 四处均打红
- ⚠️ 两处未改(在别的切片文件面内,已转交):flow/NodeDetail 节点实时日志 → 归 F2;Agents 任务日志 → 归 H1
This commit is contained in:
AskaEth committed 2026-10-05 15:37:04 +08:00
1 parent c8053b1f36
commit a3482ed06e
11 files changed
+653 -99

No files matched your search

@@ -1,36 +1,24 @@
import { useMemo } from "react";
import { adminChannelMessages } from "../../../lib/api";
import type { AdminChannelMessage } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAppStore } from "../../../store/useAppStore";
import { useRef } from "react";
import { Button, Tag } from "../../../components/primitives";
import { Notice, VirtualList, VIRTUAL_THRESHOLD } from "../../components";
import { useAutoReload } from "../../hooks";
import { useTopAnchoredScroll } from "../../logScroll";
import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView";
import { ChannelLogRow } from "./channelLogList";
import { ChannelLogEmpty, ChannelLogRow, MessageLogMeta } from "./channelLogViews";
import { useChannelMessageLog } from "./useChannelMessageLog";
/** 一行消息的估算高度(px):标签行 + 正文数行,实测后会被真实值取代。 */
const MESSAGE_ROW_ESTIMATE = 76;
/** 历史(后端最新在前)与本端 WS 实时尾(已按 id 去重)合并,翻成「旧 → 新」的
* 阅读顺序。与 `ChannelLogList` 同一份口径,保证两个面看到的是同一串消息。 */
function mergeLog(
history: readonly AdminChannelMessage[] | null,
live: readonly AdminChannelMessage[],
adapter: string,
): AdminChannelMessage[] {
const base = history ? [...history].reverse() : [];
const seen = new Set(base.map((m) => m.id));
const tail = live.filter((m) => m.adapterId === adapter).reverse();
return [...base, ...tail.filter((m) => !seen.has(m.id))];
}
/** 单适配器消息日志(design/17 §2「实时日志」)。数据面与 `ChannelLogList` 相同
* (历史分页 + `channel_persisted` WS 追帧 + 10s 兜底轮询),区别只在渲染:消息是
* 本模块最长的列表,也是 WS 每来一帧就要整表重渲染的面,因此走窗口化——只挂载
* 视口内的行(裁决 16.4.3「按效率最高的做」)。加载 / 空 / 失联三种状态照旧分开。 */
/** 单适配器消息日志(design/17 §2「实时日志」)。数据面与 `ChannelLogList` 共用
* `useChannelMessageLog`(历史分页 + `channel_persisted` WS 追帧 + 兜底轮询,
* **最新在上**),区别只在渲染:消息是本模块最长的列表,也是 WS 每来一帧就要整表
* 重渲染的面,因此走窗口化——只挂载视口内的行(裁决 16.4.3「按效率最高的做」)。
*
* 滚动维持:窗口化列表的滚动容器在列表**内部**,因此把它显式交给
* `useTopAnchoredScroll`(新条目落在顶部时不顶跑用户正在读的位置)。加载 / 空 /
* 失联三种状态照旧分开。 */
export function AdapterMessageLog({
adapter,
limit = 200,
@@ -38,17 +26,13 @@ export function AdapterMessageLog({
adapter: string;
limit?: number;
}): React.ReactNode {
const { data, loading, error, reload } = useAsyncData(
() => adminChannelMessages(adapter, limit),
[adapter, limit],
);
useAutoReload(reload, 10000);
const channelEvents = useAppStore((s) => s.channelEvents);
const msgs = useMemo(() => mergeLog(data, channelEvents, adapter), [data, channelEvents, adapter]);
const { msgs, loading, error, reload, hasData } = useChannelMessageLog(adapter, limit);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const anchorRef = useTopAnchoredScroll(msgs[0]?.id ?? null, msgs.length, scrollerRef);
const platform = platformOf(adapter);
return (
<div>
<div ref={anchorRef}>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-1 pb-2">
<Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag>
<Tag tone="neutral">{adapter}</Tag>
@@ -62,18 +46,24 @@ export function AdapterMessageLog({
<div className="pt-3">
{loading ? (
<Notice>加载中…</Notice>
) : error && !data && msgs.length === 0 ? (
) : error && !hasData && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" />
) : (
<>
<ChannelLoadError error={error} reload={reload} stale={data != null} what="消息日志" />
<ChannelLoadError error={error} reload={reload} stale={hasData} what="消息日志" />
{msgs.length === 0 ? (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
<ChannelLogEmpty />
) : (
<>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span>共 {msgs.length} 条 · 上面更旧、下面更新</span>
{msgs.length > VIRTUAL_THRESHOLD ? <Tag tone="neutral">虚拟滚动 · 仅挂载视口内的行</Tag> : null}
<div className="mb-2">
<MessageLogMeta
count={msgs.length}
extra={
msgs.length > VIRTUAL_THRESHOLD ? (
<Tag tone="neutral">虚拟滚动 · 仅挂载视口内的行</Tag>
) : null
}
/>
</div>
<VirtualList
items={msgs}
@@ -82,6 +72,7 @@ export function AdapterMessageLog({
rowClassName="pb-2"
rowHeight={MESSAGE_ROW_ESTIMATE}
maxHeight="min(60vh, 640px)"
containerRef={scrollerRef}
/>
</>
)}
@@ -1,39 +1,18 @@
import { adminChannelMessages, adminChannelLogOverview } from "../../../lib/api";
import type { AdminChannelMessage } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAppStore } from "../../../store/useAppStore";
import { Button, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { useAutoReload } from "../../hooks";
import { useTopAnchoredScroll } from "../../logScroll";
import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView";
import { ChannelLogRows } from "./channelLogViews";
import { useChannelMessageLog } from "./useChannelMessageLog";
/** One message line in the adapter log. */
export function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
const isAssistant = m.role === "assistant";
return (
<div
className={`rounded-[var(--mu-radius)] px-3 py-2 ${
isAssistant ? "bg-[var(--mu-surface-2)]" : "bg-[var(--mu-surface-3)]/70"
}`}
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
{showAdapter && m.adapterId ? <Tag tone="neutral">{m.adapterId}</Tag> : null}
{m.deviceId ? <span className="font-mono text-[var(--mu-text-lo)]">{m.deviceId}</span> : null}
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
</div>
);
}
/** Inline send/receive message stream for one adapter (adapter != null) or the
* aggregate overview (adapter == null). Backend returns newest-first; display
* oldest→newest for a natural read. Owns its toolbar so every surface (list
* detail, module log, overview drill) reuses the same fetch + reload. */
/** 单适配器的收发消息流(adapter != null)或全部适配器的总览(adapter == null)。
*
* 排序口径:**最新在上**(L1「日志界面倒序统一」);新条目落在顶部时**不顶跑**用户
* 正在读的位置(`useTopAnchoredScroll`:贴顶就跟着最新走,已经往下读就按新增高度
* 补偿)。取数与合并口径在 `useChannelMessageLog`,行渲染在 `channelLogViews`,
* 本组件只管取数、三态与滚动维持。 */
export function ChannelLogList({
adapter,
limit = 200,
@@ -45,28 +24,11 @@ export function ChannelLogList({
}): React.ReactNode {
const isOverview = adapter == null;
const platform = adapter ? platformOf(adapter) : "";
const { data, loading, error, reload } = useAsyncData(
() => (adapter ? adminChannelMessages(adapter, limit) : adminChannelLogOverview(limit)),
[adapter, limit],
);
useAutoReload(reload, 10000);
// Realtime tail pushed by `channel_persisted` WS frames (design/16 §4.6 §4). The
// store keeps them newest-first, capped at 200 and deduped by id; we filter to the
// surface this list renders (single adapter, or the overview which shows adapter
// messages only), flip to oldest→newest, then append only ids the history page has
// not already covered. This makes a new persisted message appear without a reload;
// the 10s useAutoReload stays as a fallback for events the socket missed.
const channelEvents = useAppStore((s) => s.channelEvents);
const base = data ? [...data].reverse() : [];
const live = channelEvents
.filter((m) => (adapter ? m.adapterId === adapter : m.adapterId))
.reverse();
const seen = new Set(base.map((m) => m.id));
const msgs = [...base, ...live.filter((m) => !seen.has(m.id))];
const { msgs, loading, error, reload, hasData } = useChannelMessageLog(adapter, limit);
const anchorRef = useTopAnchoredScroll(msgs[0]?.id ?? null, msgs.length);
return (
<div className={className}>
<div className={className} ref={anchorRef}>
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--mu-glass-border)] px-1 pb-2">
{platform ? <Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag> : <Tag tone="accent">总览</Tag>}
{adapter ? <Tag tone="neutral">{adapter}</Tag> : null}
@@ -79,16 +41,12 @@ export function ChannelLogList({
<div className="space-y-2 pt-3">
{loading ? (
<Notice>加载中…</Notice>
) : error && !data && msgs.length === 0 ? (
) : error && !hasData && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" />
) : (
<>
<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} />)
)}
<ChannelLoadError error={error} reload={reload} stale={hasData} what="消息日志" />
<ChannelLogRows msgs={msgs} showAdapter={isOverview} />
</>
)}
</div>
@@ -0,0 +1,114 @@
// 消息日志排序口径测试(L1「日志界面倒序统一」)。
//
// 锁三件事:
// ① **最新在上**:会随新数据增长的日志列表,倒序才符合直觉(主人 2026-10-05 报:
// 最新一条在最底下「不符合直觉的操作逻辑」);
// ② 历史(后端最新优先)+ 本端 WS 实时尾(store 也是最新优先)合并后**仍然**最新
// 在上,且按 id 去重(同一帧既在历史里又在实时尾里出现过时不能画成两条);
// ③ 顺序由**时间**决定,不是「谁先塞进数组谁在上」—— 迟到的旧帧必须落到它该在的
// 位置,否则界面会把一条旧消息画成最新。
// 顺序错了只有渲染出来才算数(见 channelLogViews.render.test.ts),但排序的正确性
// 在这里逐例钉死。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelLogOrder.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-channel-log-order.test.mjs \
// && node /tmp/cyrene-channel-log-order.test.mjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type { AdminChannelMessage } from "../../../lib/types";
import { logOrderSummary, mergeChannelLog } from "./channelLogOrder";
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)}`,
);
}
/** 造一条消息:id 只用于区分,createdAt 决定顺序。 */
function msg(id: string, createdAt: number, adapterId = "kook:acct1"): AdminChannelMessage {
return {
id,
sessionId: "sess-1",
role: id.startsWith("a") ? "assistant" : "user",
content: `内容-${id}`,
adapterId,
createdAt,
};
}
const ids = (rows: readonly AdminChannelMessage[]): string[] => rows.map((r) => r.id);
// ── ① 只有历史(后端最新优先)⇒ 原样最新在上 ───────────────────────────────────
const history = [msg("c", 3000), msg("b", 2000), msg("a", 1000)];
same(ids(mergeChannelLog(history, [], "kook:acct1")), ["c", "b", "a"], "历史最新优先 ⇒ 最新在上");
// ── ② 历史 + 实时尾:新消息必须在最上面,且 id 去重 ────────────────────────────
const live = [msg("e", 5000), msg("d", 4000)];
same(
ids(mergeChannelLog(history, live, "kook:acct1")),
["e", "d", "c", "b", "a"],
"新到的实时消息在最上面,历史跟在后面",
);
same(
ids(mergeChannelLog(history, [msg("b", 2000), msg("f", 6000)], "kook:acct1")),
["f", "c", "b", "a"],
"实时尾里与历史同 id 的行只出现一次(去重后不画两条)",
);
same(
ids(mergeChannelLog(null, live, "kook:acct1")),
["e", "d"],
"还没有历史时,实时尾自己就是最新在上",
);
same(ids(mergeChannelLog(null, [], "kook:acct1")), [], "两边都空 ⇒ 空列表");
same(ids(mergeChannelLog([], [], "kook:acct1")), [], "空历史 + 空实时尾 ⇒ 空列表");
// ── ③ 顺序由时间决定:迟到的旧帧落到它在时间轴上的位置 ────────────────────────
same(
ids(mergeChannelLog(history, [msg("z", 1500)], "kook:acct1")),
["c", "b", "z", "a"],
"迟到的旧帧不被画成最新 —— 按时间落到 b 与 a 之间",
);
same(
ids(mergeChannelLog([msg("b", 2000), msg("a", 1000)], [msg("c", 3000), msg("d", 2500)], "kook:acct1")),
["c", "d", "b", "a"],
"两边都乱序时,合并结果仍严格按时间倒序",
);
// ── ④ 作用域:单适配器只看自己;总览只看带 adapterId 的镜像行 ─────────────────
same(
ids(mergeChannelLog(history, [msg("x", 9000, "telegram:bot1")], "kook:acct1")),
["c", "b", "a"],
"别的适配器的实时帧不进这个面的列表",
);
same(
ids(mergeChannelLog(null, [msg("w", 9000, "kook:acct1"), msg("v", 9500, "")], null)),
["w"],
"总览(adapter=null)只看带 adapterId 的渠道镜像行,纯 Web 行不进总览日志",
);
same(
ids(mergeChannelLog(null, [msg("w", 9000, "kook:acct1")], "kook:acct1")),
["w"],
"单适配器面收下自己适配器的实时帧",
);
// ── ⑤ 顺序自述与界面同一句话(口径不在界面上漂移)────────────────────────────
const summary = logOrderSummary(3);
ok(summary.includes("共 3 条"), `自述要点出条数 — got ${summary}`);
ok(summary.includes("上面更新、下面更旧"), `自述要说清顺序 — got ${summary}`);
ok(!summary.includes("上面更旧"), `自述不能再写「上面更旧」(正是这次要倒过来的那件事)— got ${summary}`);
same(logOrderSummary(0), "共 0 条 · 上面更新、下面更旧", "空列表的自述照样如实报 0 条");
console.log(`channel log order test: ${checks} checks passed(历史 1 + 合并去重 5 + 时间排序 2 + 作用域 3 + 自述 4)`);
@@ -0,0 +1,44 @@
import type { AdminChannelMessage } from "../../../lib/types";
/**
* 消息日志面的**排序口径**:最新在上(L1「日志界面倒序统一」)。
*
* 判据:只要列表会随新数据增长,排序就该是「最新在上」(主人 2026-10-05 报:最新
* 一条在最底下不符合直觉)。后端 `/api/v1/admin/channels/messages` 的三个粒度
* (单适配器 / 单平台 / 总览)本来就都是**最新优先**;本模块负责把「历史 + 本端 WS
* 实时尾」合并成同一个口径,供所有消息日志面共用(列表 / 窗口化 / 弹窗),
* 不让各面各写一份排序而漂移。
*
* 刻意**不倒序**的例外:用户端聊天页(`components/messages/HistoryList` 与
* `lib/api/sessions.ts#fetchMessages`)—— 那是「读对话」,正序才自然,不在本口径内。
*/
/** 时间读数:不可解析的值一律算 0(不伪造时间,也不让比较器算出 NaN)。 */
function timeOf(msg: AdminChannelMessage): number {
const t = Number(msg.createdAt);
return Number.isFinite(t) ? t : 0;
}
/** 实时尾的过滤口径:adapter 为空 = 总览(只看带 adapterId 的镜像行)。 */
function inScope(msg: AdminChannelMessage, adapter: string | null): boolean {
return adapter ? msg.adapterId === adapter : Boolean(msg.adapterId);
}
/** 合并历史(最新优先)与本端 WS 实时尾(store 里也是最新优先):按 id 去重、
* 按时间倒序 ⇒ **最新在上**。同毫秒的行保持各自来源的顺序(`sort` 稳定)。
* 历史行由后端按同一粒度过滤过,这里不再二次筛(避免与后端口径不一致时静默吞行)。 */
export function mergeChannelLog(
history: readonly AdminChannelMessage[] | null,
live: readonly AdminChannelMessage[],
adapter: string | null,
): AdminChannelMessage[] {
const byId = new Map<string, AdminChannelMessage>();
for (const m of live) if (inScope(m, adapter)) byId.set(m.id, m);
for (const m of history ?? []) if (!byId.has(m.id)) byId.set(m.id, m);
return Array.from(byId.values()).sort((a, b) => timeOf(b) - timeOf(a));
}
/** 列表自述:条数 + 顺序。各面共用同一句话,口径不在界面上漂移。 */
export function logOrderSummary(count: number): string {
return `共 ${count} 条 · 上面更新、下面更旧`;
}
@@ -0,0 +1,99 @@
// 消息日志「最新在上」的**真实渲染**测试(L1「日志界面倒序统一」)。
//
// 为什么需要它:排序口径的纯逻辑测试(channelLogOrder.test.ts)只能证明合并函数算得对,
// 证明不了**画出来的顺序**对 —— 展示层若自己又倒一次序(或把行渲染顺序改错),纯逻辑
// 测试是绿的而界面依旧是「最新在底下」。所以这里用 react-dom/server 渲染真组件、断言
// 真 HTML 里行出现的先后。
//
// 锁四件事:
// ① 给定的顺序(最新在前)**原样**落到 DOM 上:最新一行的下标必须小于更旧的行;
// ② 展示层不偷偷排序 —— 逆序喂进去就得逆序画出来(顺序只有一个出处:数据层);
// ③ 「上面更新、下面更旧」这句自述与顺序一致,且仍是「共 N 条」;
// ④ 一行的身份信息(她/用户、发送者、适配器、设备、时间、正文)都还在,空态与
// 「读到了但没消息」是同一句话。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelLogViews.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-log-views.test.cjs \
// && node /tmp/cyrene-channel-log-views.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminChannelMessage } from "../../../lib/types";
import { ChannelLogRows } from "./channelLogViews";
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);
}
/** 造一条消息:createdAt 递减 ⇒ 数组本身就是「最新在上」。 */
function msg(id: string, createdAt: number, role: "user" | "assistant", extra: Partial<AdminChannelMessage> = {}): AdminChannelMessage {
return {
id,
sessionId: "sess-1",
role,
content: `正文-${id}`,
adapterId: "kook:acct1",
createdAt,
...extra,
};
}
const NEWEST = msg("m-新", 3000, "assistant", { deviceId: "web:chrome:aaaa1111" });
const MIDDLE = msg("m-中", 2000, "user", { senderName: "群友甲" });
const OLDEST = msg("m-旧", 1000, "user");
const html = renderToStaticMarkup(createElement(ChannelLogRows, { msgs: [NEWEST, MIDDLE, OLDEST] }));
// ── ① 最新在上:DOM 里最新一行的下标必须小于更旧的行 ──────────────────────────
const atNewest = html.indexOf("正文-m-新");
const atMiddle = html.indexOf("正文-m-中");
const atOldest = html.indexOf("正文-m-旧");
ok(atNewest >= 0, "最新一行必须渲染出来");
ok(atNewest < atMiddle, "最新一行排在中间那行**上面**(最新在上)");
ok(atMiddle < atOldest, "中间那行排在最旧那行上面");
ok(atOldest >= 0, "最旧一行也必须渲染(不倒序成丢行)");
// ── ② 展示层不偷偷排序:逆序喂进去就逆序画出来 ────────────────────────────────
const reversed = renderToStaticMarkup(createElement(ChannelLogRows, { msgs: [OLDEST, MIDDLE, NEWEST] }));
ok(
reversed.indexOf("正文-m-旧") < reversed.indexOf("正文-m-中") &&
reversed.indexOf("正文-m-中") < reversed.indexOf("正文-m-新"),
"顺序只有一个出处(数据层):展示层既不排序也不倒序",
);
// ── ③ 条数 + 顺序自述与顺序一致 ───────────────────────────────────────────────
ok(has(html, "共 3 条"), "自述要报条数");
ok(has(html, "上面更新、下面更旧"), "自述要与「最新在上」一致");
ok(!has(html, "上面更旧"), "自述不能还写着旧的「上面更旧」");
ok(!has(html, "共 3 条 · 上面更旧、下面更新"), "旧口径的整句不能再出现");
// ── ④ 一行的身份信息照旧齐全 ─────────────────────────────────────────────────
ok(has(html, "她 / 昔涟"), "助手行标「她 / 昔涟」");
ok(has(html, "用户"), "用户行标「用户」");
ok(has(html, "群友甲"), "第三方消息的发送者名字要显示");
ok(has(html, "web:chrome:aaaa1111"), "设备 id 要显示(排障要知道是哪台设备)");
ok(has(html, "2026") || /1970|20\d\d/.test(html), "每行要带时间");
const withAdapter = renderToStaticMarkup(createElement(ChannelLogRows, { msgs: [MIDDLE], showAdapter: true }));
ok(has(withAdapter, "kook:acct1"), "总览面(showAdapter)要在每行标出适配器");
const withoutAdapter = renderToStaticMarkup(createElement(ChannelLogRows, { msgs: [MIDDLE] }));
ok(!has(withoutAdapter, "kook:acct1"), "单适配器面不必每行重复适配器名");
// ── ⑤ 空态:读到了但确实没消息,说清楚为什么会空 ──────────────────────────────
const empty = renderToStaticMarkup(createElement(ChannelLogRows, { msgs: [] }));
ok(has(empty, "暂无消息"), "空列表要有明确空态");
ok(!has(empty, "共 0 条"), "空态不渲染「共 0 条」这类数字行");
console.log(`channel log views render checks passed: ${checks}`);
@@ -0,0 +1,68 @@
import type { AdminChannelMessage } from "../../../lib/types";
import { Tag } from "../../../components/primitives";
import { Notice } from "../../components";
import { logOrderSummary } from "./channelLogOrder";
/**
* 消息日志面的**展示层**(与 `channelEventViews.tsx` 同一分工):纯展示,顺序完全由
* 数据层 `channelLogOrder` 决定,本层**不排序、不倒序、不截断** —— 这样渲染测试能
* 直接钉住「最新在上」这件事(顺序错了只有画出来才算数)。
*/
/** 消息日志里的一行(收发记录)。 */
export function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode {
const isAssistant = m.role === "assistant";
return (
<div
className={`rounded-[var(--mu-radius)] px-3 py-2 ${
isAssistant ? "bg-[var(--mu-surface-2)]" : "bg-[var(--mu-surface-3)]/70"
}`}
>
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Tag tone={isAssistant ? "ok" : "neutral"}>{isAssistant ? "她 / 昔涟" : "用户"}</Tag>
{m.senderName ? <Tag tone="neutral">{m.senderName}</Tag> : null}
{showAdapter && m.adapterId ? <Tag tone="neutral">{m.adapterId}</Tag> : null}
{m.deviceId ? <span className="font-mono text-[var(--mu-text-lo)]">{m.deviceId}</span> : null}
<span className="ml-auto font-mono">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<div className="mt-1.5 whitespace-pre-wrap break-words text-sm text-[var(--mu-text)]">{m.content}</div>
</div>
);
}
/** 消息日志的条数 + 顺序自述;`extra` 让窗口化列表把自己的标记挂进同一行。 */
export function MessageLogMeta({ count, extra }: { count: number; extra?: React.ReactNode }): React.ReactNode {
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<span>{logOrderSummary(count)}</span>
{extra}
</div>
);
}
/** 消息日志的空态(读到了、确实没有消息 —— 与「读不到」不是一回事)。 */
export function ChannelLogEmpty(): React.ReactNode {
return (
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
);
}
/** 一批消息行的渲染:给定顺序就照渲染。全量列表(总览 / 弹窗)用它;窗口化的
* 单适配器日志面对应的渲染在 `AdapterMessageLog`(同一行组件 + 同一份条数自述)。 */
export function ChannelLogRows({
msgs,
showAdapter,
}: {
msgs: readonly AdminChannelMessage[];
showAdapter?: boolean;
}): React.ReactNode {
if (msgs.length === 0) return <ChannelLogEmpty />;
return (
<>
<MessageLogMeta count={msgs.length} />
{msgs.map((m) => (
<ChannelLogRow key={m.id} m={m} showAdapter={showAdapter} />
))}
</>
);
}
@@ -0,0 +1,36 @@
import { useMemo } from "react";
import { adminChannelMessages, adminChannelLogOverview } from "../../../lib/api";
import type { AdminChannelMessage } from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAppStore } from "../../../store/useAppStore";
import { useAutoReload } from "../../hooks";
import { mergeChannelLog } from "./channelLogOrder";
/** 消息日志的兜底轮询间隔(`channel_persisted` WS 追帧才是主驱动,这里是补洞)。 */
export const CHANNEL_LOG_RELOAD_MS = 10_000;
/** 消息日志面的取数结果。 */
export interface ChannelMessageLog {
/** 合并去重后的消息,**最新在上**(口径见 `channelLogOrder`)。 */
msgs: AdminChannelMessage[];
/** 首次加载中(已有数据后一律静默刷新,不闪空)。 */
loading: boolean;
error: string | null;
reload: () => void;
/** 是否读到过一份历史:用来区分「读不到」与「真的空」。 */
hasData: boolean;
}
/** 消息日志面的取数面(单适配器 / 总览共用):历史分页 + `channel_persisted` WS 追帧
* + 兜底轮询,合并与排序口径收口在 `channelLogOrder`。两个日志面(全量列表 /
* 窗口化列表)因此看到的是同一串消息、同一个顺序。 */
export function useChannelMessageLog(adapter: string | null, limit: number): ChannelMessageLog {
const { data, loading, error, reload } = useAsyncData(
() => (adapter ? adminChannelMessages(adapter, limit) : adminChannelLogOverview(limit)),
[adapter, limit],
);
useAutoReload(reload, CHANNEL_LOG_RELOAD_MS);
const channelEvents = useAppStore((s) => s.channelEvents);
const msgs = useMemo(() => mergeChannelLog(data, channelEvents, adapter), [data, channelEvents, adapter]);
return { msgs, loading, error, reload, hasData: data != null };
}