|
|
|
@ -79,6 +79,7 @@ export default function AiPlagiarismCheck() { |
|
|
|
const processedRef = useRef(new Set()); |
|
|
|
const warnedRef = useRef(new Set()); |
|
|
|
const nameMapRef = useRef({}); |
|
|
|
const scrollSyncRef = useRef(false); |
|
|
|
|
|
|
|
/* ---- 派生 ---- */ |
|
|
|
const visibleIndices = useMemo( |
|
|
|
@ -308,6 +309,82 @@ export default function AiPlagiarismCheck() { |
|
|
|
setEditContent(block.htmlContent || block.plainText); |
|
|
|
}, [taskFiles, fileBlocksCache, taskId]); |
|
|
|
|
|
|
|
// 点击一侧匹配段落时,跳转到另一侧对应段落并高亮 |
|
|
|
const jumpToCounterpart = useCallback((docIdx, idx) => { |
|
|
|
const related = matches.filter( |
|
|
|
(m) => |
|
|
|
(m.leftIdx === docIdx && m.leftPara === idx) || |
|
|
|
(m.rightIdx === docIdx && m.rightPara === idx), |
|
|
|
); |
|
|
|
if (!related.length) return; |
|
|
|
const match = related[0]; |
|
|
|
setCurrentFocusId(match.id); |
|
|
|
const targetDocIdx = match.leftIdx === docIdx ? match.rightIdx : match.leftIdx; |
|
|
|
const targetPara = match.leftIdx === docIdx ? match.rightPara : match.leftPara; |
|
|
|
const targetId = "doc-container-" + targetDocIdx + "-" + targetPara; |
|
|
|
|
|
|
|
// 被隐藏的文件没有 DOM,先恢复面板,再在目标 pane 内滚动,避免 scrollIntoView 滚动外层横向容器。 |
|
|
|
if (hiddenFiles.has(targetDocIdx)) { |
|
|
|
setHiddenFiles((prev) => { |
|
|
|
const next = new Set(prev); |
|
|
|
next.delete(targetDocIdx); |
|
|
|
return next; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
requestAnimationFrame(() => { |
|
|
|
const targetEl = document.getElementById(targetId); |
|
|
|
const targetPane = targetEl?.closest("." + styles.pane); |
|
|
|
if (!targetEl || !targetPane) return; |
|
|
|
|
|
|
|
const paneRect = targetPane.getBoundingClientRect(); |
|
|
|
const targetRect = targetEl.getBoundingClientRect(); |
|
|
|
const targetTop = targetPane.scrollTop + targetRect.top - paneRect.top; |
|
|
|
const centeredTop = targetTop - (targetPane.clientHeight - targetEl.offsetHeight) / 2; |
|
|
|
targetPane.scrollTo({ top: Math.max(0, centeredTop), behavior: "smooth" }); |
|
|
|
}); |
|
|
|
}, [matches, hiddenFiles, styles.pane]); |
|
|
|
|
|
|
|
// 按中缝匹配关系同步双屏滚动,不按两个文档总高度做比例同步。 |
|
|
|
const syncCounterpartScroll = useCallback((docIdx, sourcePane) => { |
|
|
|
if (scrollSyncRef.current || !sourcePane || !matches.length) return; |
|
|
|
|
|
|
|
const sourcePaneRect = sourcePane.getBoundingClientRect(); |
|
|
|
const candidates = matches |
|
|
|
.filter((match) => match.leftIdx === docIdx || match.rightIdx === docIdx) |
|
|
|
.map((match) => { |
|
|
|
const sourcePara = match.leftIdx === docIdx ? match.leftPara : match.rightPara; |
|
|
|
const targetDocIdx = match.leftIdx === docIdx ? match.rightIdx : match.leftIdx; |
|
|
|
const targetPara = match.leftIdx === docIdx ? match.rightPara : match.leftPara; |
|
|
|
const sourceEl = document.getElementById("doc-container-" + docIdx + "-" + sourcePara); |
|
|
|
const targetEl = document.getElementById("doc-container-" + targetDocIdx + "-" + targetPara); |
|
|
|
if (!sourceEl || !targetEl) return null; |
|
|
|
return { |
|
|
|
sourceEl, |
|
|
|
targetEl, |
|
|
|
distance: Math.abs(sourceEl.getBoundingClientRect().top - sourcePaneRect.top), |
|
|
|
}; |
|
|
|
}) |
|
|
|
.filter(Boolean) |
|
|
|
.sort((a, b) => a.distance - b.distance); |
|
|
|
|
|
|
|
const current = candidates[0]; |
|
|
|
const targetPane = current?.targetEl.closest("." + styles.pane); |
|
|
|
if (!current || !targetPane) return; |
|
|
|
|
|
|
|
const sourceTop = current.sourceEl.getBoundingClientRect().top - sourcePaneRect.top; |
|
|
|
const targetPaneRect = targetPane.getBoundingClientRect(); |
|
|
|
const targetTop = targetPane.scrollTop + current.targetEl.getBoundingClientRect().top - targetPaneRect.top; |
|
|
|
const nextTop = Math.max(0, targetTop - sourceTop); |
|
|
|
if (Math.abs(targetPane.scrollTop - nextTop) < 2) return; |
|
|
|
|
|
|
|
scrollSyncRef.current = true; |
|
|
|
targetPane.scrollTop = nextTop; |
|
|
|
requestAnimationFrame(() => { |
|
|
|
scrollSyncRef.current = false; |
|
|
|
}); |
|
|
|
}, [matches, styles.pane]); |
|
|
|
|
|
|
|
const applyAiRewrite = useCallback(async () => { |
|
|
|
if (!editingBlock || !editingBlock.taskId || !editingBlock.fileId || !editingBlock.blockId) { |
|
|
|
message.warning("无法降重:缺少必要信息"); return; |
|
|
|
@ -590,7 +667,12 @@ export default function AiPlagiarismCheck() { |
|
|
|
const activeClass = isFocused ? " " + styles.active : ""; |
|
|
|
// CSS覆盖高亮,保留样式;纯文本用 <mark> 精确标记 |
|
|
|
if (/<[^>]+>/.test(html)) { |
|
|
|
return (<div key={idx} id={blockId}><p className={styles.docP + " " + clr + activeClass} onClick={() => openEditor(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} /></div>); |
|
|
|
return ( |
|
|
|
<div key={idx} id={blockId} className={styles.matchParaWrap}> |
|
|
|
<p className={styles.docP + " " + clr + activeClass} onClick={() => jumpToCounterpart(docIdx, idx)} dangerouslySetInnerHTML={{ __html: html }} /> |
|
|
|
<Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={() => openEditor(docIdx, idx)} /> |
|
|
|
</div> |
|
|
|
); |
|
|
|
} |
|
|
|
// 纯文本精确高亮 |
|
|
|
const spans = []; |
|
|
|
@ -614,7 +696,12 @@ export default function AiPlagiarismCheck() { |
|
|
|
cursor = s.end; |
|
|
|
}); |
|
|
|
if (cursor < plain.length) parts.push(plain.slice(cursor)); |
|
|
|
return (<div key={idx} id={blockId}><p className={styles.docP} onClick={() => openEditor(docIdx, idx)}>{parts}</p></div>); |
|
|
|
return ( |
|
|
|
<div key={idx} id={blockId} className={styles.matchParaWrap}> |
|
|
|
<p className={styles.docP} onClick={() => jumpToCounterpart(docIdx, idx)}>{parts}</p> |
|
|
|
<Button size="small" type="text" icon={<EditOutlined />} className={styles.matchEditBtn} title="编辑该段" onClick={() => openEditor(docIdx, idx)} /> |
|
|
|
</div> |
|
|
|
); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -669,7 +756,10 @@ export default function AiPlagiarismCheck() { |
|
|
|
const blocks = fileBlocksCache[file?.id] || []; |
|
|
|
return ( |
|
|
|
<div key={docIdx} className={styles.panelGroup}> |
|
|
|
<div className={styles.pane} onScroll={redrawAll}> |
|
|
|
<div className={styles.pane} onScroll={(event) => { |
|
|
|
syncCounterpartScroll(docIdx, event.currentTarget); |
|
|
|
redrawAll(); |
|
|
|
}}> |
|
|
|
<div className={styles.paneHeader}> |
|
|
|
<Typography.Text strong>{(nameMapRef.current[file?.originalName] || fixGarbledName(file?.originalName) || "文档" + (docIdx + 1))}</Typography.Text> |
|
|
|
<Button size="small" danger icon={<CloseOutlined />} onClick={() => hidePanel(docIdx)}>隐藏</Button> |
|
|
|
|