Skip to content

Commit 4cd9d23

Browse files
committed
feat(notes): 时间戳回链接上毫秒并修复死链
1 parent 0ee1d0e commit 4cd9d23

7 files changed

Lines changed: 178 additions & 9 deletions

File tree

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

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* components.mark 按 hast tagName 匹配实际从未命中=无样式)。
1212
*/
1313
import { afterEach, describe, expect, it, vi } from "vitest";
14-
import { cleanup, render } from "@testing-library/react";
14+
import { cleanup, fireEvent, render } from "@testing-library/react";
1515
import type { Note } from "../types";
1616
import NoteMarkdown from "./NoteMarkdown";
1717

@@ -89,3 +89,75 @@ describe("NoteMarkdown 换行语义(v0.15 remark-breaks)", () => {
8989
expect(container.querySelector("div")).toBeNull();
9090
});
9191
});
92+
93+
/**
94+
* 批 6 T26 —— `[[ts:ms]]` 回链**把 ms 接上**(R5.5 顺带④:今天 ms 只进 `title` 文案、`onClick` 丢弃它只跳会话)。
95+
*
96+
* @ai-context 判据锚点 = 回链芯片(`span[title*="跳转到会话"]`,title 文案本任务**一字未改**)。
97+
* 四条各自独立:①带 ms 分支的调用次数+实参(两分支互斥)②缺省退回既有单参回调
98+
* ③四个边界值的 ms **逐字**(不得按秒截断)④普通链接不受影响(反例守卫)。
99+
* 诚实边界:这里只证明 **ms 被传出本组件**;「到会话页后播放头真的跳」不在此判据面内。
100+
*/
101+
describe("NoteMarkdown [[ts:ms]] 接上毫秒(批 6 T26)", () => {
102+
/** 回链芯片(title 文案是它的既有锚点;本任务未改文案) */
103+
const chipOf = (container: HTMLElement): HTMLElement => {
104+
const el = container.querySelector<HTMLElement>('span[title*="跳转到会话"]');
105+
expect(el, "回链芯片未渲染").toBeTruthy();
106+
return el as HTMLElement;
107+
};
108+
109+
it("① 点击回链 ⇒ onOpenSessionAt 恰一次收到 (sessionId, ms),且不再走单参回调", () => {
110+
const at = vi.fn();
111+
const plain = vi.fn();
112+
const { container } = render(
113+
<NoteMarkdown note={note("跳转 [⏱ 00:52]([[ts:52500]]) 处")} searchQuery="" onTaskToggle={noop} onOpenSession={plain} onOpenSessionAt={at} onImageOpen={noop} />,
114+
);
115+
116+
fireEvent.click(chipOf(container));
117+
118+
expect(at).toHaveBeenCalledTimes(1);
119+
expect(at).toHaveBeenCalledWith(42, 52_500);
120+
expect(plain, "两分支必须互斥:带 ms 分支命中时不得再调单参回调(会重复导航)").toHaveBeenCalledTimes(0);
121+
});
122+
123+
it("② 不传 onOpenSessionAt ⇒ 仍调既有 onOpenSession(sessionId)(向后兼容一字不变)", () => {
124+
const plain = vi.fn();
125+
const { container } = render(
126+
<NoteMarkdown note={note("跳转 [⏱ 00:52]([[ts:52500]]) 处")} searchQuery="" onTaskToggle={noop} onOpenSession={plain} onImageOpen={noop} />,
127+
);
128+
129+
fireEvent.click(chipOf(container));
130+
131+
expect(plain).toHaveBeenCalledTimes(1);
132+
expect(plain).toHaveBeenCalledWith(42);
133+
});
134+
135+
it("③ 四个边界值 ⇒ ms 逐字传出(不得经 sec*1000 / 按秒截断)", () => {
136+
for (const ms of [0, 1, 59_999, 3_600_000]) {
137+
const at = vi.fn();
138+
const view = render(
139+
<NoteMarkdown note={note(`跳转 [⏱ t]([[ts:${ms}]]) 处`)} searchQuery="" onTaskToggle={noop} onOpenSessionAt={at} onImageOpen={noop} />,
140+
);
141+
fireEvent.click(chipOf(view.container));
142+
expect(at, `[[ts:${ms}]] 的 ms 未逐字传出`).toHaveBeenCalledWith(42, ms);
143+
view.unmount();
144+
}
145+
});
146+
147+
it("④ 非 ts 链接不受影响:仍渲染 <a href>,点击不触发任何会话回调", () => {
148+
const at = vi.fn();
149+
const plain = vi.fn();
150+
// 用同页 hash 链接做反例点击(jsdom 里外链点击会打印 navigation 未实现告警,噪声无益)
151+
const { container } = render(
152+
<NoteMarkdown note={note("[普通链接](#section)")} searchQuery="" onTaskToggle={noop} onOpenSession={plain} onOpenSessionAt={at} onImageOpen={noop} />,
153+
);
154+
const anchor = container.querySelector("a");
155+
expect(anchor?.getAttribute("href")).toBe("#section");
156+
expect(container.querySelectorAll('span[title*="跳转到会话"]'), "普通链接被误渲染成回链芯片").toHaveLength(0);
157+
158+
fireEvent.click(anchor as HTMLAnchorElement);
159+
160+
expect(at).toHaveBeenCalledTimes(0);
161+
expect(plain).toHaveBeenCalledTimes(0);
162+
});
163+
});

‎app/src/components/NoteMarkdown.tsx‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@
1212
* React 托管 DOM(surroundContents 与虚拟 DOM 协调冲突)。
1313
* @ai-context: M5 修复——标题行号索引渲染前 useMemo 一次预计算(Map + 出现
1414
* 序号消歧同名标题),替代每个标题 O(n) findIndex + as string 断言。
15+
* @ai-context: 批 6 T26——`[[ts:ms]]` 的 ms 不再只进 title:新增**可选** `onOpenSessionAt` 把它逐字
16+
* 传出(缺省时与今天逐字相同;title 一字未改)。前置缺陷见 `noteUrlTransform`:
17+
* `react-markdown@10.1.0` 的默认 URL 消毒把它清成空串 ⇒ 回链芯片此前从未渲染。
1518
*/
1619
import { isValidElement, cloneElement, useMemo, useRef } from "react";
1720
import type { ReactElement, ReactNode } from "react";
18-
import ReactMarkdown from "react-markdown";
21+
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
1922
import remarkBreaks from "remark-breaks";
2023
import remarkGfm from "remark-gfm";
2124
import remarkMath from "remark-math";
@@ -42,6 +45,11 @@ interface Props {
4245
searchQuery: string;
4346
onTaskToggle: (newContent: string) => void;
4447
onOpenSession?: (sessionId: number) => void;
48+
/** `[[ts:ms]]` 回链的**带毫秒**分支(批 6 T26 · R5.5 顺带④):跳会话**并**把目标毫秒带出去。
49+
* @ai-context 两参而不是 `onSeekMs(ms)`:ms 只在**知道哪条会话**时才有意义 ⇒ 会话 id 由本件补;
50+
* 缺省 ⇒ 退回既有 `onOpenSession(sessionId)`(两分支**互斥**,都调会重复导航;既有 6 条判据不动)。
51+
* 边界:定位精度受音频对齐的块粒度 **±200 ms** 限制(不得声称毫秒级定位)。 */
52+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
4553
onImageOpen: (src: string, title?: string) => void;
4654
/**
4755
* **只许追加、不许替换**的 remark 插件槽(C8)。
@@ -55,6 +63,21 @@ interface Props {
5563
remarkPluginsExtra?: readonly RemarkPlugin[];
5664
}
5765

66+
/**
67+
* `[[ts:ms]]` 回链的**内部锚点**语法 + URL 消毒的唯一出口(批 6 T26 实测)。
68+
*
69+
* @ai-context 为什么必须显式放行:href 到 hast 阶段是**百分号编码**的 `%5B%5Bts:52500%5D%5D`,
70+
* 而 `react-markdown@10.1.0` 的 `defaultUrlTransform` 把无协议 URL 一律清空 ⇒ 回链芯片此前
71+
* **根本没渲染**(落成 `<a href="">⏱ 00:52</a>`,下面的 `tsMatch` 永不命中;比 R5.5 顺带④ 记的还坏)。
72+
* 安全面零放松:只放行这一种形态(解码回规范形),其余全交回默认消毒器;该 href 仅用于识别,最终渲染成 `<span>`。
73+
*/
74+
const TS_HREF_RE = /^\[\[ts:(\d+)\]\]$/;
75+
const TS_HREF_ENCODED_RE = /^%5B%5Bts:(\d+)%5D%5D$/i;
76+
const noteUrlTransform = (url: string): string => {
77+
const m = TS_HREF_ENCODED_RE.exec(url);
78+
return m ? `[[ts:${m[1]}]]` : defaultUrlTransform(url);
79+
};
80+
5881
/** 递归展平 React 子节点为纯文本(ReactNode 收窄——替代 as string 断言) */
5982
function flattenText(node: ReactNode): string {
6083
if (node == null || typeof node === "boolean") return "";
@@ -113,7 +136,7 @@ function highlightNode(node: ReactNode, query: string): ReactNode {
113136
return walk(node, "hl");
114137
}
115138

116-
export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSession, onImageOpen, remarkPluginsExtra }: Props) {
139+
export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSession, onOpenSessionAt, onImageOpen, remarkPluginsExtra }: Props) {
117140
// H1:任务行索引(源文本中每个任务行的行号,按出现顺序)
118141
// 正则与 remark-gfm 清单语法对齐:-/*/+ 无序 + 有序列表(\d{1,9}[.)]),
119142
// 勾选框大小写均认([x]/[X])——否则渲染序号与索引数组错位会写错行
@@ -180,6 +203,9 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
180203
// 存量笔记内容零数据变更自动修复;`\` 与两空格强断行语义保留)
181204
remarkPlugins={[remarkGfm, remarkMath, remarkMarkHighlight, remarkBreaks, ...(remarkPluginsExtra ?? [])]}
182205
rehypePlugins={[rehypeKatex]}
206+
// 批 6 T26:放行内部 `[[ts:ms]]` 锚点(默认消毒器会清空它 ⇒ 回链芯片曾整条不可达;
207+
// 见 `noteUrlTransform`)。**不是**放松消毒:其余 URL 仍走 `defaultUrlTransform`。
208+
urlTransform={noteUrlTransform}
183209
components={{
184210
// v0.14 B:==文本== 荧光笔(remark 插件产出 mdast mark 节点 + hName/hProperties)
185211
// v0.16.1:多色——className 由插件注入(note-mark[-{colorId}],样式见 note-mark.css);
@@ -210,7 +236,7 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
210236
li: ({ node, children, ...props }) => <li {...props}>{hl(children)}</li>,
211237
// 时间戳回链渲染(P1/A5 预览预备;L6:parseInt 补 radix 10)
212238
a: ({ node, href, children, ...props }) => {
213-
const tsMatch = href?.match(/^\[\[ts:(\d+)\]\]$/);
239+
const tsMatch = href?.match(TS_HREF_RE);
214240
if (tsMatch) {
215241
const ms = parseInt(tsMatch[1], 10);
216242
const sec = Math.floor(ms / 1000);
@@ -220,7 +246,10 @@ export default function NoteMarkdown({ note, searchQuery, onTaskToggle, onOpenSe
220246
<span
221247
style={{ cursor: "pointer", color: "#0d9488", borderBottom: "1px dashed #14b8a6", background: "#f0fdfa", borderRadius: 3, padding: "0 4px" }}
222248
onClick={() => {
223-
if (note.session_id) onOpenSession?.(note.session_id);
249+
if (!note.session_id) return;
250+
// T26:带 ms 的分支优先(ms 逐字传出,不截断);缺省退回既有单参回调
251+
if (onOpenSessionAt) onOpenSessionAt(note.session_id, ms);
252+
else onOpenSession?.(note.session_id);
224253
}}
225254
title={`⏱ 跳转到会话 ${Math.floor(ms / 60000)}:${secStr} 处 —— 点击查看视频对应片段`}
226255
>

‎app/src/components/NoteReadingView.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ interface Props {
5959
onDelete: () => void;
6060
onTagClick: (tag: string) => void;
6161
onOpenSession: (sessionId: number) => void;
62+
/** 批 6 T26:`[[ts:ms]]` 回链的**带毫秒**分支(缺省 ⇒ `NoteMarkdown` 退回既有 `onOpenSession`) */
63+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
6264
onTaskToggle: (newContent: string) => void;
6365
onImageOpen: (src: string, title?: string) => void;
6466
/** 批 8(REQ-317):正文选区菜单行动类动作上抛(转问题/模型卡预填——
@@ -70,7 +72,7 @@ export default function NoteReadingView({
7072
note, editing, editor, auxPanels, headerExtra,
7173
outlineFolded = false, outlineWidth = columnSpec("notes-outline").default,
7274
onOutlineResize, onOutlineReset, externalSearch = null, onToggleOutline,
73-
onEdit, onPinToggle, onDelete, onTagClick, onOpenSession, onTaskToggle, onImageOpen,
75+
onEdit, onPinToggle, onDelete, onTagClick, onOpenSession, onOpenSessionAt, onTaskToggle, onImageOpen,
7476
onSelectionAction,
7577
}: Props) {
7678
// A2:搜索高亮(M6:匹配集合=渲染产物只读查询,计数经此状态驱动)
@@ -316,6 +318,7 @@ export default function NoteReadingView({
316318
searchQuery={searchActive ? searchQuery : ""}
317319
onTaskToggle={onTaskToggle}
318320
onOpenSession={onOpenSession}
321+
onOpenSessionAt={onOpenSessionAt}
319322
onImageOpen={onImageOpen}
320323
/>
321324
</div>

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ interface Props {
103103
onTagClick: (tag: string) => void;
104104
/** 来源会话反跳(缺省=不可跳) */
105105
onOpenSession?: (sessionId: number) => void;
106+
/** 批 6 T26:`[[ts:ms]]` 回链的带毫秒分支(透传给 `NoteReadingView`;缺省 ⇒ 退回 `onOpenSession`) */
107+
onOpenSessionAt?: (sessionId: number, ms: number) => void;
106108
/** 图片放大预览入口(阅读态与编辑态共用同一遮罩) */
107109
onImageOpen: (src: string, title?: string) => void;
108110
/** 空组清理留痕上抛(移组触发,父层 toast) */
@@ -114,7 +116,7 @@ interface Props {
114116
export default function NotesReadingColumn({
115117
selected, editing, setEditing, readerSearch, noteColors, groups, editorRef, outlineCol,
116118
onChanged, onError, onCreateSystem, onOpenAi, onOpenModelCard, onSelectionAction,
117-
onPinToggle, onDelete, onTaskToggle, onTagClick, onOpenSession, onImageOpen, onCleanNotice,
119+
onPinToggle, onDelete, onTaskToggle, onTagClick, onOpenSession, onOpenSessionAt, onImageOpen, onCleanNotice,
118120
views,
119121
}: Props) {
120122
// H3:辅助面板插槽——VersionPanel(知识补充已迁移至编辑态 🤖 AI 菜单——
@@ -168,6 +170,7 @@ export default function NotesReadingColumn({
168170
const isDefault = viewKey === defaultKey;
169171
const LazyView = isDefault ? null : lazyOf.get(viewKey) ?? null;
170172
/** 非默认视图的槽(数据全在这里,视图自身零取数 —— C14②);空态 ⇒ `null`(连卡片流都不挂) */
173+
// ⚠️ T26:`onOpenSessionAt` **不进** NoteViewSlot(该槽类型属批 6 T24 的规范面,本任务不改)⇒ 卡片流视图的时间码回链仍无 ms,已登记为残余
171174
const slot: NoteViewSlot | null = selected
172175
? { note: selected, onTaskToggle, onOpenSession, onImageOpen }
173176
: null;
@@ -230,6 +233,7 @@ export default function NotesReadingColumn({
230233
onDelete={() => void onDelete(selected.id)}
231234
onTagClick={onTagClick}
232235
onOpenSession={(id) => onOpenSession?.(id)}
236+
onOpenSessionAt={onOpenSessionAt}
233237
onTaskToggle={onTaskToggle}
234238
onImageOpen={(src, title) => onImageOpen(src, title)}
235239
// 批 8(REQ-317):阅读态选区行动类(转问题/模型卡预填)

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,57 @@ describe("NotesPage 编辑完成即时刷新", () => {
118118
await waitFor(() => expect(screen.getByRole("heading", { name: "ESC 新标题" })).toBeTruthy());
119119
});
120120
});
121+
122+
/**
123+
* 批 6 T26 —— 笔记侧链路的**末端**判据:`[[ts:ms]]` 回链的 ms 必须**离开笔记页**
124+
* (`NotesPage` → `NotesReadingColumn` → `NoteReadingView` → `NoteMarkdown`)。
125+
*
126+
* @ai-context 这条判据的牙口覆盖**整条链**:任一跳把 ms 丢掉(只传 sessionId)都会在这里红。
127+
* ⚠️ 诚实边界:它**不**证明「到会话页后播放头跳到该毫秒」—— 那一跳要改
128+
* `App.tsx` / `SessionsPage.tsx` / `SessionDetailPanel.tsx`(全在本任务禁改面内)⇒ 未交付,
129+
* 见 `.superpowers/sdd/2026-09-12-frontend-redesign-batch6-motion/task-26-report.md`。
130+
*/
131+
describe("NotesPage [[ts:ms]] 带上毫秒(批 6 T26)", () => {
132+
beforeEach(() => {
133+
invokeMock.mockReset();
134+
confirmMock.mockReset();
135+
invokeMock.mockImplementation(async (cmd: string) => {
136+
switch (cmd) {
137+
case "list_notes":
138+
return [{ ...baseNote("带时间码"), content: "开头 [⏱ 00:05]([[ts:5000]]) 结尾" }];
139+
case "list_note_groups":
140+
case "list_fragments":
141+
case "note_versions_list":
142+
case "note_versions_usage":
143+
case "list_knowledge_systems":
144+
case "list_knowledge_links":
145+
case "list_tag_colors":
146+
case "list_links_by_target":
147+
return [];
148+
case "get_feature_flags":
149+
return { feedCapture: true };
150+
default:
151+
return null;
152+
}
153+
});
154+
});
155+
156+
afterEach(() => cleanup());
157+
158+
it("点回链 ⇒ onOpenSessions 收到 (sessionId, ms)", async () => {
159+
const onOpenSessions = vi.fn();
160+
const { container } = render(<NotesPage onOpenSessions={onOpenSessions} />);
161+
// 选中笔记 ⇒ 右栏进入阅读态(回链芯片由 NoteMarkdown 渲染)
162+
fireEvent.click(await screen.findByText("带时间码"));
163+
164+
const chip = await waitFor(() => {
165+
const el = container.querySelector<HTMLElement>('span[title*="跳转到会话"]');
166+
expect(el, "回链芯片未渲染(阅读栏未进入阅读态?)").toBeTruthy();
167+
return el as HTMLElement;
168+
});
169+
fireEvent.click(chip);
170+
171+
expect(onOpenSessions).toHaveBeenCalledTimes(1);
172+
expect(onOpenSessions).toHaveBeenCalledWith(42, 5000);
173+
});
174+
});

‎app/src/pages/NotesPage.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ interface Props {
4747
* 该组(groupId=null 保留为全量语义,当前无调用方——组侧栏全量按钮已随
4848
* 无被动提醒裁决移除);消费在 ReviewPage,本页只透传 */
4949
onOpenReview?: (groupId: number | null, name: string) => void;
50-
onOpenSessions?: (sessionId: number) => void;
50+
/**
51+
* 会话深链入口(App 注入 `goSessions`)。批 6 T26:第二实参 = `[[ts:ms]]` 回链的目标毫秒。
52+
* ⚠️ 今天的生产实现(`App.tsx` 的 `goSessions`)只读第一个实参 ⇒ **「到会话页后跳到该毫秒」那一跳
53+
* 本任务不交付**(需改 `App.tsx` + `SessionsPage.tsx` + `SessionDetailPanel.tsx`,三者都在本任务禁改面内);
54+
* 本件只保证 ms **离开笔记页**、交给深链入口(见 task-26-report 的 STOP 记录)。
55+
*/
56+
onOpenSessions?: (sessionId: number, ms?: number) => void;
5157
/** 打开体系页并选中体系(v0.13.7 触点① 组行徽标) */
5258
onOpenSystem?: (systemId: number) => void;
5359
/** TD-2026-09-05-A:空体系引导——跳体系页并打开建体系向导 */
@@ -264,6 +270,7 @@ export default function NotesPage({ focusNoteId, focusNoteSearch, focusGroupId,
264270
onTaskToggle={handleTaskToggle}
265271
onTagClick={(t) => { list.setTagFilter(t); list.setKeyword(""); setView("notes"); }}
266272
onOpenSession={onOpenSessions}
273+
onOpenSessionAt={onOpenSessions}
267274
onImageOpen={(src, title) => setPreviewImg({ src, title })}
268275
onCleanNotice={notifyCleanNotice}
269276
views={viewsFor("note")}

0 commit comments

Comments
 (0)