Răsfoiți Sursa

Merge dev into test1

Cline 4 zile în urmă
părinte
comite
62402e3664

+ 4 - 0
.gitignore

@@ -31,6 +31,10 @@ shudao-go-backend/shudao-go-backend
 shudao-go-backend/shudao-go-backend.tar.gz
 shudao-go-backend/shudao-chat-go
 shudao-go-backend/shudao-chat-go.tar.gz
+shudao-go-backend/run.bat
+shudao-vue-frontend/run.bat
+
+
 
 # Backend Copied Assets (from frontend build)
 shudao-go-backend/assets/

+ 2 - 0
shudao-go-backend/conf/app.conf

@@ -28,6 +28,8 @@ heartbeat_api_url = http://127.0.0.1:24000/api/health
 
 # Token验证API (认证网关) - 本地环境
 auth_api_url = http://127.0.0.1:28004/api/auth/verify
+# AI问答服务代理地址
+aichat_api_url = http://127.0.0.1:28002/api/v1
 
 # ==================== OSS存储配置 ====================
 oss_access_key_id = fnyfi2f368pbic74d8ll

+ 2 - 0
shudao-go-backend/conf/app.conf.prod

@@ -28,6 +28,8 @@ heartbeat_api_url = http://127.0.0.1:24000/api/health
 
 # Token验证API (认证网关) - 生产环境
 auth_api_url = http://127.0.0.1:28004/api/auth/verify
+# AI问答服务代理地址
+aichat_api_url = http://127.0.0.1:28002/api/v1
 
 # ==================== OSS存储配置 ====================
 oss_access_key_id = fnyfi2f368pbic74d8ll

+ 2 - 0
shudao-go-backend/conf/app.conf.test

@@ -28,6 +28,8 @@ heartbeat_api_url = http://127.0.0.1:24000/api/health
 
 # Token验证API (认证网关) - 测试环境
 auth_api_url = http://127.0.0.1:28004/api/auth/verify
+# AI问答服务代理地址
+aichat_api_url = http://127.0.0.1:28002/api/v1
 
 # ==================== OSS存储配置 ====================
 oss_access_key_id = fnyfi2f368pbic74d8ll

+ 40 - 3
shudao-go-backend/controllers/chat.go

@@ -1,4 +1,4 @@
-// Package controllers - chat.go
+// Package controllers - chat.go
 //
 // ⚠️ DEPRECATED NOTICE (弃用说明)
 // ================================================================================
@@ -1327,8 +1327,45 @@ func (c *ChatController) GuessYouWant() {
 
 	userMessage := requestData.Message
 
-	// 构建带有专业问题判断规则的提示词
-	promptWithRules := fmt.Sprintf(``, userMessage)
+	promptWithRules := fmt.Sprintf(`你是蜀道安全管理AI智能助手,请根据用户的问题生成3个相关的后续问题建议(猜你想问)。
+
+## 用户问题
+%s
+
+## 生成问题规则(最高优先级)
+1. 严禁生成任何政治敏感信息,包含重要国家领导人,重要国际事件等
+2. 严禁在生成的问题中包含人名信息,任何人名都不行
+3. 严禁生成色情敏感信息
+4. 严禁生成超长文本,最多只能30个字
+
+## 你的回答(仅输出3个问题,每行一个,或返回空)`, userMessage)
+>>>>>>> origin/dev
+	promptWithRules := fmt.Sprintf(`你是蜀道安全管理AI智能助手,请根据用户的问题生成3个相关的后续问题建议(猜你想问)。
+
+## 用户问题
+%s
+
+## 生成问题规则(最高优先级)
+1. 严禁生成任何政治敏感信息,包含重要国家领导人,重要国际事件等
+2. 严禁在生成的问题中包含人名信息,任何人名都不行
+3. 严禁生成色情敏感信息
+4. 严禁生成超长文本,最多只能30个字
+
+## 你的回答(仅输出3个问题,每行一个,或返回空)`, userMessage)
+=======
+	promptWithRules := fmt.Sprintf(`你是蜀道安全管理AI智能助手,请根据用户的问题生成3个相关的后续问题建议(猜你想问)。
+
+## 用户问题
+%s
+
+## 生成问题规则(最高优先级)
+1. 严禁生成任何政治敏感信息,包含重要国家领导人,重要国际事件等
+2. 严禁在生成的问题中包含人名信息,任何人名都不行
+3. 严禁生成色情敏感信息
+4. 严禁生成超长文本,最多只能30个字
+
+## 你的回答(仅输出3个问题,每行一个,或返回空)`, userMessage)
+>>>>>>> origin/dev
 
 	// 使用阿里大模型替代DeepSeek
 	reply, err := c.sendQwen3Message(promptWithRules, false)

+ 31 - 10
shudao-go-backend/controllers/hazard.go

@@ -330,18 +330,12 @@ func (c *HazardController) Hazard() {
 
 	//获取labels对应的二级场景和三级场景
 	var thirdSceneNames []string
-	// 判断是否为高速公路场景
-	isHighwayScene := strings.Contains(scene.SceneName, "运营高速公路")
-	//隧道和简支梁
-	isTunnelScene := strings.Contains(scene.SceneName, "隧道")
-	isSimplySupportedBeamScene := strings.Contains(scene.SceneName, "简支梁")
+	var displayLabels []string
+	elementHazards := make(map[string][]string)
 	fmt.Println("yoloResp.Labels", yoloResp.Labels)
 	for _, label := range yoloResp.Labels {
-		// 处理标签:对于高速公路场景,需要去掉前缀
-		processedLabel := label
-		if isHighwayScene || isTunnelScene || isSimplySupportedBeamScene {
-			processedLabel = processHighwayLabel(label)
-		}
+		processedLabel := normalizeSceneLabel(label, scene.SceneName)
+		displayLabels = append(displayLabels, processedLabel)
 
 		var secondScene models.SecondScene
 		models.DB.Where("second_scene_name = ? and is_deleted = ?", processedLabel, 0).First(&secondScene)
@@ -355,15 +349,23 @@ func (c *HazardController) Hazard() {
 			var thirdScene []models.ThirdScene
 			models.DB.Where("second_scene_id = ? and is_deleted = ?", secondScene.ID, 0).Find(&thirdScene)
 			if len(thirdScene) > 0 {
+				var currentElementHazards []string
 				for _, thirdScene := range thirdScene {
 					thirdSceneNames = append(thirdSceneNames, thirdScene.ThirdSceneName)
+					currentElementHazards = append(currentElementHazards, thirdScene.ThirdSceneName)
 				}
+				elementHazards[processedLabel] = removeDuplicates(
+					append(elementHazards[processedLabel], currentElementHazards...),
+				)
+			} else if _, exists := elementHazards[processedLabel]; !exists {
+				elementHazards[processedLabel] = []string{}
 			}
 		}
 	}
 
 	//对thirdSceneNames去重
 	thirdSceneNames = removeDuplicates(thirdSceneNames)
+	displayLabels = removeDuplicates(displayLabels)
 
 	// 将三级场景名称数组更新到recognitionRecord的Description
 	if len(thirdSceneNames) > 0 {
@@ -386,7 +388,9 @@ func (c *HazardController) Hazard() {
 			"original_image":   requestData.Image,
 			"annotated_image":  annotatedImageURL, //前端用这个预链接渲染
 			"labels":           strings.Join(yoloResp.Labels, ", "),
+			"display_labels":   displayLabels,
 			"third_scenes":     thirdSceneNames, //三级场景名称数组
+			"element_hazards":  elementHazards,
 		},
 	}
 	c.ServeJSON()
@@ -949,3 +953,20 @@ func processHighwayLabel(label string) string {
 	// 返回从下划线之后的内容
 	return label[underscoreIndex+1:]
 }
+
+func normalizeSceneLabel(label, sceneName string) string {
+	if shouldTrimSceneLabelPrefix(sceneName) {
+		return processHighwayLabel(label)
+	}
+	return label
+}
+
+func shouldTrimSceneLabelPrefix(sceneName string) bool {
+	return strings.Contains(sceneName, "运营高速公路") ||
+		strings.Contains(sceneName, "operate_highway") ||
+		strings.Contains(sceneName, "隧道") ||
+		strings.Contains(sceneName, "tunnel") ||
+		strings.Contains(sceneName, "简支梁") ||
+		strings.Contains(sceneName, "桥梁") ||
+		strings.Contains(sceneName, "simple_supported_bridge")
+}

+ 448 - 0
shudao-go-backend/controllers/report_compat.go

@@ -0,0 +1,448 @@
+package controllers
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"shudao-chat-go/models"
+	"shudao-chat-go/utils"
+
+	beego "github.com/beego/beego/v2/server/web"
+)
+
+// ReportCompatController keeps the old AI chat frontend contract working
+// while routing the request to the service that actually implements it.
+type ReportCompatController struct {
+	beego.Controller
+}
+
+type reportCompleteFlowRequest struct {
+	UserQuestion           string `json:"user_question"`
+	WindowSize             int    `json:"window_size"`
+	NResults               int    `json:"n_results"`
+	AIConversationID       uint64 `json:"ai_conversation_id"`
+	IsNetworkSearchEnabled bool   `json:"is_network_search_enabled"`
+	EnableOnlineModel      bool   `json:"enable_online_model"`
+}
+
+type updateAIMessageRequest struct {
+	AIMessageID uint64 `json:"ai_message_id"`
+	Content     string `json:"content"`
+}
+
+type stopSSERequest struct {
+	AIConversationID uint64 `json:"ai_conversation_id"`
+}
+
+type streamChatAggregateResult struct {
+	AIConversationID uint64
+	AIMessageID      uint64
+	Content          string
+}
+
+func (c *ReportCompatController) CompleteFlow() {
+	c.setSSEHeaders()
+
+	var requestData reportCompleteFlowRequest
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &requestData); err != nil {
+		c.writeSSEJSON(map[string]interface{}{
+			"type":    "online_error",
+			"message": fmt.Sprintf("请求参数解析失败: %s", err.Error()),
+		})
+		c.writeSSEJSON(map[string]interface{}{"type": "completed"})
+		return
+	}
+
+	userQuestion := strings.TrimSpace(requestData.UserQuestion)
+	if userQuestion == "" {
+		c.writeSSEJSON(map[string]interface{}{
+			"type":    "online_error",
+			"message": "问题不能为空",
+		})
+		c.writeSSEJSON(map[string]interface{}{"type": "completed"})
+		return
+	}
+
+	if c.shouldProxyToAIChat() {
+		if err := c.proxyAIChatSSE("/report/complete-flow", c.Ctx.Input.RequestBody); err == nil {
+			return
+		} else {
+			fmt.Printf("[report-compat] proxy to aichat failed, fallback to local stream: %v\n", err)
+		}
+	}
+
+	result, err := c.callStreamChatWithDB(requestData)
+	if err != nil {
+		c.writeSSEJSON(map[string]interface{}{
+			"type":               "online_error",
+			"ai_conversation_id": result.AIConversationID,
+			"ai_message_id":      result.AIMessageID,
+			"message":            err.Error(),
+		})
+		c.writeSSEJSON(map[string]interface{}{
+			"type":               "completed",
+			"ai_conversation_id": result.AIConversationID,
+			"ai_message_id":      result.AIMessageID,
+		})
+		return
+	}
+
+	c.writeSSEJSON(map[string]interface{}{
+		"type":               "online_answer",
+		"ai_conversation_id": result.AIConversationID,
+		"ai_message_id":      result.AIMessageID,
+		"content":            result.Content,
+	})
+	c.writeSSEJSON(map[string]interface{}{
+		"type":               "completed",
+		"ai_conversation_id": result.AIConversationID,
+		"ai_message_id":      result.AIMessageID,
+	})
+}
+
+func (c *ReportCompatController) UpdateAIMessage() {
+	if c.shouldProxyToAIChat() {
+		if err := c.proxyAIChatJSON("/report/update-ai-message", c.Ctx.Input.RequestBody); err == nil {
+			return
+		} else {
+			fmt.Printf("[report-compat] proxy update-ai-message failed, fallback to local update: %v\n", err)
+		}
+	}
+
+	var requestData updateAIMessageRequest
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &requestData); err != nil {
+		c.Data["json"] = map[string]interface{}{
+			"success": false,
+			"message": fmt.Sprintf("请求参数解析失败: %s", err.Error()),
+		}
+		c.ServeJSON()
+		return
+	}
+
+	if requestData.AIMessageID == 0 {
+		c.Data["json"] = map[string]interface{}{
+			"success": false,
+			"message": "ai_message_id 不能为空",
+		}
+		c.ServeJSON()
+		return
+	}
+
+	if err := models.DB.Model(&models.AIMessage{}).
+		Where("id = ? AND is_deleted = ?", requestData.AIMessageID, 0).
+		Update("content", requestData.Content).Error; err != nil {
+		c.Data["json"] = map[string]interface{}{
+			"success": false,
+			"message": fmt.Sprintf("更新 AI 消息失败: %s", err.Error()),
+		}
+		c.ServeJSON()
+		return
+	}
+
+	c.Data["json"] = map[string]interface{}{
+		"success": true,
+		"message": "AI 消息已更新",
+	}
+	c.ServeJSON()
+}
+
+func (c *ReportCompatController) StopSSE() {
+	if c.shouldProxyToAIChat() {
+		if err := c.proxyAIChatJSON("/sse/stop", c.Ctx.Input.RequestBody); err == nil {
+			return
+		} else {
+			fmt.Printf("[report-compat] proxy sse/stop failed, fallback to local success response: %v\n", err)
+		}
+	}
+
+	var requestData stopSSERequest
+	_ = json.Unmarshal(c.Ctx.Input.RequestBody, &requestData)
+
+	c.Data["json"] = map[string]interface{}{
+		"success":            true,
+		"message":            "已接收停止请求",
+		"ai_conversation_id": requestData.AIConversationID,
+	}
+	c.ServeJSON()
+}
+
+func (c *ReportCompatController) setSSEHeaders() {
+	c.Ctx.ResponseWriter.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+	c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache")
+	c.Ctx.ResponseWriter.Header().Set("Connection", "keep-alive")
+	c.Ctx.ResponseWriter.Header().Set("Access-Control-Allow-Origin", "*")
+	c.Ctx.ResponseWriter.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
+	c.Ctx.ResponseWriter.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Token, token")
+}
+
+func (c *ReportCompatController) writeSSEJSON(payload map[string]interface{}) {
+	responseJSON, _ := json.Marshal(payload)
+	fmt.Fprintf(c.Ctx.ResponseWriter, "data: %s\n\n", responseJSON)
+	c.Ctx.ResponseWriter.Flush()
+}
+
+func (c *ReportCompatController) shouldProxyToAIChat() bool {
+	token := c.getRequestToken()
+	if token == "" {
+		return false
+	}
+
+	if _, err := utils.VerifyLocalToken(token); err == nil {
+		return false
+	}
+
+	return true
+}
+
+func (c *ReportCompatController) getRequestToken() string {
+	for _, headerName := range []string{"token", "Token", "Authorization"} {
+		headerValue := strings.TrimSpace(c.Ctx.Request.Header.Get(headerName))
+		if headerValue == "" {
+			continue
+		}
+		if headerName == "Authorization" && strings.HasPrefix(headerValue, "Bearer ") {
+			return strings.TrimPrefix(headerValue, "Bearer ")
+		}
+		return headerValue
+	}
+
+	return ""
+}
+
+func (c *ReportCompatController) getAIChatBaseURL() string {
+	baseURL, err := beego.AppConfig.String("aichat_api_url")
+	if err != nil || strings.TrimSpace(baseURL) == "" {
+		baseURL = "http://127.0.0.1:28002/api/v1"
+	}
+
+	return strings.TrimRight(baseURL, "/")
+}
+
+func (c *ReportCompatController) proxyAIChatSSE(path string, requestBody []byte) error {
+	upstreamReq, err := http.NewRequest(
+		http.MethodPost,
+		c.getAIChatBaseURL()+path,
+		bytes.NewBuffer(requestBody),
+	)
+	if err != nil {
+		return fmt.Errorf("创建 aichat SSE 请求失败: %w", err)
+	}
+
+	upstreamReq.Header.Set("Content-Type", "application/json")
+	c.forwardAuthHeaders(upstreamReq)
+
+	client := &http.Client{Timeout: 10 * time.Minute}
+	resp, err := client.Do(upstreamReq)
+	if err != nil {
+		return fmt.Errorf("调用 aichat SSE 失败: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		responseBody, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("aichat SSE 返回异常状态: %d %s", resp.StatusCode, strings.TrimSpace(string(responseBody)))
+	}
+
+	buffer := make([]byte, 4096)
+	for {
+		n, readErr := resp.Body.Read(buffer)
+		if n > 0 {
+			if _, err := c.Ctx.ResponseWriter.Write(buffer[:n]); err != nil {
+				return fmt.Errorf("写入前端 SSE 响应失败: %w", err)
+			}
+			c.Ctx.ResponseWriter.Flush()
+		}
+
+		if readErr == io.EOF {
+			return nil
+		}
+		if readErr != nil {
+			return fmt.Errorf("读取 aichat SSE 响应失败: %w", readErr)
+		}
+	}
+}
+
+func (c *ReportCompatController) proxyAIChatJSON(path string, requestBody []byte) error {
+	upstreamReq, err := http.NewRequest(
+		http.MethodPost,
+		c.getAIChatBaseURL()+path,
+		bytes.NewBuffer(requestBody),
+	)
+	if err != nil {
+		return fmt.Errorf("创建 aichat JSON 请求失败: %w", err)
+	}
+
+	upstreamReq.Header.Set("Content-Type", "application/json")
+	c.forwardAuthHeaders(upstreamReq)
+
+	client := &http.Client{Timeout: 30 * time.Second}
+	resp, err := client.Do(upstreamReq)
+	if err != nil {
+		return fmt.Errorf("调用 aichat JSON 接口失败: %w", err)
+	}
+	defer resp.Body.Close()
+
+	responseBody, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return fmt.Errorf("读取 aichat JSON 响应失败: %w", err)
+	}
+
+	c.Ctx.Output.SetStatus(resp.StatusCode)
+	c.Ctx.Output.Header("Content-Type", resp.Header.Get("Content-Type"))
+	_, _ = c.Ctx.ResponseWriter.Write(responseBody)
+	return nil
+}
+
+func (c *ReportCompatController) callStreamChatWithDB(requestData reportCompleteFlowRequest) (streamChatAggregateResult, error) {
+	upstreamBody := map[string]interface{}{
+		"message":            requestData.UserQuestion,
+		"ai_conversation_id": requestData.AIConversationID,
+		"business_type":      0,
+	}
+
+	requestBody, err := json.Marshal(upstreamBody)
+	if err != nil {
+		return streamChatAggregateResult{}, fmt.Errorf("构建内部请求失败: %w", err)
+	}
+
+	httpPort, err := beego.AppConfig.Int("httpport")
+	if err != nil || httpPort == 0 {
+		httpPort = 22001
+	}
+	upstreamURL := fmt.Sprintf("http://127.0.0.1:%d/apiv1/stream/chat-with-db", httpPort)
+
+	upstreamReq, err := http.NewRequest(http.MethodPost, upstreamURL, bytes.NewBuffer(requestBody))
+	if err != nil {
+		return streamChatAggregateResult{}, fmt.Errorf("创建内部请求失败: %w", err)
+	}
+	upstreamReq.Header.Set("Content-Type", "application/json")
+	c.forwardAuthHeaders(upstreamReq)
+
+	client := &http.Client{Timeout: 10 * time.Minute}
+	resp, err := client.Do(upstreamReq)
+	if err != nil {
+		return streamChatAggregateResult{}, fmt.Errorf("调用聊天接口失败: %w", err)
+	}
+	defer resp.Body.Close()
+
+	result, parseErr := parseStreamChatResponse(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return result, fmt.Errorf("聊天接口返回异常状态: %d", resp.StatusCode)
+	}
+	if parseErr != nil {
+		return result, parseErr
+	}
+	if strings.TrimSpace(result.Content) == "" {
+		return result, fmt.Errorf("聊天接口未返回有效内容")
+	}
+
+	return result, nil
+}
+
+func (c *ReportCompatController) forwardAuthHeaders(req *http.Request) {
+	for _, headerName := range []string{"Authorization", "Token", "token"} {
+		if headerValue := strings.TrimSpace(c.Ctx.Request.Header.Get(headerName)); headerValue != "" {
+			req.Header.Set(headerName, headerValue)
+		}
+	}
+}
+
+func parseStreamChatResponse(reader io.Reader) (streamChatAggregateResult, error) {
+	scanner := bufio.NewScanner(reader)
+	scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
+
+	var result streamChatAggregateResult
+	var contentBuilder strings.Builder
+
+	for scanner.Scan() {
+		line := strings.TrimRight(scanner.Text(), "\r")
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+
+		if strings.HasPrefix(line, "data: ") {
+			data := strings.TrimPrefix(line, "data: ")
+			if data == "[DONE]" {
+				break
+			}
+
+			var payload map[string]interface{}
+			if err := json.Unmarshal([]byte(data), &payload); err == nil {
+				if result.AIConversationID == 0 {
+					result.AIConversationID = getUint64FromMap(payload, "ai_conversation_id")
+				}
+				if result.AIMessageID == 0 {
+					result.AIMessageID = getUint64FromMap(payload, "ai_message_id")
+				}
+				if errorMessage, ok := payload["error"].(string); ok && strings.TrimSpace(errorMessage) != "" {
+					result.Content = contentBuilder.String()
+					return result, fmt.Errorf("%s", errorMessage)
+				}
+				if content, ok := payload["content"].(string); ok {
+					contentBuilder.WriteString(strings.ReplaceAll(content, "\\n", "\n"))
+				}
+				continue
+			}
+
+			if errorMessage, ok := extractRawErrorMessage(data); ok {
+				result.Content = contentBuilder.String()
+				return result, fmt.Errorf("%s", errorMessage)
+			}
+
+			contentBuilder.WriteString(strings.ReplaceAll(data, "\\n", "\n"))
+			continue
+		}
+
+		contentBuilder.WriteString(strings.ReplaceAll(line, "\\n", "\n"))
+	}
+
+	if err := scanner.Err(); err != nil {
+		result.Content = contentBuilder.String()
+		return result, fmt.Errorf("读取聊天流失败: %w", err)
+	}
+
+	result.Content = contentBuilder.String()
+	return result, nil
+}
+
+func extractRawErrorMessage(data string) (string, bool) {
+	if !strings.HasPrefix(data, "{\"error\":") {
+		return "", false
+	}
+
+	errorMessage := strings.TrimPrefix(data, "{\"error\":")
+	errorMessage = strings.TrimSuffix(errorMessage, "}")
+	errorMessage = strings.TrimSpace(errorMessage)
+	errorMessage = strings.Trim(errorMessage, "\"")
+	if errorMessage == "" {
+		return "", false
+	}
+
+	return errorMessage, true
+}
+
+func getUint64FromMap(data map[string]interface{}, key string) uint64 {
+	rawValue, ok := data[key]
+	if !ok {
+		return 0
+	}
+
+	switch value := rawValue.(type) {
+	case float64:
+		return uint64(value)
+	case int:
+		return uint64(value)
+	case int64:
+		return uint64(value)
+	case uint64:
+		return value
+	default:
+		return 0
+	}
+}

+ 32 - 8
shudao-go-backend/controllers/scene.go

@@ -97,14 +97,36 @@ func (c *SceneController) GetRecognitionRecordDetail() {
 		thirdScenes = strings.Split(record.Description, " ")
 	}
 
-	// 通过关联表查询二级场景名称
-	// var secondSceneNames []string
-	// var recognitionRecordSecondScenes []models.RecognitionRecordSecondScene
-	// models.DB.Preload("SecondScene").Where("recognition_record_id = ?", record.ID).Find(&recognitionRecordSecondScenes)
+	// 通过关联表查询二级场景名称及其对应的三级隐患
+	var displayLabels []string
+	elementHazards := make(map[string][]string)
+	var recognitionRecordSecondScenes []models.RecognitionRecordSecondScene
+	models.DB.Preload("SecondScene").Where("recognition_record_id = ?", record.ID).Find(&recognitionRecordSecondScenes)
+
+	for _, relation := range recognitionRecordSecondScenes {
+		label := relation.SecondScene.SecondSceneName
+		if label == "" {
+			continue
+		}
+		displayLabels = append(displayLabels, label)
+
+		var thirdSceneRecords []models.ThirdScene
+		models.DB.Where("second_scene_id = ? and is_deleted = ?", relation.SecondSceneID, 0).Find(&thirdSceneRecords)
+		for _, thirdSceneRecord := range thirdSceneRecords {
+			elementHazards[label] = append(elementHazards[label], thirdSceneRecord.ThirdSceneName)
+		}
+		elementHazards[label] = removeDuplicates(elementHazards[label])
+	}
 
-	// for _, rrss := range recognitionRecordSecondScenes {
-	// 	secondSceneNames = append(secondSceneNames, rrss.SecondScene.SecondSceneName)
-	// }
+	if len(displayLabels) == 0 && record.Labels != "" {
+		for _, label := range strings.Split(record.Labels, ",") {
+			normalizedLabel := strings.TrimSpace(normalizeSceneLabel(label, record.TagType))
+			if normalizedLabel != "" {
+				displayLabels = append(displayLabels, normalizedLabel)
+			}
+		}
+	}
+	displayLabels = removeDuplicates(displayLabels)
 
 	// 将原始OSS URL转换为代理URL,前端需要显示图片
 	originalImageURL := record.OriginalImageUrl
@@ -134,7 +156,9 @@ func (c *SceneController) GetRecognitionRecordDetail() {
 		"updated_at":            record.UpdatedAt,
 		// 添加与隐患识别接口一致的字段
 		"labels":            record.Labels, // 二级场景名称数组
-		"third_scenes":      thirdScenes,   // 三级场景名称数组
+		"display_labels":    displayLabels,
+		"third_scenes":      thirdScenes, // 三级场景名称数组
+		"element_hazards":   elementHazards,
 		"tag_type":          record.TagType,
 		"scene_match":       record.SceneMatch,
 		"tip_accuracy":      record.TipAccuracy,

+ 4 - 0
shudao-go-backend/routers/router.go

@@ -93,6 +93,10 @@ func init() {
 		beego.NSRouter("/get_chromadb_document", &controllers.ChatController{}, "get:GetChromaDBDocument"),
 		// 知识库文件高级搜索
 		beego.NSRouter("/knowledge/files/advanced-search", &controllers.ChromaController{}, "get:AdvancedSearch"),
+		// 兼容旧版 AI 问答页的 report / sse 协议
+		beego.NSRouter("/report/complete-flow", &controllers.ReportCompatController{}, "post:CompleteFlow"),
+		beego.NSRouter("/report/update-ai-message", &controllers.ReportCompatController{}, "post:UpdateAIMessage"),
+		beego.NSRouter("/sse/stop", &controllers.ReportCompatController{}, "post:StopSSE"),
 
 		// 流式接口路由
 		beego.NSRouter("/stream/chat", &controllers.LiushiController{}, "post:StreamChat"),

+ 2 - 0
shudao-vue-frontend/index.html

@@ -6,6 +6,8 @@
     <link rel="icon" href="/favicon.ico">
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
     <title>蜀道安全管理AI智能助手</title>
+    <!-- 引入 Material Symbols Outlined 字体图标 -->
+    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
     <!-- 在任何JS模块加载之前保存原始URL(用于票据认证) -->
     <script>
         window.__ORIGINAL_URL__ = window.location.href;

+ 0 - 264
shudao-vue-frontend/package-lock.json

@@ -3299,23 +3299,6 @@
       ],
       "license": "CC-BY-4.0"
     },
-    "node_modules/canvas": {
-      "version": "3.2.0",
-      "resolved": "https://registry.npmmirror.com/canvas/-/canvas-3.2.0.tgz",
-      "integrity": "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==",
-      "dev": true,
-      "hasInstallScript": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "node-addon-api": "^7.0.0",
-        "prebuild-install": "^7.1.3"
-      },
-      "engines": {
-        "node": "^18.12.0 || >= 20.9.0"
-      }
-    },
     "node_modules/caseless": {
       "version": "0.12.0",
       "resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
@@ -3382,15 +3365,6 @@
         "node": ">=4.6.0"
       }
     },
-    "node_modules/chownr": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
-      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-      "dev": true,
-      "license": "ISC",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/clipboard": {
       "version": "2.0.11",
       "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
@@ -3773,24 +3747,6 @@
         "url": "https://github.com/sponsors/wooorm"
       }
     },
-    "node_modules/decompress-response": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
-      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "mimic-response": "^3.1.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/deep-equal": {
       "version": "1.1.2",
       "resolved": "https://registry.npmmirror.com/deep-equal/-/deep-equal-1.1.2.tgz",
@@ -3811,18 +3767,6 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
-    "node_modules/deep-extend": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
-      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
     "node_modules/default-browser": {
       "version": "5.2.1",
       "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.2.1.tgz",
@@ -4462,18 +4406,6 @@
         "url": "https://github.com/sindresorhus/execa?sponsor=1"
       }
     },
-    "node_modules/expand-template": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
-      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-      "dev": true,
-      "license": "(MIT OR WTFPL)",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=6"
-      }
-    },
     "node_modules/expect-type": {
       "version": "1.3.0",
       "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz",
@@ -4955,15 +4887,6 @@
         "assert-plus": "^1.0.0"
       }
     },
-    "node_modules/github-from-package": {
-      "version": "0.0.0",
-      "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
-      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/glob": {
       "version": "7.2.3",
       "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
@@ -6996,21 +6919,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/mimic-response": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
-      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
     "node_modules/minimalistic-assert": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -7102,15 +7010,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/mkdirp-classic": {
-      "version": "0.5.3",
-      "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/mrmime": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/mrmime/-/mrmime-2.0.1.tgz",
@@ -7164,15 +7063,6 @@
       "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
       "license": "MIT"
     },
-    "node_modules/napi-build-utils": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/needle": {
       "version": "3.3.1",
       "resolved": "https://registry.npmmirror.com/needle/-/needle-3.3.1.tgz",
@@ -7197,45 +7087,6 @@
       "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
       "license": "ISC"
     },
-    "node_modules/node-abi": {
-      "version": "3.85.0",
-      "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.85.0.tgz",
-      "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "semver": "^7.3.5"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/node-abi/node_modules/semver": {
-      "version": "7.7.3",
-      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
-      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
-      "dev": true,
-      "license": "ISC",
-      "optional": true,
-      "peer": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/node-addon-api": {
-      "version": "7.1.1",
-      "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
-      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true
-    },
     "node_modules/node-fetch": {
       "version": "2.7.0",
       "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -8029,35 +7880,6 @@
         "url": "https://opencollective.com/preact"
       }
     },
-    "node_modules/prebuild-install": {
-      "version": "7.1.3",
-      "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz",
-      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "detect-libc": "^2.0.0",
-        "expand-template": "^2.0.3",
-        "github-from-package": "0.0.0",
-        "minimist": "^1.2.3",
-        "mkdirp-classic": "^0.5.3",
-        "napi-build-utils": "^2.0.0",
-        "node-abi": "^3.3.0",
-        "pump": "^3.0.0",
-        "rc": "^1.2.7",
-        "simple-get": "^4.0.0",
-        "tar-fs": "^2.0.0",
-        "tunnel-agent": "^0.6.0"
-      },
-      "bin": {
-        "prebuild-install": "bin.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/pretty-ms": {
       "version": "9.2.0",
       "resolved": "https://registry.npmmirror.com/pretty-ms/-/pretty-ms-9.2.0.tgz",
@@ -8268,19 +8090,6 @@
         "url": "https://github.com/sponsors/lupomontero"
       }
     },
-    "node_modules/pump": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.3.tgz",
-      "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "end-of-stream": "^1.1.0",
-        "once": "^1.3.1"
-      }
-    },
     "node_modules/punycode": {
       "version": "2.3.1",
       "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
@@ -8373,24 +8182,6 @@
         "node": ">= 12.0.0"
       }
     },
-    "node_modules/rc": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
-      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
-      "dev": true,
-      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "deep-extend": "^0.6.0",
-        "ini": "~1.3.0",
-        "minimist": "^1.2.0",
-        "strip-json-comments": "~2.0.1"
-      },
-      "bin": {
-        "rc": "cli.js"
-      }
-    },
     "node_modules/readable-stream": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -8844,34 +8635,6 @@
       "license": "MIT",
       "optional": true
     },
-    "node_modules/simple-get": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
-      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-      "dev": true,
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "decompress-response": "^6.0.0",
-        "once": "^1.3.1",
-        "simple-concat": "^1.0.0"
-      }
-    },
     "node_modules/sirv": {
       "version": "3.0.1",
       "resolved": "https://registry.npmmirror.com/sirv/-/sirv-3.0.1.tgz",
@@ -9174,18 +8937,6 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
-    "node_modules/strip-json-comments": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/superjson": {
       "version": "2.2.2",
       "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz",
@@ -9238,21 +8989,6 @@
         "node": ">=10"
       }
     },
-    "node_modules/tar-fs": {
-      "version": "2.1.4",
-      "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
-      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
-      "dev": true,
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "chownr": "^1.1.1",
-        "mkdirp-classic": "^0.5.2",
-        "pump": "^3.0.0",
-        "tar-stream": "^2.1.4"
-      }
-    },
     "node_modules/tar-stream": {
       "version": "2.2.0",
       "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",

+ 158 - 0
shudao-vue-frontend/replace_layout2.py

@@ -0,0 +1,158 @@
+import re
+
+with open('src/views/ExamWorkshop.vue', 'r', encoding='utf-8') as f:
+    content = f.read()
+
+pattern = re.compile(r'(<!-- 考试工坊主界面 -->\s*<div v-if="!showExamDetail" class="exam-workshop-card">)(.*?)(^\s*<!-- 考试详情页 -->)', re.DOTALL | re.MULTILINE)
+
+new_content = """
+        <div v-if="!showExamDetail" class="flex-1 flex flex-row w-full h-full relative overflow-hidden" style="background-color: #f7f9fb; margin: -20px;">
+          <-5 中间主要工作区 -->
+          <div class="flex-1 flex flex-col h-full relative min-w-0">
+            <div class="flex-1 overflow-y-auto p-10 pb-32">
+              <div class="max-w-4xl mx-auto w-full space-y-8">
+                <-5 试卷名称 -->
+                <section class="space-y-3">
+                  <label class="block text-[15px] font-bold text-gray-800">试卷名称</label>
+                  <div class="relative">
+                    <input v-model="examName" class="w-full bg-white border border-gray-100 shadow-sm rounded-2xl px-5 py-4 text-[15px] focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none text-gray-800 placeholder-gray-400" maxlength="32" placeholder="请输入试卷名称..." type="text" :disabled="isGenerating"/>
+                    <span class="absolute right-5 bottom-4 text-xs text-gray-400">{{ examName?.length || 0 }}/32</span>
+                  </div>
+                </section>
+
+                <-5 出题依据内容 -->
+                <section class="space-y-3">
+                  <label class="block text-[15px] font-bold text-gray-800">出题依据内容</label>
+                  <textarea v-model="questionBasis" class="w-full h-48 bg-white border border-gray-100 shadow-sm rounded-2xl px-5 py-4 text-[15px] focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none resize-none text-gray-800 placeholder-gray-400 leading-relaxed" placeholder="在此输入知识点、章节或培训内容..." :disabled="isGenerating || selectedFile"></textarea>
+
+                  <-5 从PPT生成考题 -->
+                  <div class="w-full bg-white border border-gray-100 shadow-sm rounded-2xl p-5 flex items-center gap-5 cursor-pointer hover:shadow-md transition-all group mt-6" @click="!isGenerating && !selectedFile ? triggerFileUpload() : null">
+                    <div class="w-14 h-14 rounded-2xl bg-[#f3f4f6] flex items-center justify-center text-gray-500 group-hover:text-blue-600 transition-colors">
+                      <span class="material-symbols-outlined text-3xl">cloud_upload</span>
+                    </div>
+                    <div class="flex-1">
+                      <div class="flex items-center gap-2 mb-1">
+                        <h3 class="text-base font-bold text-gray-900">从PPT生成考题</h3>
+                      </div>
+                      <p class="text-[13px] text-gray-500">上传培训PPT,智能提取关键内容生成考题(单个文件可上传20M内)</p>
+                      <div v-if="selectedFile" class="mt-3 p-2.5 bg-blue-50/80 rounded-xl text-blue-600 text-sm flex justify-between items-center border border-blue-100">
+                        <span class="font-medium truncate mr-4">已上传: {{ selectedFile.name }}</span>
+                        <span @click.stop="removeSelectedFile" class="cursor-pointer text-red-400 hover:text-red-600 font-bold px-2 py-1 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">×</span>
+                      </div>
+                    </div>
+                    <span class="material-symbols-outlined text-gray-300">chevron_right</span>
+                  </div>
+                </section>
+
+                <-5 题型配置 -->
+                <section class="space-y-4 pt-4">
+                  <div class="flex items-center justify-between w-full mb-6">
+                    <span class="text-[15px] font-bold text-gray-800">题型配置</span>
+                    <div class="flex items-center gap-3">
+                      <span class="text-[13px] text-gray-500">试卷总分</span>
+                      <div class="bg-white border border-gray-100 shadow-sm rounded-xl px-6 py-2.5 text-lg font-bold text-gray-900 min-w-[80px] text-center">
+                        {{ calculatedTotalScore }}
+                      </div>
+                    </div>
+                  </div>
+
+                  <div class="grid grid-cols-2 gap-5">
+                    <div v-for="(type, index) in questionTypes" :key="index" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 hover:shadow-md transition-shadow">
+                      <div class="flex justify-between items-center mb-8">
+                        <span class="text-[15px] font-bold text-gray-900">{{ type.name }}</span>
+                        <span class="text-[13px] font-medium bg-[#ebf3ff] text-blue-600 px-3.5 py-1.5 rounded-full">每题 {{ type.scorePerQuestion }} 分</span>
+                      </div>
+                      <div class="space-y-4">
+                        <div class="flex justify-between items-center text-[13px] font-bold mb-2">
+                          <span class="text-gray-500">数量</span>
+                          <span class="text-blue-600 text-base">{{ type.questionCount }}</span>
+                        </div>
+                        <div class="relative w-full h-1.5 bg-gray-100 rounded-full mt-2">
+                          <input type="range" v-model.number="type.questionCount" min="0" :max="type.max || 50" class="absolute top-0 left-0 w-full h-full opacity-0 cursor-pointer z-10" :disabled="isGenerating">
+                          <div class="absolute top-0 left-0 h-full bg-blue-600 rounded-full pointer-events-none" :style="{ width: (type.questionCount / (type.max || 50) * 100) + '%' }"></div>
+                          <div class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-[3px] border-blue-600 rounded-full shadow-sm pointer-events-none transition-all" :style="{ left: (type.questionCount / (type.max || 50) * 100) + '%', transform: 'translate(-50%, -50%)' }"></div>
+                        </div>
+                      </div>
+                    </div>
+                  </div>
+                </section>
+              </div>
+            </div>
+
+            <-5 底部悬浮操作栏 -->
+            <footer class="absolute bottom-0 left-0 w-full bg-white/95 backdrop-blur-xl border-t border-gray-100 px-8 py-5 flex items-center justify-between z-40">
+              <div class="flex items-center gap-4">
+                <button class="flex items-center gap-2.5 text-gray-500 hover:text-red-500 transition-colors px-2" @click="clearSettings" :disabled="isGenerating">
+                  <span class="material-symbols-outlined text-[20px]">delete</span>
+                  <span class="text-[14px] font-medium">清空当前配置</span>
+                </button>
+              </div>
+              
+              <button v-if="!isGenerating" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl text-[15px] font-bold shadow-lg shadow-blue-600/20 flex items-center justify-center transition-all active:scale-[0.98]" @click="generateExam">
+                开始智能生成试卷
+              </button>
+              <button v-else class="bg-blue-400 text-white px-8 py-3 rounded-xl text-[15px] font-bold shadow-md flex items-center justify-center cursor-not-allowed">
+                <span class="flex items-center gap-2">
+                  <span class="material-symbols-outlined animate-spin text-lg">autorenew</span>
+                  生成中...
+                </span>
+              </button>
+            </footer>
+          </div>
+
+          <-5 右侧边栏 (实时预览) -->
+          <aside class="w-[280px] bg-[#f7f9fb] border-l border-gray-200 flex flex-col h-full flex-shrink-0 z-30">
+            <div class="p-7">
+              <h2 class="text-gray-900 font-bold text-[17px] mb-7">实时预览</h2>
+              
+              <div class="space-y-8">
+                <-5 试卷名称 -->
+                <div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
+                  <h3 class="text-[13px] font-bold text-gray-500 mb-3.5">试卷名称</h3>
+                  <p class="text-gray-800 font-medium text-[15px] italic leading-relaxed" :class="{'opacity-40': !examName}">{{ examName || '未命名试卷...' }}</p>
+                </div>
+                
+                <-5 结构大纲 -->
+                <div class="space-y-5">
+                  <h3 class="text-[13px] font-bold text-gray-500">结构大纲</h3>
+                  <ul class="space-y-5">
+                    <li v-for="(type, index) in questionTypes" :key="index" class="flex flex-col gap-1.5 group">
+                      <div class="flex items-center justify-between">
+                        <div class="flex items-center gap-2.5">
+                          <div class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: ['#2563eb', '#60a5fa', '#93c5fd', '#dbeafe'][index % 4] }"></div>
+                          <span class="text-[14px] font-medium text-gray-800">{{ type.name }}</span>
+                        </div>
+                        <span class="text-[14px] font-bold text-gray-800">{{ type.questionCount }}题</span>
+                      </div>
+                      <span class="text-[12px] text-gray-400 ml-4">{{ type.questionCount * type.scorePerQuestion }} 分</span>
+                    </li>
+                  </ul>
+                </div>
+                
+                <-5 总分统计 -->
+                <div class="pt-8 border-t border-gray-200 space-y-4">
+                  <div class="flex justify-between items-center">
+                    <span class="text-[13px] text-gray-500 font-medium">配置总分</span>
+                    <span class="text-[15px] font-bold text-gray-900">{{ totalScore }}</span>
+                  </div>
+                  <div class="flex justify-between items-center">
+                    <span class="text-[13px] text-gray-500 font-medium">试卷总分</span>
+                    <span class="text-[15px] font-bold text-gray-900">{{ calculatedTotalScore }}</span>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </aside>
+        </div>
+"""
+
+replaced = pattern.sub(r'\1\n' + new_content + r'\n\3', content)
+
+# 隐藏原来的 work-header 并修改 work-content 的样式
+replaced = replaced.replace('<div class="work-header">', '<div class="work-header" v-if="showExamDetail">')
+replaced = replaced.replace('<div class="work-content" :class="{ \'exam-detail-mode\': showExamDetail }">', '<div class="work-content" :class="{ \'exam-detail-mode\': showExamDetail }" :style="showExamDetail ? {} : { padding: 0, height: \'100%\', display: \'flex\', flexDirection: \'column\' }">')
+
+with open('src/views/ExamWorkshop.vue', 'w', encoding='utf-8') as f:
+    f.write(replaced)
+
+print("Replacement complete.")

+ 782 - 0
shudao-vue-frontend/scripts/refactor_exam_workshop.py

@@ -0,0 +1,782 @@
+import re
+import os
+
+filepath = '/Users/fanhong/UGIT/shudao-main/shudao-vue-frontend/src/views/ExamWorkshop.vue'
+with open(filepath, 'r', encoding='utf-8') as f:
+    content = f.read()
+
+parts = content.split('<script setup>')
+template_part = parts[0]
+rest = '<script setup>' + parts[1]
+
+script_match = re.search(r'<script setup>([\s\S]*?)</script>', rest)
+script_content_original = script_match.group(1)
+script_content = script_content_original
+
+# Add questionBasis
+if 'const questionBasis = ref("");' not in script_content:
+    script_content = script_content.replace(
+        'const examName = ref("桥梁工程施工技术考核");', 'const examName = ref("");\nconst questionBasis = ref("");')
+
+# Modify questionTypes
+old_qtypes = """const questionTypes = ref([
+  { name: "单选题", scorePerQuestion: 5, questionCount: 5, romanNumeral: "一" },
+  { name: "判断题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "二" },
+  { name: "多选题", scorePerQuestion: 8, questionCount: 5, romanNumeral: "三" },
+  { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
+]);"""
+new_qtypes = """const questionTypes = ref([
+  { key: 'single', name: "单选题", scorePerQuestion: 2, questionCount: 15, romanNumeral: "一", max: 50 },
+  { key: 'judge', name: "判断题", scorePerQuestion: 2, questionCount: 10, romanNumeral: "二", max: 50 },
+  { key: 'multiple', name: "多选题", scorePerQuestion: 3, questionCount: 10, romanNumeral: "三", max: 50 },
+  { key: 'short', name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四", max: 10 },
+]);"""
+script_content = script_content.replace(old_qtypes, new_qtypes)
+
+# Modify totalScore to be computed
+old_total_score = 'const totalScore = ref(100);'
+new_total_score = '''const totalScore = computed(() => {
+  return questionTypes.value.reduce((total, type) => {
+    return total + (type.scorePerQuestion * type.questionCount);
+  }, 0);
+});'''
+script_content = script_content.replace(old_total_score, new_total_score)
+
+# Replace projectType usage in fetchExamPrompt
+script_content = script_content.replace(
+    "projectType: projectTypes[selectedProjectType.value]?.name || '',", "projectType: questionBasis.value,")
+
+# Clear config logic
+script_content = script_content.replace("const clearSettings = () => {", """const clearSettings = () => {
+  examName.value = '';
+  questionBasis.value = '';
+  removeSelectedFile();
+  questionTypes.value.forEach(t => t.questionCount = 0);
+""")
+
+# 2. Build new template
+new_template = """<template>
+  <div class="chat-container">
+    <!-- 最左侧边栏 -->
+    <Sidebar v-if="!hideSidebar" />
+
+    <!-- 中间历史记录区域 -->
+    <div class="history-sidebar" v-if="!hideSidebar">
+      <div class="history-header">
+        <span class="section-title">历史记录</span>
+        <img src="@/assets/Chat/2.png" alt="新建任务" class="new-chat-btn" @click="createNewChat" />
+      </div>
+      
+      <div class="history-list">
+        <div v-if="isLoadingHistory && historyTotal === 0" class="history-loading">
+          <div class="loading-spinner"></div>
+          <div class="loading-text">正在加载历史记录...</div>
+        </div>
+        
+        <div v-else-if="historyTotal > 0" v-for="(item, index) in historyData" :key="index"
+          :class="['history-item', { active: item.isActive }]"
+          @click="item.isActive ? null : (isGenerating || isLoadingHistoryItem ? null : handleHistoryItem(item))"
+          :style="{ cursor: item.isActive ? 'default' : (isGenerating || isLoadingHistoryItem ? 'not-allowed' : 'pointer'), opacity: item.isActive ? '0.8' : '1' }">
+          <div class="history-content">
+            <div class="history-title">{{ item.title }}</div>
+            <div class="history-time">{{ item.time }}</div>
+          </div>
+          <div class="delete-btn" @click.stop="deleteHistoryItem(item, index)" :class="{ 'always-visible': item.isActive }">
+            <img src="/src/assets/AIWriting/8.png" alt="删除" class="delete-icon" />
+          </div>
+        </div>
+        
+        <div v-else class="empty-history">
+          <img src="@/assets/Chat/22.png" alt="暂无数据" class="empty-icon">
+          <div class="empty-text">暂无数据</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- MAIN WORK AREA -->
+    <main class="flex-1 bg-surface-container-lowest min-h-screen flex flex-col relative w-full overflow-hidden" style="background-color: #f7f9fb;">
+      
+      <!-- EXAM CONFIGURATION UI (When not showing detail) -->
+      <div v-if="!showExamDetail" class="flex-1 flex w-full h-full relative">
+        <!-- Editor Pane -->
+        <div class="flex-1 overflow-y-auto p-10 max-w-5xl mx-auto w-full space-y-8 pb-32" style="margin-right: 214px;">
+          <!-- Exam Name Input -->
+          <section class="space-y-2">
+            <label class="block text-sm font-bold text-on-surface-variant mb-2">试卷名称</label>
+            <div class="relative">
+              <input v-model="examName" class="w-full bg-white border border-gray-200 rounded-xl px-4 py-3 text-lg font-medium focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none" maxlength="32" placeholder="请输入试卷名称..." type="text" :disabled="isGenerating"/>
+              <span class="absolute right-4 bottom-3 text-xs text-gray-400">{{ examName.length }}/32</span>
+            </div>
+          </section>
+
+          <!-- Question Basis -->
+          <section class="space-y-4 mt-8">
+            <div class="flex items-center justify-between mb-2">
+              <label class="block text-sm font-bold text-on-surface-variant">出题依据内容</label>
+            </div>
+            <textarea v-model="questionBasis" class="w-full h-48 bg-white border border-gray-200 rounded-xl px-4 py-3 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none resize-none" placeholder="在此输入知识点、章节或培训内容..." :disabled="isGenerating || selectedFile"></textarea>
+            
+            <!-- Smart Fill Module -->
+            <div class="w-full bg-white border border-gray-200 rounded-xl p-6 flex items-center gap-6 cursor-pointer hover:bg-gray-50 transition-colors group mt-4" @click="!isGenerating && !selectedFile ? triggerFileUpload() : null">
+              <div class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center text-gray-500 group-hover:text-blue-500 transition-colors">
+                <span class="material-symbols-outlined text-3xl">cloud_upload</span>
+              </div>
+              <div class="flex-1">
+                <div class="flex items-center gap-2">
+                  <h3 class="text-base font-bold text-gray-900">从PPT生成考题</h3>
+                </div>
+                <p class="text-sm text-gray-500 mt-1">上传培训PPT,智能提取关键内容生成考题(单个文件可上传20M内)</p>
+                <div v-if="selectedFile" class="mt-2 p-2 bg-blue-50 rounded text-blue-600 text-sm flex justify-between items-center">
+                  <span>已上传: {{ selectedFile.name }}</span>
+                  <span @click.stop="removeSelectedFile" class="cursor-pointer text-red-500 font-bold px-2">×</span>
+                </div>
+              </div>
+              <span class="material-symbols-outlined text-gray-300">chevron_right</span>
+            </div>
+          </section>
+
+          <!-- Configuration Sliders -->
+          <section class="space-y-4 mt-8">
+            <h2 class="text-gray-600 mb-4">
+              <div class="flex items-center justify-between w-full">
+                <span class="font-bold">题型配置</span>
+                <div class="flex items-center gap-4">
+                  <span class="text-sm font-normal text-gray-500">试卷总分</span>
+                  <div class="bg-white border border-gray-200 rounded-lg px-6 py-2 text-lg font-bold text-gray-900 min-w-[80px] text-center">
+                    {{ totalScore }}
+                  </div>
+                </div>
+              </div>
+            </h2>
+            
+            <div class="grid-2-cols">
+              <div v-for="(type, index) in questionTypes" :key="index" class="bg-white rounded-2xl border border-gray-200 shadow-sm p-4">
+                <div class="flex justify-between items-center mb-4">
+                  <span class="text-sm font-bold text-gray-800">{{ type.name }}</span>
+                  <span class="text-xs bg-blue-50 text-blue-600 px-2 py-1 rounded-full">每题 {{ type.scorePerQuestion }} 分</span>
+                </div>
+                <div class="space-y-2">
+                  <div class="flex justify-between items-center text-xs font-bold mb-2">
+                    <span class="text-gray-500">数量</span>
+                    <span class="text-blue-600 text-sm">{{ type.questionCount }}</span>
+                  </div>
+                  <div class="custom-slider-container">
+                    <input type="range" v-model.number="type.questionCount" min="0" :max="type.max" class="custom-slider" :disabled="isGenerating">
+                    <div class="slider-track">
+                      <div class="slider-fill" :style="{ width: (type.questionCount / type.max * 100) + '%' }"></div>
+                    </div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </section>
+        </div>
+
+        <!-- Footer Action Bar -->
+        <footer class="absolute bottom-0 left-0 w-full bg-white/90 backdrop-blur-md border-t border-gray-200 p-6 flex items-center justify-between z-40" style="padding-right: 238px;">
+          <div class="flex items-center gap-6">
+            <button class="flex items-center gap-2 text-gray-500 hover:text-red-500 transition-colors" @click="clearSettings" :disabled="isGenerating">
+              <span class="material-symbols-outlined">delete_sweep</span>
+              <span class="text-sm font-medium">清空当前配置</span>
+            </button>
+            <div class="h-8 w-px bg-gray-200"></div>
+          </div>
+          
+          <button v-if="!isGenerating" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-xl text-sm font-bold shadow-lg flex items-center gap-3 transition-colors" @click="generateAIExam">
+            开始智能生成试卷
+          </button>
+          <button v-else class="bg-blue-300 text-white px-6 py-2.5 rounded-xl text-sm font-bold shadow-lg flex items-center gap-3 cursor-not-allowed">
+            试卷生成中...
+          </button>
+        </footer>
+
+        <!-- RIGHT SIDEBAR (The Intelligence Pane) -->
+        <aside class="absolute right-0 top-0 w-[214px] bg-gray-50 border-l border-gray-200 flex flex-col h-full z-30">
+          <div class="p-6">
+            <h2 class="text-gray-900 font-bold text-lg mb-6">实时预览</h2>
+            <div class="space-y-8">
+              <div class="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
+                <h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3">试卷名称</h3>
+                <p class="text-gray-800 font-medium italic" :class="{'opacity-40': !examName}">{{ examName || '未命名试卷...' }}</p>
+              </div>
+              <div class="space-y-4">
+                <h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">结构大纲</h3>
+                <ul class="space-y-4">
+                  <li v-for="(type, index) in questionTypes" :key="index" class="flex flex-col gap-1 group">
+                    <div class="flex items-center justify-between">
+                      <div class="flex items-center gap-2">
+                        <div class="w-1.5 h-1.5 rounded-full" :style="{ backgroundColor: ['#2563eb', '#3b82f6', '#60a5fa', '#93c5fd'][index % 4] }"></div>
+                        <span class="text-sm text-gray-800">{{ type.name }}</span>
+                      </div>
+                      <span class="text-xs font-bold text-gray-500">{{ type.questionCount }}题</span>
+                    </div>
+                    <span class="text-[10px] text-gray-400 ml-3.5">{{ type.questionCount * type.scorePerQuestion }} 分</span>
+                  </li>
+                </ul>
+              </div>
+              <div class="pt-6 border-t border-gray-200 space-y-3">
+                <div class="flex justify-between items-center">
+                  <span class="text-xs text-gray-500">试卷总分</span>
+                  <span class="text-xs font-bold text-gray-900">{{ totalScore }}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </aside>
+      </div>
+
+      <!-- EXAM COMPLETED STATE UI -->
+      <div v-else-if="showExamDetail && !isGenerating" class="flex-1 flex flex-col w-full h-full bg-white relative">
+        <!-- Exam Header -->
+        <header class="bg-white px-8 py-6 border-b border-gray-100">
+          <div class="max-w-5xl mx-auto">
+            <div class="flex items-center justify-between mb-4">
+              <h1 class="text-gray-900 text-lg font-normal">{{ currentExam?.title || examName }}</h1>
+              <button class="flex items-center space-x-2 border border-green-500 text-green-500 px-4 py-1.5 rounded-lg text-sm hover:bg-green-50 transition-colors font-medium" @click="downloadWord">
+                <span class="material-symbols-outlined text-[18px]">download</span>
+                <span>下载Word</span>
+              </button>
+            </div>
+            <div class="bg-gray-50 rounded-xl p-8 border border-gray-100">
+              <div class="flex items-end justify-between">
+                <div>
+                  <h2 class="font-bold text-gray-900 mb-6 text-2xl">{{ currentExam?.title || examName }}</h2>
+                  <div class="flex items-center space-x-8 text-[15px] text-gray-500">
+                    <span>总分: {{ currentExam?.totalScore || totalScore }}分</span>
+                    <span>题量: {{ currentExam?.totalQuestions || 0 }}题</span>
+                  </div>
+                </div>
+                <div class="text-[15px] text-gray-500">生成时间: {{ currentTime }}</div>
+              </div>
+            </div>
+          </div>
+        </header>
+        
+        <!-- Exam Content Area -->
+        <div class="flex-1 overflow-y-auto p-10 space-y-8 pb-20">
+          <div class="max-w-5xl mx-auto">
+            <!-- Render Single Choice -->
+            <div v-if="currentExam?.singleChoice?.questions?.length > 0" class="mb-8">
+              <div class="flex items-center justify-between bg-gray-50 p-3 px-4 rounded-lg mb-4 cursor-pointer" @click="toggleSection('single')">
+                <div class="flex items-center space-x-3">
+                  <div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white">
+                    <span class="material-symbols-outlined text-[16px]">{{ expandedSections.single ? 'remove' : 'add' }}</span>
+                  </div>
+                  <span class="font-bold text-gray-900">单选题</span>
+                  <span class="text-sm text-gray-500">(每题{{ currentExam.singleChoice.scorePerQuestion }}分, 共{{ currentExam.singleChoice.totalScore }}分)</span>
+                </div>
+                <div class="flex items-center space-x-2 text-gray-500">
+                  <span class="text-sm">{{ currentExam.singleChoice.count }}题</span>
+                  <span class="material-symbols-outlined">{{ expandedSections.single ? 'expand_less' : 'expand_more' }}</span>
+                </div>
+              </div>
+              
+              <div v-show="expandedSections.single" class="space-y-6">
+                <div v-for="(q, qIndex) in currentExam.singleChoice.questions" :key="'single_'+qIndex" class="bg-white rounded-xl border border-gray-200 shadow-sm relative py-4 px-6 hover:border-blue-300 transition-colors">
+                  <h3 class="text-[15px] font-medium text-gray-900 leading-relaxed pr-8">
+                    <span class="text-blue-500 font-bold mr-2">{{ qIndex + 1 }}.</span>{{ q.text }}
+                  </h3>
+                  <div class="grid grid-cols-2 mt-3 gap-y-2">
+                    <label v-for="(opt, oIndex) in q.options" :key="oIndex" class="flex items-center space-x-3 cursor-pointer">
+                      <div class="w-4 h-4 rounded-full border flex items-center justify-center" :class="q.selectedAnswer === opt.key ? 'border-blue-500' : 'border-gray-300'">
+                        <div v-if="q.selectedAnswer === opt.key" class="w-2 h-2 bg-blue-500 rounded-full"></div>
+                      </div>
+                      <span class="text-sm" :class="q.selectedAnswer === opt.key ? 'text-blue-500 font-medium' : 'text-gray-600'">
+                        <span class="font-bold mr-2">{{ opt.key }}.</span>{{ opt.text }}
+                      </span>
+                    </label>
+                  </div>
+                  <div v-if="q.selectedAnswer" class="mt-4 p-3 bg-green-50 rounded-lg text-sm text-green-700">
+                    <span class="font-bold">正确答案:</span>{{ q.selectedAnswer }}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <!-- Render Judge -->
+            <div v-if="currentExam?.judge?.questions?.length > 0" class="mb-8">
+              <div class="flex items-center justify-between bg-gray-50 p-3 px-4 rounded-lg mb-4 cursor-pointer" @click="toggleSection('judge')">
+                <div class="flex items-center space-x-3">
+                  <div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white">
+                    <span class="material-symbols-outlined text-[16px]">{{ expandedSections.judge ? 'remove' : 'add' }}</span>
+                  </div>
+                  <span class="font-bold text-gray-900">判断题</span>
+                  <span class="text-sm text-gray-500">(每题{{ currentExam.judge.scorePerQuestion }}分, 共{{ currentExam.judge.totalScore }}分)</span>
+                </div>
+                <div class="flex items-center space-x-2 text-gray-500">
+                  <span class="text-sm">{{ currentExam.judge.count }}题</span>
+                  <span class="material-symbols-outlined">{{ expandedSections.judge ? 'expand_less' : 'expand_more' }}</span>
+                </div>
+              </div>
+              
+              <div v-show="expandedSections.judge" class="space-y-6">
+                <div v-for="(q, qIndex) in currentExam.judge.questions" :key="'judge_'+qIndex" class="bg-white rounded-xl border border-gray-200 shadow-sm relative py-4 px-6 hover:border-blue-300 transition-colors">
+                  <h3 class="text-[15px] font-medium text-gray-900 leading-relaxed pr-8">
+                    <span class="text-blue-500 font-bold mr-2">{{ qIndex + 1 }}.</span>{{ q.text }}
+                  </h3>
+                  <div v-if="q.selectedAnswer" class="mt-4 p-3 bg-green-50 rounded-lg text-sm text-green-700">
+                    <span class="font-bold">正确答案:</span>{{ q.selectedAnswer }}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <!-- Render Multiple -->
+            <div v-if="currentExam?.multiple?.questions?.length > 0" class="mb-8">
+              <div class="flex items-center justify-between bg-gray-50 p-3 px-4 rounded-lg mb-4 cursor-pointer" @click="toggleSection('multiple')">
+                <div class="flex items-center space-x-3">
+                  <div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white">
+                    <span class="material-symbols-outlined text-[16px]">{{ expandedSections.multiple ? 'remove' : 'add' }}</span>
+                  </div>
+                  <span class="font-bold text-gray-900">多选题</span>
+                  <span class="text-sm text-gray-500">(每题{{ currentExam.multiple.scorePerQuestion }}分, 共{{ currentExam.multiple.totalScore }}分)</span>
+                </div>
+                <div class="flex items-center space-x-2 text-gray-500">
+                  <span class="text-sm">{{ currentExam.multiple.count }}题</span>
+                  <span class="material-symbols-outlined">{{ expandedSections.multiple ? 'expand_less' : 'expand_more' }}</span>
+                </div>
+              </div>
+              
+              <div v-show="expandedSections.multiple" class="space-y-6">
+                <div v-for="(q, qIndex) in currentExam.multiple.questions" :key="'multiple_'+qIndex" class="bg-white rounded-xl border border-gray-200 shadow-sm relative py-4 px-6 hover:border-blue-300 transition-colors">
+                  <h3 class="text-[15px] font-medium text-gray-900 leading-relaxed pr-8">
+                    <span class="text-blue-500 font-bold mr-2">{{ qIndex + 1 }}.</span>{{ q.text }}
+                  </h3>
+                  <div class="grid grid-cols-2 mt-3 gap-y-2">
+                    <label v-for="(opt, oIndex) in q.options" :key="oIndex" class="flex items-center space-x-3 cursor-pointer">
+                      <div class="w-4 h-4 rounded border flex items-center justify-center" :class="q.selectedAnswers?.includes(opt.key) ? 'border-blue-500 bg-blue-500 text-white' : 'border-gray-300'">
+                        <span v-if="q.selectedAnswers?.includes(opt.key)" class="material-symbols-outlined text-[12px]">check</span>
+                      </div>
+                      <span class="text-sm" :class="q.selectedAnswers?.includes(opt.key) ? 'text-blue-500 font-medium' : 'text-gray-600'">
+                        <span class="font-bold mr-2">{{ opt.key }}.</span>{{ opt.text }}
+                      </span>
+                    </label>
+                  </div>
+                  <div v-if="q.selectedAnswers?.length > 0" class="mt-4 p-3 bg-green-50 rounded-lg text-sm text-green-700">
+                    <span class="font-bold">正确答案:</span>{{ q.selectedAnswers.join(', ') }}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            <!-- Render Short Answer -->
+            <div v-if="currentExam?.short?.questions?.length > 0" class="mb-8">
+              <div class="flex items-center justify-between bg-gray-50 p-3 px-4 rounded-lg mb-4 cursor-pointer" @click="toggleSection('short')">
+                <div class="flex items-center space-x-3">
+                  <div class="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center text-white">
+                    <span class="material-symbols-outlined text-[16px]">{{ expandedSections.short ? 'remove' : 'add' }}</span>
+                  </div>
+                  <span class="font-bold text-gray-900">简答题</span>
+                  <span class="text-sm text-gray-500">(每题{{ currentExam.short.scorePerQuestion }}分, 共{{ currentExam.short.totalScore }}分)</span>
+                </div>
+                <div class="flex items-center space-x-2 text-gray-500">
+                  <span class="text-sm">{{ currentExam.short.count }}题</span>
+                  <span class="material-symbols-outlined">{{ expandedSections.short ? 'expand_less' : 'expand_more' }}</span>
+                </div>
+              </div>
+              
+              <div v-show="expandedSections.short" class="space-y-6">
+                <div v-for="(q, qIndex) in currentExam.short.questions" :key="'short_'+qIndex" class="bg-white rounded-xl border border-gray-200 shadow-sm relative py-4 px-6 hover:border-blue-300 transition-colors">
+                  <h3 class="text-[15px] font-medium text-gray-900 leading-relaxed pr-8">
+                    <span class="text-blue-500 font-bold mr-2">{{ qIndex + 1 }}.</span>{{ q.text }}
+                  </h3>
+                  <div v-if="q.outline?.keyFactors" class="mt-4 p-4 bg-green-50 rounded-lg text-sm text-green-800 leading-relaxed border border-green-100">
+                    <div class="font-bold mb-2 flex items-center gap-2"><span class="material-symbols-outlined text-[18px]">lightbulb</span>答题要点:</div>
+                    <div v-html="q.outline.keyFactors"></div>
+                  </div>
+                </div>
+              </div>
+            </div>
+            
+          </div>
+        </div>
+      </div>
+    </main>
+
+    <!-- 隐藏的文件输入框 -->
+    <input ref="fileInput" type="file" accept=".ppt,.pptx" style="display: none" @change="handleFileSelect" />
+    
+    <!-- 删除确认弹窗 -->
+    <DeleteConfirmModal :visible="showDeleteModal" title="删除历史记录" :message="deleteConfirmMessage" @confirm="confirmDeleteHistory" @cancel="cancelDeleteHistory" @close="cancelDeleteHistory" />
+  </div>
+</template>\n"""
+
+# Replace script
+content = content.replace(script_content_original, script_content)
+
+# We rebuild the content from parts using the new template.
+# But since we have `rest`, let's do this:
+# 1. Update `<script setup>` block
+new_rest = rest.replace(script_content_original, script_content)
+
+# 2. Append styles
+css = """
+<style scoped>
+.chat-container {
+  display: flex;
+  height: 100vh;
+  width: 100%;
+  overflow: hidden;
+  background-color: #f7f9fb;
+}
+
+/* Material Icons font is usually loaded globally, but if missing, fallback to text */
+@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap');
+.material-symbols-outlined {
+  font-family: 'Material Symbols Outlined';
+  font-weight: normal;
+  font-style: normal;
+  font-size: 24px;
+  display: inline-block;
+  line-height: 1;
+  text-transform: none;
+  letter-spacing: normal;
+  word-wrap: normal;
+  white-space: nowrap;
+  direction: ltr;
+}
+
+/* Base tailwind classes simulation */
+.flex { display: flex; }
+.flex-1 { flex: 1 1 0%; }
+.flex-col { flex-direction: column; }
+.items-center { align-items: center; }
+.justify-between { justify-content: space-between; }
+.justify-center { justify-content: center; }
+.relative { position: relative; }
+.absolute { position: absolute; }
+.w-full { width: 100%; }
+.h-full { height: 100%; }
+.h-screen { height: 100vh; }
+.overflow-hidden { overflow: hidden; }
+.overflow-y-auto { overflow-y: auto; }
+.grid-2-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
+.space-x-2 > * + * { margin-left: 0.5rem; }
+.space-x-3 > * + * { margin-left: 0.75rem; }
+.space-x-8 > * + * { margin-left: 2rem; }
+.space-y-2 > * + * { margin-top: 0.5rem; }
+.space-y-3 > * + * { margin-top: 0.75rem; }
+.space-y-4 > * + * { margin-top: 1rem; }
+.space-y-6 > * + * { margin-top: 1.5rem; }
+.space-y-8 > * + * { margin-top: 2rem; }
+.gap-1 { gap: 0.25rem; }
+.gap-2 { gap: 0.5rem; }
+.gap-3 { gap: 0.75rem; }
+.gap-4 { gap: 1rem; }
+.gap-6 { gap: 1.5rem; }
+.gap-y-2 { row-gap: 0.5rem; }
+
+/* Tailwind Utilities */
+.bg-surface-container-lowest { background-color: #ffffff; }
+.bg-white { background-color: #ffffff; }
+.bg-gray-50 { background-color: #f9fafb; }
+.bg-gray-100 { background-color: #f3f4f6; }
+.bg-gray-200 { background-color: #e5e7eb; }
+.bg-blue-50 { background-color: #eff6ff; }
+.bg-blue-300 { background-color: #93c5fd; }
+.bg-blue-500 { background-color: #3b82f6; }
+.bg-blue-600 { background-color: #2563eb; }
+.bg-blue-700 { background-color: #1d4ed8; }
+.bg-green-50 { background-color: #f0fdf4; }
+.text-white { color: #ffffff; }
+.text-gray-300 { color: #d1d5db; }
+.text-gray-400 { color: #9ca3af; }
+.text-gray-500 { color: #6b7280; }
+.text-gray-600 { color: #4b5563; }
+.text-gray-800 { color: #1f2937; }
+.text-gray-900 { color: #111827; }
+.text-blue-500 { color: #3b82f6; }
+.text-blue-600 { color: #2563eb; }
+.text-red-500 { color: #ef4444; }
+.text-green-500 { color: #22c55e; }
+.text-green-700 { color: #15803d; }
+.text-green-800 { color: #166534; }
+.text-on-surface-variant { color: #424753; }
+.border { border-width: 1px; }
+.border-t { border-top-width: 1px; }
+.border-l { border-left-width: 1px; }
+.border-gray-100 { border-color: #f3f4f6; }
+.border-gray-200 { border-color: #e5e7eb; }
+.border-gray-300 { border-color: #d1d5db; }
+.border-blue-300 { border-color: #93c5fd; }
+.border-blue-500 { border-color: #3b82f6; }
+.border-green-100 { border-color: #dcfce3; }
+.border-green-500 { border-color: #22c55e; }
+.rounded { border-radius: 0.25rem; }
+.rounded-lg { border-radius: 0.5rem; }
+.rounded-xl { border-radius: 0.75rem; }
+.rounded-2xl { border-radius: 1rem; }
+.rounded-full { border-radius: 9999px; }
+.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
+.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
+.p-2 { padding: 0.5rem; }
+.p-3 { padding: 0.75rem; }
+.p-4 { padding: 1rem; }
+.p-5 { padding: 1.25rem; }
+.p-6 { padding: 1.5rem; }
+.p-8 { padding: 2rem; }
+.p-10 { padding: 2.5rem; }
+.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
+.px-4 { padding-left: 1rem; padding-right: 1rem; }
+.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
+.px-8 { padding-left: 2rem; padding-right: 2rem; }
+.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
+.py-1\.5 { padding-top: 0.375rem; padding-bottom: 0.375rem; }
+.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
+.py-2\.5 { padding-top: 0.625rem; padding-bottom: 0.625rem; }
+.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
+.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
+.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
+.pt-6 { padding-top: 1.5rem; }
+.pt-8 { padding-top: 2rem; }
+.pb-20 { padding-bottom: 5rem; }
+.pb-32 { padding-bottom: 8rem; }
+.mb-2 { margin-bottom: 0.5rem; }
+.mb-3 { margin-bottom: 0.75rem; }
+.mb-4 { margin-bottom: 1rem; }
+.mb-6 { margin-bottom: 1.5rem; }
+.mb-8 { margin-bottom: 2rem; }
+.mt-1 { margin-top: 0.25rem; }
+.mt-2 { margin-top: 0.5rem; }
+.mt-3 { margin-top: 0.75rem; }
+.mt-4 { margin-top: 1rem; }
+.mt-8 { margin-top: 2rem; }
+.ml-3\.5 { margin-left: 0.875rem; }
+.mr-2 { margin-right: 0.5rem; }
+.w-1\.5 { width: 0.375rem; }
+.w-2 { width: 0.5rem; }
+.w-4 { width: 1rem; }
+.w-6 { width: 1.5rem; }
+.w-12 { width: 3rem; }
+.w-\[214px\] { width: 214px; }
+.h-1\.5 { height: 0.375rem; }
+.h-2 { height: 0.5rem; }
+.h-4 { height: 1rem; }
+.h-6 { height: 1.5rem; }
+.h-8 { height: 2rem; }
+.h-12 { height: 3rem; }
+.h-48 { height: 12rem; }
+.min-h-screen { min-height: 100vh; }
+.min-w-\[80px\] { min-width: 80px; }
+.max-w-5xl { max-width: 64rem; }
+.mx-auto { margin-left: auto; margin-right: auto; }
+.text-\[10px\] { font-size: 10px; }
+.text-xs { font-size: 0.75rem; line-height: 1rem; }
+.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
+.text-\[15px\] { font-size: 15px; }
+.text-base { font-size: 1rem; line-height: 1.5rem; }
+.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
+.text-2xl { font-size: 1.5rem; line-height: 2rem; }
+.text-3xl { font-size: 1.875rem; line-height: 2.25rem; }
+.font-normal { font-weight: 400; }
+.font-medium { font-weight: 500; }
+.font-bold { font-weight: 700; }
+.italic { font-style: italic; }
+.uppercase { text-transform: uppercase; }
+.tracking-wider { letter-spacing: 0.05em; }
+.leading-relaxed { line-height: 1.625; }
+.text-center { text-align: center; }
+.cursor-pointer { cursor: pointer; }
+.cursor-not-allowed { cursor: not-allowed; }
+.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
+.transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
+.outline-none { outline: 2px solid transparent; outline-offset: 2px; }
+.resize-none { resize: none; }
+.z-30 { z-index: 30; }
+.z-40 { z-index: 40; }
+.opacity-40 { opacity: 0.4; }
+.opacity-60 { opacity: 0.6; }
+.backdrop-blur-md { backdrop-filter: blur(12px); }
+
+/* Hover states */
+.hover\:bg-gray-50:hover { background-color: #f9fafb; }
+.hover\:bg-blue-700:hover { background-color: #1d4ed8; }
+.hover\:bg-green-50:hover { background-color: #f0fdf4; }
+.hover\:border-blue-300:hover { border-color: #93c5fd; }
+.hover\:text-red-500:hover { color: #ef4444; }
+
+/* Group hover */
+.group:hover .group-hover\:text-blue-500 { color: #3b82f6; }
+
+/* Focus states */
+.focus\:border-blue-500:focus { border-color: #3b82f6; }
+.focus\:ring-2:focus { box-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); }
+.focus\:ring-blue-100:focus { --tw-ring-color: #dbeafe; }
+
+/* Custom Slider */
+.custom-slider-container {
+  position: relative;
+  height: 4px;
+  width: 100%;
+  background-color: #e0e3e5;
+  border-radius: 9999px;
+  margin-top: 12px;
+  margin-bottom: 8px;
+}
+.slider-track {
+  position: absolute;
+  top: 0;
+  left: 0;
+  height: 100%;
+  width: 100%;
+  border-radius: 9999px;
+  pointer-events: none;
+}
+.slider-fill {
+  height: 100%;
+  background-color: #0058bd;
+  border-radius: 9999px;
+}
+.custom-slider {
+  position: absolute;
+  top: -8px;
+  left: 0;
+  width: 100%;
+  height: 20px;
+  opacity: 0;
+  cursor: pointer;
+  z-index: 10;
+}
+.custom-slider::-webkit-slider-thumb {
+  width: 16px;
+  height: 16px;
+  appearance: none;
+}
+/* Thumb indicator (visual only) */
+.custom-slider-container::after {
+  content: '';
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 16px;
+  height: 16px;
+  background-color: #0058bd;
+  border: 2px solid white;
+  border-radius: 50%;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.3);
+  pointer-events: none;
+  left: var(--thumb-pos, 0%);
+  margin-left: -8px;
+}
+
+/* History list (from original) */
+.history-sidebar {
+  width: 260px;
+  background: #fff;
+  border-right: 1px solid #e2e8f0;
+  display: flex;
+  flex-direction: column;
+}
+.history-header {
+  padding: 20px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  border-bottom: 1px solid #f1f5f9;
+}
+.section-title {
+  font-size: 16px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.new-chat-btn {
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  transition: transform 0.2s;
+}
+.new-chat-btn:hover {
+  transform: scale(1.1);
+}
+.history-list {
+  flex: 1;
+  overflow-y: auto;
+  padding: 12px;
+}
+.history-item {
+  padding: 12px;
+  border-radius: 8px;
+  margin-bottom: 8px;
+  cursor: pointer;
+  transition: all 0.2s;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.history-item:hover {
+  background: #f8fafc;
+}
+.history-item.active {
+  background: #eff6ff;
+}
+.history-content {
+  flex: 1;
+  overflow: hidden;
+}
+.history-title {
+  font-size: 14px;
+  color: #334155;
+  margin-bottom: 4px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.history-time {
+  font-size: 12px;
+  color: #94a3b8;
+}
+.delete-btn {
+  padding: 4px;
+  opacity: 0;
+  transition: opacity 0.2s;
+}
+.history-item:hover .delete-btn, .delete-btn.always-visible {
+  opacity: 1;
+}
+.delete-icon {
+  width: 16px;
+  height: 16px;
+}
+.empty-history {
+  text-align: center;
+  padding: 40px 0;
+}
+.empty-icon {
+  width: 64px;
+  margin-bottom: 12px;
+}
+.empty-text {
+  color: #94a3b8;
+  font-size: 14px;
+}
+.history-loading {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 40px 0;
+  color: #64748b;
+  font-size: 14px;
+}
+.loading-spinner {
+  width: 24px;
+  height: 24px;
+  border: 2px solid #e2e8f0;
+  border-top-color: #3b82f6;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+  margin-bottom: 12px;
+}
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+</style>
+"""
+
+# Strip old styles
+new_rest_without_style = re.sub(r'<style[\s\S]*?</style>', '', new_rest)
+
+final_content = new_template + "\n" + new_rest_without_style + "\n" + css
+
+with open(filepath, 'w', encoding='utf-8') as f:
+    f.write(final_content)

+ 1 - 0
shudao-vue-frontend/src/App.vue

@@ -17,6 +17,7 @@ onMounted(() => {
     isLoading.value = false
   }
 })
+
 </script>
 
 <template>

+ 0 - 21
shudao-vue-frontend/src/components/Sidebar.vue

@@ -15,18 +15,6 @@
         <img src="@/assets/Chat/8.png" alt="隐患识别" class="nav-icon">
         <span class="nav-text">隐患提示</span>
       </div>
-      <div class="nav-item" :class="{ active: currentRoute === 'SafetyHazard' }" @click="handleNavItem('safety-hazard')">
-        <img src="@/assets/Chat/14.png" alt="安全隐患" class="nav-icon">
-        <span class="nav-text">安全培训</span>
-      </div>
-      <div class="nav-item" :class="{ active: currentRoute === 'AIWriting' }" @click="handleNavItem('ai-writing')">
-        <img src="@/assets/Chat/13.png" alt="AI写作" class="nav-icon">
-        <span class="nav-text">AI写作</span>
-      </div>
-      <div class="nav-item" :class="{ active: currentRoute === 'ExamWorkshop' }" @click="handleNavItem('exam-workshop')">
-        <img src="@/assets/Chat/19.png" alt="考试工坊" class="nav-icon">
-        <span class="nav-text">考试工坊</span>
-      </div>
     </div>
 
     <!-- 退出按钮 -->
@@ -60,18 +48,9 @@ const handleNavItem = (navType) => {
     case 'ai-chat':
       router.push('/chat')
       break
-    case 'safety-hazard':
-      router.push('/safety-hazard')
-      break
     case 'hazard-detection':
       router.push('/hazard-detection')
       break
-    case 'ai-writing':
-      router.push('/ai-writing')
-      break
-    case 'exam-workshop':
-      router.push('/exam-workshop')
-      break
   }
 }
 

+ 3 - 3
shudao-vue-frontend/src/utils/api.js

@@ -3,7 +3,7 @@
  * 统一管理所有API请求
  */
 import request from './authRequest'
-import { buildApiUrl, REPORT_API_PREFIX } from './apiConfig'
+import { buildApiUrl, BACKEND_API_PREFIX } from './apiConfig'
 import { getToken } from './auth'
 
 /**
@@ -89,7 +89,7 @@ export async function stopSSEStream(userId, aiConversationId) {
       headers['Authorization'] = `Bearer ${token}`
     }
     
-    const response = await fetch(buildApiUrl('/sse/stop', REPORT_API_PREFIX), {
+    const response = await fetch(buildApiUrl('/sse/stop', BACKEND_API_PREFIX), {
       method: 'POST',
       headers,
       body: JSON.stringify({
@@ -154,7 +154,7 @@ export async function updateAIMessageContent(aiMessageId, content) {
       headers['Authorization'] = `Bearer ${token}`
     }
     
-    const response = await fetch(buildApiUrl('/report/update-ai-message', REPORT_API_PREFIX), {
+    const response = await fetch(buildApiUrl('/report/update-ai-message', BACKEND_API_PREFIX), {
       method: 'POST',
       headers,
       body: JSON.stringify({

Fișier diff suprimat deoarece este prea mare
+ 429 - 269
shudao-vue-frontend/src/views/Chat.vue


+ 674 - 238
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
@@ -53,9 +53,10 @@
     </div>
 
     <!-- 右侧工作区域 -->
-    <div class="main-work" :style="{ background: showExamDetail ? 'transparent' : '#ebf3ff' }">
+    <div class="main-work" :style="{ background: showExamDetail ? 'transparent' : '#ebf3ff', position: 'relative' }">
+
       <!-- 头部 -->
-      <div class="work-header">
+      <div class="work-header" v-if="showExamDetail">
         <h2>考试工坊</h2>
       </div>
 
@@ -67,237 +68,124 @@
           <p>正在加载历史记录...</p>
         </div>
         <!-- 考试工坊主界面 -->
-        <div v-if="!showExamDetail" class="exam-workshop-card">
-          <!-- 左侧配置区域 -->
-          <div class="config-section">
-            <!-- 1. 选择试卷类型 -->
-            <div class="config-item">
-              <div class="config-header">
-                <div class="step-number">1</div>
-                <h3>选择试卷类型</h3>
-        </div>
-              <div class="type-cards">
-                <div
-                  v-for="(type, key) in projectTypes"
-                  :key="key"
-                  :class="['type-card', { active: selectedProjectType === key }]"
-                  @click="(isGenerating || selectedFile) ? null : selectProjectType(key)"
-                  :style="{ cursor: (isGenerating || selectedFile) ? 'not-allowed' : 'pointer', opacity: (isGenerating || selectedFile) ? '0.5' : '1' }"
-                >
-                  <img :src="type.icon" :alt="type.name" class="type-icon" />
-                  <span>{{ type.name }}</span>
+        <div v-if="!showExamDetail" class="exam-workshop-card app-container">
+            <!-- 中间主操作区 -->
+            <main class="main-content" style="padding-top: 36px;">
+                <div class="form-group" style="position: relative;">
+                    <!-- 返回AI问答按钮 -->
+                    <button v-if="hideSidebar && !showExamDetail" class="return-ai-btn" @click="handleReturnToAI">
+                      返回AI问答
+                    </button>
+                    <label class="form-label">试卷名称</label>
+                    <input type="text" class="form-control" v-model="examName" maxlength="32" placeholder="请输入试卷名称..." :disabled="isGenerating">
+                    <div class="char-count">{{ examName?.length || 0 }}/32</div>
                 </div>
-              </div>
-            </div>
 
-            <!-- 2. 选择生成方式 -->
-            <div class="config-item">
-              <div class="config-header">
-                <div class="step-number">2</div>
-                <h3>选择生成方式</h3>
-              </div>
-              <div class="generation-methods">
-                <div
-                  :class="['method-card', { active: selectedFunction === 'ai' }]"
-                  @click="(isGenerating || selectedFile) ? null : selectFunction('ai')"
-                  :style="{ cursor: (isGenerating || selectedFile) ? 'not-allowed' : 'pointer', opacity: (isGenerating || selectedFile) ? '0.5' : '1' }"
-                >
-                  <img
-                    :src="aiIcon"
-                    alt="智能生成试卷"
-                    class="method-icon"
-                  />
-                  <div class="method-content">
-                    <h4>智能生成试卷</h4>
-                    <p>基于AI技术,根据所选类型自动生成完整试卷</p>
-                  </div>
-                </div>
-                <div
-                  :class="['method-card', { active: selectedFunction === 'ppt' }]"
-                  @click="isGenerating ? null : (selectedFunction === 'ppt' && !selectedFile ? triggerFileUpload() : selectFunction('ppt'))"
-                  :style="{ cursor: isGenerating ? 'not-allowed' : 'pointer', opacity: isGenerating ? '0.5' : '1' }"
-                >
-                  <img
-                    :src="pptIcon"
-                    alt="从PPT生成考题"
-                    class="method-icon"
-                  />
-                  <div class="method-content">
-                    <h4>从PPT生成考题</h4>
-                    <p>上传培训PPT,智能提取关键内容生成考题(单个文件可上传20M内)</p>
+                <div class="form-group">
+                    <label class="form-label">出题依据内容</label>
+                    <textarea class="form-control" v-model="questionBasis" placeholder="在此输入知识点、章节或培训内容..." :disabled="isGenerating || selectedFile"></textarea>
                     
-                    <!-- PPT文件预览区域 -->
-                    <div v-if="selectedFunction === 'ppt' && selectedFile" class="ppt-file-preview">
-                      <div class="file-preview">
-                        <div class="file-icon">{{ selectedFile.icon }}</div>
-                        <div class="file-info">
-                          <div class="file-name">{{ selectedFile.name }}</div>
-                          <div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
+                    <div class="ppt-upload-section" @click="!isGenerating && !selectedFile ? triggerFileUpload() : null">
+                        <div class="ppt-upload-content">
+                            <div class="ppt-upload-icon-wrapper">
+                                <span class="material-symbols-outlined" style="font-size: 28px; color: #4b5563;">cloud_upload</span>
+                            </div>
+                            <div class="ppt-upload-text-wrapper">
+                                <div class="ppt-upload-title">从PPT生成考题</div>
+                                <div class="ppt-upload-hint">上传培训PPT,智能提取关键内容生成考题(单个文件可上传20M内)</div>
+                            </div>
+                        </div>
+                        <span class="material-symbols-outlined ppt-arrow">chevron_right</span>
+                        
+                        <div v-if="selectedFile" class="file-status-badge" @click.stop>
+                          <span class="file-name truncate">已上传: {{ selectedFile.name }}</span>
+                          <span @click.stop="removeSelectedFile" class="remove-btn">×</span>
                         </div>
-                        <button class="remove-file-btn" @click="removeSelectedFile">
-                          <span class="remove-icon">×</span>
-                        </button>
-                      </div>
                     </div>
-                  </div>
                 </div>
-              </div>
-            </div>
 
-            <!-- 3. 试卷配置 -->
-            <div class="config-item">
-              <div class="config-header">
-                <div class="step-number">3</div>
-                <h3>试卷配置</h3>
-              </div>
-              <div class="exam-config-container">
-                <!-- 左侧配置区域 -->
-                <div class="config-left">
-                  <div class="config-row">
-                    <div class="config-group">
-                      <label>试卷名称</label>
-                      <div class="input-wrapper">
-                        <input
-                          v-model="examName"
-                          type="text"
-                          placeholder="请输入试卷名称"
-                          class="config-input"
-                          maxlength="32"
-                          @input="validateExamName"
-                          :disabled="isGenerating || selectedFile"
-                        />
-                        <span class="char-count-inline" :class="{ 'warning': examName.length >= 18 }">
-                          {{ examName.length }}/32
-                        </span>
-                      </div>
+                <!-- =============== 题型配置区域 开始 =============== -->
+                <div class="config-section">
+                    <div class="config-header">
+                        <h3>题型配置</h3>
+                        <div class="total-score">试卷总分 {{ calculatedTotalScore }}</div>
                     </div>
-                    <div class="config-group">
-                      <label>试卷总分</label>
-                      <div class="score-input">
-                        <input
-                          v-model="totalScore"
-                          type="number"
-                          class="config-input"
-                          min="1"
-                          max="1000"
-                          @input="validateTotalScore"
-                          :disabled="isGenerating || selectedFile"
-                        />
-                        <span class="unit">分</span>
-                      </div>
-                    </div>
-                  </div>
 
-                  <!-- 题型选择与分数分配 -->
-                  <div class="section-title">题型选择与分数分配</div>
-                  <div class="question-types">
-                    <div
-                      class="question-type"
-                      v-for="(type, index) in questionTypes"
-                      :key="index"
-                    >
-                      <div class="type-row">
-                        <span class="type-name">{{ type.name }}</span>
-                        <div class="progress-bar">
-                          <div
-                            class="progress-fill"
-                            :style="{
-                              width:
-                                ((type.scorePerQuestion * type.questionCount) /
-                                  totalScore) *
-                                  100 +
-                                '%',
-                            }"
-                          ></div>
-                        </div>
-                        <div class="score-config">
-                          <span>每题</span>
-                                                      <input
-                              v-model="type.scorePerQuestion"
-                              type="number"
-                              class="score-input-field"
-                              min="1"
-                              max="99"
-                              @input="validateScorePerQuestion(type)"
-                              :disabled="isGenerating || selectedFile"
-                            />
-                          <span>分</span>
-                          <span>一共</span>
-                                                      <input
-                              v-model="type.questionCount"
-                              type="number"
-                              class="count-input-field"
-                              min="1"
-                              max="99"
-                              @input="validateQuestionCount(type)"
-                              :disabled="isGenerating || selectedFile"
-                            />
-                          <span>题</span>
+                    <!-- 动态渲染各题型 -->
+                    <div class="question-types-grid">
+                        <div class="question-type-card" v-for="(type, index) in questionTypes" :key="index">
+                            <div class="question-type-header">
+                                <div class="question-type-title">{{ type.name }}</div>
+                                <div class="question-type-score">
+                                    每题 <input type="number" class="score-input" v-model.number="type.scorePerQuestion" min="1" max="100" :disabled="isGenerating"> 分
+                                </div>
+                            </div>
+                            <div class="slider-container">
+                                <span class="slider-label">数量</span>
+                                <input type="range" class="question-slider" v-model.number="type.questionCount" min="0" :max="type.max || 50" :disabled="isGenerating">
+                                <span class="question-count" style="text-align: right; min-width: 40px;">{{ type.questionCount }} 题</span>
+                            </div>
                         </div>
-                      </div>
                     </div>
-                  </div>
+
+                    <div class="action-buttons">
+                        <button class="clear-btn" @click="clearSettings" :disabled="isGenerating">
+                            <span class="material-symbols-outlined" style="font-size: 18px;">delete</span>
+                            清空当前配置
+                        </button>
+                        <button class="generate-btn" @click="generateExam" :disabled="isGenerating">
+                            <span class="material-symbols-outlined" v-if="!isGenerating">auto_awesome</span>
+                            <span class="material-symbols-outlined animate-spin" v-else>autorenew</span>
+                            {{ isGenerating ? '生成中...' : '开始智能生成试卷' }}
+                        </button>
+                    </div>
                 </div>
+                <!-- =============== 题型配置区域 结束 =============== -->
+            </main>
 
-                <!-- 右侧预览面板 -->
-                <div class="preview-panel">
-                  <div class="preview-header">
-                    <img :src="previewIcon" alt="预览" class="preview-icon" />
-                    <h3>预览</h3>
-                  </div>
-                  <div class="preview-content">
-                    <h4 class="preview-title">{{ examName || "试卷名称" }}</h4>
-                    <div class="question-breakdown">
-                      <div
-                        class="breakdown-item"
-                        v-for="(type, index) in questionTypes"
-                        :key="index"
-                      >
-                        <div class="breakdown-row">
-                          <span class="breakdown-left"
-                            >{{ type.romanNumeral }}、{{ type.name }} (每题{{
-                              type.scorePerQuestion
-                            }}分,共{{
-                              type.scorePerQuestion * type.questionCount
-                            }}分)</span
-                          >
-                          <span class="breakdown-right">{{ type.questionCount }}题</span>
+            <!-- =============== 实时预览区域 开始 =============== -->
+            <aside class="preview-panel">
+                <div class="preview-header">
+                    <h3>实时预览</h3>
+                </div>
+
+                <div class="preview-name-card">
+                    <div class="preview-name-label">试卷名称</div>
+                    <div class="preview-title" :style="{ fontStyle: examName ? 'normal' : 'italic' }">{{ examName || '未命名试卷...' }}</div>
+                </div>
+
+                <div class="preview-section">
+                    <div class="preview-section-title">结构大纲</div>
+                    
+                    <div class="preview-item" v-for="(type, index) in questionTypes" :key="index">
+                        <div class="preview-item-top">
+                            <div class="preview-item-left">
+                                <div class="preview-dot" :style="{ backgroundColor: ['#2563eb', '#60a5fa', '#93c5fd', '#dbeafe'][index % 4] }"></div>
+                                <span class="preview-type-name">{{ type.name }}</span>
+                            </div>
+                            <span class="preview-type-count">{{ type.questionCount }}题</span>
+                        </div>
+                        <div class="preview-item-bottom">
+                            <span class="preview-type-score">{{ type.questionCount * type.scorePerQuestion }} 分</span>
                         </div>
-                      </div>
                     </div>
-                    <div class="divider"></div>
-                    <div class="calculated-score-row">
-                      <span class="calculated-label">配置总分</span>
-                      <span class="calculated-value">{{ calculatedTotalScore }}分</span>
+                </div>
+
+                <div class="preview-footer">
+                    <div class="preview-total">
+                        <span>配置总分</span>
+                        <span class="preview-total-score" style="color: #000000; font-size: 24px;">{{ totalScore }}</span>
                     </div>
-                    <div class="total-score-row">
-                      <span class="total-label">试卷总分</span>
-                      <span class="total-value">{{ totalScore }}分</span>
+                    <div class="preview-total" style="margin-top: 20px; font-size: 20px; color: #000000;">
+                        <span>试卷总分</span>
+                        <span style="color: var(--primary-color); font-size: 24px;">{{ calculatedTotalScore }}</span>
                     </div>
-                  </div>
                 </div>
-              </div>
-
-              <!-- 底部操作按钮 -->
-              <div class="bottom-actions">
-                <button class="clear-btn" @click="clearSettings" :disabled="isGenerating || selectedFile">
-                  <img :src="clearIcon" alt="一键清除" class="clear-icon" />
-                </button>
-                <button class="generate-btn" @click="generateExam" :disabled="isGenerating">
-                                      <img v-if="!isGenerating" :src="generateIcon" alt="生成试卷" class="generate-icon" />
-                  <span v-else class="generating-text">
-                    生成中<span class="loading-dots">
-                      <span class="dot"></span>
-                      <span class="dot"></span>
-                      <span class="dot"></span>
-                    </span>
-                  </span>
-                </button>
-              </div>
-            </div>
-          </div>
+            </aside>
+            <!-- =============== 实时预览区域 结束 =============== -->
         </div>
 
+
         <!-- 考试详情页 -->
         <div v-if="showExamDetail" class="exam-detail-card">
           <!-- 详情页头部 -->
@@ -676,9 +564,22 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
+import { ref, computed, onMounted, onUnmounted, reactive, watch, defineProps, defineEmits } from "vue";
 import Sidebar from "@/components/Sidebar.vue";
 import DeleteConfirmModal from "@/components/DeleteConfirmModal.vue";
+
+const props = defineProps({
+  hideSidebar: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['return-to-ai']);
+
+const handleReturnToAI = () => {
+  emit('return-to-ai');
+};
 import { apis } from '@/request/apis.js'
 import { ElMessage } from 'element-plus'
 // ===== 已删除:getUserId - 不再需要,改用token =====
@@ -893,10 +794,10 @@ const projectTypes = {
 
 // 题型配置
 const questionTypes = ref([
-  { name: "单选题", scorePerQuestion: 5, questionCount: 5, romanNumeral: "一" },
-  { name: "判断题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "二" },
-  { name: "多选题", scorePerQuestion: 8, questionCount: 5, romanNumeral: "三" },
-  { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
+  { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+  { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
+  { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
+  { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
 ]);
 
 // 保存初始配置
@@ -995,10 +896,10 @@ const createNewChat = async () => {
   } else {
     // 如果没有初始配置,使用默认配置
     questionTypes.value = [
-      { name: "单选题", scorePerQuestion: 2, questionCount: 8, romanNumeral: "一" },
-      { name: "判断题", scorePerQuestion: 2, questionCount: 5, romanNumeral: "二" },
-      { name: "多选题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "三" },
-      { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
+      { name: "单选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "一" },
+      { name: "判断题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "二" },
+      { name: "多选题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "三" },
+      { name: "简答题", scorePerQuestion: 0, questionCount: 0, romanNumeral: "四" },
     ];
   }
   
@@ -1111,13 +1012,12 @@ const clearSettings = () => {
   // 根据当前选择的工程类型设置试卷名称
   const projectTypeName = projectTypes[selectedProjectType.value].name;
   examName.value = `${projectTypeName}工程施工技术考核`;
-  totalScore.value = 100;
-  questionTypes.value = [
-    { name: "单选题", scorePerQuestion: 2, questionCount: 8, romanNumeral: "一" },
-    { name: "判断题", scorePerQuestion: 2, questionCount: 5, romanNumeral: "二" },
-    { name: "多选题", scorePerQuestion: 3, questionCount: 5, romanNumeral: "三" },
-    { name: "简答题", scorePerQuestion: 10, questionCount: 2, romanNumeral: "四" },
-  ];
+  totalScore.value = 100; // 清空时配置总分恢复默认值 100
+  // 保留原数组引用,更新每个对象的属性,避免破坏 Vue 3 响应式绑定
+  questionTypes.value.forEach(type => {
+    type.scorePerQuestion = 0;
+    type.questionCount = 0;
+  });
   console.log("清除设置");
 };
 
@@ -2278,6 +2178,7 @@ const createHTMLContent = (exportData, includeAnswers = true) => {
       font-weight: bold;
       color: #2c5aa0;
     }
+
   </style>
 </head>
 <body>
@@ -3656,11 +3557,12 @@ onUnmounted(() => {
 /* 工作内容区域 */
 .work-content {
   flex: 1;
-  padding: 22px;
+  padding: 0;
   // overflow-y: auto;
   display: flex;
   flex-direction: column;
-  align-items: center;
+  align-items: stretch;
+  height: 100%;
   
   /* 隐藏滚动条样式 */
   &::-webkit-scrollbar {
@@ -3677,14 +3579,519 @@ onUnmounted(() => {
   }
 }
 
+.app-container {
+    --primary-color: #0d6efd;
+    --danger-color: #dc3545;
+    --warning-color: #ffc107;
+    --border-color: #dee2e6;
+    --bg-light: #f8f9fa;
+    --text-dark: #212529;
+    --text-muted: #6c757d;
+
+    display: flex;
+    height: 100%;
+    width: 100%;
+    padding: 0 !important;
+    background-color: #f5f5f5;
+    overflow: hidden;
+    margin: 0 !important;
+    max-width: 100% !important;
+    border-radius: 0 !important;
+    box-shadow: none !important;
+
+    /* 中间主操作区 */
+    .main-content {
+        flex: 1;
+        padding: 20px;
+        overflow-y: hidden;
+        background: white;
+        scrollbar-width: none; /* Firefox */
+        -ms-overflow-style: none; /* IE and Edge */
+    }
+    .main-content::-webkit-scrollbar {
+        display: none; /* Chrome, Safari and Opera */
+    }
+
+    .form-group {
+        margin-bottom: 12px;
+        max-width: 1150px; /* 限制输入框模块的最大宽度 */
+        margin-left: auto;
+        margin-right: auto; /* 使其在工作区居中 */
+    }
+
+    .form-label {
+        font-weight: 600;
+        margin-bottom: 6px;
+        display: block;
+        font-size: 14px;
+    }
+
+    .form-control {
+        width: 100%;
+        border: 1px solid rgba(0, 0, 0, 0.06); /* 统一边框 */
+        border-radius: 12px; /* 统一圆角 */
+        padding: 12px 16px; /* 稍微增加内边距让它看起来更像卡片 */
+        font-size: 14px;
+        transition: all 0.3s;
+        box-sizing: border-box;
+        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 统一阴影 */
+    }
+
+    .form-control:focus {
+        outline: none;
+        border-color: var(--primary-color);
+        box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.1);
+    }
+
+    textarea.form-control {
+        resize: none;
+        height: 250px;
+    }
+
+    .char-count {
+        text-align: right;
+        font-size: 12px;
+        color: var(--text-muted);
+        margin-top: 4px;
+    }
+
+    /* PPT上传区域 */
+    .ppt-upload-section {
+        background: white;
+        border: 1px solid rgba(0, 0, 0, 0.06); /* 统一边框 */
+        border-radius: 12px; /* 统一圆角 */
+        padding: 16px 20px;
+        margin-top: 40px;
+        cursor: pointer;
+        transition: all 0.3s;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 统一阴影 */
+        position: relative;
+    }
+
+    .ppt-upload-section:hover {
+        border-color: var(--primary-color);
+        box-shadow: 0 8px 24px rgba(13, 110, 253, 0.12); /* 悬浮时加深发光阴影 */
+    }
+
+    .ppt-upload-content {
+        display: flex;
+        align-items: center;
+        gap: 16px;
+    }
+
+    .ppt-upload-icon-wrapper {
+        width: 48px;
+        height: 48px;
+        background: #f3f4f6;
+        border-radius: 12px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        flex-shrink: 0;
+    }
+
+    .ppt-upload-text-wrapper {
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+    }
+
+    .ppt-upload-title {
+        font-size: 15px;
+        font-weight: 600;
+        color: #1f2937;
+        margin-bottom: 4px;
+    }
+
+    .ppt-upload-hint {
+        font-size: 13px;
+        color: #6b7280;
+    }
+
+    .ppt-arrow {
+        color: #9ca3af;
+        font-size: 24px;
+        transition: transform 0.3s;
+    }
+    
+    .ppt-upload-section:hover .ppt-arrow {
+        color: var(--primary-color);
+        transform: translateX(2px);
+    }
+
+    .file-status-badge {
+        position: absolute;
+        bottom: -40px;
+        left: 0;
+        background: #ebf3ff;
+        color: var(--primary-color);
+        padding: 8px 16px;
+        border-radius: 8px;
+        font-size: 13px;
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        border: 1px solid rgba(13, 110, 253, 0.1);
+        max-width: 300px;
+    }
+
+    .file-name {
+        font-weight: 500;
+    }
+
+    .remove-btn {
+        color: #ef4444;
+        font-size: 16px;
+        font-weight: bold;
+        cursor: pointer;
+        padding: 0 4px;
+    }
+
+    .remove-btn:hover {
+        color: #b91c1c;
+    }
+
+    /* =============== 题型配置区域 样式开始 =============== */
+    .config-section {
+        margin-top: 16px;
+    }
+
+    .config-header {
+        display: flex;
+        justify-content: center; /* 改为靠左对齐,而不是两端对齐 */
+        align-items: center;
+        gap: 960px; /* 控制“题型配置”和“试卷总分”之间的固定间距 */
+        margin-bottom: 6px;
+    }
+
+    .config-header h3 {
+        font-size: 18px;
+        font-weight: 600;
+    }
+
+    .total-score {
+        background: var(--bg-light);
+        padding: 8px 16px;
+        border-radius: 20px;
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--primary-color);
+    }
+
+    .question-types-grid {
+        display: grid;
+        /* 为了减小卡片宽度,我们不再让它们自动拉伸占满,而是指定最大宽度并居中,或者留出更大的列间距 */
+        grid-template-columns: repeat(2, minmax(0, 500px));
+        justify-content: center; /* 让网格居中,而不是两端拉伸 */
+        gap: 20px 150px; /* 行间距(高度)20px,列间距(宽度)40px */
+        margin-bottom: 12px;
+    }
+
+    .question-type-card {
+        background: white; /* 改为白色背景 */
+        border-radius: 12px;
+        padding: 16px 20px; /* 根据截图稍微调大内边距以容纳阴影内容 */
+        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 加深阴影 */
+        border: 1px solid rgba(0, 0, 0, 0.06); /* 稍微加深边框线 */
+    }
+
+    .question-type-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 20px; /* 稍微增加与滑动条的间距 */
+    }
+
+    .question-type-title {
+        font-weight: 600;
+        font-size: 16px; /* 字体稍微调大 */
+        color: #1f2937;
+    }
+
+    .question-type-score {
+        font-size: 13px;
+        color: #3b82f6; /* 改变字体颜色为蓝色系 */
+        background: #eff6ff; /* 添加淡蓝色背景 */
+        padding: 4px 12px;
+        border-radius: 20px; /* 胶囊形状 */
+        display: flex;
+        align-items: center;
+        gap: 6px;
+    }
+
+    .score-input {
+        width: 32px;
+        text-align: center;
+        border: none; /* 移除输入框边框 */
+        border-radius: 4px;
+        padding: 0;
+        font-size: 14px;
+        font-weight: 600;
+        color: #2563eb; /* 加深数字颜色 */
+        background: transparent; /* 背景透明,融入胶囊 */
+        transition: all 0.3s;
+        -webkit-appearance: textfield;
+        -moz-appearance: textfield;
+        appearance: textfield;
+    }
+
+    .score-input::-webkit-outer-spin-button,
+    .score-input::-webkit-inner-spin-button {
+        -webkit-appearance: none;
+        margin: 0;
+    }
+
+    .score-input:focus {
+        outline: none;
+        background: white; /* 聚焦时背景变白 */
+        box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
+    }
+
+    .slider-container {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+    }
+
+    .slider-label {
+        font-size: 14px;
+        color: #4b5563;
+        font-weight: 500;
+        min-width: 40px;
+    }
+
+    .question-slider {
+        flex: 1;
+        height: 6px;
+        -webkit-appearance: none;
+        appearance: none;
+        background: #e5e7eb; /* 滑动条底色调浅 */
+        border-radius: 3px;
+        outline: none;
+    }
+
+    .question-slider::-webkit-slider-thumb {
+        -webkit-appearance: none;
+        appearance: none;
+        width: 18px; /* 滑块调大一点 */
+        height: 18px;
+        background: #2563eb; /* 蓝色滑块 */
+        border: 2px solid white; /* 添加白色边框 */
+        box-shadow: 0 1px 3px rgba(0,0,0,0.1); /* 滑块阴影 */
+        border-radius: 50%;
+        cursor: pointer;
+        transition: all 0.3s;
+    }
+
+    .question-slider::-webkit-slider-thumb:hover {
+        background: #1d4ed8;
+        transform: scale(1.1);
+    }
+
+    .question-count {
+        font-weight: bold; /* 数字加粗 */
+        font-size: 15px;
+        color: #1f2937;
+        min-width: 40px;
+        text-align: right;
+    }
+
+    .action-buttons {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-top: 16px;
+        padding-top: 16px;
+        border-top: 1px solid var(--border-color);
+    }
+
+    .clear-btn {
+        background: white;
+        border: 1px solid rgba(0, 0, 0, 0.06); /* 统一边框 */
+        border-radius: 8px; /* 添加圆角 */
+        color: var(--text-muted);
+        font-size: 14px;
+        cursor: pointer;
+        padding: 8px 16px;
+        transition: all 0.3s;
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 统一阴影 */
+    }
+
+    .clear-btn:hover {
+        color: var(--danger-color);
+        border-color: rgba(220, 53, 69, 0.2); /* 悬浮时边框微红 */
+        box-shadow: 0 6px 20px rgba(220, 53, 69, 0.1); /* 悬浮时带红色的发光阴影 */
+    }
+
+    .generate-btn {
+        background: var(--primary-color);
+        color: white;
+        border: none;
+        padding: 10px 24px;
+        border-radius: 8px;
+        font-size: 14px;
+        font-weight: 600;
+        cursor: pointer;
+        transition: all 0.3s;
+        display: flex;
+        align-items: center;
+        gap: 8px;
+    }
+
+    .generate-btn:hover:not(:disabled) {
+        background: #0b5ed7;
+        transform: translateY(-2px);
+        box-shadow: 0 4px 12px rgba(13, 110, 253, 0.3);
+    }
+    
+    .generate-btn:disabled {
+        background: #93c5fd; /* 浅蓝色背景 */
+        cursor: not-allowed;
+        box-shadow: 0 4px 12px rgba(147, 197, 253, 0.4); /* 浅蓝色阴影 */
+        opacity: 0.9;
+    }
+    /* =============== 题型配置区域 样式结束 =============== */
+
+    /* =============== 实时预览区域 样式开始 =============== */
+    .preview-panel {
+        width: 320px;
+        background: #f7f9fb; /* 匹配截图背景 */
+        border-left: 1px solid var(--border-color);
+        padding: 24px;
+        overflow-y: hidden;
+        flex-shrink: 0;
+        scrollbar-width: none;
+        -ms-overflow-style: none;
+    }
+    .preview-panel::-webkit-scrollbar {
+        display: none;
+    }
+
+    .preview-header {
+        margin-bottom: 24px;
+    }
+
+    .preview-header h3 {
+        font-size: 18px;
+        font-weight: bold;
+        color: #1f2937;
+        margin: 0;
+    }
+
+    .preview-name-card {
+        background: white;
+        border-radius: 16px;
+        padding: 16px 20px;
+        margin-bottom: 24px;
+        box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); /* 加深阴影 */
+        border: 1px solid rgba(0, 0, 0, 0.06); /* 稍微加深边框线 */
+    }
+
+    .preview-name-label {
+        font-size: 13px;
+        font-weight: 600;
+        color: #4b5563;
+        margin-bottom: 12px;
+    }
+
+    .preview-title {
+        font-size: 15px;
+        color: #9ca3af;
+        line-height: 1.4;
+    }
+
+    .preview-section {
+        margin-bottom: 24px;
+    }
+
+    .preview-section-title {
+        font-size: 14px;
+        font-weight: bold;
+        color: #4b5563;
+        margin-bottom: 16px;
+    }
+
+    .preview-item {
+        margin-bottom: 16px;
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+    }
+
+    .preview-item-top {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+    }
+
+    .preview-item-left {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+    }
+
+    .preview-dot {
+        width: 6px;
+        height: 6px;
+        border-radius: 50%;
+    }
+
+    .preview-type-name {
+        font-size: 14px;
+        color: #1f2937;
+    }
+
+    .preview-type-count {
+        font-size: 14px;
+        font-weight: 600;
+        color: #1f2937;
+    }
+
+    .preview-item-bottom {
+        padding-left: 14px; /* Align with text (6px dot + 8px gap) */
+    }
+
+    .preview-type-score {
+        font-size: 12px;
+        color: #9ca3af;
+    }
+
+    .preview-footer {
+        margin-top: 24px;
+        padding-top: 20px;
+        border-top: 1px solid #e5e7eb;
+    }
+
+    .preview-total {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        font-size: 15px;
+        font-weight: 600;
+        color: #000000;
+        padding: 0 10px; /* 左右各加16px的内边距,使文字向中间靠拢 */
+    }
+
+    .preview-total-score {
+        font-weight: bold;
+    }
+    /* =============== 实时预览区域 样式结束 =============== */
+}
+
   .exam-workshop-card {
     background: white;
     width: 100%;
-    height: 960px;
-    padding: 32px 32px 14px 32px;
-    border-radius: 16px;
-    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
-    max-width: 1528px;
+    height: 100%;
+    padding: 0;
+    border-radius: 0;
+    box-shadow: none;
+    max-width: 100%;
 
     .config-section {
       flex: 1;
@@ -5166,6 +5573,35 @@ onUnmounted(() => {
   font-weight: 500;
 }
 
+.return-ai-btn {
+  position: absolute;
+  top: -15px;
+  right: 0;
+  z-index: 100;
+  background: white;
+  border: 1px solid rgba(0, 0, 0, 0.06);
+  border-radius: 12px;
+  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+  padding: 6px 16px;
+  font-size: 13px;
+  font-weight: 500;
+  color: #2563eb; /* 改为蓝色文字 */
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  transition: all 0.3s ease;
+}
 
+.return-ai-btn:hover {
+  box-shadow: 0 8px 24px rgba(13, 110, 253, 0.12);
+  color: #0d6efd;
+  border-color: rgba(13, 110, 253, 0.2);
+}
 
+.return-ai-btn::before {
+  content: '←';
+  font-size: 16px;
+  font-weight: bold;
+}
 </style>

+ 519 - 95
shudao-vue-frontend/src/views/HazardDetection.vue

@@ -110,53 +110,12 @@
                                 </p>
                             </div>
 
-                            <!-- 步骤一:选择场景 -->
+                            <!-- 步骤一:上传图片 -->
                             <div class="step-section">
-                                <h4>步骤一:选择场景</h4>
+                                <h4>步骤一:上传需要识别的场景图片</h4>
                                 <p class="step-description">
-                                    请先选择您要识别的工程场景
+                                    系统将自动识别场景与关键要素,无需手动选择
                                 </p>
-                                <div class="scenario-tags">
-                                    <div
-                                        v-for="(scenario, key) in scenarios"
-                                        :key="key"
-                                        :class="[
-                                            'scenario-tag',
-                                            {
-                                                active:
-                                                    selectedScenario === key,
-                                                disabled:
-                                                    key !== 'gas_station' &&
-                                                    key !==
-                                                        'simple_supported_bridge' &&
-                                                    key !== 'tunnel' &&
-                                                    key !==
-                                                        'special_equipment' &&
-                                                    key !== 'operate_highway',
-                                                'identifying-disabled':
-                                                    isIdentifying,
-                                            },
-                                        ]"
-                                        @click="
-                                            !isIdentifying &&
-                                            (key === 'gas_station' ||
-                                                key ===
-                                                    'simple_supported_bridge' ||
-                                                key === 'tunnel' ||
-                                                key === 'special_equipment' ||
-                                                key === 'operate_highway')
-                                                ? selectScenario(key)
-                                                : null
-                                        "
-                                    >
-                                        {{ scenario.name }}
-                                    </div>
-                                </div>
-                            </div>
-
-                            <!-- 步骤二:上传图片 -->
-                            <div class="step-section">
-                                <h4>步骤二:上传需要识别的场景图片</h4>
                                 <div
                                     class="upload-area"
                                     @click="triggerFileUpload"
@@ -442,9 +401,10 @@
                                 </span>
                             </div>
 
-                            <div class="image-container">
+                            <div class="image-container" ref="imageContainerRef">
                                 <!-- 显示图片:扫描时显示原图,扫描完成后显示标注图 -->
                                 <img
+                                    ref="mainImageRef"
                                     :src="
                                         showScanningEffect
                                             ? uploadedImageUrl
@@ -461,9 +421,42 @@
                                         cursor: pointer;
                                         transform: none !important;
                                     "
+                                    @load="handleMainImageLoad"
                                     @error="handleMainImageError"
                                 />
 
+                                <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
                                     v-if="showScanningEffect"
@@ -534,11 +527,7 @@
                                                     : "未知场景"
                                             }}</span
                                         >场景,检测到的关键要素为<span
-                                            v-for="(
-                                                label, index
-                                            ) in detectionResult.labels?.split(
-                                                '、'
-                                            ) || []"
+                                            v-for="(label, index) in displayLabels"
                                             :key="index"
                                             class="label-tag"
                                             >{{ label }}</span
@@ -554,6 +543,44 @@
                                     根据安全规范和施工标准,我为您梳理出以下需要重点关注的安全隐患
                                 </p>
 
+                                <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>
+
                                 <!-- 场景隐患列表 -->
                                 <div
                                     class="hazards-section"
@@ -579,11 +606,16 @@
                                             v-else
                                             class="hazard-cards-container"
                                         >
+                                            <div
+                                                v-if="!filteredHazards.length"
+                                                class="hazard-empty"
+                                            >
+                                                暂无对应隐患
+                                            </div>
                                             <div
                                                 v-for="(
                                                     hazard, index
-                                                ) in detectionResult?.third_scenes ||
-                                                []"
+                                                ) in filteredHazards"
                                                 :key="index"
                                                 class="hazard-card"
                                                 :class="{
@@ -1095,7 +1127,7 @@
 </template>
 
 <script setup>
-import { ref, onMounted, computed } from "vue";
+import { ref, onMounted, onBeforeUnmount, computed, watch, nextTick } from "vue";
 import { ElMessage } from "element-plus";
 import { Upload, View, DataAnalysis, Bell } from "@element-plus/icons-vue";
 import Sidebar from "@/components/Sidebar.vue";
@@ -1106,7 +1138,7 @@ import startIdentifyActiveImg from "@/assets/Hazard/3.png";
 
 // 响应式数据
 const messageText = ref("");
-const selectedScenario = ref("tunnel"); // 默认选择隧道工程
+const selectedScenario = ref(""); // 自动识别出的场景
 const uploadedImage = ref(null);
 const uploadedImageUrl = ref(""); // 新增:存储上传后的图片URL
 const fileInput = ref(null);
@@ -1145,6 +1177,11 @@ const isStreamingAnalysis = ref(false); // 控制整个分析文本流式输出
 const streamingAnalysis = ref(""); // 流式输出的完整分析文本
 const showAnalysisPrompt = ref(false); // 控制分析提示显示
 const visibleHazardCards = ref({}); // 控制每个隐患卡片的显示状态
+const selectedKeyElement = ref(null); // 当前选中的关键要素
+const imageContainerRef = ref(null); // 图片容器引用
+const mainImageRef = ref(null); // 主图引用
+const elementCardRef = ref(null); // 关键要素卡片引用
+const elementOverlayStyle = ref(null); // 关键要素卡片定位样式
 
 // 历史记录相关数据
 const historyData = ref([]); // 存储历史记录数据
@@ -1192,6 +1229,14 @@ const scenarios = {
     operate_highway: { name: "运营高速公路", color: "#722ED1" },
 };
 
+const autoSceneOrder = [
+    "tunnel",
+    "simple_supported_bridge",
+    "gas_station",
+    "special_equipment",
+    "operate_highway",
+];
+
 // 历史记录数据结构 - 初始为空数组,等待后端数据
 // const historyData = ref([]);
 
@@ -1239,6 +1284,243 @@ const getTagText = (tagType) => {
     return tagTypeConfig[tagType]?.text || "隧道";
 };
 
+const getUniqueLabels = (labels) => {
+    const seen = new Set();
+    return labels.filter((label) => {
+        if (!label || seen.has(label)) return false;
+        seen.add(label);
+        return true;
+    });
+};
+
+const displayLabels = computed(() => {
+    const labels =
+        detectionResult.value?.display_labels || detectionResult.value?.labels;
+    if (!labels) return [];
+
+    const normalizedLabels = (Array.isArray(labels) ? labels : String(labels).split(/[,,、]/))
+        .map((label) => normalizeLabel(String(label).trim()))
+        .filter((label) => label);
+
+    return getUniqueLabels(normalizedLabels);
+});
+
+const keyElements = computed(() => displayLabels.value);
+
+const normalizeLabel = (label) => {
+    if (!label) return "";
+    const parts = String(label).split("_").filter((part) => part);
+    if (parts.length <= 1) return String(label);
+    return parts.slice(1).join("_");
+};
+
+const hazardMatchesElement = (hazard, element) => {
+    if (!hazard || !element) return false;
+    return hazard.includes(element) || element.includes(hazard);
+};
+
+const hazardsMap = computed(() => {
+    const map = {};
+    keyElements.value.forEach((element) => {
+        map[element] = [];
+    });
+    const hazards = detectionResult.value?.third_scenes || [];
+    hazards.forEach((hazard) => {
+        let matched = false;
+        for (const element of keyElements.value) {
+            if (hazardMatchesElement(hazard, element)) {
+                map[element].push(hazard);
+                matched = true;
+                break;
+            }
+        }
+        if (!matched) {
+            if (!map.__unmatched) map.__unmatched = [];
+            map.__unmatched.push(hazard);
+        }
+    });
+    return map;
+});
+
+const filteredHazards = computed(() => {
+    if (!detectionResult.value) return [];
+    if (!selectedKeyElement.value) {
+        return detectionResult.value?.third_scenes || [];
+    }
+    const backendHazards =
+        detectionResult.value?.element_hazards?.[selectedKeyElement.value];
+    if (Array.isArray(backendHazards)) {
+        return backendHazards;
+    }
+    return hazardsMap.value[selectedKeyElement.value] || [];
+});
+
+const selectedDetection = computed(() => {
+    if (!selectedKeyElement.value) return null;
+    const detections = detectionResult.value?.detections || [];
+    return (
+        detections.find((detection) => {
+            const label = normalizeLabel(detection?.label || "");
+            return (
+                label === selectedKeyElement.value ||
+                label.includes(selectedKeyElement.value) ||
+                selectedKeyElement.value.includes(label)
+            );
+        }) || null
+    );
+});
+
+const resetKeyElementState = () => {
+    selectedKeyElement.value = null;
+    elementOverlayStyle.value = null;
+};
+
+const toggleKeyElement = async (element) => {
+    if (selectedKeyElement.value === element) {
+        resetKeyElementState();
+    } else {
+        selectedKeyElement.value = element;
+    }
+    await nextTick();
+    updateElementOverlayPosition();
+};
+
+const setVisibleHazardCards = (hazards = filteredHazards.value) => {
+    const nextVisibleHazards = {};
+    hazards.forEach((_, index) => {
+        nextVisibleHazards[index] = true;
+    });
+    visibleHazardCards.value = nextVisibleHazards;
+};
+
+const getAutoSceneCandidates = () => {
+    if (
+        selectedScenario.value &&
+        autoSceneOrder.includes(selectedScenario.value)
+    ) {
+        return [
+            selectedScenario.value,
+            ...autoSceneOrder.filter(
+                (sceneKey) => sceneKey !== selectedScenario.value
+            ),
+        ];
+    }
+    return autoSceneOrder;
+};
+
+const detectSceneAutomatically = async (baseRequestData) => {
+    let lastErrorMessage = "";
+
+    for (const sceneKey of getAutoSceneCandidates()) {
+        try {
+            const response = await apis.hazardDetection({
+                ...baseRequestData,
+                scene_name: sceneKey,
+            });
+            const isSuccess =
+                response.code === 200 || response.statusCode === 200;
+
+            if (isSuccess) {
+                return {
+                    response,
+                    sceneKey,
+                };
+            }
+
+            lastErrorMessage =
+                response.msg || response.message || lastErrorMessage;
+        } catch (error) {
+            lastErrorMessage =
+                error?.msg || error?.message || lastErrorMessage;
+        }
+    }
+
+    throw new Error(
+        lastErrorMessage || "暂未识别到支持的场景,请尝试更换更清晰的图片"
+    );
+};
+
+const updateElementOverlayPosition = async () => {
+    if (
+        !selectedKeyElement.value ||
+        showScanningEffect.value ||
+        currentView.value !== "detail"
+    ) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const container = imageContainerRef.value;
+    const imageElement = mainImageRef.value;
+    const detection = selectedDetection.value;
+
+    if (
+        !container ||
+        !imageElement ||
+        !detection ||
+        !Array.isArray(detection.box) ||
+        detection.box.length < 4 ||
+        !imageElement.naturalWidth ||
+        !imageElement.naturalHeight
+    ) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const containerWidth = container.clientWidth;
+    const containerHeight = container.clientHeight;
+    const naturalWidth = imageElement.naturalWidth;
+    const naturalHeight = imageElement.naturalHeight;
+
+    if (!containerWidth || !containerHeight) {
+        elementOverlayStyle.value = null;
+        return;
+    }
+
+    const scale = Math.min(
+        containerWidth / naturalWidth,
+        containerHeight / naturalHeight
+    );
+    const renderedWidth = naturalWidth * scale;
+    const renderedHeight = naturalHeight * scale;
+    const offsetX = (containerWidth - renderedWidth) / 2;
+    const offsetY = (containerHeight - renderedHeight) / 2;
+
+    const [x1, y1, x2, y2] = detection.box.map((value) => Number(value) || 0);
+
+    const boxLeft = offsetX + x1 * scale;
+    const boxTop = offsetY + y1 * scale;
+    const boxRight = offsetX + x2 * scale;
+
+    let cardLeft = boxRight + 12;
+    let cardTop = boxTop;
+    const cardWidth = elementCardRef.value?.offsetWidth || 260;
+    const cardHeight = elementCardRef.value?.offsetHeight || 148;
+    const safePadding = 12;
+
+    if (cardLeft + cardWidth > containerWidth - safePadding) {
+        cardLeft = Math.max(safePadding, boxLeft - cardWidth - 12);
+    }
+    if (cardTop + cardHeight > containerHeight - safePadding) {
+        cardTop = Math.max(
+            safePadding,
+            containerHeight - cardHeight - safePadding
+        );
+    }
+
+    elementOverlayStyle.value = {
+        left: `${cardLeft}px`,
+        top: `${Math.max(safePadding, cardTop)}px`,
+    };
+
+    await nextTick();
+};
+
+const handleMainImageLoad = async () => {
+    await nextTick();
+    updateElementOverlayPosition();
+};
+
 // 删除历史记录
 const deleteHistoryItem = (historyItem, index) => {
     console.log("准备删除隐患提示历史记录:", historyItem);
@@ -1306,7 +1588,7 @@ const createNewChat = () => {
 
     // 重置所有状态
     currentView.value = "main";
-    selectedScenario.value = "tunnel"; // 默认选择隧道工程
+    selectedScenario.value = "";
     uploadedImage.value = null;
     uploadedImageUrl.value = "";
     selectedHistoryItem.value = null;
@@ -1328,6 +1610,8 @@ const createNewChat = () => {
     isStreamingAnalysis.value = false; // 重置分析文本流式输出状态
     streamingAnalysis.value = ""; // 清空分析文本流式输出内容
     showAnalysisPrompt.value = false; // 重置分析提示状态
+    detectionResult.value = null;
+    resetKeyElementState();
     // 清空文件输入
     if (fileInput.value) {
         fileInput.value.value = "";
@@ -1380,13 +1664,18 @@ const handleHistoryItem = async (historyItem, event) => {
                     detailData.tag_type ||
                     getTagTypeFromLabels(detailData.labels),
                 labels: detailData.labels,
+                display_labels: detailData.display_labels || [],
                 total_detections: detailData.labels
                     ? Array.isArray(detailData.labels)
                         ? detailData.labels.length
                         : 0
                     : 0,
                 third_scenes: detailData.third_scenes || [],
+                element_hazards: detailData.element_hazards || {},
+                detections: detailData.detections || [],
             };
+            selectedScenario.value = detectionResult.value.scene_name || "";
+            resetKeyElementState();
 
             // 设置图片URL并检测加载完成
             const newImageUrl =
@@ -1405,11 +1694,9 @@ const handleHistoryItem = async (historyItem, event) => {
             historyItem.tagType = tagType;
 
             // 显示所有隐患卡片(历史记录不需要动画效果)
-            const hazards = detectionResult.value?.third_scenes || [];
-            visibleHazardCards.value = {};
-            hazards.forEach((hazard, index) => {
-                visibleHazardCards.value[index] = true;
-            });
+            setVisibleHazardCards(
+                detectionResult.value?.third_scenes || []
+            );
         } else {
             console.error("获取详情失败:", detailResponse.message);
             ElMessage.error("获取记录详情失败");
@@ -1418,8 +1705,11 @@ const handleHistoryItem = async (historyItem, event) => {
             detectionResult.value = {
                 scene_name: historyItem.tagType || "simple_supported_bridge",
                 labels: historyItem.labels,
+                display_labels: [],
                 total_detections: 0,
                 third_scenes: [],
+                element_hazards: {},
+                detections: [],
             };
             const fallbackImageUrl =
                 historyItem.recognitionImageUrl || historyItem.originalImageUrl;
@@ -1431,7 +1721,7 @@ const handleHistoryItem = async (historyItem, event) => {
             }
 
             // 显示所有隐患卡片
-            visibleHazardCards.value = {};
+            setVisibleHazardCards([]);
         }
 
         // 更新数据层的active状态
@@ -1449,19 +1739,6 @@ const handleHistoryItem = async (historyItem, event) => {
     }
 };
 
-// 选择场景
-const selectScenario = (scenarioKey) => {
-    try {
-        console.log("selectScenario 被调用,场景:", scenarioKey);
-        selectedScenario.value = scenarioKey;
-        isDragOver.value = false; // 重置拖拽状态
-        console.log("选择场景:", scenarios[scenarioKey].name);
-    } catch (error) {
-        console.error("选择场景失败:", error);
-        isDragOver.value = false; // 重置拖拽状态
-    }
-};
-
 // 触发文件上传
 const triggerFileUpload = () => {
     try {
@@ -1527,13 +1804,6 @@ const startIdentification = async () => {
             return;
         }
 
-        if (!selectedScenario.value) {
-            console.log("未选择场景");
-            ElMessage.warning("请先选择场景");
-            isDragOver.value = false; // 重置拖拽状态
-            return;
-        }
-
         if (!uploadedImageUrl.value) {
             // 使用 uploadedImageUrl.value 判断
             console.log("未上传图片");
@@ -1573,13 +1843,11 @@ const startIdentification = async () => {
             // 如果检查失败,继续执行识别流程
         }
 
-        console.log("开始识别:", {
-            scenario: scenarios[selectedScenario.value].name,
-            image: uploadedImageUrl.value, // 使用 uploadedImageUrl.value
-        });
+        console.log("开始自动识别图片场景:", uploadedImageUrl.value);
 
         // 开始识别状态
         isIdentifying.value = true;
+        resetKeyElementState();
 
         // 调用后端API进行隐患提示
         // ElMessage.success("开始进行隐患提示,请稍候...");
@@ -1601,15 +1869,16 @@ const startIdentification = async () => {
 
         const requestData = {
             // ===== 已删除:user_id - 后端从token解析 =====
-            scene_name: selectedScenario.value,
             image: uploadedImageUrl.value,
             account: accountLastFour,
             username: username,
             date: currentDate,
         };
 
-        console.log("发送隐患提示请求:", requestData);
-        const response = await apis.hazardDetection(requestData);
+        console.log("发送自动场景识别请求:", requestData);
+        const { response, sceneKey } = await detectSceneAutomatically(
+            requestData
+        );
         console.log("隐患提示响应:", response);
 
         // 检查响应结构,兼容不同的字段名
@@ -1619,7 +1888,13 @@ const startIdentification = async () => {
             ElMessage.success("隐患提示完成!");
 
             // 保存识别结果
-            detectionResult.value = response.data;
+            detectionResult.value = {
+                ...response.data,
+                scene_name: response.data.scene_name || sceneKey,
+                display_labels: response.data.display_labels || [],
+                element_hazards: response.data.element_hazards || {},
+            };
+            selectedScenario.value = detectionResult.value.scene_name || sceneKey;
 
             // 处理标注后的图片
             if (response.data.annotated_image) {
@@ -1654,6 +1929,10 @@ const startIdentification = async () => {
             // 自动选中最新创建的历史记录
             if (historyData.value.length > 0) {
                 const latestRecord = historyData.value[0]; // 假设最新的记录在数组第一位
+                latestRecord.tagType =
+                    detectionResult.value.scene_name || latestRecord.tagType;
+                latestRecord.detections =
+                    detectionResult.value.detections || [];
                 selectedHistoryItem.value = latestRecord;
 
                 // 更新所有记录的active状态
@@ -1856,14 +2135,14 @@ const startAnalysisStreaming = () => {
         // 重置状态
         isStreamingAnalysis.value = false;
         streamingAnalysis.value = "";
+        resetKeyElementState();
         visibleHazardCards.value = {}; // 重置隐患卡片显示状态
 
         // 立即开始流式输出,不需要延迟
         // 构建完整的分析文本(带HTML标签)
         const sceneName = detectionResult.value?.scene_name;
         const sceneText = sceneName ? scenarios[sceneName]?.name : "未知场景";
-        const labels = detectionResult.value?.labels || "";
-        const labelsArray = labels.split("、");
+        const labelsArray = displayLabels.value;
 
         // 构建带HTML的完整文本
         const labelsTags = labelsArray
@@ -1937,7 +2216,8 @@ const startAnalysisStreaming = () => {
 const showHazardCardsSequentially = () => {
     try {
         console.log("开始逐个显示隐患卡片");
-        const hazards = detectionResult.value?.third_scenes || [];
+        const hazards = filteredHazards.value || [];
+        visibleHazardCards.value = {};
 
         // 在第一个卡片显示之前,先滚动到隐患section
         setTimeout(() => {
@@ -1979,6 +2259,9 @@ const clearUploadedImage = () => {
         console.log("clearUploadedImage 被调用");
         uploadedImage.value = null;
         uploadedImageUrl.value = "";
+        annotatedImageUrl.value = "";
+        detectionResult.value = null;
+        resetKeyElementState();
         isDragOver.value = false; // 重置拖拽状态
         if (fileInput.value) {
             fileInput.value.value = ""; // 清空input的value
@@ -2085,7 +2368,7 @@ const processImageOrientation = (file) => {
 const processFile = async (file) => {
     try {
         console.log("processFile 被调用,文件:", file);
-        // 检查文件大小(10MB限制)
+        // 检查文件大小(5MB限制)
         if (file.size > 5 * 1024 * 1024) {
             console.log("文件大小超过限制:", file.size);
             ElMessage.error("文件大小不能超过5MB");
@@ -2098,7 +2381,7 @@ const processFile = async (file) => {
         console.log("文件类型:", file.type);
         if (!allowedTypes.includes(file.type)) {
             console.log("不支持的文件类型:", file.type);
-            ElMessage.error("只支持JPG、PNG、GIF、BMP、WEBP格式的图片");
+            ElMessage.error("只支持JPG、PNG格式的图片");
             isDragOver.value = false; // 重置拖拽状态
             return;
         }
@@ -2469,9 +2752,53 @@ const submitEvaluation = async () => {
     }
 };
 
-// 页面初始化时获取历史记录
+watch(
+    filteredHazards,
+    (hazards) => {
+        if (
+            showScanningEffect.value ||
+            showAnalysisPrompt.value ||
+            isStreamingAnalysis.value
+        ) {
+            return;
+        }
+        setVisibleHazardCards(hazards);
+    },
+    { deep: false }
+);
+
+watch(
+    [
+        selectedKeyElement,
+        currentView,
+        showScanningEffect,
+        () => detectionResult.value?.detections,
+    ],
+    async () => {
+        if (
+            !selectedKeyElement.value ||
+            currentView.value !== "detail" ||
+            showScanningEffect.value
+        ) {
+            elementOverlayStyle.value = null;
+            return;
+        }
+        await nextTick();
+        updateElementOverlayPosition();
+    }
+);
+
+const handleWindowResize = () => {
+    updateElementOverlayPosition();
+};
+
 onMounted(() => {
     getHistoryRecords();
+    window.addEventListener("resize", handleWindowResize);
+});
+
+onBeforeUnmount(() => {
+    window.removeEventListener("resize", handleWindowResize);
 });
 </script>
 
@@ -3438,6 +3765,41 @@ onMounted(() => {
                 cursor: pointer;
             }
 
+            .element-overlay-card {
+                position: absolute;
+                width: 260px;
+                max-height: 180px;
+                overflow-y: auto;
+                text-align: left;
+                padding: 14px 16px;
+                background: rgba(255, 255, 255, 0.96);
+                border: 1px solid rgba(239, 68, 68, 0.25);
+                border-radius: 14px;
+                box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
+                backdrop-filter: blur(10px);
+                z-index: 6;
+
+                .element-card-title {
+                    font-size: 14px;
+                    font-weight: 600;
+                    color: #b91c1c;
+                    margin-bottom: 10px;
+                }
+
+                .element-card-list {
+                    margin: 0;
+                    padding-left: 18px;
+                    color: #374151;
+                    font-size: 13px;
+                    line-height: 1.6;
+                }
+
+                .element-card-empty {
+                    font-size: 13px;
+                    color: #6b7280;
+                }
+            }
+
             .scanning-overlay {
                 position: absolute;
                 top: 0;
@@ -3626,6 +3988,57 @@ onMounted(() => {
                 margin-bottom: 0.75rem;
             }
 
+            .key-elements-section {
+                margin-bottom: 1rem;
+                padding: 0.875rem 1rem;
+                border-radius: 16px;
+                background: linear-gradient(135deg, #eef4ff 0%, #f8fbff 100%);
+                border: 1px solid #dbeafe;
+
+                .key-elements-title {
+                    font-size: 0.875rem;
+                    font-weight: 600;
+                    color: #1e3a8a;
+                    margin-bottom: 0.75rem;
+                }
+
+                .key-elements-buttons {
+                    display: flex;
+                    flex-wrap: wrap;
+                    gap: 0.625rem;
+                }
+
+                .key-element-btn {
+                    border: 1px solid #bfdbfe;
+                    background: #ffffff;
+                    color: #1d4ed8;
+                    border-radius: 9999px;
+                    padding: 0.45rem 0.9rem;
+                    font-size: 0.875rem;
+                    line-height: 1;
+                    cursor: pointer;
+                    transition: all 0.2s ease;
+
+                    &:hover {
+                        border-color: #60a5fa;
+                        background: #eff6ff;
+                    }
+
+                    &.active {
+                        background: #2563eb;
+                        border-color: #2563eb;
+                        color: #ffffff;
+                        box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
+                    }
+                }
+
+                .key-elements-hint {
+                    margin-top: 0.75rem;
+                    font-size: 0.8125rem;
+                    color: #64748b;
+                }
+            }
+
             /* 场景隐患列表样式 - 移到analysis-section内部 */
             .hazards-section {
                 margin-top: 0;
@@ -3644,6 +4057,17 @@ onMounted(() => {
                         margin-top: 0.75rem;
                     }
 
+                    .hazard-empty {
+                        padding: 1rem 1.25rem;
+                        margin-top: 0.75rem;
+                        background: #ffffff;
+                        border: 1px dashed #cbd5e1;
+                        border-radius: 16px;
+                        font-size: 0.875rem;
+                        color: #64748b;
+                        text-align: center;
+                    }
+
                     .hazard-card {
                         background-color: #ffffff;
                         border-radius: 9999px;

+ 12 - 3
shudao-vue-frontend/src/views/Index.vue

@@ -473,7 +473,10 @@ const goToHazardDetection = () => {
 }
 
 const goToSafetyTraining = () => {
-  router.push('/safety-hazard')
+  router.push({
+    path: '/chat',
+    query: { mode: 'safety-training' }
+  })
 }
 
 const goToAIChat = () => {
@@ -481,11 +484,17 @@ const goToAIChat = () => {
 }
 
 const goToExamWorkshop = () => {
-  router.push('/exam-workshop')
+  router.push({
+    path: '/chat',
+    query: { mode: 'exam-workshop' }
+  })
 }
 
 const goToAIWriting = () => {
-  router.push('/ai-writing')
+  router.push({
+    path: '/chat',
+    query: { mode: 'ai-writing' }
+  })
 }
 
 const goToPolicyDocument = () => {

+ 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);
+    }
+  }
 
   // 设置初始加载状态
 

+ 36 - 79
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -467,7 +467,7 @@ import { apis } from '@/request/apis.js'
 // import { getUserId } from '@/utils/userManager.js'
 import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
-import { getApiPrefix, getReportApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
+import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import { getToken, getTokenType, getUserName, getAccountId } from '@/utils/auth.js'
@@ -2475,80 +2475,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         // 非专业问题,只输出summary字段内容并终止流程
         const summaryContent = data.summary || '抱歉,我暂时无法回答您的问题。'
 
-        // 只设置summary,不设置content和displayContent,避免重复显示
+        // 先缓存summary作为兜底内容,但继续等待online_answer/completed事件
         aiMessage.summary = summaryContent
-        aiMessage.isTyping = false // 停止加载动画
-
-        // 保存到数据库
-        if (aiMessage.ai_message_id) {
-          updateAIMessageContent(aiMessage.ai_message_id, summaryContent, summaryContent)
-            .catch(err => console.error('回写AI消息失败:', err))
-        }
-
-        // 关闭SSE连接
-        if (sseConnection) {
-          closeSSEConnection(sseConnection)
-          sseConnection = null
-        }
-
-        // 重置发送状态
-        isSending.value = false
-        streamingReports.value.clear()
-
-        // 重置AI回复流程状态
-        isAIReplyProcessComplete.value = true
-        
-        // ===== 🎯 非专业问题也要更新历史记录和获取推荐问题 =====
-        // 更新历史记录
-        if (ai_conversation_id.value && ai_conversation_id.value !== 0) {
-          // 先清除所有高亮
-          historyData.value.forEach((item) => {
-            item.isActive = false
-          })
-          
-          // 获取第一条用户消息作为标题
-          const firstUserMessage = chatMessages.value.find(msg => msg.type === 'user')
-          const title = firstUserMessage ? firstUserMessage.content.substring(0, 20) + '...' : '新对话'
-          
-          // 检查是否已存在
-          const existingIndex = historyData.value.findIndex(item => item.id === ai_conversation_id.value)
-          
-          if (existingIndex === -1) {
-            // 不存在,在最前面插入新项
-            const newItem = {
-              id: ai_conversation_id.value,
-              title: title,
-              time: formatTime(new Date().toISOString()),
-              businessType: 0,
-              isActive: true,
-              rawData: {
-                id: ai_conversation_id.value,
-                content: firstUserMessage?.content || '',
-                updated_at: new Date().toISOString()
-              }
-            }
-            historyData.value.unshift(newItem)
-            console.log('✅ 非专业问题:已在列表最前面插入新历史记录')
-          } else {
-            // 已存在,设为高亮并移到最前面
-            const existingItem = historyData.value.splice(existingIndex, 1)[0]
-            existingItem.isActive = true
-            existingItem.time = formatTime(new Date().toISOString())
-            historyData.value.unshift(existingItem)
-            console.log('✅ 非专业问题:已将历史记录移到最前面')
-          }
-          
-          // 更新历史记录总数
-          historyTotal.value = historyData.value.length
-        }
-        
-        // 获取推荐问题
-        const lastUserMessage = chatMessages.value.filter(msg => msg.type === 'user').pop()
-        if (lastUserMessage && aiMessage.ai_message_id && summaryContent) {
-          getAIRelatedQuestions(lastUserMessage.content, summaryContent, aiMessage.ai_message_id)
-        }
-        
-        return // 终止处理
+        aiMessage._fullSummary = summaryContent
+        break
       }
 
       // 专业问题:意图识别完成,更新为查询知识库状态
@@ -2575,6 +2505,32 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         })
       }
       break
+
+    case 'online_answer': {
+      aiMessage.showStats = false
+      aiMessage.summary = ''
+
+      const finalContent = data.content || ''
+      aiMessage.content = finalContent
+
+      if (!finalContent.trim()) {
+        aiMessage.displayContent = ''
+        aiMessage.isTyping = false
+        break
+      }
+
+      const processedReply = processAIResponse(finalContent)
+      aiMessage.displayContent = renderMarkdownContent(processedReply)
+      aiMessage.isTyping = false
+      break
+    }
+
+    case 'online_error':
+      aiMessage.showStats = false
+      aiMessage.content = data.message || '在线模型服务暂不可用'
+      aiMessage.displayContent = renderMarkdownContent(processAIResponse(aiMessage.content))
+      aiMessage.isTyping = false
+      break
       
     case 'documents':
       aiMessage.totalFiles = data.total
@@ -2986,7 +2942,7 @@ const handleSSEComplete = () => {
         
         const collectedContent = message.reports && message.reports.length > 0 
           ? JSON.stringify(contentData)
-          : message.content
+          : (message.content || message._fullSummary || message.summary || '')
         
         if (collectedContent) {
           // 同时保存summary字段(作为单独字段)
@@ -3142,7 +3098,7 @@ const handleSSEInterrupted = (data) => {
         
         const collectedContent = message.reports && message.reports.length > 0 
           ? JSON.stringify(contentData)
-          : message.content
+          : (message.content || message._fullSummary || message.summary || '')
         
         if (collectedContent) {
           // 同时保存summary字段(作为单独字段)
@@ -3201,7 +3157,7 @@ const handleStopGeneration = async () => {
         
         const collectedContent = message.reports && message.reports.length > 0 
           ? JSON.stringify(contentData)
-          : message.content
+          : (message.content || message._fullSummary || message.summary || '')
         
         if (collectedContent) {
           // 同时保存summary字段(作为单独字段)
@@ -3262,7 +3218,7 @@ const handleReportGeneratorSubmit = async (data) => {
   })
 
   try {
-    const apiPrefix = getReportApiPrefix()
+    const apiPrefix = getApiPrefix()
     const url = `${apiPrefix}/report/complete-flow`
 
     // 构建 POST 请求体
@@ -3271,7 +3227,8 @@ const handleReportGeneratorSubmit = async (data) => {
       window_size: data.windowSize,
       n_results: 2,
       ai_conversation_id: ai_conversation_id.value,
-      is_network_search_enabled: isNetworkSearchEnabled.value
+      is_network_search_enabled: isNetworkSearchEnabled.value,
+      user_id: userId || 1 // 测试时可直接写死 1
     }
 
     console.log('📤 发起 SSE POST 请求:', {

+ 12 - 0
shudao-vue-frontend/vite.config.js

@@ -31,6 +31,18 @@ export default defineConfig({
       },
       // ===== AI对话服务 (ReportGenerator:28002) =====
       // /chatwithai/api/v1/xxx -> http://127.0.0.1:28002/api/v1/xxx
+      '/api/ticket': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
+      '/api/account': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
+      '/api/auth': {
+        target: 'http://127.0.0.1:28004',
+        changeOrigin: true,
+      },
       '/chatwithai/': {
         target: 'http://127.0.0.1:28002',
         changeOrigin: true,

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff