Parcourir la source

Merge branch 'dev' into server_test

KCY il y a 2 semaines
Parent
commit
57c7f658d4

+ 34 - 1
shudao-chat-py/prompts/document_writing_template.md

@@ -39,7 +39,40 @@ ${contextJSON}
 2. **数据强制使用原则**:公文内容中涉及的制度名称、文号、部门名称、人员职务、项目名称、数据指标必须来自向量数据库
 2. **数据强制使用原则**:公文内容中涉及的制度名称、文号、部门名称、人员职务、项目名称、数据指标必须来自向量数据库
 3. **覆盖率要求**:公文核心内容中至少70%以上应基于向量数据库中的蜀道集团知识生成
 3. **覆盖率要求**:公文核心内容中至少70%以上应基于向量数据库中的蜀道集团知识生成
 
 
+## 输出格式要求(强制执行)
+
+你必须严格按照以下HTML格式输出公文内容,确保文档结构清晰、排版美观:
+
+1. **必须使用HTML标签**:输出必须是可直接放入富文本编辑器的HTML片段
+2. **标题使用h标签**:文档主标题用`<h1>`,一级章节用`<h2>`,二级章节用`<h3>`,三级章节用`<h4>`
+3. **正文段落用p标签**:每个段落必须用`<p>`标签包裹,段落之间自然分隔
+4. **列表使用ul/ol标签**:无序列表用`<ul><li>`,有序列表用`<ol><li>`
+5. **表格使用table标签**:如需表格,使用`<table><tr><td>`结构
+6. **强调使用strong/em标签**:重点内容用`<strong>`加粗,需要斜体用`<em>`
+7. **禁止输出纯文本**:不允许输出没有HTML标签的纯文本段落
+8. **禁止输出Markdown**:不允许使用#、*、-等Markdown语法
+9. **禁止输出代码块**:不允许使用```包裹内容
+
+示例输出格式:
+```html
+<h1>关于XXX的通知</h1>
+<p>各部门、各单位:</p>
+<p>根据XXX要求,现将有关事项通知如下:</p>
+<h2>一、工作目标</h2>
+<p>具体目标内容...</p>
+<h2>二、工作要求</h2>
+<p>具体要求内容...</p>
+<ul>
+<li>第一项要求</li>
+<li>第二项要求</li>
+</ul>
+<h2>三、保障措施</h2>
+<p>具体措施内容...</p>
+<p style="text-align: right;">四川省蜀道投资集团有限责任公司</p>
+<p style="text-align: right;">2024年X月X日</p>
+```
+
 ## 用户输入内容
 ## 用户输入内容
 ${userMessage}
 ${userMessage}
 
 
-请根据以上要求生成专业的蜀道集团公文。
+请根据以上要求,直接输出HTML格式的公文正文片段。不要输出任何解释、说明或非HTML内容

+ 1 - 13
shudao-chat-py/routers/chat.py

@@ -782,19 +782,7 @@ async def _generate_ai_writing_response(message: str) -> str:
     raw_response = await deepseek_service.chat(messages)
     raw_response = await deepseek_service.chat(messages)
     raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
     raw_thinking, raw_answer = split_thinking_and_answer(raw_response)
     answer_text = _clean_ai_writing_response(raw_answer or raw_response)
     answer_text = _clean_ai_writing_response(raw_answer or raw_response)
-    if raw_thinking:
-        thinking_summary = await summarize_thinking_content(
-            user_question=message,
-            raw_thinking=raw_thinking,
-            final_answer=answer_text,
-            chat_service=deepseek_service,
-            context="document_writing",
-        )
-        return (
-            f"思考过程:\n{thinking_summary}\n\n回答:\n{answer_text}"
-            if thinking_summary
-            else answer_text
-        )
+    # AI写作输出纯HTML文档内容,不附加思考过程(避免混入纯文本破坏HTML结构)
     return answer_text
     return answer_text
 
 
 
 

+ 123 - 15
shudao-vue-frontend/src/views/AIWriting.vue

@@ -2935,24 +2935,30 @@ const handleContinueText = async () => {
 const formatContentForEditor = (content) => {
 const formatContentForEditor = (content) => {
   if (!content) return "";
   if (!content) return "";
 
 
-  console.log('formatContentForEditor 输入内容:', content);
-  
   console.log('formatContentForEditor 输入内容长度:', content.length);
   console.log('formatContentForEditor 输入内容长度:', content.length);
   console.log('formatContentForEditor 输入内容预览:', content.substring(0, 200) + '...');
   console.log('formatContentForEditor 输入内容预览:', content.substring(0, 200) + '...');
   
   
+  // 兼容旧数据:剥离 "思考过程:...\n\n回答:\n" 前缀
+  let cleaned = content;
+  const answerMarker = cleaned.indexOf('回答:\n');
+  if (answerMarker !== -1 && cleaned.substring(0, answerMarker).includes('思考过程:')) {
+    cleaned = cleaned.substring(answerMarker + '回答:\n'.length).trim();
+    console.log('已剥离思考过程前缀,剩余内容长度:', cleaned.length);
+  }
+  
   // 检测是否为 HTML 格式
   // 检测是否为 HTML 格式
-  const isHTML = /<[^>]+>/.test(content);
+  const isHTML = /<(?:h[1-6]|p|div|table|ul|ol|li|article|section|blockquote|pre|hr)\b/i.test(cleaned);
   console.log('检测到HTML格式:', isHTML);
   console.log('检测到HTML格式:', isHTML);
   
   
   if (isHTML) {
   if (isHTML) {
     console.log('直接使用后端返回的HTML内容,无需转换');
     console.log('直接使用后端返回的HTML内容,无需转换');
     // 直接返回后端生成的HTML内容,wangeditor 可以直接处理
     // 直接返回后端生成的HTML内容,wangeditor 可以直接处理
-    return content;
+    return cleaned;
   }
   }
   
   
   // 如果不是 HTML,按普通文本处理
   // 如果不是 HTML,按普通文本处理
   console.log('按普通文本处理');
   console.log('按普通文本处理');
-  return formatPlainText(content);
+  return formatPlainText(cleaned);
 };
 };
 
 
 // 普通文本格式化函数
 // 普通文本格式化函数
@@ -4637,6 +4643,14 @@ const callAIInDetail = async (historyItem, smartPrompt, currentUserMessage = nul
       
       
       console.log("AI写作完成,准备进入富文本编辑器");
       console.log("AI写作完成,准备进入富文本编辑器");
       
       
+      // AI输出完成后,默认打开右侧文档预览侧边栏
+      showDocumentPreview.value = true;
+      selectedDocument.value = {
+        hasDocument: true,
+        documentContent: historyItem.documentContent,
+        documentTitle: "AI生成的文档"
+      };
+      
       // 直接进入富文本编辑器,显示AI生成的内容
       // 直接进入富文本编辑器,显示AI生成的内容
       console.log('AI回复内容长度:', aiReply.length);
       console.log('AI回复内容长度:', aiReply.length);
       console.log('AI回复内容预览:', aiReply.substring(0, 200) + '...');
       console.log('AI回复内容预览:', aiReply.substring(0, 200) + '...');
@@ -5188,27 +5202,122 @@ const cleanMarkdownForSpeech = (text) => {
   return cleanText;
   return cleanText;
 };
 };
 
 
-// 格式化文档内容,处理Markdown链接和换行
+// 格式化文档内容,处理HTML和纯文本
 const formatDocumentContent = (content) => {
 const formatDocumentContent = (content) => {
   if (!content) return '';
   if (!content) return '';
   
   
   let formattedContent = content;
   let formattedContent = content;
   
   
-  // 后端返回的是HTML格式,无需Markdown转换
-  
-  // 后端返回的是HTML格式,无需Markdown转换
-  
   // 过滤emoji表情符号
   // 过滤emoji表情符号
   formattedContent = formattedContent.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
   formattedContent = formattedContent.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
   
   
-  // 包装在p标签中,但减少多余的段落
-  if (formattedContent.trim()) {
+  // 检测是否为HTML格式(包含块级HTML标签)
+  const hasBlockHtml = /<(?:h[1-6]|p|div|table|ul|ol|li|article|section|blockquote|pre|hr)\b/i.test(formattedContent);
+  
+  if (hasBlockHtml) {
+    // 已经是HTML格式,直接返回,不要包装在<p>中(会破坏结构)
+    return formattedContent;
+  }
+  
+  // 如果不是HTML,检查是否是Markdown格式并转换为HTML
+  const hasMarkdown = /^#{1,6}\s|^\*\*|^- |^\d+\.\s/m.test(formattedContent);
+  
+  if (hasMarkdown) {
+    // 将Markdown转换为HTML
+    formattedContent = convertMarkdownToHtml(formattedContent);
+    return formattedContent;
+  }
+  
+  // 纯文本:按换行分段
+  const paragraphs = formattedContent.split(/\n\n+/).filter(p => p.trim());
+  if (paragraphs.length > 1) {
+    formattedContent = paragraphs.map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('');
+  } else if (formattedContent.includes('\n')) {
+    formattedContent = `<p>${formattedContent.replace(/\n/g, '<br>')}</p>`;
+  } else {
     formattedContent = `<p>${formattedContent}</p>`;
     formattedContent = `<p>${formattedContent}</p>`;
   }
   }
   
   
   return formattedContent;
   return formattedContent;
 };
 };
 
 
+// 简易Markdown转HTML(用于右侧文档预览)
+const convertMarkdownToHtml = (markdown) => {
+  if (!markdown) return '';
+  
+  let html = markdown;
+  
+  // 处理标题
+  html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
+  html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
+  html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
+  html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
+  html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
+  html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
+  
+  // 处理粗体和斜体
+  html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
+  html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+  html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
+  
+  // 处理无序列表
+  html = html.replace(/^- (.+)$/gm, '<li>$1</li>');
+  html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`);
+  
+  // 处理有序列表
+  html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
+  // 将连续的<li>包裹在<ol>中(如果前面没有<ul>)
+  html = html.replace(/(?<!<\/ul>)(<li>.*<\/li>\n?)+/g, (match) => {
+    if (!match.includes('<ul>')) {
+      return `<ol>${match}</ol>`;
+    }
+    return match;
+  });
+  
+  // 处理水平线
+  html = html.replace(/^---+$/gm, '<hr>');
+  
+  // 处理段落:将非标签行包装为<p>
+  const lines = html.split('\n');
+  const result = [];
+  let inParagraph = false;
+  let paragraphLines = [];
+  
+  for (const line of lines) {
+    const trimmed = line.trim();
+    if (!trimmed) {
+      // 空行:结束当前段落
+      if (inParagraph && paragraphLines.length > 0) {
+        result.push(`<p>${paragraphLines.join('<br>')}</p>`);
+        paragraphLines = [];
+        inParagraph = false;
+      }
+      continue;
+    }
+    
+    // 检查是否是块级元素
+    if (/^<(?:h[1-6]|ul|ol|li|hr|table|div|blockquote|pre)/.test(trimmed)) {
+      // 先结束当前段落
+      if (inParagraph && paragraphLines.length > 0) {
+        result.push(`<p>${paragraphLines.join('<br>')}</p>`);
+        paragraphLines = [];
+        inParagraph = false;
+      }
+      result.push(trimmed);
+    } else {
+      inParagraph = true;
+      paragraphLines.push(trimmed);
+    }
+  }
+  
+  // 处理最后一个段落
+  if (inParagraph && paragraphLines.length > 0) {
+    result.push(`<p>${paragraphLines.join('<br>')}</p>`);
+  }
+  
+  return result.join('\n');
+};
+
 // 发送详情页消息
 // 发送详情页消息
 const sendDetailMessage = () => {
 const sendDetailMessage = () => {
   if (!detailInputText.value.trim() || isGenerating.value) {
   if (!detailInputText.value.trim() || isGenerating.value) {
@@ -5864,7 +5973,7 @@ const generateSmartPromptForDetail = (userMessage) => {
       gap: 0; // 减少gap,让左右两列更贴近
       gap: 0; // 减少gap,让左右两列更贴近
 
 
       .left-column {
       .left-column {
-        width: 50%; // 保持一半宽度
+        width: 38%; // 缩小左侧宽度,给右侧更多空间
         padding: 0 32px 0 0;
         padding: 0 32px 0 0;
         align-items: flex-start;
         align-items: flex-start;
         transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); // 平滑过渡
         transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); // 平滑过渡
@@ -6542,7 +6651,7 @@ const generateSmartPromptForDetail = (userMessage) => {
     }
     }
 
 
     .right-column {
     .right-column {
-      width: 50%; // 改为50%,与左侧列相等
+      width: 62%; // 增大右侧宽度,方便浏览和编辑
       padding-left: 31px; // 减少左边距,让分隔线更贴近
       padding-left: 31px; // 减少左边距,让分隔线更贴近
       border-left: 1px solid #e5e7eb;
       border-left: 1px solid #e5e7eb;
       display: flex;
       display: flex;
@@ -7583,4 +7692,3 @@ const generateSmartPromptForDetail = (userMessage) => {
 /* 响应式设计 */
 /* 响应式设计 */
 
 
 </style>
 </style>
-

+ 26 - 4
shudao-vue-frontend/src/views/Chat.vue

@@ -25,9 +25,9 @@
         
         
         <!-- 有历史记录时显示 -->
         <!-- 有历史记录时显示 -->
         <div 
         <div 
-          v-else-if="historyTotal > 0"
-          v-for="(item, index) in historyData" 
-          :key="index"
+          v-else-if="filteredHistoryTotal > 0"
+          v-for="(item, index) in filteredHistoryData" 
+          :key="item.id || index"
           :class="['history-item', { active: item.isActive }]"
           :class="['history-item', { active: item.isActive }]"
           @click="item.isActive ? null : ((isSending || hasTypingMessage) ? null : handleHistoryItem(item))"
           @click="item.isActive ? null : ((isSending || hasTypingMessage) ? null : handleHistoryItem(item))"
           :style="{ 
           :style="{ 
@@ -3545,7 +3545,7 @@ const handleSSEComplete = async () => {
         id: ai_conversation_id.value,
         id: ai_conversation_id.value,
         title: title,
         title: title,
         time: formatTime(new Date().toISOString()),
         time: formatTime(new Date().toISOString()),
-        businessType: 0,
+        businessType: getModeBusinessType(currentMode.value),
         isActive: true,
         isActive: true,
         rawData: {
         rawData: {
           id: ai_conversation_id.value,
           id: ai_conversation_id.value,
@@ -4580,6 +4580,28 @@ const getHotQuestions = async () => {
   }
   }
 }
 }
 
 
+// 根据当前模式获取对应的 business_type
+const getModeBusinessType = (mode) => {
+  switch (mode) {
+    case 'safety-training': return 1
+    case 'ai-writing': return 2
+    case 'exam-workshop': return 3
+    default: return 0 // ai-qa
+  }
+}
+
+// 根据当前模式过滤历史记录
+const filteredHistoryData = computed(() => {
+  if (currentMode.value === 'ai-qa') {
+    // AI问答模式显示所有记录(business_type 为 0 或未设置的)
+    return historyData.value.filter(item => !item.businessType || Number(item.businessType) === 0)
+  }
+  const targetType = getModeBusinessType(currentMode.value)
+  return historyData.value.filter(item => Number(item.businessType) === targetType)
+})
+
+const filteredHistoryTotal = computed(() => filteredHistoryData.value.length)
+
 // 监听模式变化,重新获取卡片和问题
 // 监听模式变化,重新获取卡片和问题
 watch(currentMode, () => {
 watch(currentMode, () => {
   functionCards.value = []
   functionCards.value = []

+ 402 - 190
shudao-vue-frontend/src/views/HazardDetection.vue

@@ -350,7 +350,9 @@
                         </div>
                         </div>
                     </div>
                     </div>
 
 
-                    <div class="detail-content">
+                    <div class="detail-content-wrapper">
+                        <div class="detail-main-area">
+                            <div class="detail-content">
                         <div
                         <div
                             v-if="isLoadingDetail || isImageLoading"
                             v-if="isLoadingDetail || isImageLoading"
                             class="loading-overlay"
                             class="loading-overlay"
@@ -420,38 +422,6 @@
                                     </div>
                                     </div>
                                 </div>
                                 </div>
 
 
-                                <div
-                                    v-if="
-                                        !showScanningEffect &&
-                                        selectedKeyElement &&
-                                        elementOverlayStyle
-                                    "
-                                    ref="elementCardRef"
-                                    class="element-overlay-card"
-                                    :style="elementOverlayStyle"
-                                    @click.stop
-                                >
-                                    <div class="element-card-title">
-                                        当前选中:{{ selectedKeyElement }}
-                                    </div>
-                                    <ul
-                                        v-if="filteredHazards.length"
-                                        class="element-card-list"
-                                    >
-                                        <li
-                                            v-for="(
-                                                hazard, index
-                                            ) in filteredHazards"
-                                            :key="index"
-                                        >
-                                            {{ hazard }}
-                                        </li>
-                                    </ul>
-                                    <div v-else class="element-card-empty">
-                                        暂无对应隐患
-                                    </div>
-                                </div>
-
                                 <div
                                 <div
                                     v-if="showScanningEffect"
                                     v-if="showScanningEffect"
                                     class="scanning-overlay"
                                     class="scanning-overlay"
@@ -461,169 +431,61 @@
                             </div>
                             </div>
                         </div>
                         </div>
 
 
-                        <div class="analysis-section">
-                            <div class="analysis-header">
-                                <div class="robot-avatar">
-                                    <img
-                                        src="@/assets/Hazard/21.png"
-                                        alt="蜀安AI助手"
-                                        class="robot-img"
-                                    />
-                                </div>
-                                <div class="header-title">
-                                    <div
-                                        v-if="showAnalysisPrompt"
-                                        class="analysis-prompt"
-                                    >
-                                        <div class="typing-indicator">
-                                            <span class="dot"></span>
-                                            <span class="dot"></span>
-                                            <span class="dot"></span>
-                                        </div>
-                                        <span class="prompt-text">蜀安AI助手正在为您智能分析图片,请稍候…</span>
-                                    </div>
-                                    <h3 v-else class="analysis-title">
-                                        蜀道安全管理AI智能助手慧眼识图分析出以下结果
-                                    </h3>
-                                </div>
-                            </div>
+                    </div>
 
 
-                            <div class="analysis-body" v-if="!showAnalysisPrompt">
-                                <div class="analysis-text">
-                                    <span
-                                        v-if="isStreamingAnalysis"
-                                        v-html="streamingAnalysis"
-                                        class="streaming-text"
-                                    ></span>
-                                    <span
-                                        v-else-if="
-                                            !isStreamingAnalysis &&
-                                            detectionResult
-                                        "
-                                    >
-                                        我识别到这是一个<span
-                                            class="scene-tag"
-                                            >{{
-                                                detectionResult?.scene_name
-                                                    ? scenarios[
-                                                          detectionResult
-                                                              .scene_name
-                                                      ]?.name
-                                                    : "未知场景"
-                                            }}</span
-                                        >场景,检测到的关键要素为<span
-                                            v-for="(label, index) in displayLabels"
-                                            :key="index"
-                                            class="label-tag"
-                                            >{{ label }}</span
-                                        >
-                                    </span>
+                    <!-- AI对话窗口占位 -->
+                    <div class="ai-chat-placeholder">
+                                <div class="chat-placeholder-content">
+                                    <div class="chat-icon">💬</div>
+                                    <div class="chat-text">AI对话功能即将上线</div>
+                                    <div class="chat-hint">敬请期待智能对话助手</div>
                                 </div>
                                 </div>
-                                <p
-                                    class="hazards-intro"
-                                    v-if="!isStreamingAnalysis && detectionResult"
-                                >
-                                    根据安全规范和施工标准,我为您梳理出以下需要重点关注的安全隐患
-                                </p>
+                            </div>
+                        </div>
 
 
-                                <div
-                                    v-if="
-                                        !isStreamingAnalysis &&
-                                        detectionResult &&
-                                        keyElements.length
-                                    "
-                                    class="key-elements-section"
-                                >
-                                    <div class="key-elements-title">关键要素</div>
-                                    <div class="key-elements-buttons">
-                                        <button
-                                            v-for="element in keyElements"
-                                            :key="element"
-                                            :class="[
-                                                'key-element-btn',
-                                                {
-                                                    active:
-                                                        selectedKeyElement ===
-                                                        element,
-                                                },
-                                            ]"
-                                            @click="toggleKeyElement(element)"
-                                        >
-                                            {{ element }}
-                                        </button>
-                                    </div>
-                                    <div
-                                        v-if="!selectedKeyElement"
-                                        class="key-elements-hint"
-                                    >
-                                        点击关键要素可查看对应隐患
-                                    </div>
+                        <!-- 右侧要素列表 -->
+                        <div class="elements-sidebar">
+                            <div class="sidebar-header">
+                                <h3>检测要素</h3>
+                                <span class="element-count">{{ keyElements.length }}</span>
+                            </div>
+                            <div class="sidebar-content">
+                                <div v-if="keyElements.length === 0" class="no-elements">
+                                    <div class="no-elements-icon">🔍</div>
+                                    <div class="no-elements-text">暂无检测要素</div>
                                 </div>
                                 </div>
-
-                                <div
-                                    class="hazards-section"
-                                    v-if="!showAnalysisPrompt"
-                                >
+                                <div v-else class="elements-list">
                                     <div
                                     <div
-                                        class="hazards-content"
-                                        :class="{
-                                            'scanning-mode': showScanningEffect,
-                                        }"
+                                        v-for="(element, index) in keyElements"
+                                        :key="index"
+                                        :class="['element-item', { 'element-active': selectedKeyElement === element, 'element-expanded': expandedElements[element] }]"
                                     >
                                     >
-                                        <div
-                                            v-if="showScanningEffect"
-                                            class="hazards-loading-overlay"
-                                        >
-                                            <div class="loading-spinner"></div>
-                                            <p>正在分析场景隐患...</p>
-                                        </div>
-
-                                        <div
-                                            v-else
-                                            class="hazard-cards-container"
-                                        >
-                                            <div
-                                                v-if="!filteredHazards.length"
-                                                class="hazard-empty"
-                                            >
-                                                暂无对应隐患
+                                        <div class="element-header" @click="toggleKeyElement(element)">
+                                            <div class="element-header-left">
+                                                <div class="element-number">{{ index + 1 }}</div>
+                                                <div class="element-name">{{ element }}</div>
                                             </div>
                                             </div>
-                                            <div
-                                                v-for="(
-                                                    hazard, index
-                                                ) in filteredHazards"
-                                                :key="index"
-                                                class="hazard-card"
-                                                :class="{
-                                                    show: visibleHazardCards[
-                                                        index
-                                                    ],
-                                                }"
-                                            >
-                                                <div class="hazard-number">
-                                                    {{ index + 1 }}
+                                            <div class="element-header-right">
+                                                <div class="element-hazard-count">
+                                                    {{ getElementHazards(element).length }} 项
                                                 </div>
                                                 </div>
-                                                <div class="hazard-text-container">
-                                                    <p class="hazard-desc">
-                                                        {{ hazard }}
-                                                    </p>
-                                                    <a
-                                                        href="javascript:void(0);"
-                                                        class="example-link"
-                                                        @click.prevent="
-                                                            openExampleModal({
-                                                                number:
-                                                                    index + 1,
-                                                                description:
-                                                                    hazard,
-                                                            })
-                                                        "
-                                                    >
-                                                        示例
-                                                    </a>
+                                                <div :class="['expand-arrow', { 'arrow-expanded': expandedElements[element] }]" @click.stop="toggleExpand(element)">
+                                                    ▶
                                                 </div>
                                                 </div>
                                             </div>
                                             </div>
                                         </div>
                                         </div>
+                                        <div v-if="expandedElements[element]" class="element-hazards-list">
+                                            <div v-if="getElementHazards(element).length === 0" class="no-hazards-hint">暂无对应隐患</div>
+                                            <div
+                                                v-for="(hazard, hIdx) in getElementHazards(element)"
+                                                :key="hIdx"
+                                                class="hazard-detail-item"
+                                                @click.stop="openExampleModal({ number: hIdx + 1, description: hazard })"
+                                            >
+                                                <span class="hazard-detail-num">{{ hIdx + 1 }}</span>
+                                                <span class="hazard-detail-text">{{ hazard }}</span>
+                                            </div>
+                                        </div>
                                     </div>
                                     </div>
                                 </div>
                                 </div>
                             </div>
                             </div>
@@ -1126,6 +988,8 @@ const isLoadingDetail = ref(false);
 const isImageLoading = ref(false);
 const isImageLoading = ref(false);
 const isLoadingHistory = ref(false);
 const isLoadingHistory = ref(false);
 
 
+const expandedElements = ref({});
+
 const showEvaluationModal = ref(false);
 const showEvaluationModal = ref(false);
 const evaluationData = ref({
 const evaluationData = ref({
     sceneMatch: null,
     sceneMatch: null,
@@ -1309,6 +1173,20 @@ const selectedDetection = computed(() => {
     );
     );
 });
 });
 
 
+const getElementHazards = (element) => {
+    if (!detectionResult.value) return [];
+    const backendHazards = detectionResult.value?.element_hazards?.[element];
+    if (Array.isArray(backendHazards)) return backendHazards;
+    return hazardsMap.value[element] || [];
+};
+
+const toggleExpand = (element) => {
+    expandedElements.value = {
+        ...expandedElements.value,
+        [element]: !expandedElements.value[element],
+    };
+};
+
 const resetKeyElementState = () => {
 const resetKeyElementState = () => {
     selectedKeyElement.value = null;
     selectedKeyElement.value = null;
     elementOverlayStyle.value = null;
     elementOverlayStyle.value = null;
@@ -1320,6 +1198,8 @@ const toggleKeyElement = async (element) => {
     } else {
     } else {
         selectedKeyElement.value = element;
         selectedKeyElement.value = element;
     }
     }
+    // Toggle expand in sidebar
+    toggleExpand(element);
     await nextTick();
     await nextTick();
     updateElementOverlayPosition();
     updateElementOverlayPosition();
 };
 };
@@ -2603,7 +2483,14 @@ const getDetectionBoxStyle = (detection) => {
 const handleDetectionBoxClick = (detection) => {
 const handleDetectionBoxClick = (detection) => {
     const label = normalizeLabel(detection?.label || '');
     const label = normalizeLabel(detection?.label || '');
     if (label) {
     if (label) {
-        toggleKeyElement(label);
+        // Select/deselect the element
+        if (selectedKeyElement.value === label) {
+            selectedKeyElement.value = null;
+        } else {
+            selectedKeyElement.value = label;
+        }
+        // Toggle expand in sidebar
+        toggleExpand(label);
     }
     }
 };
 };
 
 
@@ -3323,9 +3210,9 @@ onBeforeUnmount(() => {
 
 
 .detail-view .detail-content {
 .detail-view .detail-content {
     padding: 1rem;
     padding: 1rem;
-    max-width: 56rem;
-    width: 56rem;
-    margin: 0 auto;
+    max-width: 100%;
+    width: 100%;
+    margin: 0;
     position: relative;
     position: relative;
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
@@ -3394,7 +3281,8 @@ onBeforeUnmount(() => {
 .detail-view .detail-content .image-section .image-container {
 .detail-view .detail-content .image-section .image-container {
     position: relative;
     position: relative;
     width: 100%;
     width: 100%;
-    height: 350px;
+    height: calc(100vh - 380px);
+    min-height: 400px;
     border-radius: 0.5rem;
     border-radius: 0.5rem;
     box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
     box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
     border: 2px dashed #86efac;
     border: 2px dashed #86efac;
@@ -4123,4 +4011,328 @@ body > .evaluation-modal-overlay {
     padding: 0 !important;
     padding: 0 !important;
     box-sizing: border-box !important;
     box-sizing: border-box !important;
 }
 }
+
+/* 新布局样式 */
+.detail-content-wrapper {
+    display: flex;
+    gap: 20px;
+    flex: 1;
+    overflow: hidden;
+    padding: 0 24px 24px 24px;
+}
+
+.detail-main-area {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    overflow: hidden;
+}
+
+.detail-main-area .detail-content {
+    flex: 1;
+    overflow-y: auto;
+    margin-bottom: 0;
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+}
+
+.detail-main-area .detail-content::-webkit-scrollbar {
+    display: none;
+}
+
+/* AI对话窗口占位 */
+.ai-chat-placeholder {
+    background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
+    border-radius: 12px;
+    border: 2px dashed #3b82f6;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100px;
+    flex-shrink: 0;
+    margin-top: 12px;
+}
+
+.chat-placeholder-content {
+    text-align: center;
+    padding: 20px;
+}
+
+.chat-icon {
+    font-size: 48px;
+    margin-bottom: 12px;
+    animation: pulse 2s ease-in-out infinite;
+}
+
+.chat-text {
+    font-size: 16px;
+    font-weight: 600;
+    color: #1e40af;
+    margin-bottom: 8px;
+}
+
+.chat-hint {
+    font-size: 14px;
+    color: #64748b;
+}
+
+@keyframes pulse {
+    0%, 100% {
+        transform: scale(1);
+        opacity: 1;
+    }
+    50% {
+        transform: scale(1.1);
+        opacity: 0.8;
+    }
+}
+
+/* 右侧要素列表 */
+.elements-sidebar {
+    width: 280px;
+    background: white;
+    border-radius: 12px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+}
+
+.sidebar-header {
+    padding: 16px 20px;
+    border-bottom: 1px solid #e5e7eb;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
+}
+
+.sidebar-header h3 {
+    font-size: 16px;
+    font-weight: 600;
+    color: #1f2937;
+    margin: 0;
+}
+
+.element-count {
+    background: #3b82f6;
+    color: white;
+    padding: 2px 8px;
+    border-radius: 12px;
+    font-size: 12px;
+    font-weight: 600;
+}
+
+.sidebar-content {
+    flex: 1;
+    overflow-y: auto;
+    padding: 12px;
+    -ms-overflow-style: none;
+    scrollbar-width: none;
+}
+
+.sidebar-content::-webkit-scrollbar {
+    display: none;
+}
+
+.no-elements {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 40px 20px;
+    text-align: center;
+}
+
+.no-elements-icon {
+    font-size: 48px;
+    margin-bottom: 12px;
+    opacity: 0.5;
+}
+
+.no-elements-text {
+    font-size: 14px;
+    color: #6b7280;
+}
+
+.elements-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+.element-item {
+    background: #f9fafb;
+    border: 1px solid #e5e7eb;
+    border-radius: 8px;
+    padding: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+}
+
+.element-item:hover {
+    background: #eff6ff;
+    border-color: #3b82f6;
+    transform: translateX(4px);
+}
+
+.element-item.element-active {
+    background: #dbeafe;
+    border-color: #3b82f6;
+    box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
+}
+
+.element-number {
+    display: inline-block;
+    width: 20px;
+    height: 20px;
+    background: #3b82f6;
+    color: white;
+    border-radius: 50%;
+    text-align: center;
+    line-height: 20px;
+    font-size: 12px;
+    font-weight: 600;
+    margin-bottom: 8px;
+}
+
+.element-active .element-number {
+    background: #1e40af;
+}
+
+.element-name {
+    font-size: 14px;
+    font-weight: 600;
+    color: #1f2937;
+    margin-bottom: 4px;
+}
+
+.element-item {
+    padding: 0;
+    overflow: hidden;
+}
+
+.element-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px;
+    cursor: pointer;
+}
+
+.element-header-left {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex: 1;
+    min-width: 0;
+}
+
+.element-header-right {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    flex-shrink: 0;
+}
+
+.element-number {
+    margin-bottom: 0;
+}
+
+.element-name {
+    margin-bottom: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+
+.element-hazard-count {
+    font-size: 12px;
+    color: #6b7280;
+    white-space: nowrap;
+}
+
+.element-active .element-hazard-count {
+    color: #3b82f6;
+    font-weight: 500;
+}
+
+.expand-arrow {
+    font-size: 10px;
+    color: #9ca3af;
+    transition: transform 0.3s ease;
+    cursor: pointer;
+    padding: 4px;
+    border-radius: 4px;
+}
+
+.expand-arrow:hover {
+    color: #3b82f6;
+    background: rgba(59, 130, 246, 0.1);
+}
+
+.expand-arrow.arrow-expanded {
+    transform: rotate(90deg);
+    color: #3b82f6;
+}
+
+.element-hazards-list {
+    border-top: 1px solid #e5e7eb;
+    padding: 8px 12px;
+    background: #ffffff;
+}
+
+.no-hazards-hint {
+    font-size: 12px;
+    color: #9ca3af;
+    text-align: center;
+    padding: 8px 0;
+}
+
+.hazard-detail-item {
+    display: flex;
+    align-items: flex-start;
+    gap: 8px;
+    padding: 6px 8px;
+    border-radius: 6px;
+    cursor: pointer;
+    transition: background 0.2s ease;
+    margin-bottom: 4px;
+}
+
+.hazard-detail-item:last-child {
+    margin-bottom: 0;
+}
+
+.hazard-detail-item:hover {
+    background: #eff6ff;
+}
+
+.hazard-detail-num {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 18px;
+    height: 18px;
+    min-width: 18px;
+    background: #e5e7eb;
+    color: #6b7280;
+    border-radius: 50%;
+    font-size: 10px;
+    font-weight: 600;
+    margin-top: 2px;
+}
+
+.hazard-detail-text {
+    font-size: 12px;
+    color: #374151;
+    line-height: 1.5;
+    word-break: break-all;
+}
+
+.element-expanded {
+    border-color: #93c5fd;
+    background: #f0f9ff;
+}
 </style>
 </style>

+ 0 - 32
shudao-vue-frontend/src/views/SafetyHazard.vue

@@ -52,12 +52,6 @@
           @click="() => handleHistoryItemClick(item, index)"
           @click="() => handleHistoryItemClick(item, index)"
           :style="{ cursor: (item.isActive || isProcessing) ? 'default' : 'pointer' }">
           :style="{ cursor: (item.isActive || isProcessing) ? 'default' : 'pointer' }">
 
 
-          <div class="history-icon">
-
-            <img :src="getHistoryImage(item)" alt="培训图标" class="history-icon-img">
-
-          </div>
-
           <div class="history-content">
           <div class="history-content">
 
 
             <div class="history-title">{{ item.title }}</div>
             <div class="history-title">{{ item.title }}</div>
@@ -25838,32 +25832,6 @@ html {
 
 
 
 
 
 
-      .history-icon {
-
-        width: 80px;
-
-        height: 60px;
-
-        flex-shrink: 0;
-
-
-
-        .history-icon-img {
-
-          width: 100%;
-
-          height: 100%;
-
-          object-fit: contain;
-
-          border-radius: 4px;
-
-        }
-
-      }
-
-
-
       .history-content {
       .history-content {
 
 
         flex: 1;
         flex: 1;