Skip to content

Commit 02c32ba

Browse files
committed
feat(notes): 时间码芯片三链可点并补卡片流毫秒跳转
1 parent edb6009 commit 02c32ba

12 files changed

Lines changed: 266 additions & 14 deletions

‎app/src/components/NotePreviewView.render.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* (组件级取数/状态/交互不在本文件);② 串里文本的 `"`/`'` 经解析往返为字面量,属性的 `"` 仍见 `"`。
99
*/
1010
import { createElement } from "react";
11-
import { act, cleanup, render } from "@testing-library/react";
11+
import { act, cleanup, fireEvent, render } from "@testing-library/react";
1212
import { afterEach, describe, expect, it, vi } from "vitest";
1313

1414
const { invokeMock, convertMock } = vi.hoisted(() => ({
@@ -211,4 +211,24 @@ describe("NotePreviewView.renderMarkdown 表征(钉住今天的行渲染行为
211211
expect(out).toBe(P(long));
212212
expect(out.split("<p ").length).toBe(2);
213213
});
214+
215+
/**
216+
* 批 7 T17(C10.2)的**唯一新增用例**(上面 34 条一字未改 = 迁移的回归网)。
217+
* @ai_context 芯片是串渲染产物 ⇒ 点击只能在**容器上委托**(`closest("[data-ts-ms]")`):
218+
* 判据 = 点 `[data-ts-ms]` ⇒ `onOpenSessionAt(sessionId, ms)`(ms 来自 marker,sessionId 由容器补)。
219+
* 缺省不注入该回调 ⇒ 芯片不可点(与迁移前逐字相同;上一条的属性表用例即该形态)。
220+
*/
221+
it("T17 容器侧委托:点 [data-ts-ms] ⇒ onOpenSessionAt 收到 (sessionId, ms)", async () => {
222+
const onOpenSessionAt = vi.fn();
223+
convertMock.mockClear();
224+
installInvoke("[⏱ 00:05]([[ts:5000]]) 尾巴");
225+
let container!: HTMLElement;
226+
await act(async () => {
227+
({ container } = render(createElement(NotePreviewView, { sessionId: 7, onOpenSessionAt })));
228+
});
229+
const chip = container.querySelector<HTMLElement>("[data-ts-ms]");
230+
expect(chip, "芯片未渲染 ⇒ 委托无目标(T15 的 marker 或迁移落点坏了)").toBeTruthy();
231+
fireEvent.click(chip as HTMLElement);
232+
expect(onOpenSessionAt, "预览侧的芯片不可点(C10.2 的容器侧委托未兑现)").toHaveBeenCalledWith(7, 5000);
233+
});
214234
});

‎app/src/components/NotePreviewView.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ function renderMarkdown(md: string, imageBaseUrl: string, dataDir: string): stri
7272
}
7373

7474
export default function NotePreviewView({
75-
sessionId, autoTaskId, onTaskStarted,
75+
sessionId, autoTaskId, onTaskStarted, onOpenSessionAt,
7676
}: {
7777
sessionId: number;
7878
/** v0.16.1:工作台深链任务 id(透传 AiRefineCard——对话页任务视图跳转) */
7979
autoTaskId?: number | null;
8080
/** v0.16.1:精修任务启动回调(会话页 → 跳 AI 对话页) */
8181
onTaskStarted?: (sessionId: number, taskId: number) => void;
82+
/** 批 7 T17(C10.2):`[[ts:ms]]` 芯片的**容器侧事件委托**出口(串渲染的芯片没有 React 上下文
83+
* ⇒ 只能在容器上监听)。缺省 ⇒ 芯片不可点(与今天逐字相同:不新增任何导航行为)。
84+
* ⚠️ 生产调用点(`views/session/SessionNotePreview` 适配器)**本任务不接线**(见报告「诚实边界」)。 */
85+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
8286
}) {
8387
const [preview, setPreview] = useState<NoteFilterResult | null>(null);
8488
const [status, setStatus] = useState("");
@@ -193,6 +197,17 @@ export default function NotePreviewView({
193197
}
194198
};
195199

200+
/**
201+
* 批 7 T17(C10.2):**容器侧事件委托** —— 芯片是 `dangerouslySetInnerHTML` 的字符串产物
202+
* (没有 React 上下文,无法给它挂 `onClick`),故在容器上按 C10.2 逐字给的形态取目标:
203+
* `closest("[data-ts-ms]")`(marker 由 `utils/html.ts` 的 `renderTimestampAnchors` 写)。
204+
* 未注入回调 ⇒ 直接返回(**不新增任何行为**,与今天的预览逐字相同)。
205+
*/
206+
const openChip = (e: React.MouseEvent<HTMLDivElement>) => {
207+
const el = (e.target as HTMLElement).closest("[data-ts-ms]");
208+
if (el && onOpenSessionAt) onOpenSessionAt(sessionId, Number(el.getAttribute("data-ts-ms")));
209+
};
210+
196211
if (!preview) {
197212
// 批 4 T14:加载态改由 L1 原语承载(`role="status"` + 探针 + 逐字文案)。`status` 是
198213
// 可读信息(可能是"载入失败"之类的说明),故仍作 label;排版权威留在原语的 `Text` 里。
@@ -205,7 +220,7 @@ export default function NotePreviewView({
205220

206221
const stats = preview.stats;
207222
return (
208-
<div>
223+
<div onClick={openChip}>
209224
{/* 过滤统计卡(v0.7.5:口头禅/净化计数——REQ-162~164/171 口径) */}
210225
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", margin: "8px 0" }}>
211226
{[

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,25 @@ describe("RefineWorkbench 行级染色与差异视图(批 3 / 问题11)", ()
272272
expect(screen.getByRole("button", { name: "差异" })).toBeTruthy();
273273
});
274274
});
275+
276+
/** 批 7 T17(C10.2):工作台侧的 `[[ts:ms]]` 芯片靠**容器委托**可点(芯片是 `dangerouslySetInnerHTML`
277+
* 的串产物,没有 React 上下文)⇒ 判据 = 点 `[data-ts-ms]` ⇒ `onOpenSessionAt(sessionId, ms)`。 */
278+
describe("RefineWorkbench · [[ts:ms]] 芯片可点(T17 的容器侧委托)", () => {
279+
const CHIP_MD = "跳转 [⏱ 00:05]([[ts:5000]]) 处";
280+
it("点 [data-ts-ms] ⇒ onOpenSessionAt(42, 5000)", async () => {
281+
// 两栏文本来自行级 ops(只读透传的 ruleMd 仅在 ops 缺席时兜底)⇒ 夹具必须放进 ops
282+
invokeMock.mockImplementation(async (cmd: string) =>
283+
cmd === "diff_markdown_ops"
284+
? { ops: [{ unchanged: "# 标题" }, { unchanged: CHIP_MD }], added: 0, removed: 0 }
285+
: cmd === "diff_markdown_sections" ? [] : wbStub);
286+
const onOpenSessionAt = vi.fn();
287+
render(<RefineWorkbench sessionId={42} readonly ruleMd={CHIP_MD} refinedMd={CHIP_MD} onClose={vi.fn()} onOpenSessionAt={onOpenSessionAt} />);
288+
const chip = await waitFor(() => {
289+
const el = document.querySelector<HTMLElement>("[data-ts-ms]");
290+
expect(el, "芯片未渲染(markdownLine 的时间码链缺席)").toBeTruthy();
291+
return el as HTMLElement;
292+
});
293+
fireEvent.click(chip);
294+
expect(onOpenSessionAt, "工作台侧芯片仍不可点(C10.2「不再算残余」未兑现)").toHaveBeenCalledWith(42, 5000);
295+
});
296+
});

‎app/src/components/RefineWorkbench.tsx‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export default function RefineWorkbench({
120120
onRegenerate,
121121
ruleMd: propRuleMd,
122122
refinedMd: propRefinedMd,
123+
onOpenSessionAt,
123124
}: {
124125
/** 会话级目标(规则草稿基线;与 noteMode 二选一) */
125126
sessionId?: number;
@@ -140,6 +141,10 @@ export default function RefineWorkbench({
140141
ruleMd?: string;
141142
/** 只读模式透传精修版 markdown */
142143
refinedMd?: string;
144+
/** 批 7 T17(C10.2):`[[ts:ms]]` 芯片的**容器侧事件委托**出口(双栏/差异三处阅读面共用)。
145+
* @ai-context 缺省 ⇒ 芯片不可点(与今天逐字相同)。生产接线见 `VersionPanel`(笔记页版本对比);
146+
* `AiRefineCard` / `NoteAiDialog` 两处调用点未接线(理由见 T17 报告「诚实边界」)。 */
147+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
143148
}) {
144149
const [data, setData] = useState<WorkbenchData | null>(null);
145150
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
@@ -324,6 +329,12 @@ export default function RefineWorkbench({
324329
const leftHtml = renderSideHtml(baseRows);
325330
const rightHtml = hasRefined ? decorateRefined(renderSideHtml(refinedRows), sections) : "";
326331
const diffHtml = renderDiffColumnHtml(diffRows);
332+
/** 批 7 T17(C10.2):容器侧事件委托(三处阅读面都是 `dangerouslySetInnerHTML` 的串产物,
333+
* 芯片没有 React 上下文)⇒ 按 C10.2 逐字给的 `closest("[data-ts-ms]")` 取目标。 */
334+
const openChip = (e: React.MouseEvent<HTMLDivElement>) => {
335+
const el = (e.target as HTMLElement).closest("[data-ts-ms]");
336+
if (el && sessionId && onOpenSessionAt) onOpenSessionAt(sessionId, Number(el.getAttribute("data-ts-ms")));
337+
};
327338
// 差异视图仅在两版可比时展示(精修版缺失时保持并排占位——单侧无 diff 语义)
328339
const showDiff = view === "diff" && hasRefined;
329340

@@ -436,7 +447,7 @@ export default function RefineWorkbench({
436447
<span style={{ margin: "0 8px" }}>/</span>
437448
<span>灰 = 两版共有</span>
438449
</div>
439-
<div style={{ flex: 1, overflowY: "auto", padding: 12 }}>
450+
<div style={{ flex: 1, overflowY: "auto", padding: 12 }} onClick={openChip}>
440451
<Surface
441452
level="surface"
442453
radius="panel"
@@ -455,6 +466,7 @@ export default function RefineWorkbench({
455466
<div
456467
ref={leftRef}
457468
onScroll={() => onScroll("left")}
469+
onClick={openChip}
458470
style={{ flex: 1, overflowY: "auto", padding: 12, fontSize: 12, lineHeight: 1.6 }}
459471
dangerouslySetInnerHTML={{ __html: leftHtml }}
460472
/>
@@ -468,6 +480,7 @@ export default function RefineWorkbench({
468480
<div
469481
ref={rightRef}
470482
onScroll={() => onScroll("right")}
483+
onClick={openChip}
471484
style={{ flex: 1, overflowY: "auto", padding: 12, fontSize: 12, lineHeight: 1.6 }}
472485
dangerouslySetInnerHTML={{ __html: rightHtml }}
473486
/>

‎app/src/components/VersionPanel.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,13 @@ function DiffLine({ op }: { op: DiffOp }) {
3636
return <div style={{ color: "#6b7280", padding: "2px 6px" }}> {("unchanged" in op ? op.unchanged : "").replace(/^#+\s*/, "") || " "}</div>;
3737
}
3838

39-
export default function VersionPanel({ noteId, onChanged }: { noteId: number; onChanged?: () => void }) {
39+
/** 批 7 T17(C10.2):版本对比工作台的 `[[ts:ms]]` 芯片出口 —— 容器(`NotesReadingColumn`)注入,
40+
* 转交只读 `RefineWorkbench`;缺省 ⇒ 芯片不可点(与本任务之前逐字相同)。 */
41+
export default function VersionPanel({ noteId, onChanged, onOpenSessionAt }: {
42+
noteId: number;
43+
onChanged?: () => void;
44+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
45+
}) {
4046
const [open, setOpen] = useState(false);
4147
const [versions, setVersions] = useState<NoteVersion[]>([]);
4248
const [usage, setUsage] = useState<AiUsageRecord[]>([]);
@@ -206,6 +212,7 @@ export default function VersionPanel({ noteId, onChanged }: { noteId: number; on
206212
ruleMd={compareData.ruleMd}
207213
refinedMd={compareData.refinedMd}
208214
onClose={() => setCompareData(null)}
215+
onOpenSessionAt={onOpenSessionAt}
209216
/>
210217
)}
211218

‎app/src/components/noteMarkdownComponents.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,10 @@ export function noteMarkdownComponents({
226226
const secStr = String(sec % 60).padStart(2, "0");
227227
return (
228228
<span
229+
// 批 7 T17(§C37.1 · Y9):与串渲染链(`utils/html.ts` 的芯片)**同名**的定名 marker。
230+
// Why:三条链共用一个 marker ⇒ ① 容器侧委托一处可读(`closest("[data-ts-ms]")`)
231+
// ② `pages/NotesPage.test.tsx` 的脆选择器(`span[title*="跳转到会话"]`)可换成定名选择器。
232+
data-ts-ms={ms}
229233
style={{ cursor: "pointer", color: "#0d9488", borderBottom: "1px dashed #14b8a6", background: "#f0fdfa", borderRadius: 3, padding: "0 4px" }}
230234
onClick={() => {
231235
if (!note.session_id) return;

‎app/src/components/notes/NotesReadingColumn.tsx‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
* `StatusLine` 原语 · 间距走 `--ed-space-*`),`style` 只承载布局(ADR-033 §4)。
5252
*/
5353
import { Suspense, createElement, lazy, useCallback, useMemo, useState } from "react";
54-
import type { RefObject } from "react";
54+
import type { ComponentType, RefObject } from "react";
5555
import type { Note, NoteGroup } from "../../types";
5656
import type { NoteEditHandle } from "../NoteEditView";
5757
import type { ColumnLayout } from "../../hooks/useColumnLayout";
@@ -68,6 +68,11 @@ import { StatusLine, Text, ViewSwitcher } from "../../ui/primitives";
6868
/** `views` 缺席时的空清单:**模块级常量**(稳定引用 ⇒ 下面的 `useMemo` 不每渲染重算) */
6969
const NO_VIEWS: readonly ViewSpec<NoteViewSlot>[] = [];
7070

71+
/** 卡片流键(= 注册表的 `NOTE_VIEWS[1]`;本件不 import 注册表 —— C1①)与其**带 seek 包装件**加载器
72+
* (T17/C10.3:该视图槽缺 `onOpenSessionAt`);仍经 `lazy()` ⇒ 模块级惰性不变。 */
73+
const CARD_FLOW_KEY = "cardflow";
74+
const CARD_FLOW_LOAD = (): Promise<{ default: ComponentType<NoteViewSlot> }> => import("../../views/note/NoteCardFlowWithSeek");
75+
7176
interface Props {
7277
/** 当前选中笔记(null=空态占位) */
7378
selected: Note | null;
@@ -123,7 +128,7 @@ export default function NotesReadingColumn({
123128
// v0.17.0 REQ-246:阅读态独立面板移除,用 AI 直接进入编辑态)
124129
const auxPanels = selected ? (
125130
<>
126-
<VersionPanel key={`version-${selected.id}`} noteId={selected.id} onChanged={() => void onChanged()} />
131+
<VersionPanel key={`version-${selected.id}`} noteId={selected.id} onChanged={() => void onChanged()} onOpenSessionAt={onOpenSessionAt} />
127132
</>
128133
) : null;
129134

@@ -162,15 +167,16 @@ export default function NotesReadingColumn({
162167
/**
163168
* 惰性映射:**默认视图无 `load` ⇒ 值为 `null`**,永不进 `React.lazy`(§7.3② 的构造性证据)。
164169
* `lazy()` 只按 `specs` 引用建一次 —— 每渲染新建会让子树恒重挂(F5 的挂载计数会当场红)。
170+
* T17:**卡片流**经包装件加载(键漂移会让它静默失效 ⇒ F11 用例即守卫)。
165171
*/
166172
const lazyOf = useMemo(
167-
() => new Map(specs.map((spec) => [spec.key, spec.load ? lazy(spec.load) : null])),
173+
() => new Map(specs.map((spec) => [spec.key, spec.load ? lazy(spec.key === CARD_FLOW_KEY ? CARD_FLOW_LOAD : spec.load) : null])),
168174
[specs],
169175
);
170176
const isDefault = viewKey === defaultKey;
171177
const LazyView = isDefault ? null : lazyOf.get(viewKey) ?? null;
172-
/** 非默认视图的槽(数据全在这里,视图自身零取数 —— C14②);空态 ⇒ `null`(连卡片流都不挂) */
173-
// ⚠️ T26:`onOpenSessionAt` **不进** NoteViewSlot(该槽类型属批 6 T24 的规范面,本任务不改)⇒ 卡片流视图的时间码回链仍无 ms,已登记为残余
178+
/** 非默认视图的槽(数据全在这里,视图自身零取数 —— C14②);空态 ⇒ `null`(连卡片流都不挂)。
179+
* T17:`onOpenSessionAt` 仍不进 `NoteViewSlot`(批 6 已裁)⇒ 卡片流的 ms 由包装件补齐。 */
174180
const slot: NoteViewSlot | null = selected
175181
? { note: selected, onTaskToggle, onOpenSession, onImageOpen }
176182
: null;

‎app/src/components/notes/NotesReadingColumn.views.test.tsx‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,13 @@ interface HarnessProps {
7979
readonly editing?: boolean;
8080
readonly selected?: Note | null;
8181
readonly handle?: NoteEditHandle | null;
82+
/** 批 7 T17:卡片流那条链的 ms 出口(F11 用;缺省 = 今天的形态) */
83+
readonly onOpenSession?: (sessionId: number) => void;
84+
readonly onOpenSessionAt?: (sessionId: number, ms: number) => void;
8285
}
8386

8487
/** 页面形态的宿主:真 `useColumnLayout` + 真注册表清单(与 `NotesPage` 的那一行同源) */
85-
function Harness({ views = viewsFor("note"), editing = false, selected = note, handle = null }: HarnessProps) {
88+
function Harness({ views = viewsFor("note"), editing = false, selected = note, handle = null, onOpenSession, onOpenSessionAt }: HarnessProps) {
8689
const outlineCol = useColumnLayout("notes-outline", columnSpec("notes-outline"));
8790
const editorRef = useRef<NoteEditHandle | null>(handle);
8891
return (
@@ -104,6 +107,8 @@ function Harness({ views = viewsFor("note"), editing = false, selected = note, h
104107
onDelete={noop}
105108
onTaskToggle={noop}
106109
onTagClick={noop}
110+
onOpenSession={onOpenSession}
111+
onOpenSessionAt={onOpenSessionAt}
107112
onImageOpen={noop}
108113
onCleanNotice={noop}
109114
views={views}
@@ -277,4 +282,19 @@ describe("笔记视图宿主(批 5 T14 · C4 阻断 / §7.3 三条硬约束 /
277282
await act(async () => {});
278283
expect(invokeMock.mock.calls.length).toBe(before);
279284
});
285+
286+
/** F11(T17 · C10.3):卡片流必须经**带 seek 包装件**加载 —— 点真芯片看回调(包装件未生效 / 键改名 ⇒ 命中单参分支 ⇒ `at` 收不到)。 */
287+
it("F11 卡片流芯片带毫秒:点 [data-ts-ms] ⇒ onOpenSessionAt(42, 5000),单参回调不被调", async () => {
288+
const plain = vi.fn(); const at = vi.fn();
289+
const { container } = render(<Harness selected={{ ...note, content: "跳转 [⏱ 00:05]([[ts:5000]]) 处" }} onOpenSession={plain} onOpenSessionAt={at} />);
290+
await clickSegment("卡片流");
291+
const chip = await waitFor(() => {
292+
const el = container.querySelector<HTMLElement>("[data-ts-ms]");
293+
expect(el, "卡片流没渲染出回链芯片").toBeTruthy();
294+
return el as HTMLElement;
295+
});
296+
fireEvent.click(chip);
297+
expect(at, "包装件没生效 —— 检查 CARD_FLOW_KEY 是否与注册表一致").toHaveBeenCalledWith(42, 5000);
298+
expect(plain).not.toHaveBeenCalled();
299+
});
280300
});

‎app/src/pages/NotesPage.test.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ describe("NotesPage [[ts:ms]] 带上毫秒(批 6 T26)", () => {
162162
fireEvent.click(await screen.findByText("带时间码"));
163163

164164
const chip = await waitFor(() => {
165-
const el = container.querySelector<HTMLElement>('span[title*="跳转到会话"]');
165+
// 批 7 T17(Y9 · C37.1/§C10.2):原选择器 `span[title*="跳转到会话"]` 依赖 **title 文案**,
166+
// 而三条链(`noteMarkdownComponents` / `utils/html.ts`)今天共用同名定名 marker ⇒
167+
// 改读 `[data-ts-ms="5000"]`:比原来**更强**(不再依赖文案,且顺带钉住 ms 载体本身)。
168+
const el = container.querySelector<HTMLElement>('[data-ts-ms="5000"]');
166169
expect(el, "回链芯片未渲染(阅读栏未进入阅读态?)").toBeTruthy();
167170
return el as HTMLElement;
168171
});

0 commit comments

Comments
 (0)