Browse Source

feat: 增强溯源定位功能 - 支持评审代码高亮定位

- 修复 vite @ 别名配置
- 添加 ruleName 到评审代码映射表
- 智能高亮评审代码(如5.1.5)
- 滚动定位到高亮位置
- 添加调试日志
何文松 16 giờ trước cách đây
mục cha
commit
0977fb8090
3 tập tin đã thay đổi với 532 bổ sung12 xóa
  1. 334 0
      deploy-frontend.sh
  2. 196 11
      frontend/vue-demo/src/views/Editor.vue
  3. 2 1
      frontend/vue-demo/vite.config.js

+ 334 - 0
deploy-frontend.sh

@@ -0,0 +1,334 @@
+#!/bin/bash
+
+# ============================================
+# 灵越智报 - 前端部署到 sygpudev 服务器
+# 本地构建 → 上传 dist → 配置 Nginx
+# ============================================
+
+# 颜色定义
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+# ========== 配置区域 ==========
+SERVER_USER="root"
+SERVER_HOST="sygpudev"
+SERVER_PORT="${SERVER_PORT:-28529}"
+
+# 路径配置
+LOCAL_PROJECT_DIR="/home/hws/workspace/GitLab/ay/lingyue-zhibao"
+LOCAL_FRONTEND_DIR="${LOCAL_PROJECT_DIR}/frontend/vue-demo"
+LOCAL_DIST_DIR="${LOCAL_FRONTEND_DIR}/dist"
+
+REMOTE_APP_DIR="/data/application/lingyue-zhibao"
+REMOTE_FRONTEND_DIR="/data/web/lingyue-zhibao"
+REMOTE_NGINX_CONF="/etc/nginx/conf.d/lingyue-frontend.conf"
+
+# 后端 API 地址(前端请求代理目标)
+BACKEND_API="http://127.0.0.1:8001"
+# 前端监听端口
+FRONTEND_PORT=28082
+# ==============================
+
+log_info()  { echo -e "${GREEN}[INFO]${NC} $1"; }
+log_warn()  { echo -e "${YELLOW}[WARN]${NC} $1"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
+log_title() { echo -e "\n${BLUE}========== $1 ==========${NC}\n"; }
+
+ssh_cmd() {
+    ssh -p ${SERVER_PORT} ${SERVER_USER}@${SERVER_HOST} "$@"
+}
+
+# 检查服务器连接
+check_connection() {
+    log_title "检查服务器连接"
+
+    if ssh_cmd "echo 'OK'" > /dev/null 2>&1; then
+        log_info "服务器连接成功: ${SERVER_USER}@${SERVER_HOST}"
+    else
+        log_error "无法连接服务器: ${SERVER_USER}@${SERVER_HOST}"
+        log_warn "请检查:"
+        log_warn "  1. 服务器地址是否正确"
+        log_warn "  2. SSH 密钥是否配置"
+        exit 1
+    fi
+}
+
+# 安装依赖
+install_deps() {
+    log_title "安装前端依赖"
+
+    cd ${LOCAL_FRONTEND_DIR}
+
+    if [ ! -d "node_modules" ]; then
+        log_info "安装 npm 依赖..."
+        yarn install || npm install
+    else
+        log_info "依赖已存在,跳过安装"
+    fi
+}
+
+# 本地构建
+build_local() {
+    log_title "本地构建前端"
+
+    cd ${LOCAL_FRONTEND_DIR}
+
+    # 清理旧构建
+    rm -rf dist
+
+    log_info "执行 yarn build..."
+    yarn build || npm run build
+
+    if [ ! -d "${LOCAL_DIST_DIR}" ]; then
+        log_error "构建失败: dist 目录不存在"
+        exit 1
+    fi
+
+    local dist_size=$(du -sh "${LOCAL_DIST_DIR}" | awk '{print $1}')
+    log_info "构建完成,dist 大小: ${dist_size}"
+}
+
+# 上传 dist
+upload_dist() {
+    log_title "上传前端文件到服务器"
+
+    # 创建远程目录
+    ssh_cmd "mkdir -p ${REMOTE_FRONTEND_DIR}"
+
+    # 备份旧版本
+    ssh_cmd "cd ${REMOTE_APP_DIR} && \
+             [ -d frontend ] && mv frontend frontend.bak.$(date +%Y%m%d%H%M%S) || true"
+
+    # 上传新版本
+    log_info "正在上传 dist 目录..."
+    scp -P ${SERVER_PORT} -r "${LOCAL_DIST_DIR}" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_FRONTEND_DIR}"
+
+    # 移动文件到正确位置
+    ssh_cmd "cd ${REMOTE_FRONTEND_DIR} && mv dist/* . && rmdir dist"
+
+    local remote_size=$(ssh_cmd "du -sh ${REMOTE_FRONTEND_DIR}" | awk '{print $1}')
+    log_info "上传完成,远程目录大小: ${remote_size}"
+}
+
+# 配置 Nginx
+setup_nginx() {
+    log_title "配置 Nginx"
+
+    # 检查 Nginx 是否安装
+    if ! ssh_cmd "which nginx" > /dev/null 2>&1; then
+        log_warn "Nginx 未安装,正在安装..."
+        ssh_cmd "apt update && apt install -y nginx"
+    fi
+
+    # 生成 Nginx 配置
+    local nginx_conf="server {
+    listen ${FRONTEND_PORT};
+    server_name _;
+
+    root ${REMOTE_FRONTEND_DIR};
+    index index.html;
+
+    # Gzip 压缩
+    gzip on;
+    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
+
+    # 静态资源缓存
+    location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)\$ {
+        expires 1y;
+        add_header Cache-Control \"public, immutable\";
+    }
+
+    # API 代理到后端
+    location /api {
+        proxy_pass ${BACKEND_API};
+        proxy_set_header Host \\\$host;
+        proxy_set_header X-Real-IP \\\$remote_addr;
+        proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto \\\$scheme;
+
+        # WebSocket 支持
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade \\\$http_upgrade;
+        proxy_set_header Connection \"upgrade\";
+
+        # 超时设置
+        proxy_connect_timeout 60s;
+        proxy_send_timeout 60s;
+        proxy_read_timeout 60s;
+    }
+
+    # SPA 路由支持
+    location / {
+        try_files \\\$uri \\\$uri/ /index.html;
+    }
+
+    # 文件上传大小限制
+    client_max_body_size 100M;
+}"
+
+    # 写入配置文件
+    echo "${nginx_conf}" | ssh_cmd "cat > ${REMOTE_NGINX_CONF}"
+
+    # 测试配置
+    if ssh_cmd "nginx -t" 2>&1 | grep -q "successful"; then
+        log_info "Nginx 配置测试通过"
+    else
+        log_error "Nginx 配置有误"
+        ssh_cmd "nginx -t"
+        exit 1
+    fi
+
+    # 重载 Nginx
+    ssh_cmd "systemctl reload nginx"
+    log_info "Nginx 已重载"
+}
+
+# 健康检查
+health_check() {
+    log_title "健康检查"
+
+    local max_attempts=6
+    local attempt=1
+
+    while [ $attempt -le $max_attempts ]; do
+        log_info "尝试 ${attempt}/${max_attempts}..."
+
+        local response=$(ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${FRONTEND_PORT}/ 2>/dev/null" || echo "000")
+
+        if [ "$response" = "200" ]; then
+            log_info "前端服务正常 ✓ (HTTP ${response})"
+            return 0
+        fi
+
+        sleep 2
+        attempt=$((attempt + 1))
+    done
+
+    log_warn "健康检查未通过,请手动检查"
+}
+
+# 完整部署
+full_deploy() {
+    check_connection
+    install_deps
+    build_local
+    upload_dist
+    setup_nginx
+    health_check
+
+    log_title "前端部署完成 🎉"
+    
+    # 获取服务器 IP
+    local server_ip=$(ssh_cmd "hostname -I | awk '{print \$1}'" 2>/dev/null || echo "${SERVER_HOST}")
+    
+    echo -e "${GREEN}访问地址: http://${server_ip}:${FRONTEND_PORT}${NC}"
+    echo ""
+}
+
+# 快速部署(跳过依赖安装)
+quick_deploy() {
+    if [ ! -d "${LOCAL_DIST_DIR}" ]; then
+        log_error "本地 dist 不存在,请先构建或使用 deploy 命令"
+        exit 1
+    fi
+
+    check_connection
+    upload_dist
+    setup_nginx
+    health_check
+
+    log_title "快速部署完成 🎉"
+}
+
+# 仅构建
+build_only() {
+    install_deps
+    build_local
+    log_info "构建完成,dist 目录: ${LOCAL_DIST_DIR}"
+}
+
+# 查看状态
+show_status() {
+    log_title "前端服务状态"
+
+    check_connection
+
+    if ssh_cmd "curl -s -o /dev/null -w '%{http_code}' http://localhost:${FRONTEND_PORT}/" 2>/dev/null | grep -q "200"; then
+        log_info "前端服务运行中 ✓"
+    else
+        log_warn "前端服务未响应"
+    fi
+
+    log_info "Nginx 状态:"
+    ssh_cmd "systemctl status nginx --no-pager -l" 2>/dev/null | head -10
+}
+
+# 帮助
+show_help() {
+    cat <<EOF
+灵越智报 - 前端部署到 sygpudev 服务器
+
+用法: ./deploy-frontend.sh [命令]
+
+命令:
+    deploy          完整部署(安装依赖 + 构建 + 上传 + 配置 Nginx)
+    quick           快速部署(上传已构建的 dist + 配置 Nginx)
+    build           仅本地构建
+    upload          仅上传 dist(不配置 Nginx)
+    nginx           仅配置 Nginx
+    status          查看服务状态
+    help            显示此帮助
+
+示例:
+    ./deploy-frontend.sh deploy       # 完整部署
+    ./deploy-frontend.sh quick        # 快速部署(已构建过)
+    ./deploy-frontend.sh build        # 仅构建
+
+配置:
+    服务器: ${SERVER_USER}@${SERVER_HOST}:${SERVER_PORT}
+    前端端口: ${FRONTEND_PORT}
+    后端 API: ${BACKEND_API}
+
+EOF
+}
+
+# 主函数
+main() {
+    case "${1:-deploy}" in
+        deploy)
+            full_deploy
+            ;;
+        quick)
+            quick_deploy
+            ;;
+        build)
+            build_only
+            ;;
+        upload)
+            check_connection
+            upload_dist
+            log_info "上传完成(未配置 Nginx)"
+            ;;
+        nginx)
+            check_connection
+            setup_nginx
+            health_check
+            ;;
+        status)
+            show_status
+            ;;
+        help|--help|-h)
+            show_help
+            ;;
+        *)
+            log_error "未知命令: $1"
+            show_help
+            exit 1
+            ;;
+    esac
+}
+
+main "$@"

+ 196 - 11
frontend/vue-demo/src/views/Editor.vue

@@ -257,7 +257,7 @@
                           v-for="inp in rule.inputs" 
                           :key="inp.inputId" 
                           class="rule-trace-att clickable"
-                          @click="openSourceInViewer(inp)"
+                          @click="openSourceInViewer(inp, rule)"
                           title="点击查看来源"
                         >📎 {{ formatInputSource(inp) }}</span>
                       </div>
@@ -1144,16 +1144,90 @@ function getInputSourceText(rule) {
   return ''
 }
 
-async function openSourceInViewer(inp) {
+async function openSourceInViewer(inp, rule = null) {
+  console.log('[openSourceInViewer] 开始溯源定位')
+  console.log('[openSourceInViewer] inp:', inp)
+  console.log('[openSourceInViewer] rule:', rule)
+  
   // 关闭弹窗
   highlightPopover.visible = false
   
   const attId = inp.sourceNodeId
   const entryPath = inp.entryPath
-  const sourceText = inp.sourceText
+  let sourceText = inp.sourceText || ''
+  
+  console.log('[openSourceInViewer] attId:', attId, 'entryPath:', entryPath, 'sourceText:', sourceText)
+  
+  // 如果没有 sourceText,尝试从规则的各个字段中提取评审代码(如 5.1.5)
+  if (!sourceText && rule) {
+    // 尝试从 description、actionConfig、ruleName 等字段提取
+    const searchFields = [
+      rule.description,
+      rule.actionConfig,
+      rule.ruleName
+    ].filter(Boolean).join(' ')
+    console.log('[openSourceInViewer] 尝试从规则字段提取评审代码:', searchFields)
+    
+    // 匹配 "评审代码5.1.5" 或 "代码5.1.5" 或直接匹配 "5.1.5" 格式
+    let codeMatch = searchFields.match(/评审代码\s*(\d+\.\d+(?:\.\d+)*)/i)
+    if (!codeMatch) {
+      codeMatch = searchFields.match(/代码\s*(\d+\.\d+(?:\.\d+)*)/i)
+    }
+    if (!codeMatch) {
+      // 尝试从 actionConfig JSON 中提取
+      try {
+        const cfg = typeof rule.actionConfig === 'string' ? JSON.parse(rule.actionConfig) : rule.actionConfig
+        if (cfg?.reviewCode) {
+          sourceText = cfg.reviewCode
+          console.log('[openSourceInViewer] 从 actionConfig.reviewCode 提取:', sourceText)
+        } else if (cfg?.sourceText) {
+          sourceText = cfg.sourceText
+          console.log('[openSourceInViewer] 从 actionConfig.sourceText 提取:', sourceText)
+        }
+      } catch (e) {}
+    }
+    if (codeMatch) {
+      sourceText = codeMatch[1]
+      console.log('[openSourceInViewer] 提取到评审代码:', sourceText)
+    }
+    
+    // 如果还是没有,尝试根据 ruleName 匹配评审代码(核心要素评审表的项目名称)
+    if (!sourceText && rule.ruleName) {
+      const ruleNameToCode = {
+        '安全文化': '5.1.5',
+        '安全文化建设': '5.1.5',
+        '安全投入': '5.1.4',
+        '安全生产投入': '5.1.4',
+        '目标职责': '5.1',
+        '目标': '5.1.1',
+        '目标制定': '5.1.1.1',
+        '目标落实': '5.1.1.2',
+        '目标考核': '5.1.1.3',
+        '机构和职责': '5.1.2',
+        '机构设置': '5.1.2.1',
+        '全员参与': '5.1.3',
+        '信息化建设': '5.1.6',
+        '安全生产信息化建设': '5.1.6',
+        '制度化管理': '5.2',
+        '法规标准识别': '5.2.1',
+        '规章制度': '5.2.2',
+        '操作规程': '5.2.3',
+        '评估和修订': '5.2.4',
+        '文档管理': '5.2.5',
+        '设备设施管理': '5.4.1',
+        '设备设施': '5.4.1',
+      }
+      const code = ruleNameToCode[rule.ruleName]
+      if (code) {
+        sourceText = code
+        console.log('[openSourceInViewer] 根据 ruleName 映射到评审代码:', rule.ruleName, '->', code)
+      }
+    }
+  }
   
   // 查找附件
   const att = attachments.value.find(a => a.id === attId)
+  console.log('[openSourceInViewer] 找到附件:', att)
   if (!att) {
     ElMessage.warning('未找到来源附件')
     return
@@ -1161,6 +1235,7 @@ async function openSourceInViewer(inp) {
   
   // 设置高亮文本(用于在查看器中高亮)
   highlightSourceText.value = sourceText || ''
+  console.log('[openSourceInViewer] 设置 highlightSourceText:', highlightSourceText.value)
   
   // 判断是否是 ZIP 文件
   const ext = (att.fileType || '').toLowerCase()
@@ -1181,11 +1256,14 @@ async function openSourceInViewer(inp) {
   } else {
     // 直接打开附件查看器
     const state = parseStates[att.id]
+    console.log('[openSourceInViewer] 附件解析状态:', state?.status)
     if (state?.status === 'completed') {
+      console.log('[openSourceInViewer] 调用 viewParseResult')
       viewParseResult(att)
     } else {
       // 先解析再查看
-      await triggerParse(att)
+      console.log('[openSourceInViewer] 附件未解析,调用 handleParseAttachment')
+      await handleParseAttachment(att)
     }
   }
 }
@@ -1262,14 +1340,75 @@ const parseResultHtml = computed(() => {
       html = `<pre>${parseResultContent.value}</pre>`
     }
   }
-  // 如果有高亮文本,在 HTML 中高亮显示
-  if (highlightSourceText.value && highlightSourceText.value.length > 5) {
-    const escaped = highlightSourceText.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-    const regex = new RegExp(`(${escaped})`, 'gi')
-    html = html.replace(regex, '<mark class="source-highlight">$1</mark>')
+  // 如果有高亮文本,在 HTML 中高亮显示(评审代码如 5.1.5 长度为5,所以用 >= 3)
+  if (highlightSourceText.value && highlightSourceText.value.length >= 3) {
+    html = highlightSourceInHtml(html, highlightSourceText.value)
   }
   return html
 })
+
+// 智能高亮来源文本
+function highlightSourceInHtml(html, sourceText) {
+  console.log('[highlightSourceInHtml] 开始高亮, sourceText:', sourceText, 'html长度:', html?.length)
+  if (!sourceText || sourceText.length < 3) {
+    console.log('[highlightSourceInHtml] sourceText 太短,跳过')
+    return html
+  }
+  
+  // 0. 检测评审代码模式(如 5.1.5, 5.1.4.1 等),高亮整个表格行
+  const codeMatch = sourceText.match(/\b(\d+\.\d+(?:\.\d+)*)\b/)
+  console.log('[highlightSourceInHtml] 评审代码匹配结果:', codeMatch)
+  if (codeMatch) {
+    const code = codeMatch[1]
+    console.log('[highlightSourceInHtml] 检测到评审代码:', code)
+    
+    // 直接高亮评审代码本身(更简单可靠的方式)
+    const codeEscaped = code.replace(/\./g, '\\.')
+    // 匹配 >5.1.5< 格式(在标签之间的评审代码)
+    const codeRegex = new RegExp(`(>[^<]*?)(${codeEscaped})([^<]*?<)`, 'gi')
+    if (codeRegex.test(html)) {
+      console.log('[highlightSourceInHtml] 高亮评审代码')
+      return html.replace(new RegExp(`(>[^<]*?)(${codeEscaped})([^<]*?<)`, 'gi'), 
+        '$1<mark class="source-highlight">$2</mark>$3')
+    }
+  }
+  
+  // 1. 先尝试精确匹配
+  const escaped = sourceText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  const exactRegex = new RegExp(`(${escaped})`, 'gi')
+  if (exactRegex.test(html)) {
+    return html.replace(exactRegex, '<mark class="source-highlight">$1</mark>')
+  }
+  
+  // 2. 尝试匹配前50个字符(处理截断的情况)
+  const prefix = sourceText.slice(0, 50).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  const prefixRegex = new RegExp(`(${prefix}[^<]*)`, 'gi')
+  if (prefixRegex.test(html)) {
+    return html.replace(prefixRegex, '<mark class="source-highlight">$1</mark>')
+  }
+  
+  // 3. 对于表格内容,尝试高亮包含关键词的表格行
+  // 提取关键词(去除常见词,取前几个有意义的词)
+  const keywords = sourceText
+    .replace(/[,。、:;""''()\[\]【】\n\r]/g, ' ')
+    .split(/\s+/)
+    .filter(w => w.length >= 2)
+    .slice(0, 5)
+  
+  if (keywords.length > 0) {
+    // 高亮包含关键词的 <tr> 或 <td>
+    let result = html
+    for (const kw of keywords) {
+      const kwEscaped = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+      // 高亮关键词本身
+      const kwRegex = new RegExp(`(${kwEscaped})`, 'gi')
+      result = result.replace(kwRegex, '<mark class="source-highlight">$1</mark>')
+    }
+    return result
+  }
+  
+  return html
+}
 const parseResultSource = computed(() => {
   if (!parseResultContent.value) return ''
   // 源码视图:将 base64 数据替换为简短占位符
@@ -2281,7 +2420,15 @@ async function confirmCitation(elem) {
     })
     // 查找附件节点ID(如果有的话)
     const attId = referenceModeAttId.value || null
-    const inputs = attId ? [{ sourceNodeId: attId, inputKey: 'attachment', inputType: 'ATTACHMENT', inputName: referenceModeAttName.value || parseResultAttName.value }] : []
+    const attName = referenceModeAttName.value || parseResultAttName.value || ''
+    const inputs = attId ? [{ 
+      sourceNodeId: attId, 
+      inputKey: 'attachment', 
+      inputType: 'ATTACHMENT', 
+      inputName: attName,
+      sourceName: attName,
+      sourceText: selectedText  // 传入来源段落文本,用于溯源定位
+    }] : []
 
     const ruleData = {
       elementKey: elem.elementKey,
@@ -2571,10 +2718,23 @@ function viewParseResult(att) {
   }
 }
 
-function scrollToHighlight() {
+function scrollToHighlight(retries = 3) {
+  console.log('[scrollToHighlight] 尝试滚动定位, retries:', retries)
+  // 查找高亮元素
   const highlight = document.querySelector('.parse-result-rendered mark.source-highlight')
+  console.log('[scrollToHighlight] 找到高亮元素:', highlight)
   if (highlight) {
     highlight.scrollIntoView({ behavior: 'smooth', block: 'center' })
+    // 添加闪烁效果
+    highlight.classList.add('highlight-flash')
+    setTimeout(() => highlight.classList.remove('highlight-flash'), 2000)
+    console.log('[scrollToHighlight] 滚动完成')
+  } else if (retries > 0) {
+    // 如果没找到,可能是 DOM 还没渲染完,重试
+    console.log('[scrollToHighlight] 未找到元素,重试...')
+    setTimeout(() => scrollToHighlight(retries - 1), 200)
+  } else {
+    console.log('[scrollToHighlight] 重试次数用尽,未找到高亮元素')
   }
 }
 
@@ -6603,6 +6763,31 @@ onMounted(async () => {
         0%, 100% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
         50% { background: linear-gradient(180deg, #ffe066, #ffc107); }
       }
+      
+      /* 溯源定位时的闪烁效果 */
+      mark.source-highlight.highlight-flash {
+        animation: highlight-flash 0.5s ease-in-out 3;
+        outline: 2px solid #f59e0b;
+        outline-offset: 2px;
+      }
+      
+      @keyframes highlight-flash {
+        0%, 100% { background: linear-gradient(180deg, #ffc107, #ff9800); }
+        50% { background: linear-gradient(180deg, #fff3cd, #ffeeba); }
+      }
+      
+      /* 表格行高亮样式(用于评审代码定位) */
+      mark.source-highlight-row {
+        display: contents;
+      }
+      mark.source-highlight-row td {
+        background: linear-gradient(180deg, #fff3cd, #ffeeba) !important;
+        border-left: 3px solid #f59e0b !important;
+      }
+      tr:has(mark.source-highlight-row) {
+        outline: 2px solid #f59e0b;
+        outline-offset: -1px;
+      }
 
       code {
         background: #f3f4f6;

+ 2 - 1
frontend/vue-demo/vite.config.js

@@ -1,5 +1,6 @@
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
 
 // ==================== 配置说明 ====================
 // 后端统一入口: http://47.108.80.98:8001
@@ -22,7 +23,7 @@ export default defineConfig({
   },
   resolve: {
     alias: {
-      '@': '/src'
+      '@': fileURLToPath(new URL('./src', import.meta.url))
     }
   }
 })