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}
/>
</>
)}