Skip to content

Commit 2452216

Browse files
committed
feat(refine): 工作台行级染色与并排/差异视图切换
1 parent e1cbb3f commit 2452216

4 files changed

Lines changed: 501 additions & 38 deletions

File tree

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

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1111
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
12-
import type { AiRefineResult, WorkbenchData } from "../types";
12+
import type { AiRefineResult, MarkdownDiffOps, WorkbenchData } from "../types";
1313

1414
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
1515
vi.mock("@tauri-apps/api/core", () => ({
@@ -40,9 +40,25 @@ const wbStub: WorkbenchData = {
4040
meta: { costYuan: null, model: "test-model", slices: 1, mergedFrom: null },
4141
};
4242

43+
/** 批 3(问题11):行级 ops 桩(与 wbStub/resultStub 两版文本同构的 LCS 流) */
44+
const opsStub: MarkdownDiffOps = {
45+
ops: [
46+
{ unchanged: "# 标题" },
47+
{ removed: "规则内容" },
48+
{ added: "精修内容" },
49+
],
50+
added: 1,
51+
removed: 1,
52+
};
53+
4354
beforeEach(() => {
4455
invokeMock.mockReset();
45-
invokeMock.mockImplementation(async (_cmd: string) => wbStub);
56+
invokeMock.mockImplementation(async (cmd: string) => {
57+
// 批 3:diff_markdown_ops 按需返回行级 ops(其余命令默认回工作台桩)
58+
if (cmd === "diff_markdown_ops") return opsStub;
59+
if (cmd === "diff_markdown_sections") return wbStub.sections;
60+
return wbStub;
61+
});
4662
});
4763

4864
afterEach(() => cleanup());
@@ -105,3 +121,97 @@ describe("RefineWorkbench 采纳前数据源(Bug# 回归)", () => {
105121
expect(screen.getByText(/规则内容/)).toBeTruthy();
106122
});
107123
});
124+
125+
describe("RefineWorkbench 行级染色与差异视图(批 3 / 问题11)", () => {
126+
it("双栏行级染色:统一 ops 取数 + 左栏 removed 删除线红 / 右栏 added 绿", async () => {
127+
// Arrange/Act:会话级(带内存结果——workbench 返回 wbStub 文本对)
128+
render(<RefineWorkbench sessionId={1} onClose={vi.fn()} taskResult={resultStub} />);
129+
// Assert:行级数据源 = 对工作台实际展示的两版文本统一取数(与
130+
// refine_workbench 同对文本——渲染行与数据逐行对齐)
131+
await waitFor(() => {
132+
expect(invokeMock).toHaveBeenCalledWith("diff_markdown_ops", {
133+
oldMd: wbStub.ruleMarkdown,
134+
newMd: wbStub.refinedMarkdown,
135+
});
136+
});
137+
// Assert:左栏规则版被删行 = 红 + 删除线
138+
const removedRow = await screen.findByText("规则内容");
139+
expect(removedRow.style.color).toBe("rgb(185, 28, 28)");
140+
expect(removedRow.style.textDecoration).toBe("line-through");
141+
// Assert:右栏精修版新增行 = 绿底绿字
142+
const addedRow = screen.getByText("精修内容");
143+
expect(addedRow.style.color).toBe("rgb(4, 120, 87)");
144+
expect(addedRow.style.backgroundColor).toBe("rgb(236, 253, 245)");
145+
// Assert:unchanged 标题行不染色(左栏无徽标污染文本——右侧标题带徽标
146+
// 其 textContent 为「标题修改」,精确匹配仅命中左栏行)
147+
expect(screen.getByText("标题").style.backgroundColor).toBe("");
148+
// Assert:章节徽标与统计保留(差异显示不替代既有章节级标注)
149+
expect(screen.getByText("修改")).toBeTruthy();
150+
});
151+
152+
it("差异视图切换:单列三态(−删除线红 / +新增绿 / 灰共有),可切回并排", async () => {
153+
// Arrange/Act
154+
render(<RefineWorkbench sessionId={1} onClose={vi.fn()} taskResult={resultStub} />);
155+
await screen.findByText("规则内容");
156+
fireEvent.click(screen.getByRole("button", { name: "差异" }));
157+
// Assert:单列展示两版行并置——removed 带 − 前缀红删除线
158+
const removedRow = await screen.findByText(/− 规则内容/);
159+
expect(removedRow.style.textDecoration).toBe("line-through");
160+
expect(removedRow.style.color).toBe("rgb(185, 28, 28)");
161+
// Assert:added 带 + 前缀绿
162+
const addedRow = screen.getByText(/\+ 精修内容/);
163+
expect(addedRow.style.color).toBe("rgb(4, 120, 87)");
164+
// Assert:unchanged 灰显(剥标题符后展示「标题」)
165+
const sharedRow = screen.getByText(/标题/);
166+
expect(sharedRow.style.color).toBe("rgb(107, 114, 128)");
167+
// Assert:并排模式专属栏头在差异模式消失
168+
expect(screen.queryByText(/📄 规则版/)).toBeNull();
169+
// Assert:切回并排恢复双栏(纯前端 toggle)
170+
fireEvent.click(screen.getByRole("button", { name: "并排" }));
171+
expect(await screen.findByText(/📄 规则版/)).toBeTruthy();
172+
expect(screen.queryByText(/− 规则内容/)).toBeNull();
173+
});
174+
175+
it("笔记级与只读路径:同一 diff_markdown_ops 命令取数(三入口不双轨)", async () => {
176+
// Arrange:笔记级(noteMode——文本对来自 taskResult)
177+
const { unmount } = render(
178+
<RefineWorkbench sessionId={1} noteId={9} noteMode onClose={vi.fn()} taskResult={resultStub} />,
179+
);
180+
// Act/Assert:章节分组走 diff_markdown_sections、行级走 diff_markdown_ops
181+
await waitFor(() => {
182+
expect(invokeMock).toHaveBeenCalledWith("diff_markdown_sections", {
183+
oldMd: resultStub.baseMarkdown,
184+
newMd: resultStub.refinedMarkdown,
185+
});
186+
expect(invokeMock).toHaveBeenCalledWith("diff_markdown_ops", {
187+
oldMd: resultStub.baseMarkdown,
188+
newMd: resultStub.refinedMarkdown,
189+
});
190+
});
191+
expect((await screen.findByText("规则内容")).style.textDecoration).toBe("line-through");
192+
unmount();
193+
194+
// Arrange:只读路径(VersionPanel 对比——文本对由 props 透传)
195+
invokeMock.mockClear();
196+
render(
197+
<RefineWorkbench
198+
sessionId={1}
199+
readonly
200+
ruleMd={wbStub.ruleMarkdown}
201+
refinedMd={wbStub.refinedMarkdown ?? ""}
202+
onClose={vi.fn()}
203+
/>,
204+
);
205+
// Assert:只读入口同样统一走 diff_markdown_ops(对透传文本对取数)
206+
await waitFor(() => {
207+
expect(invokeMock).toHaveBeenCalledWith("diff_markdown_ops", {
208+
oldMd: wbStub.ruleMarkdown,
209+
newMd: wbStub.refinedMarkdown,
210+
});
211+
});
212+
// Assert:差异切换在只读模式可用(底部无操作按钮)
213+
expect(await screen.findByText("规则内容")).toBeTruthy();
214+
expect(screen.queryByRole("button", { name: /采纳落库/ })).toBeNull();
215+
expect(screen.getByRole("button", { name: "差异" })).toBeTruthy();
216+
});
217+
});

‎app/src/components/RefineWorkbench.tsx‎

Lines changed: 105 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22
* RefineWorkbench — 精修工作台模态组件(v0.11.5 Task 11 / spec 6️⃣)。
33
*
44
* @ai-context: 并排双栏(规则版 + 精修版)+ 章节级 diff 高亮 + 同步滚动 +
5-
* 采纳/重新生成/放弃。数据源:非只读带 taskResult(采纳前内存
6-
* 结果)→ refine_workbench 回传 result(消除未落库右侧恒空);
7-
* 无 taskResult → refine_workbench(后端兜底未采纳任务/已落库
8-
* 笔记,重启可恢复);只读(VersionPanel)→ ruleMd/refinedMd 透传。
5+
* 采纳/重新生成/放弃。批 3(问题11):行级三态染色(removed
6+
* 删除线红/added 绿)与 并排/差异 单列视图切换——数据源为整篇
7+
* 有序行级 diff(diff_markdown_ops,见 load 内 Why)。数据源:
8+
* 非只读带 taskResult(采纳前内存结果)→ refine_workbench
9+
* 回传 result(消除未落库右侧恒空);无 taskResult →
10+
* refine_workbench(后端兜底未采纳任务/已落库笔记,重启可恢复);
11+
* 只读(VersionPanel)→ ruleMd/refinedMd 透传。
912
* @ai-context: 只读模式(VersionPanel 对比):ruleMd/refinedMd 透传,底部无操作。
1013
* 普通模式(AiRefineCard):taskResult 可选——传入则采纳按钮可用。
1114
*/
1215
import { useCallback, useEffect, useRef, useState } from "react";
1316
import { invoke } from "@tauri-apps/api/core";
14-
import type { AiRefineResult, RefineStrategyInfo, RefineStrategyMeta, WorkbenchData } from "../types";
15-
import { escapeHtml, renderTimestampAnchors } from "../utils/html";
17+
import type { AiRefineResult, DiffOp, MarkdownDiffOps, RefineStrategyInfo, RefineStrategyMeta, WorkbenchData } from "../types";
18+
import { escapeHtml } from "../utils/html";
19+
// 批 3(问题11):行级 diff 纯渲染工具(ops 流→栏行序/单列 HTML——渲染内核
20+
// 移出组件,行级染色与差异视图共用,见 utils/refineDiff.ts)
21+
import { mdFallbackRows, opsToRows, renderDiffColumnHtml, renderSideHtml, splitDiffSides } from "../utils/refineDiff";
1622

1723
/** 档位显示名(meta 声明解析;intent:xxx 前缀 → intent 名;未知 id 原样——诚实不猜) */
1824
function strategyName(presetId: string, meta: RefineStrategyMeta | null): string {
@@ -50,18 +56,11 @@ const headerBtn: React.CSSProperties = {
5056
padding: "4px 10px", cursor: "pointer", fontSize: 11, borderRadius: 6,
5157
};
5258

53-
function renderMd(md: string): string {
54-
return md.split("\n").map((line) => {
55-
// v0.12.0:先转义再替换时间戳锚点(章节锚点 `## 标题 [[⏱ ...]]` 与段落锚点;
56-
// 精修版回挂的章节锚点此前以原始 markdown 文本显示——真机验收修复)
57-
if (line.startsWith("# ")) return `<h2 style="font-size:14px;margin:8px 0 3px">${renderTimestampAnchors(escapeHtml(line.slice(2)))}</h2>`;
58-
if (line.startsWith("## ")) return `<h3 style="font-size:13px;margin:6px 0 2px;color:#0f766e">${renderTimestampAnchors(escapeHtml(line.slice(3)))}</h3>`;
59-
if (line.startsWith("### ")) return `<h4 style="font-size:12px;margin:4px 0 2px;color:#374151">${renderTimestampAnchors(escapeHtml(line.slice(4)))}</h4>`;
60-
if (line.startsWith("- ")) return `<div style="font-size:12px;color:#4b5563">• ${renderTimestampAnchors(escapeHtml(line.slice(2)))}</div>`;
61-
if (line.trim() === "") return "";
62-
return `<p style="font-size:12px;color:#374151;margin:2px 0">${renderTimestampAnchors(escapeHtml(line))}</p>`;
63-
}).join("");
64-
}
59+
/**
60+
* md-lite 行级渲染与三态 HTML 生成已移入 utils/refineDiff.ts(批 3:行级
61+
* 染色/差异视图两模式共用同一渲染内核;unchanged 行输出与原 renderMd
62+
* 字节一致——组件内不再持有第二份逐行渲染实现)。
63+
*/
6564

6665
/** 按 sections 插入 diff 徽标 */
6766
function decorateRefined(md: string, sections: WorkbenchData["sections"]): string {
@@ -130,6 +129,10 @@ export default function RefineWorkbench({
130129
const [msg, setMsg] = useState("");
131130
// v0.17.0:策略溯源条(档位/旋钮 chips——meta 声明解析名称)
132131
const [strategyMeta, setStrategyMeta] = useState<RefineStrategyMeta | null>(null);
132+
// 批 3(问题11):整篇有序行级 diff ops(行级染色数据源;null=未取到→不染色
133+
// 兜底)+ 视图切换(并排/差异——纯前端 toggle,不改变底部操作与保存路径)
134+
const [ops, setOps] = useState<DiffOp[] | null>(null);
135+
const [view, setView] = useState<"side" | "diff">("side");
133136

134137
const leftRef = useRef<HTMLDivElement>(null);
135138
const rightRef = useRef<HTMLDivElement>(null);
@@ -143,24 +146,22 @@ export default function RefineWorkbench({
143146
const load = useCallback(async () => {
144147
setStatus("loading");
145148
try {
149+
let d: WorkbenchData;
146150
if (readonly && propRuleMd !== undefined && propRefinedMd !== undefined) {
147151
const secs = await invoke<WorkbenchData["sections"]>("diff_markdown_sections", {
148152
oldMd: propRuleMd,
149153
newMd: propRefinedMd,
150154
}).catch(() => []);
151155
const totalAdded = secs.reduce((s, x) => s + x.added_lines.length, 0);
152156
const totalRemoved = secs.reduce((s, x) => s + x.removed_lines.length, 0);
153-
setData({
157+
d = {
154158
ruleMarkdown: propRuleMd,
155159
refinedMarkdown: propRefinedMd,
156160
sections: secs,
157161
stats: { added: totalAdded, removed: totalRemoved, unchanged: secs.filter((s) => s.status === "unchanged").length },
158162
meta: null,
159-
});
160-
setStatus("ready");
161-
return;
162-
}
163-
if (noteMode) {
163+
};
164+
} else if (noteMode) {
164165
// 笔记级:内存结果即基线(无规则草稿链路);章节 diff 前端按基线/精修版算
165166
if (!taskResult) {
166167
throw new Error("笔记级精修缺少任务结果(请重新发起精修)");
@@ -171,23 +172,37 @@ export default function RefineWorkbench({
171172
}).catch(() => []);
172173
const totalAdded = secs.reduce((s, x) => s + x.added_lines.length, 0);
173174
const totalRemoved = secs.reduce((s, x) => s + x.removed_lines.length, 0);
174-
setData({
175+
d = {
175176
ruleMarkdown: taskResult.baseMarkdown,
176177
refinedMarkdown: taskResult.refinedMarkdown,
177178
sections: secs,
178179
stats: { added: totalAdded, removed: totalRemoved, unchanged: secs.filter((s) => s.status === "unchanged").length },
179180
meta: null,
181+
};
182+
} else {
183+
// 非只读 + 精修结果在内存(采纳前)→ 回传后端 refine_workbench:
184+
// 后端优先采用该结果(消除未落库右侧恒空 + 事件先行的 DB 写库竞态)
185+
d = await invoke<WorkbenchData>("refine_workbench", {
186+
sessionId,
187+
refineResult: !readonly && taskResult ? taskResult : null,
180188
});
181-
setStatus("ready");
182-
return;
183189
}
184-
// 非只读 + 精修结果在内存(采纳前)→ 回传后端 refine_workbench:
185-
// 后端优先采用该结果(消除未落库右侧恒空 + 事件先行的 DB 写库竞态)
186-
const d = await invoke<WorkbenchData>("refine_workbench", {
187-
sessionId,
188-
refineResult: !readonly && taskResult ? taskResult : null,
189-
});
190+
// 批 3(问题11)行级差异统一取数:三入口(会话级/笔记级/只读)都对
191+
// "工作台实际展示的两版文本" 同一命令取整篇有序 ops——行级染色与渲染
192+
// 逐行对齐,不引入第二套 diff 口径。不采用 taskResult.diff 的 Why:
193+
// 其基线是任务时刻的含锚点草稿,与工作台剥离锚点后的展示文本可能错位。
194+
// 取数失败降级 null → 行不染色(文本仍完整渲染,不阻断工作台);精修版
195+
// 为空串时无 diff 语义(整栏删除线会误读),同样不取数
196+
const refinedText = d.refinedMarkdown;
197+
const canDiff = refinedText != null && refinedText.trim() !== "";
198+
const opsData = canDiff
199+
? await invoke<MarkdownDiffOps>("diff_markdown_ops", {
200+
oldMd: d.ruleMarkdown ?? "",
201+
newMd: refinedText,
202+
}).catch(() => null)
203+
: null;
190204
setData(d);
205+
setOps(opsData?.ops ?? null);
191206
setStatus("ready");
192207
} catch (e) {
193208
setErrMsg(`加载失败:${e}`);
@@ -275,8 +290,23 @@ export default function RefineWorkbench({
275290
const sections = wb.sections ?? [];
276291
const stats = wb.stats ?? { added: 0, removed: 0, unchanged: 0 };
277292
const hasRefined = refinedMd != null;
278-
const leftHtml = renderMd(ruleMd);
279-
const rightHtml = hasRefined ? decorateRefined(renderMd(refinedMd), sections) : "";
293+
// 批 3(问题11):行级数据派生——ops 缺失(未精修/取数失败)→ 原文行全
294+
// unchanged 兜底(不染色也不破坏渲染);ops 存在 → 每栏各消费一侧流
295+
// (栏行序与其源文本逐行一致,见 utils/refineDiff.splitDiffSides Why)
296+
const sides = ops ? splitDiffSides(ops) : null;
297+
const baseRows = sides?.base ?? mdFallbackRows(ruleMd);
298+
const refinedRows = sides?.refined ?? (hasRefined ? mdFallbackRows(refinedMd) : []);
299+
// 差异单列 = 整篇流序三态;取数失败兜底 = 两版文本顺序堆叠(灰显,仍可读全文)
300+
const diffRows = ops
301+
? opsToRows(ops)
302+
: hasRefined
303+
? [...mdFallbackRows(ruleMd), ...mdFallbackRows(refinedMd)]
304+
: mdFallbackRows(ruleMd);
305+
const leftHtml = renderSideHtml(baseRows);
306+
const rightHtml = hasRefined ? decorateRefined(renderSideHtml(refinedRows), sections) : "";
307+
const diffHtml = renderDiffColumnHtml(diffRows);
308+
// 差异视图仅在两版可比时展示(精修版缺失时保持并排占位——单侧无 diff 语义)
309+
const showDiff = view === "diff" && hasRefined;
280310

281311
return (
282312
<div style={overlay} onClick={onClose}>
@@ -295,6 +325,26 @@ export default function RefineWorkbench({
295325
{wb.meta?.costYuan != null && (
296326
<span style={{ fontSize: 10, color: "#b45309" }}>¥{wb.meta.costYuan.toFixed(4)}</span>
297327
)}
328+
{/* 批 3(问题11):视图切换(并排/差异——纯前端 toggle;差异=两版行
329+
并置一列有序展示:灰=共有/删除线红=原版独有/绿=精修新增) */}
330+
{hasRefined && (
331+
<div style={{ display: "flex", border: "1px solid #d1d5db", borderRadius: 6, overflow: "hidden", flexShrink: 0 }}>
332+
{(["side", "diff"] as const).map((m) => (
333+
<button
334+
key={m}
335+
onClick={() => setView(m)}
336+
style={{
337+
...headerBtn, border: "none", borderRadius: 0,
338+
background: view === m ? "#0d9488" : "transparent",
339+
color: view === m ? "#fff" : "#6b7280",
340+
fontWeight: 600,
341+
}}
342+
>
343+
{m === "side" ? "并排" : "差异"}
344+
</button>
345+
))}
346+
</div>
347+
)}
298348
<span style={{ flex: 1 }} />
299349
<button style={{ ...headerBtn, border: "none", background: "transparent", fontWeight: 600, color: "#6b7280" }} onClick={onClose}>✕</button>
300350
</div>
@@ -332,7 +382,25 @@ export default function RefineWorkbench({
332382
</div>
333383
)}
334384

335-
{/* 双栏 */}
385+
{/* 双栏 / 差异单列(行级染色数据源同 diff_markdown_ops——并排与差异
386+
两模式共用一行数据,见 load 内统一取数 Why) */}
387+
{showDiff ? (
388+
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
389+
<div style={{ fontSize: 11, color: "#6b7280", padding: "6px 12px", background: "#f3f4f6", borderBottom: "1px solid #e5e7eb", borderRight: "1px solid #e5e7eb" }}>
390+
<span style={{ color: "#b91c1c" }}>− 原版独有</span>
391+
<span style={{ margin: "0 8px" }}>/</span>
392+
<span style={{ color: "#047857" }}>+ 精修新增</span>
393+
<span style={{ margin: "0 8px" }}>/</span>
394+
<span>灰 = 两版共有</span>
395+
</div>
396+
<div style={{ flex: 1, overflowY: "auto", padding: 12 }}>
397+
<div
398+
style={{ border: "1px solid #e5e7eb", borderRadius: 6, background: "#fff", padding: 6, fontSize: 11, fontFamily: "monospace", lineHeight: 1.7 }}
399+
dangerouslySetInnerHTML={{ __html: diffHtml }}
400+
/>
401+
</div>
402+
</div>
403+
) : (
336404
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
337405
<div style={{ flex: 1, display: "flex", flexDirection: "column", borderRight: "1px solid #e5e7eb" }}>
338406
<div style={{
@@ -368,6 +436,7 @@ export default function RefineWorkbench({
368436
)}
369437
</div>
370438
</div>
439+
)}
371440

372441
{/* 底栏 */}
373442
{!readonly && (

0 commit comments

Comments
 (0)