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 */
1215import { useCallback , useEffect , useRef , useState } from "react" ;
1316import { 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 原样——诚实不猜) */
1824function 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 徽标 */
6766function 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