FanHong 1 неделя назад
Родитель
Сommit
459cc38609

+ 280 - 41
shudao-vue-frontend/src/views/Chat.vue

@@ -65,8 +65,13 @@
         <h2 v-else class="default-title">AI问答</h2>
       </div>
 
+      <!-- 考试工坊内容区域 -->
+      <div v-if="currentMode === 'exam-workshop'" class="exam-workshop-wrapper">
+        <ExamWorkshop :hideSidebar="true" />
+      </div>
+
       <!-- 聊天内容区域 -->
-      <div class="chat-content">
+      <div v-else class="chat-content">
         <!-- 初始状态:AI助手介绍和功能卡片 -->
         <div v-if="!showChat" class="initial-content">
           <!-- AI助手介绍 -->
@@ -294,9 +299,23 @@
                       <WebSearchSummary :summary="message.webSearchSummary" />
                     </div>
                     
+                    <!-- 安全培训文档卡片 -->
+                    <div v-if="message.isDocument" class="safety-doc-card" @click="handleSafetyDocClick(message)">
+                      <div class="doc-icon-wrapper">
+                        <img src="../assets/Chat/26.png" alt="文档" class="doc-icon">
+                      </div>
+                      <div class="doc-info">
+                        <div class="doc-title">{{ currentQuestion || '安全培训文档' }}</div>
+                        <div class="doc-time">刚刚生成</div>
+                      </div>
+                      <div class="doc-action">
+                        查看详情 >
+                      </div>
+                    </div>
+
                     <!-- 原有的AI文本内容(如果没有报告数据时显示) -->
                     <div v-if="!message.reports || message.reports.length === 0" class="ai-text">
-                    <div v-if="message.displayContent && message.displayContent.length > 0" class="ai-markdown-content">
+                    <div v-if="message.displayContent && message.displayContent.length > 0 && !message.isDocument" class="ai-markdown-content">
                       <div v-html="message.displayContent"></div>
                     </div>
                   </div>
@@ -489,7 +508,9 @@
                 <!-- 模式标签组 -->
                 <div class="mode-chips">
                   <div 
+                    v-if="currentMode === 'ai-qa' || currentMode === 'ai-writing'"
                     class="mode-chip" 
+                    :class="{ active: currentMode === 'ai-writing' }"
                     @click="setMode('ai-writing')"
                   >
                     <img src="../assets/chat/13.png" alt="AI写作" class="chip-icon">
@@ -497,7 +518,9 @@
                   </div>
                   
                   <div 
+                    v-if="currentMode === 'ai-qa' || currentMode === 'safety-training'"
                     class="mode-chip" 
+                    :class="{ active: currentMode === 'safety-training' }"
                     @click="setMode('safety-training')"
                   >
                     <img src="../assets/chat/14.png" alt="安全培训" class="chip-icon">
@@ -505,7 +528,9 @@
                   </div>
 
                   <div 
+                    v-if="currentMode === 'ai-qa' || currentMode === 'exam-workshop'"
                     class="mode-chip" 
+                    :class="{ active: currentMode === 'exam-workshop' }"
                     @click="setMode('exam-workshop')"
                   >
                     <img src="../assets/chat/19.png" alt="考试工坊" class="chip-icon">
@@ -606,6 +631,7 @@
 import { ref, nextTick, reactive, triggerRef, markRaw, onMounted, onBeforeUnmount, computed, watch, onActivated } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import Sidebar from '@/components/Sidebar.vue'
+import ExamWorkshop from '@/views/ExamWorkshop.vue'
 import * as mammoth from 'mammoth'
 
 // 导入Element Plus组件
@@ -685,14 +711,15 @@ const toggleModelType = () => {
   showToast(isOnlineModel.value ? '已切换至在线大模型 (glm-4-plus)' : '已切换至本地模型')
 }
 
-// 跳转到对应模块
+// 当前激活的模块模式:'ai-qa', 'ai-writing', 'safety-training', 'exam-workshop'
+const currentMode = ref('ai-qa')
+
+// 切换到对应模块
 const setMode = (mode) => {
-  if (mode === 'ai-writing') {
-    router.push('/ai-writing')
-  } else if (mode === 'safety-training') {
-    router.push('/safety-hazard')
-  } else if (mode === 'exam-workshop') {
-    router.push('/exam-workshop')
+  if (currentMode.value === mode) {
+    currentMode.value = 'ai-qa' // 再次点击取消选中,回到默认问答
+  } else {
+    currentMode.value = mode
   }
 }
 
@@ -1182,30 +1209,45 @@ const historyData = ref([])
 const historyTotal = ref(0) // 历史记录总数
 const isLoadingHistory = ref(false) // 是否正在加载历史记录
 
-// 获取历史记录列表
+// 获取历史记录列表 (合并所有模块)
 const getHistoryRecordList = async () => {
   try {
     isLoadingHistory.value = true
     
-    const response = await apis.getHistoryRecord({ 
-      // ===== 已删除:user_id - 后端从token解析 =====
-      ai_conversation_id: 0,
-      business_type: 0
+    // 分别请求四种业务类型的历史记录
+    const businessTypes = [0, 1, 2, 3] // 0:AI问答, 1:安全培训, 2:AI写作, 3:考试工坊
+    
+    const requests = businessTypes.map(type => 
+      apis.getHistoryRecord({ 
+        ai_conversation_id: 0,
+        business_type: type
+      })
+    )
+    
+    const responses = await Promise.all(requests)
+    
+    let allRecords = []
+    let totalCount = 0
+    
+    responses.forEach(response => {
+      if (response.statusCode === 200 && response.data) {
+        totalCount += response.total || response.data.length || 0
+        allRecords = allRecords.concat(response.data)
+      }
     })
     
-    if (response.statusCode === 200) {
-      historyTotal.value = response.total || 0
-      historyData.value = response.data.map(conversation => ({
-        id: conversation.id,
-        title: conversation.title || generateConversationTitle(conversation.content),
-        time: formatTime(conversation.updated_at),
-        businessType: conversation.business_type,
-        isActive: false,
-        rawData: conversation
-      }))
-    } else {
-      console.error('获取历史记录失败:', response.statusCode)
-    }
+    // 按时间降序排序
+    allRecords.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at))
+    
+    historyTotal.value = totalCount
+    historyData.value = allRecords.map(conversation => ({
+      id: conversation.id,
+      title: conversation.title || generateConversationTitle(conversation.content),
+      time: formatTime(conversation.updated_at),
+      businessType: conversation.business_type,
+      isActive: false,
+      rawData: conversation
+    }))
   } catch (error) {
     console.error('获取历史记录失败:', error)
   } finally {
@@ -1609,28 +1651,114 @@ const clearNewConversationState = () => {
 const handleSendMessage = async () => {
   if (!messageText.value.trim() || isSending.value) return
     
-    clearRecommendQuestions()
-    aiRelatedQuestions.value = []
-    relatedQuestionsMessageId.value = null
-    isSending.value = true
-    showChat.value = true
+  clearRecommendQuestions()
+  aiRelatedQuestions.value = []
+  relatedQuestionsMessageId.value = null
+  isSending.value = true
+  showChat.value = true
     
   clearNewConversationState()
   
+  if (currentMode.value === 'ai-writing' || currentMode.value === 'safety-training') {
+    await handleNonStreamingSubmit({
+      question: messageText.value,
+      businessType: currentMode.value === 'ai-writing' ? 2 : 1
+    })
+  } else {
     await handleReportGeneratorSubmit({
       question: messageText.value,
       windowSize: 3,
       nResults: 10
     })
+  }
     
-    messageText.value = ''
-    clearRecommendQuestions()
-    
-    if (selectedFile.value) {
-      removeSelectedFile()
+  messageText.value = ''
+  clearRecommendQuestions()
+  
+  if (selectedFile.value) {
+    removeSelectedFile()
+  }
+  
+  scrollToBottom()
+}
+
+// 处理非流式请求 (AI写作 和 安全培训)
+const handleNonStreamingSubmit = async (data) => {
+  currentQuestion.value = data.question
+  
+  // 添加用户消息
+  chatMessages.value.push({
+    id: Date.now(),
+    type: 'user',
+    content: data.question,
+    timestamp: new Date().toISOString()
+  })
+  
+  // 添加AI消息占位符
+  const aiMessageIndex = chatMessages.value.length
+  chatMessages.value.push({
+    id: Date.now() + 1,
+    type: 'ai',
+    userQuestion: data.question,
+    isTyping: true, // 显示 loading 状态
+    content: '',
+    displayContent: '',
+    timestamp: new Date().toISOString(),
+    reports: []
+  })
+  
+  try {
+    const response = await apis.sendDeepseekMessage({
+      user_question: data.question,
+      business_type: data.businessType,
+      enable_online_model: isOnlineModel.value,
+      ai_conversation_id: ai_conversation_id.value
+    })
+
+    if (response.statusCode === 200) {
+      const aiMessage = chatMessages.value[aiMessageIndex]
+      aiMessage.isTyping = false
+      aiMessage.content = response.data
+      
+      // 更新 conversation ID
+      if (response.ai_conversation_id) {
+        ai_conversation_id.value = response.ai_conversation_id
+      }
+      
+      // 添加打字机效果显示
+      if (data.businessType === 1) {
+        // 安全培训: 只展示一个输出文档
+        aiMessage.isDocument = true
+        aiMessage.displayContent = `<div class="safety-training-doc-card">
+          <div class="doc-header">
+            <span class="doc-icon">📄</span>
+            <span class="doc-title">安全培训生成文档</span>
+          </div>
+          <div class="doc-actions">
+            <button class="view-doc-btn">查看详情</button>
+          </div>
+        </div>`
+        // 实际内容保存起来,点击查看详情时可以使用
+        aiMessage.fullContent = response.data
+      } else {
+        // AI写作等: 正常打字机输出
+        startTypewriterEffect(aiMessage, response.data, 30)
+      }
+      
+      // 刷新历史记录
+      getHistoryRecordList()
+    } else {
+      throw new Error(response.msg || '请求失败')
     }
-    
-    scrollToBottom()
+  } catch (error) {
+    console.error('发送请求失败:', error)
+    const aiMessage = chatMessages.value[aiMessageIndex]
+    aiMessage.isTyping = false
+    aiMessage.displayContent = '抱歉,生成内容时发生错误,请重试。'
+    ElMessage.error(`请求失败: ${error.message}`)
+  } finally {
+    isSending.value = false
+  }
 }
 // 功能卡片图标计数器
 let functionCardIconIndex = 0
@@ -1673,6 +1801,15 @@ const isCategoryExpanded = (messageIndex, category) => {
   return categoryExpandStates.value[messageIndex][category] !== false
 }
 
+// 处理安全培训文档点击
+const handleSafetyDocClick = (message) => {
+  if (message.rawData && message.rawData.ai_conversation_id) {
+    router.push({ path: '/safety-hazard', query: { id: message.rawData.ai_conversation_id } })
+  } else if (ai_conversation_id.value) {
+    router.push({ path: '/safety-hazard', query: { id: ai_conversation_id.value } })
+  }
+}
+
 // 检查reports数组是否只包含分类标题,没有实际报告
 const hasOnlyCategoryTitles = (reports) => {
   if (!reports || reports.length === 0) return false
@@ -2691,6 +2828,18 @@ const handleHistoryItem = async (historyItem) => {
     
     if (success) {
       chatMessages.value = chatMessages.value.filter(msg => msg.id !== loadingMessage.id)
+      
+      // 根据历史记录的业务类型切换当前模式
+      if (historyItem.businessType === 2) {
+        currentMode.value = 'ai-writing'
+      } else if (historyItem.businessType === 1) {
+        currentMode.value = 'safety-training'
+      } else if (historyItem.businessType === 3) {
+        currentMode.value = 'exam-workshop'
+      } else {
+        currentMode.value = 'ai-qa'
+      }
+      
       await nextTick()
       scrollToBottom()
       
@@ -3347,7 +3496,12 @@ const removeSelectedFile = () => {
 const getFunctionCards = async () => {
   try {
     console.log('开始获取功能卡片...')
-    const response = await apis.getFunctionCard({ function_type: 0 }) // 0为AI问答类型
+    let functionType = 0; // AI问答
+    if (currentMode.value === 'ai-writing') functionType = 2;
+    if (currentMode.value === 'safety-training') functionType = 1;
+    if (currentMode.value === 'exam-workshop') functionType = 3;
+
+    const response = await apis.getFunctionCard({ function_type: functionType })
     console.log('功能卡片响应:', response)
     
     if (response.statusCode === 200) {
@@ -3365,7 +3519,12 @@ const getFunctionCards = async () => {
 const getHotQuestions = async () => {
   try {
     console.log('开始获取热点问题...')
-    const response = await apis.getHotQuestion({ question_type: 0 }) // 0为AI问答类型
+    let questionType = 0; // AI问答
+    if (currentMode.value === 'ai-writing') questionType = 2;
+    if (currentMode.value === 'safety-training') questionType = 1;
+    if (currentMode.value === 'exam-workshop') questionType = 3;
+
+    const response = await apis.getHotQuestion({ question_type: questionType })
     console.log('热点问题响应:', response)
     
     if (response.statusCode === 200) {
@@ -3379,6 +3538,12 @@ const getHotQuestions = async () => {
   }
 }
 
+// 监听模式变化,重新获取卡片和问题
+watch(currentMode, () => {
+  getFunctionCards()
+  getHotQuestions()
+})
+
 // 清除推荐问题
 const clearRecommendQuestions = () => {
   userRecommendQuestions.value = []
@@ -4823,6 +4988,21 @@ onActivated(async () => {
 
 
 /* 主聊天区域 */
+.embedded-exam-workshop {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  height: 100vh;
+}
+
+.exam-workshop-wrapper {
+  flex: 1;
+  overflow-y: auto;
+  position: relative;
+  background: #f5f7fb;
+}
+
 .main-chat {
   flex: 1;
   background: #EBF3FF;
@@ -5632,6 +5812,65 @@ onActivated(async () => {
         }
       }
       
+      .safety-doc-card {
+        display: flex;
+        align-items: center;
+        background: white;
+        border: 1px solid #e2e8f0;
+        border-radius: 12px;
+        padding: 16px 20px;
+        margin-bottom: 16px;
+        cursor: pointer;
+        transition: all 0.3s ease;
+        box-shadow: 0 2px 8px rgba(0,0,0,0.04);
+        max-width: 400px;
+
+        &:hover {
+          box-shadow: 0 4px 12px rgba(0,0,0,0.08);
+          transform: translateY(-2px);
+          border-color: #3e7bfa;
+        }
+
+        .doc-icon-wrapper {
+          width: 48px;
+          height: 48px;
+          background: #f0f5ff;
+          border-radius: 10px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          margin-right: 16px;
+
+          .doc-icon {
+            width: 24px;
+            height: 24px;
+            object-fit: contain;
+          }
+        }
+
+        .doc-info {
+          flex: 1;
+
+          .doc-title {
+            font-size: 16px;
+            font-weight: 600;
+            color: #1f2937;
+            margin-bottom: 4px;
+          }
+
+          .doc-time {
+            font-size: 12px;
+            color: #9ca3af;
+          }
+        }
+
+        .doc-action {
+          font-size: 14px;
+          color: #3e7bfa;
+          font-weight: 500;
+        }
+      }
+      
       .ai-markdown-content {
         /* Markdown样式 - 与StreamMarkdown保持一致 */
         :deep(h2) {

+ 10 - 3
shudao-vue-frontend/src/views/ExamWorkshop.vue

@@ -1,10 +1,10 @@
 <template>
   <div class="chat-container">
     <!-- 最左侧边栏 -->
-    <Sidebar />
+    <Sidebar v-if="!hideSidebar" />
 
     <!-- 中间历史记录区域 -->
-    <div class="history-sidebar">
+    <div class="history-sidebar" v-if="!hideSidebar">
       <div class="history-header">
         <span class="section-title">历史记录</span>
         <img
@@ -676,9 +676,16 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
+import { ref, computed, onMounted, onUnmounted, reactive, watch, defineProps } from "vue";
 import Sidebar from "@/components/Sidebar.vue";
 import DeleteConfirmModal from "@/components/DeleteConfirmModal.vue";
+
+const props = defineProps({
+  hideSidebar: {
+    type: Boolean,
+    default: false
+  }
+});
 import { apis } from '@/request/apis.js'
 import { ElMessage } from 'element-plus'
 // ===== 已删除:getUserId - 不再需要,改用token =====

+ 26 - 2
shudao-vue-frontend/src/views/SafetyHazard.vue

@@ -2067,7 +2067,7 @@
 <script setup>
 
 import { ref, computed, onMounted, onUnmounted, reactive, nextTick, watch } from 'vue'
-import { useRouter } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 
 import Sidebar from '@/components/Sidebar.vue'
 
@@ -2214,6 +2214,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
 
 // 路由
 const router = useRouter()
+const route = useRoute()
 
 // 响应式数据
 
@@ -11355,7 +11356,30 @@ onMounted(async () => {
 
   console.log('🚀 页面初始化开始,优先加载历史记录...')
 
-
+  // 检查URL中是否有id参数,如果有,将其设置为当前的 ai_conversation_id
+  if (route.query.id) {
+    const id = parseInt(route.query.id);
+    if (!isNaN(id) && id > 0) {
+      ai_conversation_id.value = id;
+      console.log('从URL获取到对话ID:', id);
+      
+      // 等待历史记录加载完成后,自动选中并触发点击事件
+      const checkHistory = setInterval(() => {
+        if (historyData.value && historyData.value.length > 0) {
+          clearInterval(checkHistory);
+          const targetItem = historyData.value.find(item => item.id === id);
+          if (targetItem) {
+            handleHistoryItem(targetItem);
+          } else {
+            handleHistoryItem({ id }); // 降级处理
+          }
+        }
+      }, 500);
+      
+      // 5秒后自动清除定时器,避免死循环
+      setTimeout(() => clearInterval(checkHistory), 5000);
+    }
+  }
 
   // 设置初始加载状态