React 合同审查组件:按合同原文定位

点击定位原文后平滑滚动到所匹配的内容区域并高亮显示

功能概述

实现了在合同审核页面中,从右侧风险点列表快速定位到中间文档对应原文内容的功能。点击"定位原文"按钮后,系统会在 Markdown 文档中查找匹配的文本,高亮显示并自动滚动到该位置。

函数签名

复制代码
const handleLocate = (index, originalText) => { ... }

参数说明:

  • index - 当前风险点卡片在列表中的索引
  • originalText - 需要定位的原文文本内容

工作流程

复制代码
┌─────────────────────────────────────────────────────────────┐
│                    1. 设置当前选中卡片                        │
│                   setSelectedCardIndex(index)                │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│                    2. 清除旧的高亮样式                        │
│      document.querySelectorAll('.risk-locate-highlight')     │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│              3. 查找 Markdown 容器元素                        │
│      #markdownContent 或 .centerContent                      │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│          4. 构建正则表达式(精确匹配 + 模糊匹配)              │
│  • 转义特殊字符                                               │
│  • 处理空格变体(\s+)                                        │
│  • 支持标点后跟括号的模糊匹配模式                              │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│              5. 遍历 DOM 树查找匹配节点                        │
│         使用 TreeWalker 遍历文本节点                          │
│         • 优先精确匹配                                        │
│         • 失败时使用模糊匹配 + 相似度计算(阈值 > 0.8)        │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│           6. 高亮显示并滚动到可视区域                          │
│  • 查找合适的父级元素(p/div/li/span/td/th)                 │
│  • 添加 .risk-locate-highlight 高亮样式                       │
│  • 滚动容器使目标元素位于可视区域顶部(预留 150px 间距)       │
└─────────────────────────────────────────────────────────────┘

辅助函数

calculateSimilarity计算两个字符串的相似度,用于模糊匹配场景。

算法: 基于编辑距离(Levenshtein Distance)

复制代码
const calculateSimilarity = (str1, str2) => {
    // 1. 清理字符串(移除空格和标点符号)
    // 2. 计算编辑距离矩阵
    // 3. 返回相似度分数: 1 - (编辑距离 / 最大长度)
}

调用位置

在风险点卡片底部操作区的"定位原文"按钮上绑定:

复制代码
<Button onClick={() => handleLocate(index, item.originalText)}>
    定位原文
</Button>

CSS 样式类

类名 作用
.risk-locate-highlight 定位到原文时的高亮样式
.heading-highlight 目录树点击时标题的高亮样式
复制代码
:global(.risk-locate-highlight) {
  background-color: rgba(241, 160, 160, 0.3) !important;
  border-radius: 4px;
  padding: 4px 8px;
  margin: -4px -8px;
  transition: all 0.3s ease;
  animation: riskLocatePulse 1.5s ease-in-out;
  scroll-margin-top: 40px;
}

特性

  • 双重匹配策略: 优先精确匹配,失败时使用模糊匹配 + 相似度评分
  • 智能元素选择: 自动向上查找合适的父级元素进行高亮
  • 平滑滚动 : 使用 behavior: 'smooth' 提供流畅的滚动体验
  • 容错处理: 支持空格变体、标点符号后跟括号等常见格式差异

完整代码

复制代码
// 定位原文功能
    const handleLocate = (index, originalText) => {
        setSelectedCardIndex(index);

        if (!originalText) {
            return;
        }

        document.querySelectorAll('.risk-locate-highlight').forEach(el => {
            el.classList.remove('risk-locate-highlight');
        });

        const markdownContainer = document.querySelector('#markdownContent') || document.querySelector('.centerContent');
        if (!markdownContainer) {
            message.warning('未找到文档内容');
            return;
        }
        const escapedSearchText = originalText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const normalizedPattern = escapedSearchText.replace(/\s+/g, '\\s+');
        const fuzzyPattern = normalizedPattern
            .split('')
            .map(char => {
                if (/[,。、;:,.:;]|[\u4e00-\u9fa5]/.test(char)) {
                    return char + '(?:[^,。、;:,.:;]*?([^)]*?))?';
                }
                return char;
            })
            .join('');

        // 同时尝试精确匹配和模糊匹配
        const searchRegex = new RegExp(fuzzyPattern, 'i');
        const exactRegex = new RegExp(normalizedPattern, 'i');

        const walker = document.createTreeWalker(
            markdownContainer,
            NodeFilter.SHOW_TEXT,
            null
        );

        let foundNode = null;
        let bestMatch = null;
        let bestMatchScore = 0;
        let currentNode;

        while (currentNode = walker.nextNode()) {
            const nodeText = currentNode.textContent;
            if (nodeText) {
                // 优先尝试精确匹配
                if (exactRegex.test(nodeText)) {
                    foundNode = currentNode;
                    break;
                }
                // 如果精确匹配失败,尝试模糊匹配并计算匹配度
                if (searchRegex.test(nodeText)) {
                    const score = calculateSimilarity(originalText, nodeText);
                    if (score > bestMatchScore && score > 0.8) {
                        bestMatch = currentNode;
                        bestMatchScore = score;
                    }
                }
            }
        }

        // 使用找到的最佳匹配节点
        foundNode = foundNode || bestMatch;

        if (foundNode) {
            let parentElement = foundNode.parentElement;
            let elementToHighlight = null;

            while (parentElement && parentElement !== markdownContainer) {
                const tagName = parentElement.tagName.toLowerCase();
                if (['p', 'div', 'li', 'span', 'td', 'th'].includes(tagName)) {
                    if (parentElement !== markdownContainer && !parentElement.classList.contains('markdown-body')) {
                        elementToHighlight = parentElement;
                        break;
                    }
                }
                parentElement = parentElement.parentElement;
            }

            if (!elementToHighlight && foundNode.parentElement) {
                elementToHighlight = foundNode.parentElement;
            }
            if (elementToHighlight) {
                elementToHighlight.classList.add('risk-locate-highlight');
                const scrollContainer = document.querySelector('.centerContent');
                if (scrollContainer) {
                    setTimeout(() => {
                        const containerRect = scrollContainer.getBoundingClientRect();
                        const elementRect = elementToHighlight.getBoundingClientRect();
                        const relativeTop = elementRect.top - containerRect.top + scrollContainer.scrollTop;
                        scrollContainer.scrollTo({
                            top: relativeTop - 150,
                            behavior: 'smooth'
                        });
                    }, 100);
                } else {
                    elementToHighlight.scrollIntoView({ behavior: 'smooth', block: 'start' });
                }
            }
        } else {
            message.warning('未找到对应的原文内容');
        }
    };

    // 计算两个字符串的相似度(基于字符重合度)
    const calculateSimilarity = (str1, str2) => {
        const len1 = str1.length;
        const len2 = str2.length;
        if (len1 === 0 || len2 === 0) return 0;

        // 移除空格和标点符号进行比较
        const cleanStr1 = str1.replace(/[\s,。、;:,.:;()()]/g, '');
        const cleanStr2 = str2.replace(/[\s,。、;:,.:;()()]/g, '');

        // 计算编辑距离(Levenshtein距离)
        const matrix = [];
        for (let i = 0; i <= cleanStr1.length; i++) {
            matrix[i] = [i];
        }
        for (let j = 0; j <= cleanStr2.length; j++) {
            matrix[0][j] = j;
        }
        for (let i = 1; i <= cleanStr1.length; i++) {
            for (let j = 1; j <= cleanStr2.length; j++) {
                if (cleanStr1.charAt(i - 1) === cleanStr2.charAt(j - 1)) {
                    matrix[i][j] = matrix[i - 1][j - 1];
                } else {
                    matrix[i][j] = Math.min(
                        matrix[i - 1][j - 1] + 1,
                        matrix[i][j - 1] + 1,
                        matrix[i - 1][j] + 1
                    );
                }
            }
        }

        const maxLen = Math.max(cleanStr1.length, cleanStr2.length);
        return 1 - matrix[cleanStr1.length][cleanStr2.length] / maxLen;
    };
相关推荐
lbh14 分钟前
当我开始像写代码一样和AI对话,一切都变了
前端·openai·ai编程
We་ct1 小时前
LeetCode 918. 环形子数组的最大和:两种解法详解
前端·数据结构·算法·leetcode·typescript·动态规划·取反
wefly20172 小时前
m3u8live.cn 在线M3U8播放器,免安装高效验流排错
前端·后端·python·音视频·前端开发工具
C澒2 小时前
微前端容器标准化 —— 公共能力篇:通用打印
前端·架构
德育处主任Pro2 小时前
前端元素转图片,dom-to-image-more入门教程
前端·javascript·vue.js
木斯佳2 小时前
前端八股文面经大全:小红书前端一二面OC(下)·(2026-03-17)·面经深度解析
前端·vue3·proxy·八股·响应式
陈天伟教授3 小时前
人工智能应用- 预测新冠病毒传染性:04. 中国:强力措施遏制疫情
前端·人工智能·安全·xss·csrf
zayzy3 小时前
前端八股总结
开发语言·前端·javascript
今天减肥吗3 小时前
前端面试题
开发语言·前端·javascript
Rabbit_QL3 小时前
【前端UI行话】前端 UI 术语速查表
前端·ui·状态模式