Skip to content

Commit 5f39cef

Browse files
committed
feat(session-detail): wire views to registry
批 5 T10(会话接线)。三条硬约束(规格 §7.3)与 C1/C5/C11 在本提交落地: - SessionViewHost 由 T2 的过渡形态改为**注册表驱动**:默认视图(views[0],无 load)由面板 同步渲染后经 resident 注入、包在常驻层里只用 display 切显隐(§7.3① 原文永远保留;修回 T2 的 viewMode === "preview" ? … : … 互斥卸载);非默认视图走 React.lazy + Suspense (复用 ShellFallback)且切走即卸载(§7.3②);切换器改用 L1 原语 ViewSwitcher。 - C11:切换器紧下一行预留恰 1 个空错误槽位(class + data- 双钩子,批 5 不加 CSS 规则、 不重排任何既有 StatusLine 调用点)。 - C5 显式改判旧「裁决 D1」:删掉 SessionDetailPanel.tsx:64 的 useEffect(() => setViewMode("raw"), [sessionId]) 与 viewMode state,视图态改由 useViewMemory 的 view:default:session 记忆持有 —— 有记忆 ⇒ 用记忆值(切会话不再静默 丢弃用户选择),无记忆 ⇒ 用默认 views[0].key(注册表 raw = 原文)。web 会话保持早退 分支一字不动 ⇒ 不渲染切换器、不写记忆(污染面 0)。 - SessionsPage 注入 views={viewsFor("session")}(C1①;行数零增长,豁免登记值 332 不变)。 - 视图槽 SessionViewSlot 由面板注入(imageUrl 把 convertFileSrc 收口在容器侧)⇒ views/** 零取数、零 invoke(C14②)。 判据(app/src/components/session-detail/SessionViewHost.test.tsx,新增 250 行,11 用例): D0 默认视图重锚(viewsFor("session")[0].key === FROZEN_VIEW_KEYS.session[0])· D1 有记忆用 记忆 · H1 常驻(切到每个非默认视图后原文仍在 DOM ∧ 挂载计数不减)· H2 惰性挂载/卸载 · H3 Suspense + ShellFallback · H4 记忆写/重挂恢复 · H4b 换会话不复位 · H5 web 早退不写记忆 · H6 C11 槽位 · H6b 槽位可承载 · H0 不 invoke。每条各带变异体(14 次运行,ran=true)。 T2 安全网 SessionDetailPanel.test.tsx 6 处改写(控制方 GO-1 授权;用例数仍 4 = LOST=0): 补 views 接线 ×2;3 处同步 DOM 断言改 await findByTestId(React.lazy 的解析是异步的); 2 处「原文锚点不在 DOM」的存在性断言改为「在 DOM ∧ 语义不可见」(常驻语义下强度不降反升, 新增 visibleSegAnchors 可见性代理);切换器文案随注册表由「原料视图」改为「原文」。 nativeButtonBaseline:删掉 T2 登记的 SPLIT_MOVES 条目 ("components/session-detail/SessionViewHost.tsx|components/SessionDetailPanel.tsx")—— 这是**迁移**不是**搬运**:手写 <button> 迁进原语 ViewSwitcher(ui/primitives/** 在扫描域外) ⇒ 计数真降(1 → 0),属棘轮方向。冻结计数一律未动(393 / 114 键 / Σentries=393 自洽不变; 实测扫描总量 393 → 392 ≤ 上界)。
1 parent a9f8fe9 commit 5f39cef

6 files changed

Lines changed: 460 additions & 139 deletions

File tree

‎app/src/components/SessionDetailPanel.test.tsx‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
* 异步 setState 竞态**(resolve 型桩会在用例结束后才 setState ⇒ act 噪声)。
2424
*/
2525
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
26-
import { cleanup, fireEvent, render } from "@testing-library/react";
26+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2727
import type { SessionDetail } from "../types";
2828

2929
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
@@ -51,6 +51,7 @@ vi.mock("./session-detail/SessionScreenCards", () => ({
5151
default: () => <div data-testid="screen-cards" />,
5252
}));
5353

54+
import { viewsFor } from "../views/registry";
5455
import SessionDetailPanel from "./SessionDetailPanel";
5556

5657
function detailOf(over: Partial<SessionDetail> = {}): SessionDetail {
@@ -79,6 +80,7 @@ const noop = () => {};
7980
function propsOf(detail: SessionDetail, over: Record<string, unknown> = {}) {
8081
return {
8182
detail,
83+
views: viewsFor("session"),
8284
fusing: false,
8385
degradedBanner: null,
8486
onToNote: noop,
@@ -92,6 +94,20 @@ function propsOf(detail: SessionDetail, over: Record<string, unknown> = {}) {
9294
const segAnchors = (): string[] =>
9395
[...document.querySelectorAll("[id^='seg-']")].map((el) => el.id).sort();
9496

97+
/** 语义可见性代理:祖先链上出现 `display:none` ⇒ 视为不可见(jsdom 不排版,取不到 offsetParent) */
98+
const visibleSegAnchors = (): string[] =>
99+
[...document.querySelectorAll("[id^='seg-']")]
100+
.filter((el) => {
101+
let cur: Element | null = el;
102+
while (cur) {
103+
if (cur instanceof HTMLElement && cur.style.display === "none") return false;
104+
cur = cur.parentElement;
105+
}
106+
return true;
107+
})
108+
.map((el) => el.id)
109+
.sort();
110+
95111
const findButton = (label: string): HTMLButtonElement => {
96112
const hit = [...document.querySelectorAll("button")].find((b) => b.textContent?.includes(label));
97113
if (!hit) throw new Error(`找不到文案含「${label}」的按钮:实测 ${[...document.querySelectorAll("button")].map((b) => b.textContent).join(" | ")}`);
@@ -105,7 +121,7 @@ beforeEach(() => {
105121
afterEach(() => cleanup());
106122

107123
describe("SessionDetailPanel · 安全网(批 5 T2 Step 1;抽件前后逐字不变)", () => {
108-
it("P1 默认视图是原文:不点任何按钮,每段都在 DOM 里;切到预览后换会话 ⇒ 复位回原文", () => {
124+
it("P1 默认视图是原文:不点任何按钮,每段都在 DOM 里;切到预览后换会话 ⇒ 复位回原文", async () => {
109125
const detail = detailOf();
110126
const view = render(<SessionDetailPanel {...propsOf(detail)} />);
111127
expect(segAnchors()).toEqual(["seg-1042-7001", "seg-1042-7002", "seg-1042-7003"]);
@@ -117,26 +133,30 @@ describe("SessionDetailPanel · 安全网(批 5 T2 Step 1;抽件前后逐字
117133
// `:64` 的复位 effect(裁决 D1:viewMode 状态留面板)—— 没有它,用户切到预览后
118134
// 换会话会停在预览视图(原文不默认可见)。这条是「默认视图是原文」的另一半。
119135
fireEvent.click(findButton("笔记预览"));
120-
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
136+
await screen.findByTestId("note-preview-view");
121137
const next = detailOf();
122138
next.session.id = 2043;
123139
next.segments = [{ id: 9001, session_id: 2043, start_ms: 0, end_ms: 1200, text: "新会话第一段", source: "asr", confidence: 0.5 }];
124140
view.rerender(<SessionDetailPanel {...propsOf(next)} />);
125141
expect(segAnchors()).toEqual(["seg-2043-9001"]);
126142
});
127143

128-
it("P2 切换器存在且会切:点「笔记预览」⇒ NotePreviewView 渲染 且 原文区不再可见(本任务仍是互斥渲染)", () => {
144+
it("P2 切换器存在且会切:点「笔记预览」⇒ NotePreviewView 渲染 且 原文区不再可见(T10:常驻语义)", async () => {
129145
render(<SessionDetailPanel {...propsOf(detailOf())} />);
130146
expect(document.querySelector('[data-testid="note-preview-view"]')).toBeNull();
131147
expect(document.querySelectorAll("[id^='seg-']").length).toBeGreaterThan(0);
132148

133149
fireEvent.click(findButton("笔记预览"));
134150

135-
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
136-
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
137-
// 切回原文:锚点全回来(互斥渲染的两个方向都判)
138-
fireEvent.click(findButton("原料视图"));
151+
await screen.findByTestId("note-preview-view");
152+
// §7.3①(T10 常驻语义):原文**节点仍在 DOM**,只是被常驻层的 `display:none` 隐藏 ⇒
153+
// 断言从「DOM 缺席」升级为「语义缺席」,强度不减(隐藏层没生效 ⇒ `visibleSegAnchors` 非空 ⇒ 红)。
154+
expect(segAnchors(), "常驻语义下原文锚点应仍在 DOM").not.toHaveLength(0);
155+
expect(visibleSegAnchors(), "切到预览后原文区仍可见(常驻层没藏住)").toHaveLength(0);
156+
// 切回原文:锚点全回来(T10 起还判「重新可见」)
157+
fireEvent.click(findButton("原文"));
139158
expect(segAnchors()).toEqual(["seg-1042-7001", "seg-1042-7002", "seg-1042-7003"]);
159+
expect(visibleSegAnchors()).toEqual(["seg-1042-7001", "seg-1042-7002", "seg-1042-7003"]);
140160
});
141161

142162
it("P3 web 会话早退:kind==='web' ⇒ 渲染 WebArticleView 且**切换器 0 个**(也没有段锚点)", () => {
@@ -149,11 +169,12 @@ describe("SessionDetailPanel · 安全网(批 5 T2 Step 1;抽件前后逐字
149169
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
150170
});
151171

152-
it("P4 工作台深链:autoRefineTaskId 非空 ⇒ 切到 preview 且 onAutoTaskConsumed **恰 1 次**", () => {
172+
it("P4 工作台深链:autoRefineTaskId 非空 ⇒ 切到 preview 且 onAutoTaskConsumed **恰 1 次**", async () => {
153173
const onAutoTaskConsumed = vi.fn();
154174
render(<SessionDetailPanel {...propsOf(detailOf(), { autoRefineTaskId: 88, onAutoTaskConsumed })} />);
155-
expect(document.querySelector('[data-testid="note-preview-view"]')).not.toBeNull();
156-
expect(document.querySelectorAll("[id^='seg-']")).toHaveLength(0);
175+
await screen.findByTestId("note-preview-view");
176+
expect(segAnchors()).not.toHaveLength(0);
177+
expect(visibleSegAnchors(), "深链切走后原文区仍可见").toHaveLength(0);
157178
expect(onAutoTaskConsumed).toHaveBeenCalledTimes(1);
158179
});
159180
});

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* SessionDetailPanel — 会话详情面板(原料 / 笔记预览两视图;v0.11.5 产物视图下线)。
2+
* SessionDetailPanel — 会话详情面板(注册表驱动的多视图;v0.11.5 产物视图下线)。
33
*
44
* @ai-context: v0.7.1 自 SessionsPage 拆出(豁免清单登记拆分计划)——质量报告、大纲、
55
* 视图模式为面板内部状态(仅依赖 sessionId),与列表页解耦。
@@ -13,20 +13,33 @@
1313
* 分支 → `session-detail/SessionViewHost.tsx`(过渡形态,T10 改注册表驱动);
1414
* 可信度总览卡 + 两裁决面板 + `refineMsg` 行 → `session-detail/SessionAuxBlocks.tsx`。
1515
* 行为等价由 `SessionDetailPanel.test.tsx` 的 P1–P4 在拆前拆后都绿证明。
16-
* @ai-context: 本件仍是**唯一数据面**(`useSessionDetailData`)与**唯一视图状态持有者**
17-
* (`viewMode` / 深链快照写入——裁决 D1;C5 会在 T10 改判 `:64` 的复位 effect)。
16+
* @ai-context: 批 5 T10(C5 · 本件是**视图记忆的唯一真身与唯一写入者**):视图态从 `useState`
17+
* 迁到 `useViewMemory("session", …)`,键 = `view:default:session`(**不含 `kind`**;
18+
* 代价:web/photo/video 共享一份记忆 —— 已登记)。`objectType` 常量 `"session"` 与
19+
* 该 hook 同住本件(不变量:hook 与它的键口径不能分居两处)。真正的视图宿主是
20+
* `SessionViewHost`,它收 `viewKey` / `onViewKeyChange` 两个受控 props(它自己的
21+
* 头注写了「为什么 hook 上移到面板而不是下沉到宿主」的完整理由)。
22+
* @ai-context: 本件仍是**唯一数据面**(`useSessionDetailData`);`viewMode` 入参按 plan Step 2
23+
* 的逐字口径映射:`viewKey === views[0].key ? "raw" : "preview"`。**等价性**:该 hook
24+
* 只拿 `viewMode` 做「进入原料视图懒触发 `auto_refine_session`」一个判定
25+
* (`useSessionDetailData.ts:169` `if (viewMode !== "raw") return;`),即**只要「当前
26+
* 是不是默认(原文)视图」这一个布尔**;默认视图的键由注册表 `views[0].key` 唯一决定,
27+
* 故 `raw`/`preview` 两档字面量在这里**退化为「默认 / 非默认」**,与 T2 的等价语义一致。
1828
* @ai-context: DOM 顺序契约(逐字不变):`SessionDetailHeader` → `SessionQualityCard` →
1929
* `SpeakerSwitchCard` → 切换器行(含 `SessionRefineSection`)→ `refineMsg` 行
20-
* → 视图区 → 两个裁决面板;全部为**同级兄弟**,无新增 wrapper。
30+
* → 视图区 → 两个裁决面板;全部为**同级兄弟**(视图区内的两个新包裹层见宿主头注)。
2131
*/
2232
import { useEffect, useState } from "react";
33+
import { convertFileSrc } from "@tauri-apps/api/core";
2334
import { useSessionDetailData } from "../hooks/useSessionDetailData";
2435
import SessionDetailHeader from "./session-detail/SessionDetailHeader";
2536
import SessionRawView from "./session-detail/SessionRawView";
2637
import SessionViewHost from "./session-detail/SessionViewHost";
2738
import { SessionAuxPanels, SessionQualityCard } from "./session-detail/SessionAuxBlocks";
2839
import WebArticleView from "../components/WebArticleView";
2940
import SpeakerSwitchCard from "../components/SpeakerSwitchCard";
41+
import { useViewMemory } from "../views/useViewMemory";
42+
import type { SessionViewSlot, ViewSpec } from "../views/registry";
3043
import type { GlossaryTerm, SessionDetail } from "../types";
3144

3245
/** 术语表 summary 的三种文案(null=加载中 / 0 条 / N 条)——原文案逐字,条件整串注入视图 */
@@ -39,6 +52,8 @@ const glossarySummaryOf = (glossary: GlossaryTerm[] | null): string =>
3952

4053
interface Props {
4154
detail: SessionDetail;
55+
/** 视图清单(C1①:由 `SessionsPage` 从 `views/registry` 取后注入;本件不 import `viewsFor`) */
56+
views: readonly ViewSpec<SessionViewSlot>[];
4257
/** 本会话是否融合中(父层 fusingId === detail.session.id) */ fusing: boolean;
4358
/** 关键降级一次性横幅(null=无) */ degradedBanner: string | null;
4459
/** 转为笔记 / 删除会话 / 重新拉详情(三者都由父层负责反馈与刷新) */
@@ -51,31 +66,35 @@ interface Props {
5166
onRefineTaskStarted?: (sessionId: number, taskId: number) => void;
5267
}
5368

54-
export default function SessionDetailPanel({ detail, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail, autoRefineTaskId, onAutoTaskConsumed, onRefineTaskStarted }: Props) {
55-
const [viewMode, setViewMode] = useState<"raw" | "preview">("raw"); // v0.5.0 M7 + v0.6.0 M6:两视图
69+
export default function SessionDetailPanel({ detail, views, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail, autoRefineTaskId, onAutoTaskConsumed, onRefineTaskStarted }: Props) {
5670
const sessionId = detail.session.id;
57-
// v0.5.0 M7:会话切换回到原料视图(裁决 D1:viewMode 状态留面板;数据面重置见 hook)
58-
useEffect(() => { setViewMode("raw"); }, [sessionId]);
71+
/** 默认视图键 = 注册表 `views[0].key`(A5①:`registry.ts` 的 `[0]` 就是「原文」) */
72+
const defaultKey = views.length > 0 ? views[0].key : "";
73+
// 批 5 C5:**显式改判**旧「裁决 D1」——视图态改由 `view:default:session` 记忆持有;**有记忆 ⇒ 用
74+
// 记忆值**(切会话不再静默丢弃用户选择)。**旧的「裁决 D1」在视图记忆范围内作废**(其余部分不受
75+
// 影响),见 v0.22「过程中纠正的计划错误」段。T2 转来的 `:64` 复位 effect **整条删除**(不是加
76+
// 条件):记忆 hook 的惰性初始化 + `validKeys` 校验已覆盖「无记忆 ⇒ 默认 `views[0].key`(原文)」。
77+
const [viewKey, setViewKey] = useViewMemory("session", defaultKey, views.map((spec) => spec.key));
5978
// 数据面(质量/术语/baseUrl/屏→OCR 分组)+ 精修链路 + 屏卡瞬时态(hook 持有 ⇒ 跨视图切换不丢)
6079
const {
6180
quality, glossary, baseUrl, ocrBlocksByScreen, refining, refineMsg, deepTaskId, setDeepTaskId, startRefine,
6281
selectingScreen, setSelectingScreen, panelToast, showPanelToast, clearPanelToast,
63-
} = useSessionDetailData({ detail, viewMode, onRefreshDetail });
82+
} = useSessionDetailData({ detail, viewMode: viewKey === defaultKey ? "raw" : "preview", onRefreshDetail });
6483
// REQ-282(v0.19.6):标题行内改名 —— 连同改名状态与提交逻辑拆至 session-detail/SessionDetailHeader.tsx
6584
// v0.20.2(REQ-268/270):两个裁决面板的显隐 —— 「会话切换即关」的 effect 随面板搬至
6685
// `SessionAuxPanels`(state 留本件:工具条三按钮在切换器行里,就近持有回调更少暴露面)。
6786
const [showPass2, setShowPass2] = useState(false);
6887
const [showProofread, setShowProofread] = useState(false);
6988
// v0.16.1 工作台深链:autoTaskId 到达即切预览视图。深链快照由 hook 持有——App 侧 focus 清空
7089
// 早于本层 effect,直接透传 prop 会在卡片挂载前被置空(竞态);快照 + 会话切换清除保证
71-
// 「只消费一次、不跨会话遗留」。切换 effect 按裁决 D1 留面板。
90+
// 「只消费一次、不跨会话遗留」。切换 effect 按裁决 D1 留面板(T10 起写入视图记忆)。
7291
useEffect(() => {
7392
if (autoRefineTaskId != null) {
7493
setDeepTaskId(autoRefineTaskId);
75-
setViewMode("preview");
94+
setViewKey("preview");
7695
onAutoTaskConsumed?.();
7796
}
78-
}, [autoRefineTaskId, onAutoTaskConsumed, setDeepTaskId]);
97+
}, [autoRefineTaskId, onAutoTaskConsumed, setDeepTaskId, setViewKey]);
7998

8099
// v0.20.4(REQ-303):web 会话专用详情(文章阅读 + 元数据回链 + 转笔记;
81100
// 无时间轴/屏卡/精修面——h2 标题即页标题,改名在会话列表进行)
@@ -93,6 +112,21 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
93112
);
94113
}
95114

115+
// 视图槽(C14②):**数据面全在这里** ⇒ 非默认视图自身零取数、零 `invoke`;`imageUrl` 把
116+
// `convertFileSrc` 收口在容器侧(T9 的 K6:视图自己 import Tauri 会破 `views/**` 的边界)。
117+
const slot: SessionViewSlot = {
118+
detail,
119+
imageUrl: (imageRef) => (imageRef && baseUrl ? convertFileSrc(`${baseUrl}/${imageRef}`) : null),
120+
ocrBlocksByScreen,
121+
selectingScreen,
122+
onSelectScreen: setSelectingScreen,
123+
panelToast,
124+
onShowToast: showPanelToast,
125+
onClearToast: clearPanelToast,
126+
autoRefineTaskId: deepTaskId,
127+
onRefineTaskStarted,
128+
};
129+
96130
return (
97131
<>
98132
{/* 降级横幅 + 详情头(改名/状态行/融合中徽标/操作)—— 拆至 session-detail/SessionDetailHeader.tsx */}
@@ -105,33 +139,33 @@ export default function SessionDetailPanel({ detail, fusing, degradedBanner, onT
105139
v0.12.1:图文会话跳过(无音频,不再误报红色错误)) */}
106140
<SpeakerSwitchCard sessionId={sessionId} kind={detail.session.kind} />
107141

108-
{/* 切换器行 + 视图区 —— 拆至 SessionViewHost.tsx(过渡形态:手写 2 按钮切换组逐字
109-
搬移,T10 再改注册表驱动的多视图宿主);children = 原文视图节点 */}
142+
{/* 注册表驱动的视图宿主(T10):默认视图常驻 + 非默认惰性 + C11 错误槽位;
143+
`resident` = 原文视图节点(默认视图**不经** `React.lazy`,同步渲染后常驻) */}
110144
<SessionViewHost
111-
sessionId={sessionId}
112-
viewMode={viewMode}
113-
onViewModeChange={setViewMode}
114-
autoRefineTaskId={deepTaskId}
115-
onRefineTaskStarted={onRefineTaskStarted}
145+
views={views}
146+
viewKey={viewKey}
147+
onViewKeyChange={setViewKey}
148+
slot={slot}
149+
resident={
150+
<SessionRawView
151+
detail={detail}
152+
baseUrl={baseUrl}
153+
ocrBlocksByScreen={ocrBlocksByScreen}
154+
selectingScreen={selectingScreen}
155+
onSelectScreen={setSelectingScreen}
156+
panelToast={panelToast}
157+
onShowToast={showPanelToast}
158+
onClearToast={clearPanelToast}
159+
glossarySummary={glossarySummaryOf(glossary)}
160+
glossary={glossary}
161+
/>
162+
}
116163
refining={refining}
117164
onStartRefine={startRefine}
118165
canSecondPass={detail.session.status === "finished" && detail.session.kind !== "photo"}
119166
onOpenPass2={() => setShowPass2(true)}
120167
onOpenProofread={() => setShowProofread(true)}
121-
>
122-
<SessionRawView
123-
detail={detail}
124-
baseUrl={baseUrl}
125-
ocrBlocksByScreen={ocrBlocksByScreen}
126-
selectingScreen={selectingScreen}
127-
onSelectScreen={setSelectingScreen}
128-
panelToast={panelToast}
129-
onShowToast={showPanelToast}
130-
onClearToast={clearPanelToast}
131-
glossarySummary={glossarySummaryOf(glossary)}
132-
glossary={glossary}
133-
/>
134-
</SessionViewHost>
168+
/>
135169

136170
{/* refineMsg 行 + 两个裁决面板 —— 拆至 SessionAuxBlocks.tsx 的 SessionAuxPanels */}
137171
<SessionAuxPanels

0 commit comments

Comments
 (0)