fix(admin): 日志类列表统一「最新在上」+ 顶部前插不顶跑阅读位置
- 审计 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:
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user