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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user