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

+12 -3
View File
@@ -1,4 +1,4 @@
import { useMemo, type ReactNode } from "react"; import { useMemo, type ReactNode, type RefObject } from "react";
import { EmptyState, Tag, type TagTone } from "../components/primitives"; import { EmptyState, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon"; import { Icon } from "../components/Icon";
import { useVirtualRows, VIRTUAL_ROW_ESTIMATE } from "./hooks"; import { useVirtualRows, VIRTUAL_ROW_ESTIMATE } from "./hooks";
@@ -198,6 +198,9 @@ export interface VirtualListProps<T> {
/** 少于该行数时全量渲染(与 `AdminTable` 同口径)。 */ /** 少于该行数时全量渲染(与 `AdminTable` 同口径)。 */
threshold?: number; threshold?: number;
className?: string; className?: string;
/** 外部滚动容器 ref:窗口化时调用方要在同一容器上做滚动锚定(「顶部前插不顶跑
* 阅读位置」)时传进来;非窗口化时不生效(那种渲染没有滚动容器)。 */
containerRef?: RefObject<HTMLDivElement | null>;
} }
/** 窗口化的长列表:日志 / 消息流这类「条数多、还会被 WS 持续追帧」的面用它, /** 窗口化的长列表:日志 / 消息流这类「条数多、还会被 WS 持续追帧」的面用它,
@@ -212,10 +215,16 @@ export function VirtualList<T>({
rowClassName = "", rowClassName = "",
threshold = VIRTUAL_THRESHOLD, threshold = VIRTUAL_THRESHOLD,
className = "", className = "",
containerRef,
}: VirtualListProps<T>): ReactNode { }: VirtualListProps<T>): ReactNode {
const count = items.length; const count = items.length;
const windowed = count > threshold; const windowed = count > threshold;
const { range, containerRef, rowAttrs } = useVirtualRows({ count, enabled: windowed, rowHeight }); const { range, containerRef: innerRef, rowAttrs } = useVirtualRows({
count,
enabled: windowed,
rowHeight,
containerRef,
});
if (!windowed) { if (!windowed) {
return ( return (
@@ -231,7 +240,7 @@ export function VirtualList<T>({
const from = range.start; const from = range.start;
return ( return (
<div ref={containerRef} className={`overflow-auto ${className}`} style={{ maxHeight }}> <div ref={innerRef} className={`overflow-auto ${className}`} style={{ maxHeight }}>
<div style={{ height: range.padTop }} aria-hidden="true" /> <div style={{ height: range.padTop }} aria-hidden="true" />
{items.slice(from, range.end).map((item, i) => ( {items.slice(from, range.end).map((item, i) => (
<div key={rowKey(item)} {...rowAttrs(from + i)} className={rowClassName}> <div key={rowKey(item)} {...rowAttrs(from + i)} className={rowClassName}>
+6 -1
View File
@@ -104,6 +104,9 @@ export interface UseVirtualRowsOptions {
/** 未实测行的行高估算值(px)。 */ /** 未实测行的行高估算值(px)。 */
rowHeight?: number; rowHeight?: number;
overscan?: number; overscan?: number;
/** 外部滚动容器:调用方还要在同一个容器上做别的事(如「顶部前插不顶跑阅读
* 位置」的滚动锚定)时传进来;不给就由本 hook 自建 ref。 */
containerRef?: RefObject<HTMLDivElement | null>;
} }
export interface VirtualRows { export interface VirtualRows {
@@ -120,8 +123,10 @@ export function useVirtualRows({
enabled = true, enabled = true,
rowHeight = VIRTUAL_ROW_ESTIMATE, rowHeight = VIRTUAL_ROW_ESTIMATE,
overscan = VIRTUAL_OVERSCAN, overscan = VIRTUAL_OVERSCAN,
containerRef: externalRef,
}: UseVirtualRowsOptions): VirtualRows { }: UseVirtualRowsOptions): VirtualRows {
const containerRef = useRef<HTMLDivElement | null>(null); const ownRef = useRef<HTMLDivElement | null>(null);
const containerRef = externalRef ?? ownRef;
const measured = useRef<number[]>([]); const measured = useRef<number[]>([]);
const [scrollTop, setScrollTop] = useState(0); const [scrollTop, setScrollTop] = useState(0);
const [viewport, setViewport] = useState(0); const [viewport, setViewport] = useState(0);
+99
View File
@@ -0,0 +1,99 @@
// 「最新在上」日志面的滚动锚定逻辑测试(L1「日志界面倒序统一」)。
//
// 锁两件事(都是主人报过痛的那一类):
// ① 顶部前插新条目时,**已经往下读**的用户不能被顶跑 —— scrollTop 必须按新增高度
// 补偿,正在看的那几条留在原地;
// ② 贴在顶部的用户要跟着最新一条走(保持贴顶),不能停在半路。
// 顺序与滚动在浏览器里「画出来才算数」,但位置数学是纯函数:项目没有前端测试框架,
// 这里用零依赖的 plain-node 检查直接钉住它。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/logScroll.test.ts \
// --bundle --platform=node --format=esm \
// --outfile=/tmp/cyrene-log-scroll.test.mjs \
// && node /tmp/cyrene-log-scroll.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
// 只从项目内导入、不 import node:*。
import { LOG_TOP_EPSILON, initialLogScroll, isAtLogTop, topAnchoredScrollTop } from "./logScroll";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same(got: number, want: number, what: string): void {
ok(got === want, `${what} — got ${got}, want ${want}`);
}
// ── ① 「贴着顶部」的判定(容差内算贴顶)────────────────────────────────────────
ok(initialLogScroll().atTop, "初始快照按「贴着顶部」处理(首屏不该被当成在读旧内容)");
same(initialLogScroll().scrollTop, 0, "初始滚动位置为 0");
same(initialLogScroll().contentHeight, 0, "初始内容高度为 0(还没测量)");
ok(isAtLogTop(0), "scrollTop=0 是贴顶");
ok(isAtLogTop(LOG_TOP_EPSILON), "容差边缘(=LOG_TOP_EPSILON)仍算贴顶");
ok(!isAtLogTop(LOG_TOP_EPSILON + 1), "超出容差就不算贴顶");
// ── ② 贴顶用户:新条目落在顶部 ⇒ 保持贴顶(最新一条立刻可见)──────────────────
same(
topAnchoredScrollTop({ scrollTop: 0, contentHeight: 1000, atTop: true }, 1180),
0,
"贴顶时保持 0:新条目就在眼前,不该被挪到半路",
);
same(
topAnchoredScrollTop({ scrollTop: 3, contentHeight: 1000, atTop: true }, 900),
0,
"贴顶且内容反而变短时也回到顶部(不留在悬空位置)",
);
// ── ③ 已经往下读的用户:按新增高度补偿 ⇒ 阅读位置不动 ─────────────────────────
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 1180),
580,
"顶部前插 180px ⇒ scrollTop 加 180,正在看的内容留在原地",
);
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 1000),
400,
"没有新条目(高度没变)⇒ 位置一点不动",
);
same(
topAnchoredScrollTop({ scrollTop: 400, contentHeight: 1000, atTop: false }, 820),
400,
"内容变短时不补偿:不编造一个不存在的位移",
);
// ── ④ 端到端语义:被阅读的那一行在补偿前后都停在视口同样的位置 ────────────────
// 先把「用户停在 scrollTop=300」这件事换算成「某一行距列表顶 300px」;顶部前插 3 行
// (每行 76px = 228px)后,这一行跑到 528px —— 补偿后它必须仍然正好在视口顶。
const anchored = topAnchoredScrollTop({ scrollTop: 300, contentHeight: 5000, atTop: false }, 5228);
same(anchored, 528, "补偿后 scrollTop 等于该行的新绝对位置(视口顶仍是同一行)");
let scrollTop = 300;
let contentHeight = 5000;
for (let frame = 0; frame < 3; frame += 1) {
const grew = 76;
scrollTop = topAnchoredScrollTop({ scrollTop, contentHeight, atTop: false }, contentHeight + grew);
contentHeight += grew;
}
same(scrollTop, 300 + 3 * 76, "连来三帧(每次前插 1 行)后累计补偿 3 行高度");
ok(scrollTop >= 0, "补偿永远不会把 scrollTop 推到负数");
// ── ⑤ 贴顶 / 已下读两种用户的对照:同一份新数据,两种正确的行为 ────────────────
const frame = { contentHeight: 1000 } as const;
same(
topAnchoredScrollTop({ scrollTop: 0, contentHeight: frame.contentHeight, atTop: true }, 1240),
0,
"贴顶用户:跟住最新一条",
);
same(
topAnchoredScrollTop({ scrollTop: 600, contentHeight: frame.contentHeight, atTop: false }, 1240),
840,
"已下读用户:留在原来那几条上",
);
console.log(`log scroll test: ${checks} checks passed(初始快照 + 贴顶判定 6 + 贴顶行为 2 + 下读补偿 3 + 连续帧累计 3 + 两态对照 2)`);
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useLayoutEffect, useRef, type RefObject } from "react";
/**
* 「最新在上」的日志面在**顶部插入新条目**时的滚动位置维持(L1 日志倒序统一的配套件)。
*
* 为什么必须有它:倒序之后新条目落在列表顶部。若不管,用户正在读的那几条会被整个
* 往下推 —— 每次 WS 追帧 / 兜底轮询都会把阅读位置顶跑一次(正是「新条目把用户顶跑」
* 这个问题)。口径:
* ① 用户贴着顶部(正在看最新一条)⇒ 保持贴顶,新条目立刻可见;
* ② 用户已经往下读 ⇒ 按新增高度把 scrollTop 下移同样的距离,正在看的那几条留在
* 原地(内容不动 ⇒ 阅读不被打断)。
*
* 数学是纯函数 `topAnchoredScrollTop`(纯 node 测试直接钉住),DOM 侧只负责取数。
* 前提:这些日志面只**追加/前插**、不从底部裁掉已渲染的行(裁掉了高度差就不等于
* 前插的高度,本补偿会偏小 —— 现网三个消息日志面都是全量渲染,没有底部裁剪)。
*/
/** 距顶多少像素以内算「贴着顶部」(子像素滚动 / 设备像素比容差)。 */
export const LOG_TOP_EPSILON = 4;
/** 上一次渲染后记下的滚动快照。 */
export interface LogScrollSnapshot {
/** 滚动位置(px)。 */
scrollTop: number;
/** 滚动容器的内容总高(px):顶部前插的行就体现在这里。 */
contentHeight: number;
/** 上一次渲染时用户是否贴着顶部。 */
atTop: boolean;
}
/** 是否贴着顶部。 */
export function isAtLogTop(scrollTop: number, epsilon: number = LOG_TOP_EPSILON): boolean {
return scrollTop <= epsilon;
}
/** 初始快照:还没测量过,按「贴着顶部」处理(首屏不该被当成「用户在读旧内容」)。 */
export function initialLogScroll(): LogScrollSnapshot {
return { scrollTop: 0, contentHeight: 0, atTop: true };
}
/** 顶部前插新条目后该把 scrollTop 放到哪:贴顶就跟顶,否则按新增高度补偿。
* 没长高(或反而变矮)时位置不动 —— 不编造一个不存在的位移。 */
export function topAnchoredScrollTop(prev: LogScrollSnapshot, contentHeight: number): number {
if (prev.atTop) return 0;
const grew = contentHeight - prev.contentHeight;
return grew > 0 ? prev.scrollTop + grew : prev.scrollTop;
}
/** 最近的可滚动祖先:日志面可能自带宽高容器(`VirtualList` / 弹窗),也可能靠页面
* 级滚动。只在真的能滚(内容高于容器)时才认它,否则继续往上找。 */
export function scrollParentOf(el: HTMLElement | null): HTMLElement | null {
let cur = el?.parentElement ?? null;
while (cur) {
const overflowY = window.getComputedStyle(cur).overflowY;
const scrollable = overflowY === "auto" || overflowY === "scroll";
if (scrollable && cur.scrollHeight > cur.clientHeight) return cur;
cur = cur.parentElement;
}
return null;
}
/** 带「首条键」的快照:首条键变了才认为顶部前插了新条目。 */
interface AnchoredSnapshot extends LogScrollSnapshot {
headKey: string | null;
}
/** 绑定到哪个滚动容器:显式给的就用它(窗口化列表自带的 scroller),否则向上找。
* 找到的祖先会缓存 —— 否则每次追帧都要对每个祖先做一次 `getComputedStyle`(读样式
* 会强制重算布局),而这些面每来一帧都要渲染。缓存元素只有在离树后才重找。 */
function resolveScroller(
host: HTMLElement | null,
explicit?: RefObject<HTMLDivElement | null>,
cache?: { current: HTMLElement | null },
): HTMLElement | null {
if (explicit?.current) return explicit.current;
if (cache?.current?.isConnected) return cache.current;
const found = scrollParentOf(host);
if (cache) cache.current = found;
return found;
}
/** 给「最新在上」的日志面挂上滚动锚定,返回要绑到列表外层元素的 ref。
*
* `headKey` = 列表首条(最新一条)的稳定键;`count` = 当前条数。两者一起说明
* 「列表变过」,因此只在它们变化时读一次内容高度(读高度会强制布局,而这些面每帧
* 都在重渲染,不能每帧都读)。`headKey` 为 null(空列表 / 尚未加载)时只记录快照、
* 不做补偿。窗口化列表请把它的滚动容器经 `scrollerRef` 传进来(那种容器在列表
* **内部**,向上找是找不到的)。 */
export function useTopAnchoredScroll(
headKey: string | null,
count: number,
scrollerRef?: RefObject<HTMLDivElement | null>,
): RefObject<HTMLDivElement | null> {
const hostRef = useRef<HTMLDivElement | null>(null);
const scrollerCache = useRef<HTMLElement | null>(null);
const seen = useRef<{ headKey: string | null; count: number }>({ headKey: null, count: -1 });
const snapshot = useRef<AnchoredSnapshot>({ ...initialLogScroll(), headKey: null });
// 列表变过才测量:只在「首条换了 + 内容真的长高了」时补偿滚动位置。
useLayoutEffect(() => {
const unchanged = seen.current.headKey === headKey && seen.current.count === count;
seen.current = { headKey, count };
if (unchanged) return;
const el = resolveScroller(hostRef.current, scrollerRef, scrollerCache);
if (!el) return;
const prev = snapshot.current;
const contentHeight = el.scrollHeight;
const prepended = headKey !== null && prev.headKey !== null && prev.headKey !== headKey;
if (prepended && contentHeight !== prev.contentHeight) {
el.scrollTop = topAnchoredScrollTop(prev, contentHeight);
}
const scrollTop = el.scrollTop;
snapshot.current = { headKey, scrollTop, contentHeight, atTop: isAtLogTop(scrollTop) };
});
// 用户滚动时刷新快照:atTop 必须在「新条目到来之前」就记下,事后补算不回来。
// 无依赖重跑一次是刻意的 —— 滚动容器会变(弹窗空载时还不滚、数据到了才开始滚;
// 列表从全量渲染切到窗口化时容器也从页面级换成了列表自己的)。
useEffect(() => {
const el = resolveScroller(hostRef.current, scrollerRef, scrollerCache);
if (!el) return;
const sync = (): void => {
snapshot.current = { ...snapshot.current, scrollTop: el.scrollTop, atTop: isAtLogTop(el.scrollTop) };
};
sync();
el.addEventListener("scroll", sync, { passive: true });
return () => el.removeEventListener("scroll", sync);
});
return hostRef;
}
@@ -1,36 +1,24 @@
import { useMemo } from "react"; import { useRef } from "react";
import { adminChannelMessages } 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 { Button, Tag } from "../../../components/primitives";
import { Notice, VirtualList, VIRTUAL_THRESHOLD } from "../../components"; import { Notice, VirtualList, VIRTUAL_THRESHOLD } from "../../components";
import { useAutoReload } from "../../hooks"; import { useTopAnchoredScroll } from "../../logScroll";
import { PLATFORM_LABEL } from "../channelFields"; import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils"; import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView"; import { ChannelLoadError } from "./channelStatusView";
import { ChannelLogRow } from "./channelLogList"; import { ChannelLogEmpty, ChannelLogRow, MessageLogMeta } from "./channelLogViews";
import { useChannelMessageLog } from "./useChannelMessageLog";
/** 一行消息的估算高度(px):标签行 + 正文数行,实测后会被真实值取代。 */ /** 一行消息的估算高度(px):标签行 + 正文数行,实测后会被真实值取代。 */
const MESSAGE_ROW_ESTIMATE = 76; const MESSAGE_ROW_ESTIMATE = 76;
/** 历史(后端最新在前)与本端 WS 实时尾(已按 id 去重)合并,翻成「旧 → 新」的 /** 单适配器消息日志(design/17 §2「实时日志」)。数据面与 `ChannelLogList` 共用
* 阅读顺序。与 `ChannelLogList` 同一份口径,保证两个面看到的是同一串消息。 */ * `useChannelMessageLog`(历史分页 + `channel_persisted` WS 追帧 + 兜底轮询,
function mergeLog( * **最新在上**),区别只在渲染:消息是本模块最长的列表,也是 WS 每来一帧就要整表
history: readonly AdminChannelMessage[] | null, * 重渲染的面,因此走窗口化——只挂载视口内的行(裁决 16.4.3「按效率最高的做」)。
live: readonly AdminChannelMessage[], *
adapter: string, * 滚动维持:窗口化列表的滚动容器在列表**内部**,因此把它显式交给
): AdminChannelMessage[] { * `useTopAnchoredScroll`(新条目落在顶部时不顶跑用户正在读的位置)。加载 / 空 /
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「按效率最高的做」)。加载 / 空 / 失联三种状态照旧分开。 */
export function AdapterMessageLog({ export function AdapterMessageLog({
adapter, adapter,
limit = 200, limit = 200,
@@ -38,17 +26,13 @@ export function AdapterMessageLog({
adapter: string; adapter: string;
limit?: number; limit?: number;
}): React.ReactNode { }): React.ReactNode {
const { data, loading, error, reload } = useAsyncData( const { msgs, loading, error, reload, hasData } = useChannelMessageLog(adapter, limit);
() => adminChannelMessages(adapter, limit), const scrollerRef = useRef<HTMLDivElement | null>(null);
[adapter, limit], const anchorRef = useTopAnchoredScroll(msgs[0]?.id ?? null, msgs.length, scrollerRef);
);
useAutoReload(reload, 10000);
const channelEvents = useAppStore((s) => s.channelEvents);
const msgs = useMemo(() => mergeLog(data, channelEvents, adapter), [data, channelEvents, adapter]);
const platform = platformOf(adapter); const platform = platformOf(adapter);
return ( 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"> <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="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag>
<Tag tone="neutral">{adapter}</Tag> <Tag tone="neutral">{adapter}</Tag>
@@ -62,18 +46,24 @@ export function AdapterMessageLog({
<div className="pt-3"> <div className="pt-3">
{loading ? ( {loading ? (
<Notice>加载中…</Notice> <Notice>加载中…</Notice>
) : error && !data && msgs.length === 0 ? ( ) : error && !hasData && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" /> <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 ? ( {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)]"> <div className="mb-2">
<span>共 {msgs.length} 条 · 上面更旧、下面更新</span> <MessageLogMeta
{msgs.length > VIRTUAL_THRESHOLD ? <Tag tone="neutral">虚拟滚动 · 仅挂载视口内的行</Tag> : null} count={msgs.length}
extra={
msgs.length > VIRTUAL_THRESHOLD ? (
<Tag tone="neutral">虚拟滚动 · 仅挂载视口内的行</Tag>
) : null
}
/>
</div> </div>
<VirtualList <VirtualList
items={msgs} items={msgs}
@@ -82,6 +72,7 @@ export function AdapterMessageLog({
rowClassName="pb-2" rowClassName="pb-2"
rowHeight={MESSAGE_ROW_ESTIMATE} rowHeight={MESSAGE_ROW_ESTIMATE}
maxHeight="min(60vh, 640px)" 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 { Button, Tag } from "../../../components/primitives";
import { Notice } from "../../components"; import { Notice } from "../../components";
import { useAutoReload } from "../../hooks"; import { useTopAnchoredScroll } from "../../logScroll";
import { PLATFORM_LABEL } from "../channelFields"; import { PLATFORM_LABEL } from "../channelFields";
import { platformOf } from "./channelUtils"; import { platformOf } from "./channelUtils";
import { ChannelLoadError } from "./channelStatusView"; import { ChannelLoadError } from "./channelStatusView";
import { ChannelLogRows } from "./channelLogViews";
import { useChannelMessageLog } from "./useChannelMessageLog";
/** One message line in the adapter log. */ /** 单适配器的收发消息流(adapter != null)或全部适配器的总览(adapter == null)。
export function ChannelLogRow({ m, showAdapter }: { m: AdminChannelMessage; showAdapter?: boolean }): React.ReactNode { *
const isAssistant = m.role === "assistant"; * 排序口径:**最新在上**(L1「日志界面倒序统一」);新条目落在顶部时**不顶跑**用户
return ( * 正在读的位置(`useTopAnchoredScroll`:贴顶就跟着最新走,已经往下读就按新增高度
<div * 补偿)。取数与合并口径在 `useChannelMessageLog`,行渲染在 `channelLogViews`,
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. */
export function ChannelLogList({ export function ChannelLogList({
adapter, adapter,
limit = 200, limit = 200,
@@ -45,28 +24,11 @@ export function ChannelLogList({
}): React.ReactNode { }): React.ReactNode {
const isOverview = adapter == null; const isOverview = adapter == null;
const platform = adapter ? platformOf(adapter) : ""; const platform = adapter ? platformOf(adapter) : "";
const { data, loading, error, reload } = useAsyncData( const { msgs, loading, error, reload, hasData } = useChannelMessageLog(adapter, limit);
() => (adapter ? adminChannelMessages(adapter, limit) : adminChannelLogOverview(limit)), const anchorRef = useTopAnchoredScroll(msgs[0]?.id ?? null, msgs.length);
[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))];
return ( 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"> <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>} {platform ? <Tag tone="accent">{PLATFORM_LABEL[platform] ?? platform}</Tag> : <Tag tone="accent">总览</Tag>}
{adapter ? <Tag tone="neutral">{adapter}</Tag> : null} {adapter ? <Tag tone="neutral">{adapter}</Tag> : null}
@@ -79,16 +41,12 @@ export function ChannelLogList({
<div className="space-y-2 pt-3"> <div className="space-y-2 pt-3">
{loading ? ( {loading ? (
<Notice>加载中…</Notice> <Notice>加载中…</Notice>
) : error && !data && msgs.length === 0 ? ( ) : error && !hasData && msgs.length === 0 ? (
<ChannelLoadError error={error} reload={reload} what="消息日志" /> <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 ? ( <ChannelLogRows msgs={msgs} showAdapter={isOverview} />
<Notice>暂无消息。向该渠道发送消息、或接入 /ws/chat 发起会话后,这里会显示收发记录。</Notice>
) : (
msgs.map((m) => <ChannelLogRow key={m.id} m={m} showAdapter={isOverview} />)
)}
</> </>
)} )}
</div> </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 };
}