Commit 955eb364 authored by yuguo's avatar yuguo

fix

parent 59503a6c
......@@ -26,7 +26,11 @@
"Bash(go mod:*)",
"Bash(go vet:*)",
"Bash(cmd:*)",
"Bash(go get:*)"
"Bash(go get:*)",
"Bash(find:*)",
"Bash(psql:*)",
"Bash(xargs grep:*)",
"Bash(xargs:*)"
]
}
}
......@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"time"
"github.com/gin-gonic/gin"
......@@ -39,10 +40,21 @@ func main() {
log.Fatalf("Failed to connect to database: %v", err)
}
// 自动迁移新表
// 自动迁移新表(每个 model 单独执行,避免单个失败影响其他表)
log.Println("开始执行数据库表迁移...")
db := database.GetDB()
if err := db.AutoMigrate(
// 修复:给已有 consultations 记录补充 serial_number(迁移 NOT NULL 前必须)
if db.Migrator().HasTable("consultations") {
if db.Migrator().HasColumn(&model.Consultation{}, "serial_number") {
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
} else {
// 列不存在时先添加为可空,填值后再由 AutoMigrate 加约束
db.Exec("ALTER TABLE consultations ADD COLUMN IF NOT EXISTS serial_number VARCHAR(20)")
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
}
}
allModels := []interface{}{
// 用户相关
&model.User{},
&model.UserVerification{},
......@@ -62,7 +74,7 @@ func main() {
&model.Medicine{},
&model.Prescription{},
&model.PrescriptionItem{},
// AI & 系统
// 健康档案
&model.LabReport{},
&model.FamilyMember{},
// 慢病管理
......@@ -70,6 +82,7 @@ func main() {
&model.RenewalRequest{},
&model.MedicationReminder{},
&model.HealthMetric{},
// AI & 系统
&model.AIConfig{},
&model.AIUsageLog{},
&model.PromptTemplate{},
......@@ -97,10 +110,23 @@ func main() {
&model.SafetyFilterLog{},
// HTTP 动态工具
&model.HTTPToolDefinition{},
); err != nil {
log.Printf("Warning: AutoMigrate failed: %v", err)
} else {
// v13: 快捷回复 + 转诊
&model.QuickReplyTemplate{},
&model.ConsultTransfer{},
// v14: Agent技能包
&model.AgentSkill{},
}
failCount := 0
for _, m := range allModels {
if err := db.AutoMigrate(m); err != nil {
log.Printf("Warning: AutoMigrate partial error: %v", err)
failCount++
}
}
if failCount == 0 {
log.Println("数据库表迁移成功")
} else {
log.Printf("数据库表迁移完成(%d 个模型有警告,新表已创建)", failCount)
}
log.Println("数据库表检查完成")
......@@ -230,8 +256,16 @@ func main() {
Timestamp: msg.CreatedAt,
}, nil
})
// v13: 注入已读回执处理器
wsHandler.SetReadReceiptHandler(func(messageID string) error {
now := time.Now()
return db.Model(&model.ConsultMessage{}).Where("id = ?", messageID).Update("read_at", now).Error
})
wsHandler.RegisterRoutes(api)
// v13: 静态文件服务(上传的媒体文件)
api.Static("/uploads", "./uploads")
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
......
......@@ -7,94 +7,110 @@ import (
)
// defaultAgentDefinitions 返回内置Agent的默认数据库配置
// 当数据库中不存在时,会自动写入并作为初始配置
// 三个角色专属通用智能体:患者、医生、管理员
func defaultAgentDefinitions() []model.AgentDefinition {
preConsultTools, _ := json.Marshal([]string{"query_symptom_knowledge", "recommend_department"})
diagnosisTools, _ := json.Marshal([]string{"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge"})
prescriptionTools, _ := json.Marshal([]string{"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage"})
followUpTools, _ := json.Marshal([]string{"query_medical_record", "query_drug", "query_symptom_knowledge"})
// 患者通用智能体 — 合并 pre_consult + patient_assistant + follow_up 能力
patientTools, _ := json.Marshal([]string{
"query_symptom_knowledge", "recommend_department",
"search_medical_knowledge", "query_drug",
"query_medical_record", "generate_follow_up_plan",
"send_notification",
})
// 医生通用智能体 — 合并 diagnosis + prescription + follow_up 能力
doctorTools, _ := json.Marshal([]string{
"query_medical_record", "query_symptom_knowledge", "search_medical_knowledge",
"query_drug", "check_drug_interaction", "check_contraindication", "calculate_dosage",
"generate_follow_up_plan", "send_notification",
})
// 管理员通用智能体 — 合并 admin_assistant + general 管理能力
adminTools, _ := json.Marshal([]string{
"eval_expression", "query_workflow_status", "call_agent",
"trigger_workflow", "request_human_review",
"list_knowledge_collections", "send_notification",
"query_drug", "search_medical_knowledge",
})
return []model.AgentDefinition{
{
AgentID: "pre_consult_agent",
Name: "预问诊智能助手",
Description: "通过多轮对话收集患者症状,生成预问诊报告",
AgentID: "patient_universal_agent",
Name: "患者智能助手",
Description: "患者端全能AI助手:预问诊、找医生、挂号、查处方、健康咨询、随访管理",
Category: "patient",
SystemPrompt: `你是一位专业的AI预问诊助手。你的职责是:
1. 通过友好的对话收集患者的症状信息
2. 询问症状的持续时间、严重程度、伴随症状等
3. 利用工具查询症状相关知识
4. 推荐合适的就诊科室
5. 生成简洁的预问诊报告
SystemPrompt: `你是互联网医院的患者专属AI智能助手,为患者提供全方位的医疗健康服务。
你的核心能力:
1. **预问诊**:通过友好对话收集症状信息(持续时间、严重程度、伴随症状),利用知识库分析症状,推荐合适的就诊科室
2. **找医生/挂号**:根据患者症状推荐科室和医生,帮助了解就医流程
3. **健康咨询**:搜索医学知识提供健康科普,查询药品信息和用药指导
4. **随访管理**:查询处方和用药情况,提醒按时用药,评估病情变化,生成随访计划
5. **药品查询**:查询药品信息、规格、用法和注意事项
请用中文与患者交流,语气温和专业。不要做出确定性诊断,只提供参考建议。`,
Tools: string(preConsultTools),
MaxIterations: 5,
使用原则:
- 用通俗易懂、温和专业的中文与患者交流
- 主动使用工具获取真实数据,不要凭空回答
- 不做确定性诊断,只提供参考建议
- 关注患者的用药依从性和健康状况变化
- 所有医疗建议仅供参考,请以专业医生判断为准`,
Tools: string(patientTools),
Config: "{}",
MaxIterations: 10,
Status: "active",
},
{
AgentID: "diagnosis_agent",
Name: "诊断辅助Agent",
Description: "辅助医生进行诊断,提供鉴别诊断建议",
AgentID: "doctor_universal_agent",
Name: "医生智能助手",
Description: "医生端全能AI助手:辅助诊断、处方审核、用药建议、病历生成、随访计划",
Category: "doctor",
SystemPrompt: `你是一位经验丰富的诊断辅助AI,协助医生进行临床决策。
你可以:
1. 查询患者病历记录(使用query_medical_record)
2. 检索医学知识库获取临床指南和疾病信息(使用search_medical_knowledge)
3. 分析症状和检验结果
4. 提供鉴别诊断建议
5. 推荐进一步检查项目
SystemPrompt: `你是互联网医院的医生专属AI智能助手,协助医生进行临床决策和日常工作。
你的核心能力:
1. **辅助诊断**:查询患者病历,检索临床指南和疾病信息,分析症状和检验结果,提供鉴别诊断建议,推荐进一步检查项目
2. **处方审核**:查询药品信息(规格、用法、禁忌),检查药物相互作用,检查患者禁忌症,验证剂量是否合理,综合评估处方安全性
3. **用药方案**:根据患者情况推荐药物、用法用量和注意事项
4. **病历生成**:根据对话记录生成标准门诊病历(主诉、现病史、既往史、查体、辅助检查、初步诊断、处置意见)
5. **随访计划**:制定随访方案,包含复诊时间、复查项目、用药提醒、生活方式建议
6. **医嘱生成**:生成结构化医嘱(检查、治疗、护理、饮食、活动)
诊断流程:
- 首先查询患者病历了解病史
- 使用知识库检索相关疾病的诊断标准和鉴别要点
- 综合分析后给出诊断建议
诊断流程:首先查询患者病历了解病史 → 使用知识库检索诊断标准 → 综合分析后给出建议
处方审核流程:检查药物相互作用 → 检查禁忌症 → 验证剂量 → 综合评估
请基于循证医学原则提供建议,所有建议仅供医生参考。`,
Tools: string(diagnosisTools),
使用原则:
- 基于循证医学原则提供建议
- 主动使用工具获取真实数据
- 对存在风险的处方要明确指出
- 所有建议仅供医生参考,请结合临床实际情况`,
Tools: string(doctorTools),
Config: "{}",
MaxIterations: 10,
Status: "active",
},
{
AgentID: "prescription_agent",
Name: "处方审核Agent",
Description: "审核处方合理性,检查药物相互作用、禁忌症和剂量",
Category: "pharmacy",
SystemPrompt: `你是一位专业的临床药师AI,负责处方审核。
你的职责:
1. 查询药品信息(规格、用法、禁忌)
2. 检查药物相互作用(使用check_drug_interaction工具)
3. 检查患者禁忌症(使用check_contraindication工具)
4. 验证剂量是否合理(使用calculate_dosage工具)
5. 综合评估处方安全性,给出审核意见
AgentID: "admin_universal_agent",
Name: "管理员智能助手",
Description: "管理端全能AI助手:运营数据查询、Agent状态监控、工作流管理、系统帮助",
Category: "admin",
SystemPrompt: `你是互联网医院管理后台的专属AI智能助手,帮助管理员高效管理平台。
审核流程:
- 首先使用check_drug_interaction检查所有药品的相互作用
- 然后对每个药品使用check_contraindication检查患者禁忌
- 使用calculate_dosage验证剂量是否在安全范围内
- 最后综合所有检查结果给出审核意见
你的核心能力:
1. **运营数据**:查询和计算运营指标,分析平台运行状况
2. **Agent监控**:调用其他Agent获取信息,监控Agent运行状态
3. **工作流管理**:触发和查询工作流执行状态
4. **知识库管理**:浏览知识库集合,了解知识库使用情况
5. **人工审核**:发起和管理人工审核任务
6. **通知管理**:发送系统通知
7. **药品/医学查询**:查询药品信息和医学知识辅助决策
请严格按照药品说明书和临床指南进行审核,对于存在风险的处方要明确指出。`,
Tools: string(prescriptionTools),
使用原则:
- 以简洁专业的方式回答管理员的问题
- 主动使用工具获取真实数据
- 提供可操作的建议和方案
- 用中文回答`,
Tools: string(adminTools),
Config: "{}",
MaxIterations: 10,
Status: "active",
},
{
AgentID: "follow_up_agent",
Name: "随访管理Agent",
Description: "管理患者随访,提醒用药、复诊,收集健康数据",
Category: "patient",
SystemPrompt: `你是一位专业的随访管理AI助手。你的职责是:
1. 查询患者的处方和用药情况
2. 提醒患者按时用药
3. 收集患者的健康数据(血压、血糖等)
4. 评估病情变化,必要时建议复诊
5. 生成随访报告
请用温和关怀的语气与患者交流,关注患者的用药依从性和健康状况变化。`,
Tools: string(followUpTools),
MaxIterations: 8,
Status: "active",
},
}
}
......@@ -12,6 +12,12 @@ import (
"internet-hospital/pkg/response"
)
// sendSSEEvent 发送 SSE 事件
func sendSSEEvent(c *gin.Context, flusher interface{ Flush() }, event string, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flusher.Flush()
}
// Handler Agent HTTP处理器
type Handler struct {
svc *AgentService
......@@ -24,6 +30,7 @@ func NewHandler() *Handler {
func (h *Handler) RegisterRoutes(r gin.IRouter) {
g := r.Group("/agent")
g.POST("/:agent_id/chat", h.Chat)
g.POST("/:agent_id/chat/stream", h.ChatStream)
g.GET("/sessions", h.ListSessions)
g.DELETE("/session/:session_id", h.DeleteSession)
g.GET("/list", h.ListAgents)
......@@ -44,6 +51,12 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.PUT("/definitions/:agent_id", h.UpdateDefinition)
g.PUT("/definitions/:agent_id/reload", h.ReloadAgent)
g.PUT("/tools/:name/status", h.UpdateToolStatus)
// 技能包管理
g.GET("/skills", h.ListSkills)
g.POST("/skills", h.CreateSkill)
g.PUT("/skills/:skill_id", h.UpdateSkill)
g.DELETE("/skills/:skill_id", h.DeleteSkill)
}
func (h *Handler) Chat(c *gin.Context) {
......@@ -73,6 +86,41 @@ func (h *Handler) Chat(c *gin.Context) {
response.Success(c, output)
}
// ChatStream SSE 流式 Agent 对话
func (h *Handler) ChatStream(c *gin.Context) {
agentID := c.Param("agent_id")
var req struct {
SessionID string `json:"session_id"`
Message string `json:"message" binding:"required"`
Context map[string]interface{} `json:"context"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 设置 SSE 响应头
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(interface{ Flush() })
if !ok {
c.JSON(500, gin.H{"error": "streaming not supported"})
return
}
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
emit := func(event, data string) {
sendSSEEvent(c, flusher, event, data)
}
h.svc.ChatStream(c.Request.Context(), agentID, uid, req.SessionID, req.Message, req.Context, emit)
}
func (h *Handler) ListAgents(c *gin.Context) {
response.Success(c, h.svc.ListAgents())
}
......@@ -237,6 +285,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"`
}
if err := c.ShouldBindJSON(&req); err != nil {
......@@ -244,6 +293,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
return
}
toolsJSON, _ := json.Marshal(req.Tools)
skillsJSON, _ := json.Marshal(req.Skills)
if req.MaxIterations <= 0 {
req.MaxIterations = 10
}
......@@ -254,6 +304,7 @@ func (h *Handler) CreateDefinition(c *gin.Context) {
Category: req.Category,
SystemPrompt: req.SystemPrompt,
Tools: string(toolsJSON),
Skills: string(skillsJSON),
MaxIterations: req.MaxIterations,
Status: "active",
}
......@@ -274,6 +325,7 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
Category string `json:"category"`
SystemPrompt string `json:"system_prompt"`
Tools []string `json:"tools"`
Skills []string `json:"skills"`
MaxIterations int `json:"max_iterations"`
Status string `json:"status"`
}
......@@ -306,6 +358,10 @@ func (h *Handler) UpdateDefinition(c *gin.Context) {
toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON)
}
if req.Skills != nil {
skillsJSON, _ := json.Marshal(req.Skills)
updates["skills"] = string(skillsJSON)
}
if req.MaxIterations > 0 {
updates["max_iterations"] = req.MaxIterations
}
......
......@@ -28,6 +28,7 @@ func GetService() *AgentService {
globalAgentService = &AgentService{
agents: make(map[string]*agent.ReActAgent),
}
ensureBuiltinSkills()
globalAgentService.loadFromDB()
globalAgentService.ensureBuiltinAgents()
}
......@@ -57,6 +58,40 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
if def.Tools != "" {
json.Unmarshal([]byte(def.Tools), &tools)
}
// 加载技能包中的工具和提示词
systemPrompt := def.SystemPrompt
var skillIDs []string
if def.Skills != "" {
json.Unmarshal([]byte(def.Skills), &skillIDs)
}
if len(skillIDs) > 0 {
db := database.GetDB()
if db != nil {
var skills []model.AgentSkill
db.Where("skill_id IN ? AND status = 'active'", skillIDs).Find(&skills)
toolSet := make(map[string]bool, len(tools))
for _, t := range tools {
toolSet[t] = true
}
for _, skill := range skills {
// 合并工具(去重)
var skillTools []string
json.Unmarshal([]byte(skill.Tools), &skillTools)
for _, st := range skillTools {
if !toolSet[st] {
tools = append(tools, st)
toolSet[st] = true
}
}
// 追加系统提示
if skill.SystemPromptAddon != "" {
systemPrompt += "\n\n[技能-" + skill.Name + "] " + skill.SystemPromptAddon
}
}
}
}
maxIter := def.MaxIterations
if maxIter <= 0 {
maxIter = 10
......@@ -65,7 +100,7 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
ID: def.AgentID,
Name: def.Name,
Description: def.Description,
SystemPrompt: def.SystemPrompt,
SystemPrompt: systemPrompt,
Tools: tools,
MaxIterations: maxIter,
})
......@@ -236,3 +271,109 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
return output, nil
}
// ChatStream 流式执行Agent对话(SSE),完成后持久化
func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, sessionID, message string, contextData map[string]interface{}, emit func(event, data string)) {
a, ok := s.GetAgent(agentID)
if !ok {
errJSON, _ := json.Marshal(map[string]string{"error": "agent not found"})
emit("error", string(errJSON))
return
}
db := database.GetDB()
if sessionID == "" {
sessionID = uuid.New().String()
}
var session model.AgentSession
db.Where("session_id = ?", sessionID).First(&session)
// 发送 session 事件
sessionJSON, _ := json.Marshal(map[string]string{"session_id": sessionID})
emit("session", string(sessionJSON))
var history []ai.ChatMessage
if session.History != "" {
json.Unmarshal([]byte(session.History), &history)
}
input := agent.AgentInput{
SessionID: sessionID,
UserID: userID,
Message: message,
Context: contextData,
History: history,
}
start := time.Now()
// 将 StreamEvent 转发为 SSE 事件
onEvent := func(ev agent.StreamEvent) error {
data, _ := json.Marshal(ev.Data)
emit(string(ev.Type), string(data))
return ctx.Err()
}
output, err := a.RunStream(ctx, input, onEvent)
durationMs := int(time.Since(start).Milliseconds())
if err != nil {
errJSON, _ := json.Marshal(map[string]string{"error": err.Error()})
emit("error", string(errJSON))
return
}
// 持久化会话
history = append(history,
ai.ChatMessage{Role: "user", Content: message},
ai.ChatMessage{Role: "assistant", Content: output.Response},
)
historyJSON, _ := json.Marshal(history)
contextJSON, _ := json.Marshal(contextData)
if session.ID == 0 {
session = model.AgentSession{
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
History: string(historyJSON),
Context: string(contextJSON),
Status: "active",
}
db.Create(&session)
} else {
db.Model(&session).Updates(map[string]interface{}{
"history": string(historyJSON),
"updated_at": time.Now(),
})
}
// 记录执行日志
inputJSON, _ := json.Marshal(input)
outputJSON, _ := json.Marshal(output)
toolCallsJSON, _ := json.Marshal(output.ToolCalls)
db.Create(&model.AgentExecutionLog{
TraceID: output.TraceID,
SessionID: sessionID,
AgentID: agentID,
UserID: userID,
Input: string(inputJSON),
Output: string(outputJSON),
ToolCalls: string(toolCallsJSON),
Iterations: output.Iterations,
TotalTokens: output.TotalTokens,
DurationMs: durationMs,
FinishReason: output.FinishReason,
Success: true,
})
// 发送 done 事件
doneData, _ := json.Marshal(map[string]interface{}{
"session_id": sessionID,
"iterations": output.Iterations,
"total_tokens": output.TotalTokens,
"finish_reason": output.FinishReason,
})
emit("done", string(doneData))
}
package internalagent
import (
"encoding/json"
"log"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// ListSkills 列出所有技能包
func (h *Handler) ListSkills(c *gin.Context) {
var skills []model.AgentSkill
database.GetDB().Order("id asc").Find(&skills)
response.Success(c, skills)
}
// CreateSkill 创建技能包
func (h *Handler) CreateSkill(c *gin.Context) {
var req struct {
SkillID string `json:"skill_id" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Category string `json:"category"`
Tools []string `json:"tools"`
SystemPromptAddon string `json:"system_prompt_addon"`
ContextSchema string `json:"context_schema"`
QuickReplies []string `json:"quick_replies"`
Icon string `json:"icon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
toolsJSON, _ := json.Marshal(req.Tools)
quickRepliesJSON, _ := json.Marshal(req.QuickReplies)
if req.ContextSchema == "" {
req.ContextSchema = "{}"
}
skill := model.AgentSkill{
SkillID: req.SkillID,
Name: req.Name,
Description: req.Description,
Category: req.Category,
Tools: string(toolsJSON),
SystemPromptAddon: req.SystemPromptAddon,
ContextSchema: req.ContextSchema,
QuickReplies: string(quickRepliesJSON),
Icon: req.Icon,
Status: "active",
}
if err := database.GetDB().Create(&skill).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, skill)
}
// UpdateSkill 更新技能包
func (h *Handler) UpdateSkill(c *gin.Context) {
skillID := c.Param("skill_id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Category string `json:"category"`
Tools []string `json:"tools"`
SystemPromptAddon string `json:"system_prompt_addon"`
ContextSchema string `json:"context_schema"`
QuickReplies []string `json:"quick_replies"`
Icon string `json:"icon"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
db := database.GetDB()
var skill model.AgentSkill
if err := db.Where("skill_id = ?", skillID).First(&skill).Error; err != nil {
response.Error(c, 404, "skill not found")
return
}
updates := map[string]interface{}{}
if req.Name != "" {
updates["name"] = req.Name
}
if req.Description != "" {
updates["description"] = req.Description
}
if req.Category != "" {
updates["category"] = req.Category
}
if req.Tools != nil {
toolsJSON, _ := json.Marshal(req.Tools)
updates["tools"] = string(toolsJSON)
}
if req.SystemPromptAddon != "" {
updates["system_prompt_addon"] = req.SystemPromptAddon
}
if req.ContextSchema != "" {
updates["context_schema"] = req.ContextSchema
}
if req.QuickReplies != nil {
qrJSON, _ := json.Marshal(req.QuickReplies)
updates["quick_replies"] = string(qrJSON)
}
if req.Icon != "" {
updates["icon"] = req.Icon
}
if req.Status != "" {
updates["status"] = req.Status
}
if err := db.Model(&skill).Updates(updates).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, skill)
}
// DeleteSkill 删除技能包
func (h *Handler) DeleteSkill(c *gin.Context) {
skillID := c.Param("skill_id")
if err := database.GetDB().Where("skill_id = ?", skillID).Delete(&model.AgentSkill{}).Error; err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
// ensureBuiltinSkills 初始化内置技能包
func ensureBuiltinSkills() {
db := database.GetDB()
if db == nil {
return
}
defaults := defaultSkillDefinitions()
for _, skill := range defaults {
var existing model.AgentSkill
if err := db.Where("skill_id = ?", skill.SkillID).First(&existing).Error; err != nil {
if err := db.Create(&skill).Error; err != nil {
log.Printf("[AgentService] 写入默认Skill失败: %v", err)
}
}
}
}
func defaultSkillDefinitions() []model.AgentSkill {
mustJSON := func(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
return []model.AgentSkill{
{
SkillID: "symptom_analysis",
Name: "症状分析",
Description: "分析患者症状,推荐科室",
Category: "patient",
Tools: mustJSON([]string{"query_symptom_knowledge", "recommend_department"}),
SystemPromptAddon: "你具备症状分析能力,可以查询症状知识库并推荐合适的科室。",
QuickReplies: mustJSON([]string{"头痛", "发热", "咳嗽", "腹痛"}),
Icon: "heart",
Status: "active",
},
{
SkillID: "drug_lookup",
Name: "药品查询",
Description: "查询药品信息、药物相互作用",
Category: "general",
Tools: mustJSON([]string{"query_drug", "check_drug_interaction"}),
SystemPromptAddon: "你可以查询药品信息和检查药物之间的相互作用。",
QuickReplies: mustJSON([]string{"查询药品", "药物相互作用"}),
Icon: "medicine-box",
Status: "active",
},
{
SkillID: "prescription_review",
Name: "处方审核",
Description: "审核处方安全性:药物相互作用、禁忌症、剂量检查",
Category: "doctor",
Tools: mustJSON([]string{"check_drug_interaction", "check_contraindication", "calculate_dosage"}),
SystemPromptAddon: "你具备处方审核能力,能检查药物相互作用、禁忌症和剂量合理性。",
QuickReplies: mustJSON([]string{"审核处方", "检查禁忌症"}),
Icon: "safety",
Status: "active",
},
{
SkillID: "medical_record",
Name: "病历查询",
Description: "查询病历记录、搜索医学知识",
Category: "doctor",
Tools: mustJSON([]string{"query_medical_record", "search_medical_knowledge"}),
SystemPromptAddon: "你可以查询患者病历记录和检索医学知识库。",
QuickReplies: mustJSON([]string{"查看病历", "搜索指南"}),
Icon: "file-text",
Status: "active",
},
{
SkillID: "health_education",
Name: "健康教育",
Description: "提供健康知识科普和症状自查",
Category: "patient",
Tools: mustJSON([]string{"search_medical_knowledge", "query_symptom_knowledge"}),
SystemPromptAddon: "你具备健康教育能力,可以提供医学知识科普和症状自查参考。",
QuickReplies: mustJSON([]string{"健康知识", "症状自查", "预防建议"}),
Icon: "book",
Status: "active",
},
{
SkillID: "follow_up_mgmt",
Name: "随访管理",
Description: "管理随访计划、生成随访方案",
Category: "patient",
Tools: mustJSON([]string{"query_medical_record", "generate_follow_up_plan"}),
SystemPromptAddon: "你具备随访管理能力,可以查询病历并生成随访计划。",
QuickReplies: mustJSON([]string{"随访计划", "复诊提醒"}),
Icon: "calendar",
Status: "active",
},
}
}
......@@ -4,81 +4,82 @@ import "time"
// AgentTool 工具定义
type AgentTool struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"`
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
Parameters string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'active'"`
CacheTTL int `gorm:"default:0"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30"` // 执行超时秒数
MaxRetries int `gorm:"default:0"` // 失败重试次数
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
Parameters string `gorm:"type:jsonb" json:"parameters"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CacheTTL int `gorm:"default:0" json:"cache_ttl"` // 缓存秒数,0=不缓存
Timeout int `gorm:"default:30" json:"timeout"` // 执行超时秒数
MaxRetries int `gorm:"default:0" json:"max_retries"` // 失败重试次数
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentToolLog 工具调用日志
type AgentToolLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
ToolName string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
SessionID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
InputParams string `gorm:"type:jsonb"`
OutputResult string `gorm:"type:jsonb"`
Success bool
ErrorMessage string `gorm:"type:text"`
DurationMs int
Iteration int // Agent第几轮迭代
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
ToolName string `gorm:"type:varchar(100);index" json:"tool_name"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
InputParams string `gorm:"type:jsonb" json:"input_params"`
OutputResult string `gorm:"type:jsonb" json:"output_result"`
Success bool `json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
DurationMs int `json:"duration_ms"`
Iteration int `json:"iteration"`
CreatedAt time.Time `json:"created_at"`
}
// AgentDefinition Agent定义
type AgentDefinition struct {
ID uint `gorm:"primaryKey"`
AgentID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
SystemPrompt string `gorm:"type:text"`
Tools string `gorm:"type:jsonb"`
Config string `gorm:"type:jsonb"`
MaxIterations int `gorm:"default:10"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
AgentID string `gorm:"type:varchar(100);uniqueIndex" json:"agent_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
SystemPrompt string `gorm:"type:text" json:"system_prompt"`
Tools string `gorm:"type:jsonb" json:"tools"`
Config string `gorm:"type:jsonb" json:"config"`
MaxIterations int `gorm:"default:10" json:"max_iterations"`
Skills string `gorm:"type:jsonb;default:'[]'" json:"skills"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentSession Agent会话
type AgentSession struct {
ID uint `gorm:"primaryKey"`
SessionID string `gorm:"type:varchar(100);uniqueIndex"`
AgentID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
Context string `gorm:"type:jsonb"`
History string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
SessionID string `gorm:"type:varchar(100);uniqueIndex" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
Context string `gorm:"type:jsonb" json:"context"`
History string `gorm:"type:jsonb" json:"history"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AgentExecutionLog Agent执行日志
type AgentExecutionLog struct {
ID uint `gorm:"primaryKey"`
TraceID string `gorm:"type:varchar(100);index"` // 链路追踪ID
SessionID string `gorm:"type:varchar(100);index"`
AgentID string `gorm:"type:varchar(100);index"`
UserID string `gorm:"type:uuid;index"`
Input string `gorm:"type:jsonb"`
Output string `gorm:"type:jsonb"`
ToolCalls string `gorm:"type:jsonb"`
Iterations int
TotalTokens int
DurationMs int
FinishReason string `gorm:"type:varchar(50)"`
Success bool
ErrorMessage string `gorm:"type:text"`
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
TraceID string `gorm:"type:varchar(100);index" json:"trace_id"`
SessionID string `gorm:"type:varchar(100);index" json:"session_id"`
AgentID string `gorm:"type:varchar(100);index" json:"agent_id"`
UserID string `gorm:"type:uuid;index" json:"user_id"`
Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb" json:"output"`
ToolCalls string `gorm:"type:jsonb" json:"tool_calls"`
Iterations int `json:"iterations"`
TotalTokens int `json:"total_tokens"`
DurationMs int `json:"duration_ms"`
FinishReason string `gorm:"type:varchar(50)" json:"finish_reason"`
Success bool `json:"success"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time `json:"created_at"`
}
package model
import "time"
// AgentSkill 技能包定义
type AgentSkill struct {
ID uint `gorm:"primaryKey" json:"id"`
SkillID string `gorm:"type:varchar(100);uniqueIndex" json:"skill_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"` // patient/doctor/admin/general
Tools string `gorm:"type:jsonb;default:'[]'" json:"tools"` // JSON array of tool names
SystemPromptAddon string `gorm:"type:text" json:"system_prompt_addon"` // 追加到agent system prompt
ContextSchema string `gorm:"type:jsonb;default:'{}'" json:"context_schema"` // 技能需要的上下文字段定义
QuickReplies string `gorm:"type:jsonb;default:'[]'" json:"quick_replies"` // 快捷回复模板
Icon string `gorm:"type:varchar(50)" json:"icon"` // antd icon名
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
......@@ -8,6 +8,7 @@ import (
type Consultation struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
SerialNumber string `gorm:"type:varchar(20);uniqueIndex;not null" json:"serial_number"` // 就诊流水号,如 C20260303-0001
PatientID string `gorm:"type:uuid;index;not null" json:"patient_id"`
DoctorID string `gorm:"type:uuid;index;not null" json:"doctor_id"`
Type string `gorm:"type:varchar(20);not null" json:"type"`
......@@ -21,6 +22,12 @@ type Consultation struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// v13 新增字段
Diagnosis string `gorm:"type:text" json:"diagnosis"` // 诊断结论
Summary string `gorm:"type:text" json:"summary"` // 问诊小结
DurationMinutes int `gorm:"default:0" json:"duration_minutes"` // 问诊时长(分钟)
FollowUpDays int `gorm:"default:0" json:"follow_up_days"` // 随访天数
SatisfactionScore *int `gorm:"default:null" json:"satisfaction_score"` // 患者满意度(1-5)
}
func (Consultation) TableName() string {
......@@ -35,6 +42,11 @@ type ConsultMessage struct {
Content string `gorm:"type:text;not null" json:"content"`
ContentType string `gorm:"type:varchar(20);default:'text'" json:"content_type"`
CreatedAt time.Time `json:"created_at"`
// v13 新增字段
ReadAt *time.Time `json:"read_at"` // 已读时间
MediaURL string `gorm:"type:varchar(500)" json:"media_url"` // 媒体文件URL
MediaType string `gorm:"type:varchar(20)" json:"media_type"` // image/audio/file
ReplyToID *string `gorm:"type:uuid" json:"reply_to_id"` // 引用回复的消息ID
}
func (ConsultMessage) TableName() string {
......
......@@ -4,22 +4,22 @@ import "time"
// HTTPToolDefinition 动态 HTTP 工具定义(管理员从 UI 配置,无需改代码)
type HTTPToolDefinition struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100);uniqueIndex"` // 工具名,如 get_weather
DisplayName string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50);default:'http'"`
Method string `gorm:"type:varchar(10);default:'GET'"` // GET/POST/PUT/DELETE
URL string `gorm:"type:text"` // 支持 {{param}} 模板变量
Headers string `gorm:"type:jsonb;default:'{}'"` // {"X-Key": "{{api_key}}"}
BodyTemplate string `gorm:"type:text"` // JSON body 模板,支持 {{param}}
AuthType string `gorm:"type:varchar(20);default:'none'"` // none/bearer/basic/apikey
AuthConfig string `gorm:"type:jsonb;default:'{}'"` // 认证配置
Parameters string `gorm:"type:jsonb;default:'[]'"` // ToolParameter 数组 JSON
Timeout int `gorm:"default:10"` // 超时秒数
CacheTTL int `gorm:"default:0"` // 缓存 TTL 秒,0=不缓存
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedBy string `gorm:"type:varchar(100)"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);uniqueIndex" json:"name"`
DisplayName string `gorm:"type:varchar(200)" json:"display_name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50);default:'http'" json:"category"`
Method string `gorm:"type:varchar(10);default:'GET'" json:"method"`
URL string `gorm:"type:text" json:"url"`
Headers string `gorm:"type:jsonb;default:'{}'" json:"headers"`
BodyTemplate string `gorm:"type:text" json:"body_template"`
AuthType string `gorm:"type:varchar(20);default:'none'" json:"auth_type"`
AuthConfig string `gorm:"type:jsonb;default:'{}'" json:"auth_config"`
Parameters string `gorm:"type:jsonb;default:'[]'" json:"parameters"`
Timeout int `gorm:"default:10" json:"timeout"`
CacheTTL int `gorm:"default:0" json:"cache_ttl"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedBy string `gorm:"type:varchar(100)" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
......@@ -4,40 +4,40 @@ import "time"
// KnowledgeCollection 知识库集合
type KnowledgeCollection struct {
ID uint `gorm:"primaryKey"`
CollectionID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
DocumentCount int `gorm:"default:0"`
Status string `gorm:"type:varchar(20);default:'active'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
CollectionID string `gorm:"type:varchar(100);uniqueIndex" json:"collection_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
DocumentCount int `gorm:"default:0" json:"document_count"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KnowledgeDocument 知识文档
type KnowledgeDocument struct {
ID uint `gorm:"primaryKey"`
DocumentID string `gorm:"type:varchar(100);uniqueIndex"`
CollectionID string `gorm:"type:varchar(100);index"`
Title string `gorm:"type:varchar(500)"`
Content string `gorm:"type:text"`
Metadata string `gorm:"type:jsonb"`
FileType string `gorm:"type:varchar(50)"`
ChunkCount int `gorm:"default:0"`
Status string `gorm:"type:varchar(20);default:'ready'"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
DocumentID string `gorm:"type:varchar(100);uniqueIndex" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Title string `gorm:"type:varchar(500)" json:"title"`
Content string `gorm:"type:text" json:"content"`
Metadata string `gorm:"type:jsonb" json:"metadata"`
FileType string `gorm:"type:varchar(50)" json:"file_type"`
ChunkCount int `gorm:"default:0" json:"chunk_count"`
Status string `gorm:"type:varchar(20);default:'ready'" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// KnowledgeChunk 知识分块
type KnowledgeChunk struct {
ID uint `gorm:"primaryKey"`
ChunkID string `gorm:"type:varchar(100);uniqueIndex"`
DocumentID string `gorm:"type:varchar(100);index"`
CollectionID string `gorm:"type:varchar(100);index"`
Content string `gorm:"type:text"`
ChunkIndex int
TokenCount int
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
ChunkID string `gorm:"type:varchar(100);uniqueIndex" json:"chunk_id"`
DocumentID string `gorm:"type:varchar(100);index" json:"document_id"`
CollectionID string `gorm:"type:varchar(100);index" json:"collection_id"`
Content string `gorm:"type:text" json:"content"`
ChunkIndex int `json:"chunk_index"`
TokenCount int `json:"token_count"`
CreatedAt time.Time `json:"created_at"`
}
package model
import "time"
// QuickReplyTemplate 快捷回复模板
type QuickReplyTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
DoctorID string `gorm:"type:uuid;index" json:"doctor_id"` // 空=系统级,非空=个人级
Category string `gorm:"type:varchar(50);index" json:"category"` // greeting/inquiry/diagnosis/prescription/closing
Title string `gorm:"type:varchar(100)" json:"title"`
Content string `gorm:"type:text" json:"content"`
SortOrder int `gorm:"default:0" json:"sort_order"`
UseCount int `gorm:"default:0" json:"use_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (QuickReplyTemplate) TableName() string {
return "quick_reply_templates"
}
// ConsultTransfer 转诊记录(Phase 6 使用)
type ConsultTransfer struct {
ID uint `gorm:"primaryKey" json:"id"`
ConsultID string `gorm:"type:uuid;index" json:"consult_id"`
FromDoctorID string `gorm:"type:uuid;index" json:"from_doctor_id"`
ToDoctorID string `gorm:"type:uuid;index" json:"to_doctor_id"`
ToDepartmentID string `gorm:"type:uuid" json:"to_department_id"`
Reason string `gorm:"type:text" json:"reason"`
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"` // pending/accepted/rejected
TransferNote string `gorm:"type:text" json:"transfer_note"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ConsultTransfer) TableName() string {
return "consult_transfers"
}
......@@ -4,49 +4,49 @@ import "time"
// WorkflowDefinition 工作流定义
type WorkflowDefinition struct {
ID uint `gorm:"primaryKey"`
WorkflowID string `gorm:"type:varchar(100);uniqueIndex"`
Name string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
Category string `gorm:"type:varchar(50)"`
Version int `gorm:"default:1"`
Definition string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'draft'"`
CreatedBy string `gorm:"type:uuid"`
CreatedAt time.Time
UpdatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
WorkflowID string `gorm:"type:varchar(100);uniqueIndex" json:"workflow_id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Description string `gorm:"type:text" json:"description"`
Category string `gorm:"type:varchar(50)" json:"category"`
Version int `gorm:"default:1" json:"version"`
Definition string `gorm:"type:jsonb" json:"definition"`
Status string `gorm:"type:varchar(20);default:'draft'" json:"status"`
CreatedBy string `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// WorkflowExecution 工作流执行实例
type WorkflowExecution struct {
ID uint `gorm:"primaryKey"`
ExecutionID string `gorm:"type:varchar(100);uniqueIndex"`
WorkflowID string `gorm:"type:varchar(100);index"`
Version int
TriggerType string `gorm:"type:varchar(50)"`
TriggerBy string `gorm:"type:uuid"`
Input string `gorm:"type:jsonb"`
Output string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20)"` // pending, running, completed, failed
CurrentNode string `gorm:"type:varchar(100)"`
StartedAt *time.Time
CompletedAt *time.Time
ErrorMessage string `gorm:"type:text"`
CreatedAt time.Time
ID uint `gorm:"primaryKey" json:"id"`
ExecutionID string `gorm:"type:varchar(100);uniqueIndex" json:"execution_id"`
WorkflowID string `gorm:"type:varchar(100);index" json:"workflow_id"`
Version int `json:"version"`
TriggerType string `gorm:"type:varchar(50)" json:"trigger_type"`
TriggerBy string `gorm:"type:uuid" json:"trigger_by"`
Input string `gorm:"type:jsonb" json:"input"`
Output string `gorm:"type:jsonb" json:"output"`
Status string `gorm:"type:varchar(20)" json:"status"` // pending, running, completed, failed
CurrentNode string `gorm:"type:varchar(100)" json:"current_node"`
StartedAt *time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
CreatedAt time.Time `json:"created_at"`
}
// WorkflowHumanTask 人工审核任务
type WorkflowHumanTask struct {
ID uint `gorm:"primaryKey"`
TaskID string `gorm:"type:varchar(100);uniqueIndex"`
ExecutionID string `gorm:"type:varchar(100);index"`
NodeID string `gorm:"type:varchar(100)"`
AssigneeRole string `gorm:"type:varchar(50)"`
Title string `gorm:"type:varchar(200)"`
Description string `gorm:"type:text"`
FormData string `gorm:"type:jsonb"`
Result string `gorm:"type:jsonb"`
Status string `gorm:"type:varchar(20);default:'pending'"`
CreatedAt time.Time
CompletedAt *time.Time
ID uint `gorm:"primaryKey" json:"id"`
TaskID string `gorm:"type:varchar(100);uniqueIndex" json:"task_id"`
ExecutionID string `gorm:"type:varchar(100);index" json:"execution_id"`
NodeID string `gorm:"type:varchar(100)" json:"node_id"`
AssigneeRole string `gorm:"type:varchar(50)" json:"assignee_role"`
Title string `gorm:"type:varchar(200)" json:"title"`
Description string `gorm:"type:text" json:"description"`
FormData string `gorm:"type:jsonb" json:"form_data"`
Result string `gorm:"type:jsonb" json:"result"`
Status string `gorm:"type:varchar(20);default:'pending'" json:"status"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
}
......@@ -130,12 +130,12 @@ func (s *Service) GetAIRenewalAdvice(ctx context.Context, userID, renewalID stri
msg := fmt.Sprintf("患者%s申请续药%d个月,当前用药:%s,原因:%s。请评估续药合理性并给出专业建议。",
r.DiseaseName, durationMonths, r.Medicines, r.Reason)
output, err := internalagent.GetService().Chat(ctx, "follow_up_agent", userID, "", msg, agentCtx)
output, err := internalagent.GetService().Chat(ctx, "patient_universal_agent", userID, "", msg, agentCtx)
if err != nil {
return "", fmt.Errorf("AI续药建议获取失败: %w", err)
}
if output == nil {
return "", fmt.Errorf("follow_up_agent 未初始化")
return "", fmt.Errorf("patient_universal_agent 未初始化")
}
advice := output.Response
......
......@@ -2,8 +2,12 @@ package consult
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"internet-hospital/pkg/middleware"
"internet-hospital/pkg/response"
......@@ -29,6 +33,14 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.GET("/doctor/waiting", h.GetWaitingList)
consult.GET("/doctor/patients", h.GetPatientList)
consult.GET("/doctor/workbench-stats", h.GetDoctorWorkbenchStats)
consult.GET("/doctor/patient-profile/:patient_id", h.GetPatientProfile)
// v13: 转诊
consult.POST("/:id/transfer", h.TransferConsult)
consult.GET("/doctor/transfer-inbox", h.GetTransferInbox)
consult.POST("/transfer/:transfer_id/accept", h.AcceptTransfer)
consult.POST("/transfer/:transfer_id/reject", h.RejectTransfer)
// v13: 处方状态追踪
consult.GET("/prescription/:id/status", h.GetPrescriptionStatus)
consult.POST("/:id/accept", h.AcceptConsult)
consult.POST("/:id/reject", h.RejectConsult)
consult.GET("/:id", h.GetConsultDetail)
......@@ -38,6 +50,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.POST("/:id/end", h.EndConsult)
consult.POST("/:id/cancel", h.CancelConsult)
consult.POST("/:id/ai-assist", h.AIAssist)
consult.POST("/:id/upload", h.UploadMedia)
// 患者端处方
consult.GET("/patient/prescriptions", h.GetPatientPrescriptions)
......@@ -77,7 +90,11 @@ func (h *Handler) GetConsultList(c *gin.Context) {
}
func (h *Handler) GetConsultDetail(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
result, err := h.service.GetConsultByID(c.Request.Context(), id)
if err != nil {
......@@ -113,7 +130,11 @@ func (h *Handler) GetDoctorWorkbenchStats(c *gin.Context) {
}
func (h *Handler) GetConsultMessages(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
messages, err := h.service.GetConsultMessages(c.Request.Context(), id)
if err != nil {
......@@ -130,7 +151,11 @@ type SendMessageRequest struct {
}
func (h *Handler) SendMessage(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id")
userRole, _ := c.Get("role")
......@@ -161,7 +186,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
}
func (h *Handler) GetVideoRoomInfo(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
roomInfo, err := h.service.GetVideoRoomInfo(c.Request.Context(), id)
if err != nil {
......@@ -173,9 +202,17 @@ func (h *Handler) GetVideoRoomInfo(c *gin.Context) {
}
func (h *Handler) EndConsult(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
if err := h.service.EndConsult(c.Request.Context(), id); err != nil {
var req EndConsultRequest
// 允许空body(向后兼容简单结束)
c.ShouldBindJSON(&req)
if err := h.service.EndConsult(c.Request.Context(), id, &req); err != nil {
response.Error(c, 400, err.Error())
return
}
......@@ -188,7 +225,11 @@ type CancelRequest struct {
}
func (h *Handler) CancelConsult(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
if err := h.service.CancelConsult(c.Request.Context(), id); err != nil {
response.Error(c, 500, "取消问诊失败")
......@@ -200,7 +241,11 @@ func (h *Handler) CancelConsult(c *gin.Context) {
// AIAssist AI辅助诊断(SSE流式返回)
func (h *Handler) AIAssist(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct {
Scene string `json:"scene" binding:"required"` // consult_diagnosis | consult_medication
}
......@@ -249,6 +294,177 @@ func (h *Handler) GetPrescriptionDetail(c *gin.Context) {
response.Success(c, result)
}
// TransferConsult 发起转诊
func (h *Handler) TransferConsult(c *gin.Context) {
consultID, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id")
var req TransferRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.TransferConsult(c.Request.Context(), consultID, userID.(string), &req); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// GetTransferInbox 获取转诊收件箱
func (h *Handler) GetTransferInbox(c *gin.Context) {
userID, _ := c.Get("user_id")
list, err := h.service.GetTransferInbox(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取转诊收件箱失败")
return
}
response.Success(c, list)
}
// AcceptTransfer 接受转诊
func (h *Handler) AcceptTransfer(c *gin.Context) {
transferID := c.Param("transfer_id")
userID, _ := c.Get("user_id")
var id uint
fmt.Sscanf(transferID, "%d", &id)
if err := h.service.AcceptTransfer(c.Request.Context(), id, userID.(string)); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// RejectTransfer 拒绝转诊
func (h *Handler) RejectTransfer(c *gin.Context) {
transferID := c.Param("transfer_id")
var id uint
fmt.Sscanf(transferID, "%d", &id)
var req struct {
Reason string `json:"reason"`
}
c.ShouldBindJSON(&req)
if err := h.service.RejectTransfer(c.Request.Context(), id, req.Reason); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// GetPrescriptionStatus 获取处方状态
func (h *Handler) GetPrescriptionStatus(c *gin.Context) {
id := c.Param("id")
result, err := h.service.GetPrescriptionStatus(c.Request.Context(), id)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, result)
}
// UploadMedia 上传多媒体文件(图片/PDF/音频)
func (h *Handler) UploadMedia(c *gin.Context) {
consultID, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
file, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "请上传文件")
return
}
defer file.Close()
// 检测文件类型
contentType := header.Header.Get("Content-Type")
var mediaType string
switch {
case contentType == "image/jpeg" || contentType == "image/png" || contentType == "image/gif":
mediaType = "image"
if header.Size > 5*1024*1024 {
response.BadRequest(c, "图片大小不能超过5MB")
return
}
case contentType == "application/pdf":
mediaType = "file"
if header.Size > 20*1024*1024 {
response.BadRequest(c, "PDF大小不能超过20MB")
return
}
case contentType == "audio/wav" || contentType == "audio/mpeg" || contentType == "audio/mp3":
mediaType = "audio"
if header.Size > 2*1024*1024 {
response.BadRequest(c, "音频大小不能超过2MB")
return
}
default:
response.BadRequest(c, "不支持的文件类型: "+contentType)
return
}
// 确保目录存在
uploadDir := fmt.Sprintf("uploads/consult/%s", consultID)
if err := os.MkdirAll(uploadDir, 0755); err != nil {
response.Error(c, 500, "创建上传目录失败")
return
}
// 生成文件名
ext := filepath.Ext(header.Filename)
if ext == "" {
switch mediaType {
case "image":
ext = ".jpg"
case "file":
ext = ".pdf"
case "audio":
ext = ".mp3"
}
}
fileName := fmt.Sprintf("%s%s", uuid.New().String()[:8], ext)
filePath := filepath.Join(uploadDir, fileName)
// 保存文件
out, err := os.Create(filePath)
if err != nil {
response.Error(c, 500, "保存文件失败")
return
}
defer out.Close()
if _, err := io.Copy(out, file); err != nil {
response.Error(c, 500, "写入文件失败")
return
}
mediaURL := fmt.Sprintf("/api/v1/%s", filePath)
response.Success(c, map[string]interface{}{
"media_url": mediaURL,
"media_type": mediaType,
"file_name": header.Filename,
})
}
// GetPatientProfile 获取患者完整画像
func (h *Handler) GetPatientProfile(c *gin.Context) {
patientID := c.Param("patient_id")
if patientID == "" {
response.BadRequest(c, "patient_id 必填")
return
}
profile, err := h.service.GetPatientProfile(c.Request.Context(), patientID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
response.Success(c, profile)
}
// ========== 医生端 API ==========
// GetDoctorConsultList 医生端获取自己的问诊列表
......@@ -280,7 +496,11 @@ func (h *Handler) GetWaitingList(c *gin.Context) {
// AcceptConsult 医生接诊
func (h *Handler) AcceptConsult(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
userID, _ := c.Get("user_id")
if err := h.service.AcceptConsult(c.Request.Context(), id, userID.(string)); err != nil {
......@@ -293,7 +513,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// RejectConsult 医生拒诊
func (h *Handler) RejectConsult(c *gin.Context) {
id := c.Param("id")
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct {
Reason string `json:"reason"`
......
......@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
......@@ -28,6 +29,47 @@ func NewService() *Service {
}
}
// ResolveConsultID 解析问诊标识:支持 UUID 或流水号(C开头)
// 返回 consultation UUID,供所有 handler 统一调用
func (s *Service) ResolveConsultID(ctx context.Context, idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := s.db.Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// ResolveConsultIDStatic 静态版本,供其他包(doctorportal / websocket)调用
func ResolveConsultIDStatic(db *gorm.DB, idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := db.Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// generateSerialNumber 生成就诊流水号,格式:C20260303-0001(日期+当日自增序号)
func (s *Service) generateSerialNumber(ctx context.Context) (string, error) {
today := time.Now().Format("20060102")
key := fmt.Sprintf("consult:serial:%s", today)
seq, err := s.redis.Incr(ctx, key).Result()
if err != nil {
return "", err
}
// 首次创建时设置24小时过期
if seq == 1 {
s.redis.Expire(ctx, key, 48*time.Hour)
}
return fmt.Sprintf("C%s-%04d", today, seq), nil
}
type CreateConsultRequest struct {
DoctorID string `json:"doctor_id" binding:"required"`
Type string `json:"type" binding:"required,oneof=text video"`
......@@ -54,8 +96,14 @@ func (s *Service) CreateConsult(ctx context.Context, patientID string, req *Crea
return nil, errors.New("该医生尚未通过认证,暂时无法接诊")
}
serialNumber, err := s.generateSerialNumber(ctx)
if err != nil {
return nil, fmt.Errorf("生成流水号失败: %w", err)
}
consult := &model.Consultation{
ID: uuid.New().String(),
SerialNumber: serialNumber,
PatientID: patientID,
DoctorID: req.DoctorID,
Type: req.Type,
......@@ -184,7 +232,15 @@ func (s *Service) GetVideoRoomInfo(ctx context.Context, consultID string) (*Vide
}, nil
}
func (s *Service) EndConsult(ctx context.Context, consultID string) error {
// EndConsultRequest 结束问诊请求
type EndConsultRequest struct {
Diagnosis string `json:"diagnosis"`
Summary string `json:"summary"`
FollowUpDays int `json:"follow_up_days"`
SendSurvey bool `json:"send_survey"`
}
func (s *Service) EndConsult(ctx context.Context, consultID string, req *EndConsultRequest) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
......@@ -195,13 +251,52 @@ func (s *Service) EndConsult(ctx context.Context, consultID string) error {
}
now := time.Now()
if err := s.db.Model(&consult).Updates(map[string]interface{}{
updates := map[string]interface{}{
"status": "completed",
"ended_at": now,
}).Error; err != nil {
}
// 自动计算问诊时长
if consult.StartedAt != nil {
durationMinutes := int(now.Sub(*consult.StartedAt).Minutes())
updates["duration_minutes"] = durationMinutes
}
if req != nil {
if req.Diagnosis != "" {
updates["diagnosis"] = req.Diagnosis
}
if req.Summary != "" {
updates["summary"] = req.Summary
}
if req.FollowUpDays > 0 {
updates["follow_up_days"] = req.FollowUpDays
}
}
if err := s.db.Model(&consult).Updates(updates).Error; err != nil {
return err
}
// 发送问诊小结系统消息
if req != nil && req.Summary != "" {
s.SendMessage(ctx, consultID, "", "system",
fmt.Sprintf("【问诊小结】%s", req.Summary), "text")
}
// 发送随访提醒
if req != nil && req.FollowUpDays > 0 {
followUpDate := now.AddDate(0, 0, req.FollowUpDays).Format("2006-01-02")
s.SendMessage(ctx, consultID, "", "system",
fmt.Sprintf("医生建议您于 %s 前来复诊", followUpDate), "text")
}
// 发送满意度评价链接
if req != nil && req.SendSurvey {
s.SendMessage(ctx, consultID, "", "system",
"本次问诊已结束,请对本次服务进行评价,您的反馈有助于我们提升服务质量。", "text")
}
// 触发 consult_ended 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "consult_ended", map[string]interface{}{
"consult_id": consultID,
......@@ -215,7 +310,9 @@ func (s *Service) CancelConsult(ctx context.Context, consultID string) error {
return s.db.Model(&model.Consultation{}).Where("id = ?", consultID).Update("status", "cancelled").Error
}
// AIAssist AI辅助分析(鉴别诊断/用药建议)—— 通过 Agent 调用
// AIAssist AI辅助分析 —— 通过 Agent 调用
// 支持场景: consult_diagnosis, consult_medication, consult_lab_advice,
// consult_medical_record, consult_follow_up, consult_order_template
func (s *Service) AIAssist(ctx context.Context, consultID string, scene string) (map[string]interface{}, error) {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
......@@ -235,14 +332,49 @@ func (s *Service) AIAssist(ctx context.Context, consultID string, scene string)
agentCtx["pre_consult_analysis"] = preConsult.AIAnalysis
}
var agentID, message string
// v13: 注入更丰富的上下文
// 聊天历史(最近20条)
var messages []model.ConsultMessage
s.db.Where("consult_id = ?", consultID).Order("created_at DESC").Limit(20).Find(&messages)
chatHistory := make([]map[string]string, 0, len(messages))
for i := len(messages) - 1; i >= 0; i-- {
chatHistory = append(chatHistory, map[string]string{
"role": messages[i].SenderType,
"content": messages[i].Content,
})
}
agentCtx["chat_history"] = chatHistory
// 过敏史
var profile model.PatientProfile
if err := s.db.Where("user_id = ?", consult.PatientID).First(&profile).Error; err == nil {
agentCtx["allergy_history"] = profile.AllergyHistory
}
// 慢病
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", consult.PatientID).Find(&chronicRecords)
diseases := make([]string, 0, len(chronicRecords))
for _, cr := range chronicRecords {
diseases = append(diseases, cr.DiseaseName)
}
agentCtx["chronic_diseases"] = diseases
var message string
agentID := "doctor_universal_agent"
switch scene {
case "consult_diagnosis":
agentID = "diagnosis_agent"
message = "请对患者当前情况进行诊断分析,提供鉴别诊断建议"
case "consult_medication":
agentID = "prescription_agent"
message = "请根据患者情况给出用药建议,包括推荐药物、用法用量和注意事项"
case "consult_lab_advice":
message = "请根据患者主诉和初步诊断,推荐需要进行的检查项目,按优先级排列,说明每项检查的目的和注意事项"
case "consult_medical_record":
message = "请根据以上对话记录,生成一份标准门诊病历,包含:主诉、现病史、既往史、查体、辅助检查、初步诊断、处置意见。格式规范,内容完整。"
case "consult_follow_up":
message = "请根据患者情况制定随访计划,包含:复诊时间、需要复查的项目、用药提醒、生活方式建议、注意事项和复诊指标"
case "consult_order_template":
message = "请根据患者情况生成结构化医嘱,分为以下类别:检查医嘱、治疗医嘱、护理医嘱、饮食医嘱、活动医嘱。每条医嘱需明确具体内容。"
default:
return nil, fmt.Errorf("不支持的AI场景: %s", scene)
}
......@@ -359,6 +491,7 @@ func (s *Service) GetWaitingList(ctx context.Context, doctorUserID string) ([]Wa
type PatientListItem struct {
ID string `json:"id"`
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
......@@ -371,6 +504,14 @@ type PatientListItem struct {
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
// v13 新增字段
AllergyHistory string `json:"allergy_history"`
ChronicDiseases []string `json:"chronic_diseases"`
IsRevisit bool `json:"is_revisit"`
VisitCount int `json:"visit_count"`
RiskLevel string `json:"risk_level"`
LastVisitDate string `json:"last_visit_date"`
PreConsultSummary string `json:"pre_consult_summary"`
}
// GetPatientList 医生端获取统一患者列表(待接诊+进行中+已完成)
......@@ -399,9 +540,10 @@ func (s *Service) GetPatientList(ctx context.Context, doctorUserID string) ([]Pa
waitingSeconds = int(now.Sub(c.CreatedAt).Seconds())
}
results = append(results, PatientListItem{
item := PatientListItem{
ID: c.PatientID,
ConsultID: c.ID,
SerialNumber: c.SerialNumber,
PatientID: c.PatientID,
PatientName: patient.RealName,
PatientGender: patient.Gender,
......@@ -414,16 +556,103 @@ func (s *Service) GetPatientList(ctx context.Context, doctorUserID string) ([]Pa
CreatedAt: c.CreatedAt,
StartedAt: c.StartedAt,
EndedAt: c.EndedAt,
})
}
// 查询过敏史
var profile model.PatientProfile
if err := s.db.Where("user_id = ?", c.PatientID).First(&profile).Error; err == nil {
item.AllergyHistory = profile.AllergyHistory
}
// 查询慢病标签
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", c.PatientID).Find(&chronicRecords)
for _, cr := range chronicRecords {
item.ChronicDiseases = append(item.ChronicDiseases, cr.DiseaseName)
}
if item.ChronicDiseases == nil {
item.ChronicDiseases = []string{}
}
// 查询是否复诊 + 就诊次数
var visitCount int64
s.db.Model(&model.Consultation{}).
Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Count(&visitCount)
item.VisitCount = int(visitCount)
item.IsRevisit = visitCount > 0
// 上次就诊时间
if visitCount > 0 {
var lastConsult model.Consultation
if err := s.db.Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Order("ended_at DESC").First(&lastConsult).Error; err == nil && lastConsult.EndedAt != nil {
item.LastVisitDate = lastConsult.EndedAt.Format("2006-01-02")
}
}
// 计算风险等级
item.RiskLevel = calcRiskLevel(patient.Age, len(chronicRecords), item.AllergyHistory)
// 预问诊摘要
var preConsult model.PreConsultation
if err := s.db.Where("consultation_id = ?", c.ID).First(&preConsult).Error; err == nil {
parts := []string{}
if preConsult.AIDepartment != "" {
parts = append(parts, preConsult.AIDepartment)
}
if preConsult.AISeverity != "" {
parts = append(parts, preConsult.AISeverity)
}
if len(parts) > 0 {
item.PreConsultSummary = fmt.Sprintf("%s", joinStrings(parts, " · "))
}
}
results = append(results, item)
}
// 排序:in_progress > pending/waiting > completed
// 同状态内按创建时间排序
sortPatientList(results)
return results, nil
}
// calcRiskLevel 根据年龄、慢病数量、过敏史计算风险等级
func calcRiskLevel(age int, chronicCount int, allergyHistory string) string {
score := 0
if age >= 65 {
score += 2
} else if age >= 50 {
score += 1
}
score += chronicCount
if allergyHistory != "" {
score += 1
}
if score >= 4 {
return "high"
}
if score >= 2 {
return "medium"
}
return "low"
}
// joinStrings 用分隔符连接字符串切片
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}
// sortPatientList 对患者列表排序
func sortPatientList(list []PatientListItem) {
// 定义状态优先级
......@@ -509,6 +738,13 @@ type DoctorWorkbenchStats struct {
Rating float64 `json:"rating"`
IncomeToday int `json:"income_today"`
IncomeMonth int `json:"income_month"`
// v13 效率指标
AvgWaitMinutes float64 `json:"avg_wait_minutes"`
AvgConsultMinutes float64 `json:"avg_consult_minutes"`
PrescriptionRate float64 `json:"prescription_rate"`
AvgSatisfaction float64 `json:"avg_satisfaction"`
TransferCount int `json:"transfer_count"`
AIUsageRate float64 `json:"ai_usage_rate"`
}
// GetDoctorWorkbenchStats 获取医生工作台统计数据
......@@ -568,5 +804,268 @@ func (s *Service) GetDoctorWorkbenchStats(ctx context.Context, doctorUserID stri
Count(&count)
stats.IncomeMonth = int(count) * 5000
// v13: 效率指标
// 平均候诊时长(今日已接诊的,started_at - created_at 的平均值)
var avgWaitResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(EXTRACT(EPOCH FROM (started_at - created_at)) / 60) as avg").
Where("doctor_id = ? AND started_at IS NOT NULL AND DATE(created_at) = ?", doctor.ID, today).
Scan(&avgWaitResult)
if avgWaitResult.Avg != nil {
stats.AvgWaitMinutes = *avgWaitResult.Avg
}
// 平均问诊时长(今日已完成的 duration_minutes 平均)
var avgConsultResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(duration_minutes) as avg").
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND duration_minutes > 0", doctor.ID, "completed", today).
Scan(&avgConsultResult)
if avgConsultResult.Avg != nil {
stats.AvgConsultMinutes = *avgConsultResult.Avg
}
// 处方率(今日完成中有处方的比例)
if stats.CompletedToday > 0 {
var withPrescription int64
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND prescription_id IS NOT NULL",
doctor.ID, "completed", today).
Count(&withPrescription)
stats.PrescriptionRate = float64(withPrescription) / float64(stats.CompletedToday) * 100
}
// 平均满意度(有评分的)
var avgSatResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(satisfaction_score) as avg").
Where("doctor_id = ? AND satisfaction_score IS NOT NULL AND DATE(ended_at) = ?", doctor.ID, today).
Scan(&avgSatResult)
if avgSatResult.Avg != nil {
stats.AvgSatisfaction = *avgSatResult.Avg
}
// 今日转诊数
var transferCount int64
s.db.Model(&model.ConsultTransfer{}).
Where("from_doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Count(&transferCount)
stats.TransferCount = int(transferCount)
// AI 使用率(今日问诊中使用过 AI 辅助的比例)
if stats.CompletedToday > 0 {
var aiUsedCount int64
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND ai_session_id != ''",
doctor.ID, "completed", today).
Count(&aiUsedCount)
stats.AIUsageRate = float64(aiUsedCount) / float64(stats.CompletedToday) * 100
}
return stats, nil
}
// ========== 患者画像 API (v13) ==========
// PatientBasicInfo 患者基本信息
type PatientBasicInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
AllergyHistory string `json:"allergy_history"`
MedicalHistory string `json:"medical_history"`
InsuranceType string `json:"insurance_type"`
InsuranceNo string `json:"insurance_no"`
}
// ConsultSummary 问诊摘要
type ConsultSummary struct {
ID string `json:"id"`
DoctorName string `json:"doctor_name"`
ChiefComplaint string `json:"chief_complaint"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at"`
}
// PrescriptionBrief 处方摘要
type PrescriptionBrief struct {
ID string `json:"id"`
PrescriptionNo string `json:"prescription_no"`
Diagnosis string `json:"diagnosis"`
ItemCount int `json:"item_count"`
TotalAmount int `json:"total_amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ChronicBrief 慢病摘要
type ChronicBrief struct {
ID string `json:"id"`
DiseaseName string `json:"disease_name"`
DiagnosisDate *time.Time `json:"diagnosis_date"`
ControlStatus string `json:"control_status"`
CurrentMeds string `json:"current_meds"`
}
// LabReportBrief 检查报告摘要
type LabReportBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
ReportDate *time.Time `json:"report_date"`
FileURL string `json:"file_url"`
}
// HealthMetricPoint 健康指标数据点
type HealthMetricPoint struct {
MetricType string `json:"metric_type"`
Value1 float64 `json:"value1"`
Value2 float64 `json:"value2"`
Unit string `json:"unit"`
RecordedAt time.Time `json:"recorded_at"`
}
// FamilyMemberBrief 家族成员摘要
type FamilyMemberBrief struct {
Name string `json:"name"`
Relation string `json:"relation"`
MedicalHistory string `json:"medical_history"`
}
// PatientProfileResponse 患者画像完整响应
type PatientProfileResponse struct {
BasicInfo PatientBasicInfo `json:"basic_info"`
ConsultHistory []ConsultSummary `json:"consult_history"`
Prescriptions []PrescriptionBrief `json:"prescriptions"`
ChronicRecords []ChronicBrief `json:"chronic_records"`
LabReports []LabReportBrief `json:"lab_reports"`
HealthMetrics []HealthMetricPoint `json:"health_metrics"`
FamilyHistory []FamilyMemberBrief `json:"family_history"`
}
// GetPatientProfile 获取患者完整画像
func (s *Service) GetPatientProfile(ctx context.Context, patientID string) (*PatientProfileResponse, error) {
profile := &PatientProfileResponse{}
// 基本信息
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
profile.BasicInfo = PatientBasicInfo{
ID: user.ID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
}
var patientProfile model.PatientProfile
if err := s.db.Where("user_id = ?", patientID).First(&patientProfile).Error; err == nil {
profile.BasicInfo.AllergyHistory = patientProfile.AllergyHistory
profile.BasicInfo.MedicalHistory = patientProfile.MedicalHistory
profile.BasicInfo.InsuranceType = patientProfile.InsuranceType
profile.BasicInfo.InsuranceNo = patientProfile.InsuranceNo
}
// 问诊历史(最近10条)
var consultations []model.Consultation
s.db.Where("patient_id = ? AND status = ?", patientID, "completed").
Order("created_at DESC").Limit(10).Find(&consultations)
profile.ConsultHistory = make([]ConsultSummary, 0, len(consultations))
for _, c := range consultations {
var doc model.Doctor
doctorName := ""
if err := s.db.Where("id = ?", c.DoctorID).First(&doc).Error; err == nil {
doctorName = doc.Name
}
profile.ConsultHistory = append(profile.ConsultHistory, ConsultSummary{
ID: c.ID,
DoctorName: doctorName,
ChiefComplaint: c.ChiefComplaint,
Type: c.Type,
Status: c.Status,
CreatedAt: c.CreatedAt,
EndedAt: c.EndedAt,
})
}
// 处方历史(最近10条)
var prescriptions []model.Prescription
s.db.Where("patient_id = ?", patientID).
Order("created_at DESC").Limit(10).Preload("Items").Find(&prescriptions)
profile.Prescriptions = make([]PrescriptionBrief, 0, len(prescriptions))
for _, p := range prescriptions {
profile.Prescriptions = append(profile.Prescriptions, PrescriptionBrief{
ID: p.ID,
PrescriptionNo: p.PrescriptionNo,
Diagnosis: p.Diagnosis,
ItemCount: len(p.Items),
TotalAmount: p.TotalAmount,
Status: p.Status,
CreatedAt: p.CreatedAt,
})
}
// 慢病档案
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&chronicRecords)
profile.ChronicRecords = make([]ChronicBrief, 0, len(chronicRecords))
for _, cr := range chronicRecords {
profile.ChronicRecords = append(profile.ChronicRecords, ChronicBrief{
ID: cr.ID,
DiseaseName: cr.DiseaseName,
DiagnosisDate: cr.DiagnosisDate,
ControlStatus: cr.ControlStatus,
CurrentMeds: cr.CurrentMeds,
})
}
// 检查报告(最近10条)
var labReports []model.LabReport
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("report_date DESC").Limit(10).Find(&labReports)
profile.LabReports = make([]LabReportBrief, 0, len(labReports))
for _, lr := range labReports {
profile.LabReports = append(profile.LabReports, LabReportBrief{
ID: lr.ID,
Title: lr.Title,
Category: lr.Category,
ReportDate: lr.ReportDate,
FileURL: lr.FileURL,
})
}
// 健康指标(最近30条)
var healthMetrics []model.HealthMetric
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("recorded_at DESC").Limit(30).Find(&healthMetrics)
profile.HealthMetrics = make([]HealthMetricPoint, 0, len(healthMetrics))
for _, hm := range healthMetrics {
profile.HealthMetrics = append(profile.HealthMetrics, HealthMetricPoint{
MetricType: hm.MetricType,
Value1: hm.Value1,
Value2: hm.Value2,
Unit: hm.Unit,
RecordedAt: hm.RecordedAt,
})
}
// 家族史
var familyMembers []model.FamilyMember
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&familyMembers)
profile.FamilyHistory = make([]FamilyMemberBrief, 0, len(familyMembers))
for _, fm := range familyMembers {
profile.FamilyHistory = append(profile.FamilyHistory, FamilyMemberBrief{
Name: fm.Name,
Relation: fm.Relation,
MedicalHistory: fm.MedicalHistory,
})
}
return profile, nil
}
package consult
import (
"context"
"errors"
"fmt"
"time"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"github.com/google/uuid"
)
// TransferRequest 转诊请求
type TransferRequest struct {
ToDoctorID string `json:"to_doctor_id" binding:"required"`
ToDepartmentID string `json:"to_department_id"`
Reason string `json:"reason" binding:"required"`
TransferNote string `json:"transfer_note"`
}
// TransferConsult 发起转诊
func (s *Service) TransferConsult(ctx context.Context, consultID string, doctorUserID string, req *TransferRequest) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
}
if consult.Status != "in_progress" {
return fmt.Errorf("当前问诊状态为 %s,无法转诊", consult.Status)
}
// 获取当前医生 ID
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return errors.New("医生信息不存在")
}
transfer := &model.ConsultTransfer{
ConsultID: consultID,
FromDoctorID: doctor.ID,
ToDoctorID: req.ToDoctorID,
ToDepartmentID: req.ToDepartmentID,
Reason: req.Reason,
TransferNote: req.TransferNote,
Status: "pending",
}
if err := s.db.Create(transfer).Error; err != nil {
return fmt.Errorf("创建转诊记录失败: %w", err)
}
// 发送系统消息通知患者
var toDoctor model.Doctor
s.db.Where("id = ?", req.ToDoctorID).First(&toDoctor)
s.SendMessage(ctx, consultID, "", "system",
fmt.Sprintf("医生已为您发起转诊,目标医生:%s,原因:%s", toDoctor.Name, req.Reason), "text")
return nil
}
// TransferInboxItem 转诊收件箱项
type TransferInboxItem struct {
ID uint `json:"id"`
ConsultID string `json:"consult_id"`
FromDoctorName string `json:"from_doctor_name"`
PatientName string `json:"patient_name"`
ChiefComplaint string `json:"chief_complaint"`
Reason string `json:"reason"`
TransferNote string `json:"transfer_note"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// GetTransferInbox 获取转诊收件箱
func (s *Service) GetTransferInbox(ctx context.Context, doctorUserID string) ([]TransferInboxItem, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []TransferInboxItem{}, nil
}
var transfers []model.ConsultTransfer
if err := s.db.Where("to_doctor_id = ? AND status = ?", doctor.ID, "pending").
Order("created_at DESC").Find(&transfers).Error; err != nil {
return nil, err
}
var results []TransferInboxItem
for _, t := range transfers {
var fromDoc model.Doctor
s.db.Where("id = ?", t.FromDoctorID).First(&fromDoc)
var consult model.Consultation
s.db.Where("id = ?", t.ConsultID).First(&consult)
var patient model.User
s.db.Where("id = ?", consult.PatientID).First(&patient)
results = append(results, TransferInboxItem{
ID: t.ID,
ConsultID: t.ConsultID,
FromDoctorName: fromDoc.Name,
PatientName: patient.RealName,
ChiefComplaint: consult.ChiefComplaint,
Reason: t.Reason,
TransferNote: t.TransferNote,
Status: t.Status,
CreatedAt: t.CreatedAt,
})
}
return results, nil
}
// AcceptTransfer 接受转诊
func (s *Service) AcceptTransfer(ctx context.Context, transferID uint, doctorUserID string) error {
db := database.GetDB()
var transfer model.ConsultTransfer
if err := db.Where("id = ?", transferID).First(&transfer).Error; err != nil {
return errors.New("转诊记录不存在")
}
if transfer.Status != "pending" {
return errors.New("该转诊已处理")
}
// 更新转诊状态
db.Model(&transfer).Update("status", "accepted")
// 结束原问诊
now := time.Now()
db.Model(&model.Consultation{}).Where("id = ?", transfer.ConsultID).Updates(map[string]interface{}{
"status": "completed",
"ended_at": now,
})
// 获取原问诊信息
var origConsult model.Consultation
db.Where("id = ?", transfer.ConsultID).First(&origConsult)
// 创建新问诊
newConsult := &model.Consultation{
ID: uuid.New().String(),
PatientID: origConsult.PatientID,
DoctorID: transfer.ToDoctorID,
Type: origConsult.Type,
Status: "pending",
ChiefComplaint: origConsult.ChiefComplaint,
MedicalHistory: origConsult.MedicalHistory + "\n[转诊备注] " + transfer.TransferNote,
}
db.Create(newConsult)
// 发送系统消息
s.SendMessage(ctx, transfer.ConsultID, "", "system", "转诊已被接受,正在为您转接新医生", "text")
return nil
}
// RejectTransfer 拒绝转诊
func (s *Service) RejectTransfer(ctx context.Context, transferID uint, reason string) error {
db := database.GetDB()
var transfer model.ConsultTransfer
if err := db.Where("id = ?", transferID).First(&transfer).Error; err != nil {
return errors.New("转诊记录不存在")
}
if transfer.Status != "pending" {
return errors.New("该转诊已处理")
}
db.Model(&transfer).Update("status", "rejected")
s.SendMessage(ctx, transfer.ConsultID, "", "system",
fmt.Sprintf("转诊被拒绝,原因:%s", reason), "text")
return nil
}
// GetPrescriptionStatus 获取处方状态
func (s *Service) GetPrescriptionStatus(ctx context.Context, prescriptionID string) (map[string]interface{}, error) {
var prescription model.Prescription
if err := s.db.Where("id = ?", prescriptionID).First(&prescription).Error; err != nil {
return nil, errors.New("处方不存在")
}
timeline := []map[string]interface{}{
{"status": "signed", "label": "已签名", "time": prescription.SignedAt},
{"status": "approved", "label": "审核通过", "time": prescription.ReviewedAt},
}
return map[string]interface{}{
"id": prescription.ID,
"prescription_no": prescription.PrescriptionNo,
"status": prescription.Status,
"total_amount": prescription.TotalAmount,
"item_count": 0, // will be counted
"timeline": timeline,
}, nil
}
......@@ -2,14 +2,28 @@ package doctorportal
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
"internet-hospital/pkg/workflow"
)
// resolveConsultID 解析问诊标识:支持 UUID 或流水号(C开头)
func resolveConsultID(idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := database.GetDB().Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// Handler 医生端API处理器
type Handler struct {
service *Service
......@@ -63,6 +77,9 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
dp.POST("/prescription/check", h.CheckPrescriptionSafety)
dp.GET("/prescriptions", h.GetDoctorPrescriptions)
dp.GET("/prescription/:id", h.GetPrescriptionDetail)
// v13: 快捷回复
h.RegisterQuickReplyRoutes(dp)
}
}
......@@ -134,7 +151,11 @@ func (h *Handler) GetWaitingQueue(c *gin.Context) {
// AcceptConsult 接受问诊
func (h *Handler) AcceptConsult(c *gin.Context) {
id := c.Param("id")
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
result, err := h.service.AcceptConsult(c.Request.Context(), id)
if err != nil {
response.Error(c, 500, "接诊失败")
......@@ -145,7 +166,11 @@ func (h *Handler) AcceptConsult(c *gin.Context) {
// GetConsultDetail 获取问诊详情
func (h *Handler) GetConsultDetail(c *gin.Context) {
id := c.Param("id")
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
detail, err := h.service.GetConsultDetail(c.Request.Context(), id)
if err != nil {
response.Error(c, 404, "问诊不存在")
......@@ -156,7 +181,11 @@ func (h *Handler) GetConsultDetail(c *gin.Context) {
// GetConsultMessages 获取问诊消息
func (h *Handler) GetConsultMessages(c *gin.Context) {
id := c.Param("id")
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
messages, err := h.service.GetConsultMessages(c.Request.Context(), id)
if err != nil {
response.Error(c, 500, "获取消息失败")
......@@ -167,7 +196,11 @@ func (h *Handler) GetConsultMessages(c *gin.Context) {
// SendMessage 发送消息
func (h *Handler) SendMessage(c *gin.Context) {
id := c.Param("id")
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct {
Content string `json:"content" binding:"required"`
ContentType string `json:"content_type"`
......@@ -186,7 +219,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
// EndConsult 结束问诊
func (h *Handler) EndConsult(c *gin.Context) {
id := c.Param("id")
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
var req struct {
Summary string `json:"summary"`
}
......
......@@ -17,6 +17,7 @@ import (
type CreatePrescriptionReq struct {
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id" binding:"required"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
......@@ -55,6 +56,15 @@ type PrescriptionListResp struct {
// ==================== 处方开具服务 ====================
func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *CreatePrescriptionReq) (*model.Prescription, error) {
// 解析 serial_number → consult_id
if req.ConsultID == "" && req.SerialNumber != "" {
resolved, err := resolveConsultID(req.SerialNumber)
if err != nil {
return nil, err
}
req.ConsultID = resolved
}
// 获取医生信息
var doctor model.Doctor
if err := s.db.First(&doctor, "user_id = ?", doctorID).Error; err != nil {
......@@ -200,7 +210,7 @@ func (s *Service) GetPrescriptionByID(ctx context.Context, id string) (*model.Pr
return &prescription, nil
}
// CheckPrescriptionSafety 通过 prescription_agent 审核处方安全性
// CheckPrescriptionSafety 通过 doctor_universal_agent 审核处方安全性
func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID string, drugs []string) (map[string]interface{}, error) {
drugList := strings.Join(drugs, "、")
agentCtx := map[string]interface{}{
......@@ -210,12 +220,12 @@ func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID
message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList)
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(ctx, "prescription_agent", userID, "", message, agentCtx)
output, err := agentSvc.Chat(ctx, "doctor_universal_agent", userID, "", message, agentCtx)
if err != nil {
return nil, fmt.Errorf("处方安全审核失败: %w", err)
}
if output == nil {
return nil, fmt.Errorf("prescription_agent 未初始化")
return nil, fmt.Errorf("doctor_universal_agent 未初始化")
}
// 判断是否有警告(简单关键词检测)
......
package doctorportal
import (
"fmt"
"github.com/gin-gonic/gin"
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// RegisterQuickReplyRoutes 注册快捷回复路由
func (h *Handler) RegisterQuickReplyRoutes(rg *gin.RouterGroup) {
qr := rg.Group("/quick-replies")
{
qr.GET("", h.ListQuickReplies)
qr.POST("", h.CreateQuickReply)
qr.PUT("/:id", h.UpdateQuickReply)
qr.DELETE("/:id", h.DeleteQuickReply)
qr.POST("/ai-suggest", h.AIGenerateReplies)
qr.POST("/:id/use", h.IncrementUseCount)
}
}
// ListQuickReplies 获取快捷回复列表(系统级 + 个人级)
func (h *Handler) ListQuickReplies(c *gin.Context) {
userID, _ := c.Get("user_id")
category := c.Query("category")
db := database.GetDB()
var templates []model.QuickReplyTemplate
query := db.Where("doctor_id = '' OR doctor_id = ?", userID.(string))
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("sort_order ASC, use_count DESC").Find(&templates).Error; err != nil {
response.Error(c, 500, "获取快捷回复失败")
return
}
response.Success(c, templates)
}
// CreateQuickReply 创建快捷回复模板
func (h *Handler) CreateQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
var req struct {
Category string `json:"category" binding:"required"`
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
db := database.GetDB()
template := model.QuickReplyTemplate{
DoctorID: userID.(string),
Category: req.Category,
Title: req.Title,
Content: req.Content,
SortOrder: req.SortOrder,
}
if err := db.Create(&template).Error; err != nil {
response.Error(c, 500, "创建快捷回复失败")
return
}
response.Success(c, template)
}
// UpdateQuickReply 更新快捷回复模板
func (h *Handler) UpdateQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
id := c.Param("id")
db := database.GetDB()
var template model.QuickReplyTemplate
if err := db.Where("id = ? AND doctor_id = ?", id, userID.(string)).First(&template).Error; err != nil {
response.Error(c, 404, "快捷回复不存在或无权修改")
return
}
var req struct {
Category *string `json:"category"`
Title *string `json:"title"`
Content *string `json:"content"`
SortOrder *int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
updates := map[string]interface{}{}
if req.Category != nil {
updates["category"] = *req.Category
}
if req.Title != nil {
updates["title"] = *req.Title
}
if req.Content != nil {
updates["content"] = *req.Content
}
if req.SortOrder != nil {
updates["sort_order"] = *req.SortOrder
}
if err := db.Model(&template).Updates(updates).Error; err != nil {
response.Error(c, 500, "更新快捷回复失败")
return
}
response.Success(c, template)
}
// DeleteQuickReply 删除快捷回复模板
func (h *Handler) DeleteQuickReply(c *gin.Context) {
userID, _ := c.Get("user_id")
id := c.Param("id")
db := database.GetDB()
result := db.Where("id = ? AND doctor_id = ?", id, userID.(string)).Delete(&model.QuickReplyTemplate{})
if result.RowsAffected == 0 {
response.Error(c, 404, "快捷回复不存在或无权删除")
return
}
response.Success(c, nil)
}
// IncrementUseCount 增加使用次数
func (h *Handler) IncrementUseCount(c *gin.Context) {
id := c.Param("id")
db := database.GetDB()
db.Model(&model.QuickReplyTemplate{}).Where("id = ?", id).UpdateColumn("use_count", db.Raw("use_count + 1"))
response.Success(c, nil)
}
// AIGenerateReplies AI 推荐快捷回复
func (h *Handler) AIGenerateReplies(c *gin.Context) {
userID, _ := c.Get("user_id")
var req struct {
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "consult_id 或 serial_number 必填")
return
}
// 支持 serial_number 作为替代参数
consultID := req.ConsultID
if consultID == "" && req.SerialNumber != "" {
consultID = req.SerialNumber
}
if consultID == "" {
response.BadRequest(c, "consult_id 或 serial_number 必填")
return
}
resolved, err := resolveConsultID(consultID)
if err != nil {
response.Error(c, 404, err.Error())
return
}
db := database.GetDB()
// 获取最近几条消息作为上下文
var messages []model.ConsultMessage
db.Where("consult_id = ?", resolved).Order("created_at DESC").Limit(10).Find(&messages)
chatContext := ""
for i := len(messages) - 1; i >= 0; i-- {
role := "患者"
if messages[i].SenderType == "doctor" {
role = "医生"
}
chatContext += fmt.Sprintf("%s: %s\n", role, messages[i].Content)
}
// 获取问诊主诉
var consult model.Consultation
db.Where("id = ?", resolved).First(&consult)
agentCtx := map[string]interface{}{
"chat_context": chatContext,
"chief_complaint": consult.ChiefComplaint,
}
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(
c.Request.Context(),
"doctor_universal_agent",
userID.(string),
"",
"请根据以上对话上下文,生成3-5条医生可以使用的快捷回复建议。每条回复独立一行,不要编号,直接输出回复内容。回复应该专业、简洁、有针对性。",
agentCtx,
)
if err != nil {
response.Error(c, 500, "AI生成回复失败: "+err.Error())
return
}
if output == nil {
response.Error(c, 500, "AI agent不可用")
return
}
response.Success(c, map[string]interface{}{
"suggestions": output.Response,
})
}
......@@ -5,10 +5,27 @@ import (
"encoding/json"
"fmt"
"internet-hospital/pkg/ai"
"time"
"github.com/google/uuid"
)
// StreamEventType SSE 事件类型
type StreamEventType string
const (
StreamEventThinking StreamEventType = "thinking"
StreamEventToolCall StreamEventType = "tool_call"
StreamEventToolResult StreamEventType = "tool_result"
StreamEventChunk StreamEventType = "chunk"
)
// StreamEvent 流式事件
type StreamEvent struct {
Type StreamEventType `json:"type"`
Data map[string]interface{} `json:"data"`
}
// AgentInput Agent输入
type AgentInput struct {
SessionID string `json:"session_id"`
......@@ -183,26 +200,167 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
}, nil
}
// RunStream 流式执行Agent
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onChunk func(AgentChunk) error) (*AgentOutput, error) {
// 简化:先用非流式,后续可升级为真正的流式
output, err := a.Run(ctx, input)
// RunStream 流式执行Agent,通过 onEvent 实时推送中间过程
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent func(StreamEvent) error) (*AgentOutput, error) {
traceID := input.TraceID
if traceID == "" {
traceID = uuid.New().String()
}
client := ai.GetClient()
if client == nil {
msg := "AI服务未配置,请在管理端配置API Key"
emitChunks(ctx, msg, onEvent)
return &AgentOutput{
Response: msg,
FinishReason: "no_client",
TraceID: traceID,
}, nil
}
maxIter := a.cfg.MaxIterations
if input.MaxIterations > 0 {
maxIter = input.MaxIterations
}
if maxIter <= 0 {
maxIter = 10
}
messages := []ai.ChatMessage{
{Role: "system", Content: a.buildSystemPrompt(input.Context)},
}
messages = append(messages, input.History...)
messages = append(messages, ai.ChatMessage{Role: "user", Content: input.Message})
tools := a.getToolSchemas()
var toolCallResults []ToolCallResult
totalTokens := 0
for i := 0; i < maxIter; i++ {
// 发射 thinking 事件
onEvent(StreamEvent{
Type: StreamEventThinking,
Data: map[string]interface{}{"iteration": i + 1, "status": "calling_llm"},
})
resp, err := client.ChatWithTools(ctx, messages, tools)
if err != nil {
return nil, err
return nil, fmt.Errorf("AI调用失败: %w", err)
}
// 模拟流式输出
for _, tc := range output.ToolCalls {
if err := onChunk(AgentChunk{Type: "tool_call", Content: tc}); err != nil {
return output, err
totalTokens += resp.Usage.TotalTokens
ai.SaveLog(ai.LogParams{
Scene: a.cfg.ID,
UserID: input.UserID,
RequestContent: input.Message,
ResponseContent: resp.Choices[0].Message.Content,
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
TotalTokens: resp.Usage.TotalTokens,
Success: true,
TraceID: traceID,
AgentID: a.cfg.ID,
SessionID: input.SessionID,
Iteration: i + 1,
})
choice := resp.Choices[0]
// 最终文本响应 — 流式推送
if choice.FinishReason == "stop" || len(choice.Message.ToolCalls) == 0 {
emitChunks(ctx, choice.Message.Content, onEvent)
return &AgentOutput{
Response: choice.Message.Content,
ToolCalls: toolCallResults,
Iterations: i + 1,
FinishReason: "completed",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
// 执行工具调用
assistantMsg := ai.ChatMessage{
Role: "assistant",
Content: choice.Message.Content,
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
// 发射 tool_call 事件
onEvent(StreamEvent{
Type: StreamEventToolCall,
Data: map[string]interface{}{
"tool_name": tc.Function.Name,
"call_id": tc.ID,
"arguments": tc.Function.Arguments,
"iteration": i + 1,
},
})
result := a.executor.ExecuteWithLog(ctx, tc.Function.Name, tc.Function.Arguments,
traceID, a.cfg.ID, input.SessionID, input.UserID, i+1)
resultJSON, _ := json.Marshal(result)
toolCallResults = append(toolCallResults, ToolCallResult{
ToolName: tc.Function.Name,
CallID: tc.ID,
Arguments: tc.Function.Arguments,
Result: result,
Success: result.Success,
})
// 发射 tool_result 事件
onEvent(StreamEvent{
Type: StreamEventToolResult,
Data: map[string]interface{}{
"tool_name": tc.Function.Name,
"call_id": tc.ID,
"success": result.Success,
},
})
messages = append(messages, ai.ChatMessage{
Role: "tool",
ToolCallID: tc.ID,
Content: string(resultJSON),
})
}
}
if err := onChunk(AgentChunk{Type: "text", Content: output.Response}); err != nil {
return output, err
msg := "已达到最大迭代次数"
emitChunks(ctx, msg, onEvent)
return &AgentOutput{
Response: msg,
ToolCalls: toolCallResults,
Iterations: maxIter,
FinishReason: "max_iterations",
TotalTokens: totalTokens,
TraceID: traceID,
}, nil
}
// emitChunks 将文本按 rune 分块发射 chunk 事件(模拟打字效果)
func emitChunks(ctx context.Context, text string, onEvent func(StreamEvent) error) {
runes := []rune(text)
chunkSize := 3
for i := 0; i < len(runes); i += chunkSize {
select {
case <-ctx.Done():
return
default:
}
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
if err := onChunk(AgentChunk{Type: "done", Content: output.FinishReason}); err != nil {
return output, err
onEvent(StreamEvent{
Type: StreamEventChunk,
Data: map[string]interface{}{"content": string(runes[i:end])},
})
time.Sleep(15 * time.Millisecond)
}
return output, nil
}
func (a *ReActAgent) buildSystemPrompt(ctx map[string]interface{}) string {
......
......@@ -26,7 +26,7 @@ func (t *AgentCallerTool) Parameters() []agent.ToolParameter {
{
Name: "agent_id",
Type: "string",
Description: "目标 Agent ID,如 diagnosis_agent、prescription_agent、follow_up_agent",
Description: "目标 Agent ID,如 patient_universal_agent、doctor_universal_agent、admin_universal_agent",
Required: true,
},
{
......
......@@ -2,13 +2,18 @@ package websocket
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
var upgrader = websocket.Upgrader{
......@@ -22,10 +27,14 @@ var upgrader = websocket.Upgrader{
// MessageHandler 消息处理回调
type MessageHandler func(consultID, senderID, senderType, content, contentType string) (*Message, error)
// ReadReceiptHandler 已读回执处理回调
type ReadReceiptHandler func(messageID string) error
// Handler WebSocket处理器
type Handler struct {
hub *Hub
messageHandler MessageHandler
readReceiptHandler ReadReceiptHandler
}
// NewHandler 创建WebSocket处理器
......@@ -36,14 +45,57 @@ func NewHandler(msgHandler MessageHandler) *Handler {
}
}
// SetReadReceiptHandler 设置已读回执处理器
func (h *Handler) SetReadReceiptHandler(handler ReadReceiptHandler) {
h.readReceiptHandler = handler
}
// BroadcastConsultUpdate 广播问诊状态变更
func (h *Handler) BroadcastConsultUpdate(consultID string, status string, extra map[string]interface{}) {
msg := &Message{
Type: "consult_update",
ConsultID: consultID,
Content: status,
Timestamp: time.Now(),
Extra: extra,
}
h.hub.BroadcastToConsult(consultID, msg, "")
}
// BroadcastNewPatient 广播新患者排队通知
func (h *Handler) BroadcastNewPatient(doctorUserID string, consultID string, patientName string) {
h.hub.SendToUser(doctorUserID, &Message{
Type: "new_patient",
ConsultID: consultID,
Content: patientName + " 进入候诊队列",
Timestamp: time.Now(),
})
}
// RegisterRoutes 注册WebSocket路由
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/ws/consult/:id", h.HandleConsultWS)
}
// resolveConsultID 解析问诊标识:支持 UUID 或流水号(C开头)
func resolveConsultID(idOrSerial string) (string, error) {
if strings.HasPrefix(idOrSerial, "C") {
var consult model.Consultation
if err := database.GetDB().Where("serial_number = ?", idOrSerial).Select("id").First(&consult).Error; err != nil {
return "", fmt.Errorf("流水号 %s 对应的问诊不存在", idOrSerial)
}
return consult.ID, nil
}
return idOrSerial, nil
}
// HandleConsultWS 处理问诊WebSocket连接
func (h *Handler) HandleConsultWS(c *gin.Context) {
consultID := c.Param("id")
consultID, err := resolveConsultID(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
userID := c.Query("user_id")
userType := c.Query("user_type") // patient or doctor
token := c.Query("token")
......@@ -135,6 +187,12 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
}, client.UserID)
case "read":
// 持久化已读状态
if h.readReceiptHandler != nil && msg.MessageID != "" {
if err := h.readReceiptHandler(msg.MessageID); err != nil {
log.Printf("已读回执更新失败: %v", err)
}
}
// 广播已读状态
h.hub.BroadcastToConsult(client.ConsultID, &Message{
Type: "read",
......@@ -145,6 +203,9 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
Timestamp: time.Now(),
}, client.UserID)
case "consult_update", "new_patient", "online_status":
// 这些类型由服务端主动推送,客户端发送时忽略
case "ping":
// 心跳响应
client.SendMessage(&Message{
......
# v13 升级方案:医生问诊大厅深度优化
> 版本:v13 | 日期:2026-03-02 | 范围:医生端问诊大厅全链路优化
---
## 一、现状分析与问题诊断
### 1.1 当前架构概览
```
┌────────────────────────────────────────────────────────────────────────┐
│ 问诊大厅 (ConsultPage) — 三栏式布局 │
├──────────┬───────────────────────────────┬─────────────────────────────┤
│ 患者列表 │ 聊天面板 │ AI辅助面板 │
│ 272px │ flex │ 340px │
│ │ │ │
│ 候诊/接诊 │ 消息列表 + 输入框 │ 预问诊 / 鉴别诊断 / 用药 │
│ /已结束 │ 快捷回复(11条) + 开处方 │ Agent分析 │
└──────────┴───────────────────────────────┴─────────────────────────────┘
```
### 1.2 核心问题梳理
| 编号 | 问题 | 严重度 | 详情 |
|------|------|--------|------|
| P1 | **信息密度不足** | 高 | 患者列表仅显示姓名/年龄/主诉,缺少过敏史、慢病标签、复诊标记、就诊次数等关键临床信息 |
| P2 | **缺少患者画像** | 高 | 医生接诊前无法快速了解患者全貌(历史处方、检查报告、慢病档案),需手动切换页面 |
| P3 | **实时通信依赖轮询** | 中 | 消息3秒轮询 + 患者列表5秒轮询,延迟高、资源浪费,不支持输入状态提示 |
| P4 | **AI面板功能单一** | 中 | 仅有诊断+用药两个场景,缺少医嘱模板、检查建议、病历自动生成等医生刚需功能 |
| P5 | **快捷回复不可定制** | 中 | 11条硬编码快捷回复,不支持医生自定义、按科室分组、智能推荐 |
| P6 | **缺少问诊计时** | 低 | 没有接诊时长统计,无法支持效率考核和超时预警 |
| P7 | **无多媒体支持** | 中 | 仅支持纯文本消息,不支持图片/报告/语音发送和查看 |
| P8 | **工作台与问诊大厅割裂** | 低 | 工作台统计和问诊大厅是两个独立页面,缺少一体化工作流 |
| P9 | **无转诊/会诊支持** | 中 | 无法将患者转给其他科室医生或发起多科会诊 |
| P10 | **处方流程断裂** | 低 | 处方创建后无状态追踪,医生看不到审批进度和患者取药情况 |
---
## 二、改造目标
1. **信息密度提升 200%**:患者卡片展示过敏标签、慢病标签、复诊标记、风险等级
2. **患者画像一键可达**:右侧抽屉展示完整患者档案(历史问诊/处方/检查报告/慢病数据)
3. **WebSocket 实时通信**:消息零延迟送达,支持输入状态、在线状态、消息已读回执
4. **AI 能力全面扩展**:新增医嘱生成、检查建议、病历书写、随访计划一键生成
5. **智能快捷回复**:可定制模板 + AI 根据上下文推荐回复
6. **多媒体消息**:支持图片、检查报告、语音消息的发送和预览
7. **转诊/会诊**:支持转诊流转和在线多科会诊
8. **全链路追踪**:处方状态追踪 + 问诊时长统计 + 效率看板
---
## 三、改造方案(8 个 Phase)
### Phase 1:患者列表信息增强 + 患者画像侧抽屉
**目标**:让医生在接诊前/接诊中快速获取完整患者信息
#### 1.1 后端:扩展 PatientListItem 返回字段
**文件**:`server/internal/service/consult/service.go`
```go
type PatientListItem struct {
// ... 现有字段
AllergyHistory string `json:"allergy_history"` // NEW: 过敏史
ChronicDiseases []string `json:"chronic_diseases"` // NEW: 慢病标签
IsRevisit bool `json:"is_revisit"` // NEW: 是否复诊
VisitCount int `json:"visit_count"` // NEW: 就诊次数
RiskLevel string `json:"risk_level"` // NEW: 风险等级 (low/medium/high)
LastVisitDate string `json:"last_visit_date"` // NEW: 上次就诊时间
PreConsultSummary string `json:"pre_consult_summary"` // NEW: 预问诊摘要
}
```
查询逻辑:
- `AllergyHistory`:JOIN `patient_profiles` 表
- `ChronicDiseases`:JOIN `chronic_records` 取活跃慢病诊断
- `IsRevisit`:检查同一 doctor_id 历史 consultation 记录
- `VisitCount`:COUNT 历史 consultation
- `RiskLevel`:根据年龄 + 慢病数量 + 过敏史计算
- `PreConsultSummary`:从关联的 PreConsultation 取 ai_department + ai_severity
#### 1.2 后端:新增患者画像 API
**文件**:`server/internal/service/consult/handler.go` 新增路由
```
GET /consult/doctor/patient-profile/:patient_id → GetPatientProfile
```
返回结构:
```go
type PatientProfile struct {
BasicInfo PatientBasicInfo `json:"basic_info"`
ConsultHistory []ConsultSummary `json:"consult_history"` // 最近10次问诊
Prescriptions []PrescriptionBrief `json:"prescriptions"` // 最近10个处方
ChronicRecords []ChronicBrief `json:"chronic_records"` // 慢病档案
LabReports []LabReportBrief `json:"lab_reports"` // 检查报告
HealthMetrics []HealthMetricPoint `json:"health_metrics"` // 近期健康指标
AllergyHistory string `json:"allergy_history"`
FamilyHistory string `json:"family_history"`
}
```
#### 1.3 前端:PatientList 组件增强
**文件**:`web/src/pages/doctor/Consult/PatientList.tsx`
改造内容:
- 患者卡片增加:过敏 Tag(红色)、慢病 Tag(橙色)、复诊标记(蓝色闪电图标)
- 风险等级用颜色条指示(左边框 green/orange/red)
- 候诊时间改为倒计时样式
- 新增搜索筛选栏(按姓名/主诉搜索,按状态/风险等级筛选)
- 新增排序选项(等待时长/风险等级/复诊优先)
#### 1.4 前端:新增 PatientProfileDrawer 组件
**文件**:`web/src/pages/doctor/Consult/PatientProfileDrawer.tsx`(新建)
- Drawer 从右侧滑出(宽度 480px)
- Tabs:基本信息 / 就诊记录 / 处方历史 / 检查报告 / 慢病管理 / 健康指标
- 健康指标用 mini 折线图(Ant Design Charts 或简易 SVG)
- 支持从 PatientList 和 ChatPanel 两个入口打开
---
### Phase 2:WebSocket 实时通信升级
**目标**:消息零延迟 + 输入状态 + 在线状态 + 已读回执
#### 2.1 后端:扩展 WebSocket 协议
**文件**:`server/pkg/websocket/handler.go`
现有 WebSocket 仅用于消息推送,扩展消息类型:
```go
// 消息类型枚举
const (
MsgTypeChat = "message" // 聊天消息(已有)
MsgTypeTyping = "typing" // 对方正在输入
MsgTypeRead = "read" // 已读回执
MsgTypeOnline = "online_status" // 在线状态变更
MsgTypeConsultUpdate = "consult_update" // 问诊状态变更(接诊/结束)
MsgTypeNewPatient = "new_patient" // 新患者排队通知
)
```
新增处理逻辑:
- `typing`:接收后广播给对方,3秒超时自动消除
- `read`:接收后更新 `consult_messages.read_at` 字段,通知对方
- `online_status`:维护内存中的在线用户 Map,定时心跳检测
- `consult_update`:接诊/拒诊/结束时推送给患者端
- `new_patient`:新问诊创建时推送给对应医生
#### 2.2 数据库:ConsultMessage 新增字段
**文件**:`server/internal/model/consult.go`
```go
type ConsultMessage struct {
// ... 现有字段
ReadAt *time.Time `json:"read_at"` // NEW: 已读时间
MediaURL string `json:"media_url"` // NEW: 媒体文件URL
MediaType string `json:"media_type"` // NEW: image/audio/file
ReplyToID *string `json:"reply_to_id"` // NEW: 引用回复的消息ID
}
```
#### 2.3 前端:ChatPanel WebSocket 接入
**文件**:`web/src/pages/doctor/Consult/ChatPanel.tsx`
改造要点:
- 使用 `useWebSocket` Hook 替代轮询
- 消息气泡显示已读标记(双勾 ✓✓)
- 输入框下方显示"对方正在输入..."
- 发送 typing 事件(debounce 1秒)
- 新消息通知音效(配置可关闭)
- 消息引用回复功能(长按/右键菜单)
#### 2.4 前端:PatientList 实时更新
去除 5 秒轮询,改为 WebSocket 推送驱动:
- `new_patient`:追加患者到列表
- `consult_update`:更新患者状态
---
### Phase 3:多媒体消息支持
**目标**:支持图片、检查报告、语音消息
#### 3.1 后端:文件上传 API
**文件**:`server/internal/service/consult/handler.go` 新增
```
POST /consult/:id/upload → UploadMedia
```
- 支持类型:image/jpeg, image/png, application/pdf, audio/wav, audio/mp3
- 限制大小:图片 5MB,PDF 20MB,音频 2MB
- 存储:本地 `/uploads/consult/{consult_id}/` 或 OSS
- 返回 media_url,前端发送消息时携带
#### 3.2 前端:ChatPanel 多媒体渲染
**文件**:`web/src/pages/doctor/Consult/ChatPanel.tsx`
新增消息类型渲染:
- `image`:缩略图 + 点击放大预览(Ant Image)
- `file`/`pdf`:PDF 预览卡片 + 下载按钮
- `audio`:内联音频播放器
- `prescription`:处方卡片样式(金色边框,显示处方号/药品数/金额)
输入区域增加:
- 图片上传按钮(PictureOutlined)
- 文件上传按钮(FileAddOutlined)
- 语音录制按钮(AudioOutlined,长按录音)
---
### Phase 4:AI 辅助面板全面扩展
**目标**:从"诊断+用药"扩展为完整的 AI 临床助手
#### 4.1 后端:新增 AI 场景
**文件**:`server/internal/service/consult/service.go` → `AIAssist` 方法
新增场景:
```go
const (
SceneDiagnosis = "consult_diagnosis" // 已有:鉴别诊断
SceneMedication = "consult_medication" // 已有:用药建议
SceneLabAdvice = "consult_lab_advice" // NEW:检查建议
SceneMedicalRecord = "consult_medical_record" // NEW:病历生成
SceneFollowUpPlan = "consult_follow_up" // NEW:随访计划
SceneOrderTemplate = "consult_order_template" // NEW:医嘱模板
)
```
每个场景对应不同的 Agent 或 prompt 模板:
- `consult_lab_advice`:根据主诉和初步诊断推荐检查项目和优先级
- `consult_medical_record`:根据聊天记录自动生成门诊病历(主诉/现病史/查体/诊断/处置)
- `consult_follow_up`:调用 `generate_follow_up_plan` 工具生成随访计划
- `consult_order_template`:生成结构化医嘱(检查/治疗/护理/饮食/活动)
#### 4.2 前端:AIPanel 重构
**文件**:`web/src/pages/doctor/Consult/AIPanel.tsx`
新增 Tab:
```
预问诊 | 诊断 | 用药 | 检查 | 病历 | 随访 | 医嘱
```
每个 Tab 布局:
- 顶部:一键分析按钮 + 上次分析时间
- 中部:AI 分析结果(Markdown 渲染)
- 底部:工具调用轨迹(可折叠)+ "采纳到聊天" 按钮
新增功能:
- **病历生成 Tab**:AI 自动抽取对话信息生成标准门诊病历,支持一键复制或导出
- **随访计划 Tab**:展示结构化随访计划卡片(复诊时间/检查项目/用药提醒/生活建议),支持一键发送给患者
- **医嘱模板 Tab**:展示结构化医嘱,支持勾选 + 编辑后一键发送
- **检查建议 Tab**:推荐检查项目列表,显示优先级/费用预估/注意事项
#### 4.3 新增:AI 上下文增强
**文件**:`server/internal/service/consult/service.go` → `AIAssist`
AI 调用时注入更丰富的上下文:
```go
agentCtx := map[string]interface{}{
"patient_id": consult.PatientID,
"consult_id": consultID,
"chief_complaint": consult.ChiefComplaint,
"chat_history": formatChatHistory(messages), // NEW: 完整对话历史
"pre_consult": preConsultAnalysis,
"allergy_history": patientProfile.AllergyHistory, // NEW
"chronic_diseases": chronicDiseases, // NEW
"recent_lab_reports": recentLabReports, // NEW
"recent_prescriptions": recentPrescriptions, // NEW
}
```
---
### Phase 5:智能快捷回复系统
**目标**:从硬编码 11 条升级为可定制 + AI 推荐
#### 5.1 数据库:新增快捷回复模板表
**文件**:`server/internal/model/doctor.go` 新增
```go
type QuickReplyTemplate struct {
ID uint `gorm:"primaryKey"`
DoctorID string `gorm:"type:uuid;index"` // 空=系统级,非空=个人级
Category string `gorm:"type:varchar(50)"` // greeting/inquiry/diagnosis/prescription/closing
Title string `gorm:"type:varchar(100)"`
Content string `gorm:"type:text"`
SortOrder int `gorm:"default:0"`
UseCount int `gorm:"default:0"`
CreatedAt time.Time
UpdatedAt time.Time
}
```
#### 5.2 后端 API
```
GET /doctor-portal/quick-replies → ListQuickReplies
POST /doctor-portal/quick-replies → CreateQuickReply
PUT /doctor-portal/quick-replies/:id → UpdateQuickReply
DELETE /doctor-portal/quick-replies/:id → DeleteQuickReply
POST /doctor-portal/quick-replies/ai-suggest → AIGenerateReplies
```
`AIGenerateReplies`:根据当前对话上下文,让 AI 推荐 3-5 条合适的回复。
#### 5.3 前端:QuickReply 组件重构
**文件**:`web/src/pages/doctor/Consult/ChatPanel.tsx` → 抽出 QuickReplyPopover
- **分类标签**:问候/询问/诊断/处方/结束
- **AI 推荐**:顶部独立区域,显示 3-5 条基于上下文的智能推荐(闪电图标)
- **常用排序**:按使用频率排序,最常用的排在前面
- **个人模板管理**:底部"管理模板"入口,弹窗内可 CRUD
- **一键发送 vs 编辑后发送**:单击直接发送,双击填入编辑框
---
### Phase 6:问诊流程增强
**目标**:转诊/会诊 + 问诊计时 + 处方状态追踪
#### 6.1 转诊功能
**数据库**:新增模型
```go
type ConsultTransfer struct {
ID uint `gorm:"primaryKey"`
ConsultID string `gorm:"type:uuid;index"`
FromDoctorID string `gorm:"type:uuid;index"`
ToDoctorID string `gorm:"type:uuid;index"`
ToDepartmentID string `gorm:"type:uuid"`
Reason string `gorm:"type:text"`
Status string `gorm:"type:varchar(20);default:'pending'"` // pending/accepted/rejected
TransferNote string `gorm:"type:text"` // 转诊备注(病情摘要)
CreatedAt time.Time
UpdatedAt time.Time
}
```
**后端 API**:
```
POST /consult/:id/transfer → TransferConsult
GET /consult/doctor/transfer-inbox → GetTransferInbox
POST /consult/transfer/:id/accept → AcceptTransfer
POST /consult/transfer/:id/reject → RejectTransfer
```
转诊逻辑:
1. 当前医生发起转诊 → 创建 ConsultTransfer + 发系统消息通知患者
2. 目标医生在"转诊收件箱"中看到 → 接受/拒绝
3. 接受后原问诊状态变更,新问诊自动创建(继承聊天历史引用)
**前端**:ChatPanel toolbar 增加"转诊"按钮,弹出科室+医生选择器
#### 6.2 问诊计时器
**前端**:`web/src/pages/doctor/Consult/ChatPanel.tsx`
- 接诊后顶部显示计时器(从 `started_at` 开始计时)
- 超过 15 分钟显示黄色提醒,超过 30 分钟显示红色警告
- 结束时自动记录总时长到 `Consultation.duration_minutes`
**后端**:
```go
type Consultation struct {
// ... 现有字段
DurationMinutes int `json:"duration_minutes"` // NEW: 问诊时长(分钟)
}
```
EndConsult 时自动计算:`duration = now - started_at`
#### 6.3 处方状态追踪
**前端**:ChatPanel 中发送处方后,在消息流中展示处方状态卡片
```
[处方卡片]
├── 处方号:RX20260302xxxx
├── 药品:3种 总计:¥128.00
├── 状态流转:已开具 → 待审核 → 审核通过 → 已取药
└── 当前状态:待审核 (黄色)
```
**后端**:
```
GET /doctor-portal/prescription/:id/status → GetPrescriptionStatus
```
返回:处方当前状态 + 状态流转时间线
---
### Phase 7:问诊结束流程增强
**目标**:问诊结束时自动生成病历摘要、触发随访、发送满意度评价
#### 7.1 结束问诊弹窗增强
**前端**:将 `Modal.confirm` 升级为专业结束问诊弹窗
```
┌──────────────────────────────────────────┐
│ 结束问诊 │
├──────────────────────────────────────────┤
│ 诊断结论:[AI预填,可编辑] │
│ 问诊小结:[AI自动生成,可编辑] │
│ 处方状态:✅ 已开具 / ❌ 未开具 │
│ 随访计划:[可选] 7天/14天/30天/自定义 │
│ 满意度评价邀请:[x] 发送评价链接给患者 │
│ │
│ [取消] [确认结束] │
└──────────────────────────────────────────┘
```
#### 7.2 后端:EndConsult 增强
**文件**:`server/internal/service/consult/handler.go`
```go
type EndConsultRequest struct {
Diagnosis string `json:"diagnosis"`
Summary string `json:"summary"` // 问诊小结
FollowUpDays int `json:"follow_up_days"` // 随访天数
SendSurvey bool `json:"send_survey"` // 是否发送评价
}
```
EndConsult 扩展逻辑:
1. 更新 Consultation 的 diagnosis + summary + duration
2. 如果 follow_up_days > 0 → 调用 `generate_follow_up_plan` 工具 + 创建 MedicationReminder
3. 如果 send_survey → 通过 `send_notification` 工具发送评价链接
4. 异步触发 `consult_ended` 工作流
#### 7.3 数据库:Consultation 新增字段
```go
type Consultation struct {
// ... 现有字段
Diagnosis string `gorm:"type:text"` // NEW: 诊断结论
Summary string `gorm:"type:text"` // NEW: 问诊小结
DurationMinutes int `gorm:"default:0"` // NEW: 问诊时长
FollowUpDays int `gorm:"default:0"` // NEW: 随访天数
SatisfactionScore *int `gorm:"default:null"` // NEW: 患者满意度(1-5)
}
```
---
### Phase 8:效率看板 + 数据统计
**目标**:问诊大厅顶部统计增强,支持效率考核
#### 8.1 后端:扩展 WorkbenchStats
**文件**:`server/internal/service/consult/service.go`
```go
type DoctorWorkbenchStats struct {
// 现有字段
TodayConsultCount int `json:"today_consult_count"`
WaitingCount int `json:"waiting_count"`
InProgressCount int `json:"in_progress_count"`
CompletedToday int `json:"completed_today"`
// NEW 效率指标
AvgWaitMinutes float64 `json:"avg_wait_minutes"` // 平均候诊时长
AvgConsultMinutes float64 `json:"avg_consult_minutes"` // 平均问诊时长
PrescriptionRate float64 `json:"prescription_rate"` // 开方率
AvgSatisfaction float64 `json:"avg_satisfaction"` // 平均满意度
TransferCount int `json:"transfer_count"` // 今日转诊数
AIUsageRate float64 `json:"ai_usage_rate"` // AI使用率
TodayIncome int `json:"today_income"` // 今日收入(分)
}
```
#### 8.2 前端:问诊大厅顶部增强
**文件**:`web/src/pages/doctor/Consult/index.tsx`
现有 3 个统计卡片扩展为两行:
```
第一行(核心指标):候诊中 | 接诊中 | 今日完成 | 今日收入
第二行(效率指标):平均候诊 | 平均问诊 | 开方率 | 满意度 | AI使用率
```
第二行默认折叠,点击"展开效率面板"显示。
---
## 四、技术方案要点
### 4.1 WebSocket 协议升级
```
消息格式:
{
"type": "message|typing|read|consult_update|new_patient",
"consult_id": "xxx",
"sender_id": "xxx",
"sender_type": "patient|doctor|system",
"payload": { ... },
"timestamp": "2026-03-02T10:00:00Z"
}
```
前端 Hook:
```typescript
useConsultWebSocket(consultId: string) {
messages, sendMessage, sendTyping, markRead,
isConnected, reconnect, typingUsers
}
```
### 4.2 文件上传方案
- Phase 1 使用本地存储 + Gin Static
- 后续可替换为 OSS(MinIO/S3)
- 文件路径:`/uploads/consult/{consult_id}/{uuid}.{ext}`
- 访问路由:`GET /api/v1/uploads/*filepath`
### 4.3 AI 上下文管理
避免每次 AI 调用传递全量聊天记录:
- 聊天超过 20 条 → 自动截取最近 20 条 + 预问诊摘要
- AI 生成的诊断结论缓存在 Redis,后续调用直接引用
- 不同 AI 场景使用不同的 context 组合,避免 token 浪费
### 4.4 数据库迁移清单
新增表:
- `quick_reply_templates` — 快捷回复模板
- `consult_transfers` — 转诊记录
修改表:
- `consultations` — 新增 diagnosis, summary, duration_minutes, follow_up_days, satisfaction_score
- `consult_messages` — 新增 read_at, media_url, media_type, reply_to_id
---
## 五、实施优先级与依赖关系
```
Phase 1 ──→ Phase 4 ──→ Phase 7
患者画像 AI扩展 结束增强
│
Phase 2 ──→ Phase 3
WebSocket 多媒体
│
Phase 5 Phase 6 ──→ Phase 8
快捷回复 转诊/计时 效率看板
```
**推荐执行顺序**:
1. **Phase 1**(患者画像)— 独立性强,即刻提升信息密度
2. **Phase 4**(AI 扩展)— 在现有轮询架构上即可实现
3. **Phase 5**(快捷回复)— 轻量改造,体验提升明显
4. **Phase 2**(WebSocket)— 架构改造,为后续 Phase 奠基
5. **Phase 3**(多媒体)— 依赖 Phase 2 的 WebSocket 基础设施
6. **Phase 6**(转诊/计时)— 依赖 Phase 2 的实时通知
7. **Phase 7**(结束增强)— 依赖 Phase 1 的患者数据 + Phase 4 的 AI 能力
8. **Phase 8**(效率看板)— 依赖 Phase 6 的计时数据 + Phase 7 的满意度数据
---
## 六、文件变更清单
| 操作 | 文件路径 | 改造内容 |
|------|---------|---------|
| 修改 | `server/internal/model/consult.go` | Consultation 新增字段,ConsultMessage 新增字段 |
| 新建 | `server/internal/model/quick_reply.go` | QuickReplyTemplate + ConsultTransfer 模型 |
| 修改 | `server/internal/service/consult/service.go` | PatientListItem 扩展,AIAssist 新增场景,EndConsult 增强 |
| 修改 | `server/internal/service/consult/handler.go` | 新增路由:patient-profile, upload, transfer |
| 新建 | `server/internal/service/consult/transfer_service.go` | 转诊业务逻辑 |
| 新建 | `server/internal/service/consult/upload_service.go` | 文件上传服务 |
| 新建 | `server/internal/service/doctorportal/quick_reply_handler.go` | 快捷回复 CRUD |
| 修改 | `server/pkg/websocket/handler.go` | 扩展消息类型协议 |
| 修改 | `web/src/pages/doctor/Consult/index.tsx` | 统计增强,PatientProfileDrawer 接入 |
| 修改 | `web/src/pages/doctor/Consult/PatientList.tsx` | 信息密度增强,搜索筛选排序 |
| 修改 | `web/src/pages/doctor/Consult/ChatPanel.tsx` | WebSocket, 多媒体, 计时器, 已读回执, 转诊 |
| 修改 | `web/src/pages/doctor/Consult/AIPanel.tsx` | 新增 Tab(检查/病历/随访/医嘱) |
| 新建 | `web/src/pages/doctor/Consult/PatientProfileDrawer.tsx` | 患者画像侧抽屉 |
| 新建 | `web/src/pages/doctor/Consult/EndConsultModal.tsx` | 专业结束问诊弹窗 |
| 新建 | `web/src/pages/doctor/Consult/TransferModal.tsx` | 转诊弹窗 |
| 新建 | `web/src/hooks/useConsultWebSocket.ts` | WebSocket 通信 Hook |
| 修改 | `web/src/api/consult.ts` | 新增 API:patient-profile, upload, transfer, quick-replies |
| 修改 | `web/src/api/doctorPortal.ts` | 新增 API:quick-reply CRUD, prescription-status |
| 修改 | `server/cmd/api/main.go` | AutoMigrate 新模型 |
---
## 七、预期效果
| 指标 | 现状 | 改造后 |
|------|------|--------|
| 患者卡片信息密度 | 4 个字段 | 10+ 个字段(提升 150%) |
| 消息延迟 | 3000ms(轮询) | < 100ms(WebSocket) |
| AI 辅助场景 | 2 个(诊断/用药) | 6 个(+检查/病历/随访/医嘱) |
| 快捷回复 | 11 条固定 | 无限自定义 + AI 推荐 |
| 支持消息类型 | 1 种(文本) | 4 种(+图片/文件/语音) |
| 问诊结束 | 1 步确认 | 完整结束流程(诊断/小结/随访/评价) |
| 效率指标 | 3 个 | 9+ 个(含 AI 使用率/满意度/候诊时长) |
......@@ -20,6 +20,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4",
"zustand": "^5.0.5"
},
......@@ -1659,6 +1660,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
......@@ -2248,6 +2258,12 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/json2mq": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz",
......@@ -2516,6 +2532,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
......@@ -3254,6 +3282,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/parse-entities": {
"version": "4.0.2",
"resolved": "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz",
......@@ -3313,6 +3350,23 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz",
......@@ -3935,6 +3989,16 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/re-resizable": {
"version": "6.11.2",
"resolved": "https://registry.npmmirror.com/re-resizable/-/re-resizable-6.11.2.tgz",
"integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
......@@ -3956,6 +4020,20 @@
"react": "^19.2.4"
}
},
"node_modules/react-draggable": {
"version": "4.4.6",
"resolved": "https://registry.npmmirror.com/react-draggable/-/react-draggable-4.4.6.tgz",
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
"license": "MIT",
"dependencies": {
"clsx": "^1.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
......@@ -3989,6 +4067,27 @@
"react": ">=18"
}
},
"node_modules/react-rnd": {
"version": "10.5.2",
"resolved": "https://registry.npmmirror.com/react-rnd/-/react-rnd-10.5.2.tgz",
"integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==",
"license": "MIT",
"dependencies": {
"re-resizable": "6.11.2",
"react-draggable": "4.4.6",
"tslib": "2.6.2"
},
"peerDependencies": {
"react": ">=16.3.0",
"react-dom": ">=16.3.0"
}
},
"node_modules/react-rnd/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD"
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz",
......
......@@ -21,6 +21,7 @@
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-rnd": "^10.5.2",
"tailwindcss": "^4",
"zustand": "^5.0.5"
},
......
import { get, post, put, del } from './request';
import { sseRequest } from './sse';
// ==================== 类型定义 ====================
......@@ -70,6 +71,7 @@ export interface AgentDefinition {
category: string;
system_prompt: string;
tools: string; // JSON array string
skills: string; // JSON array string of skill_ids
config: string;
max_iterations: number;
status: string;
......@@ -84,10 +86,27 @@ export type AgentDefinitionParams = {
category?: string;
system_prompt?: string;
tools?: string;
skills?: string[];
max_iterations?: number;
status?: string;
};
export interface AgentSkill {
id: number;
skill_id: string;
name: string;
description: string;
category: string;
tools: string;
system_prompt_addon: string;
context_schema: string;
quick_replies: string;
icon: string;
status: string;
created_at: string;
updated_at: string;
}
export interface WorkflowCreateParams {
workflow_id: string;
name: string;
......@@ -108,6 +127,18 @@ export interface KnowledgeDocumentParams {
content: string;
}
// ==================== Agent 流式回调类型 ====================
export interface AgentStreamCallbacks {
onSession?: (info: { session_id: string }) => void;
onThinking?: (info: { iteration: number; status: string }) => void;
onToolCall?: (info: { tool_name: string; arguments: string; call_id: string; iteration: number }) => void;
onToolResult?: (info: { tool_name: string; success: boolean; call_id: string }) => void;
onChunk?: (content: string) => void;
onDone?: (info: { session_id: string; iterations: number; total_tokens: number; finish_reason: string }) => void;
onError?: (error: string) => void;
}
// ==================== Agent API ====================
// AI 推理调用专用超时(2分钟)
......@@ -120,6 +151,23 @@ export const agentApi = {
context?: Record<string, unknown>;
}) => post<AgentOutput>(`/agent/${agentId}/chat`, params, { timeout: AI_TIMEOUT }),
/** SSE 流式 Agent 对话 */
chatStream: (
agentId: string,
params: { session_id?: string; message: string; context?: Record<string, unknown> },
callbacks: AgentStreamCallbacks,
): AbortController => {
return sseRequest(`/agent/${agentId}/chat/stream`, params, {
session: (data) => callbacks.onSession?.(data as { session_id: string }),
thinking: (data) => callbacks.onThinking?.(data as { iteration: number; status: string }),
tool_call: (data) => callbacks.onToolCall?.(data as { tool_name: string; arguments: string; call_id: string; iteration: number }),
tool_result: (data) => callbacks.onToolResult?.(data as { tool_name: string; success: boolean; call_id: string }),
chunk: (data) => callbacks.onChunk?.((data.content as string) || ''),
done: (data) => callbacks.onDone?.(data as { session_id: string; iterations: number; total_tokens: number; finish_reason: string }),
error: (data) => callbacks.onError?.((data.error as string) || '未知错误'),
});
},
listAgents: () =>
get<{ id: string; name: string; description: string }[]>('/agent/list'),
......@@ -165,6 +213,20 @@ export const agentApi = {
put<null>(`/agent/tools/${name}/status`, { status }),
};
// ==================== Skill API ====================
export const skillApi = {
list: () => get<AgentSkill[]>('/agent/skills'),
create: (data: Partial<AgentSkill>) =>
post<AgentSkill>('/agent/skills', data),
update: (skillId: string, data: Partial<AgentSkill>) =>
put<AgentSkill>(`/agent/skills/${skillId}`, data),
delete: (skillId: string) => del<null>(`/agent/skills/${skillId}`),
};
// ==================== HTTP Tool API ====================
export interface HTTPToolDefinition {
......
......@@ -26,8 +26,13 @@ export interface ConsultMessage {
consult_id: string;
sender_type: 'patient' | 'doctor' | 'system' | 'ai';
content: string;
content_type: 'text' | 'image' | 'file' | 'prescription';
content_type: 'text' | 'image' | 'file' | 'prescription' | 'audio';
created_at: string;
// v13 新增
read_at?: string | null;
media_url?: string;
media_type?: string;
reply_to_id?: string | null;
}
export interface CreateConsultParams {
......@@ -80,6 +85,7 @@ export interface VideoRoomInfo {
export interface PatientListItem {
id: string;
consult_id: string;
serial_number: string;
patient_id: string;
patient_name: string;
patient_gender: string;
......@@ -92,6 +98,111 @@ export interface PatientListItem {
created_at: string;
started_at: string | null;
ended_at: string | null;
// v13 新增字段
allergy_history: string;
chronic_diseases: string[];
is_revisit: boolean;
visit_count: number;
risk_level: 'low' | 'medium' | 'high';
last_visit_date: string;
pre_consult_summary: string;
}
// ========== 转诊类型 (v13) ==========
export interface TransferInboxItem {
id: number;
consult_id: string;
from_doctor_name: string;
patient_name: string;
chief_complaint: string;
reason: string;
transfer_note: string;
status: string;
created_at: string;
}
export interface PrescriptionStatusResponse {
id: string;
prescription_no: string;
status: string;
total_amount: number;
item_count: number;
timeline: { status: string; label: string; time: string | null }[];
}
// ========== 患者画像类型 (v13) ==========
export interface PatientBasicInfo {
id: string;
name: string;
gender: string;
age: number;
phone: string;
allergy_history: string;
medical_history: string;
insurance_type: string;
insurance_no: string;
}
export interface ConsultSummary {
id: string;
doctor_name: string;
chief_complaint: string;
type: ConsultType;
status: ConsultStatus;
created_at: string;
ended_at: string | null;
}
export interface PrescriptionBrief {
id: string;
prescription_no: string;
diagnosis: string;
item_count: number;
total_amount: number;
status: string;
created_at: string;
}
export interface ChronicBrief {
id: string;
disease_name: string;
diagnosis_date: string | null;
control_status: string;
current_meds: string;
}
export interface LabReportBrief {
id: string;
title: string;
category: string;
report_date: string | null;
file_url: string;
}
export interface HealthMetricPoint {
metric_type: string;
value1: number;
value2: number;
unit: string;
recorded_at: string;
}
export interface FamilyMemberBrief {
name: string;
relation: string;
medical_history: string;
}
export interface PatientProfile {
basic_info: PatientBasicInfo;
consult_history: ConsultSummary[];
prescriptions: PrescriptionBrief[];
chronic_records: ChronicBrief[];
lab_reports: LabReportBrief[];
health_metrics: HealthMetricPoint[];
family_history: FamilyMemberBrief[];
}
// 问诊 API
......@@ -104,36 +215,62 @@ export const consultApi = {
getConsultList: (status?: ConsultStatus) =>
get<Consultation[]>('/consult/list', { params: { status } }),
// 获取问诊详情
getConsultDetail: (id: string) => get<Consultation>(`/consult/${id}`),
// 获取问诊详情(支持 consult_id 或 serial_number)
getConsultDetail: (idOrSerial: string) => get<Consultation>(`/consult/${idOrSerial}`),
// 获取问诊消息历史
getConsultMessages: (id: string) => get<ConsultMessage[]>(`/consult/${id}/messages`),
// 获取问诊消息历史(支持 consult_id 或 serial_number)
getConsultMessages: (idOrSerial: string) => get<ConsultMessage[]>(`/consult/${idOrSerial}/messages`),
// 发送消息
sendMessage: (consult_id: string, content: string, content_type: string = 'text') =>
post<ConsultMessage>(`/consult/${consult_id}/message`, { content, content_type }),
// 发送消息(支持 consult_id 或 serial_number)
sendMessage: (idOrSerial: string, content: string, content_type: string = 'text') =>
post<ConsultMessage>(`/consult/${idOrSerial}/message`, { content, content_type }),
// 获取视频房间信息
getVideoRoomInfo: (consult_id: string) =>
get<VideoRoomInfo>(`/consult/${consult_id}/video-room`),
// 获取视频房间信息(支持 consult_id 或 serial_number)
getVideoRoomInfo: (idOrSerial: string) =>
get<VideoRoomInfo>(`/consult/${idOrSerial}/video-room`),
// 结束问诊
endConsult: (id: string) => post<null>(`/consult/${id}/end`),
// 结束问诊(支持 consult_id 或 serial_number)
endConsult: (idOrSerial: string, data?: { diagnosis?: string; summary?: string; follow_up_days?: number; send_survey?: boolean }) =>
post<null>(`/consult/${idOrSerial}/end`, data || {}),
// AI辅助分析(鉴别诊断/用药建议)—— 通过 Agent,返回 response + tool_calls
aiAssist: (id: string, scene: string) =>
// AI辅助分析(支持 consult_id 或 serial_number)
aiAssist: (idOrSerial: string, scene: string) =>
post<{
scene: string;
response: string;
tool_calls?: import('./agent').ToolCall[];
iterations?: number;
total_tokens?: number;
}>(`/consult/${id}/ai-assist`, { scene }, { timeout: 120000 }),
}>(`/consult/${idOrSerial}/ai-assist`, { scene }, { timeout: 120000 }),
// 取消问诊(支持 consult_id 或 serial_number)
cancelConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${idOrSerial}/cancel`, { reason }),
// 取消问诊
cancelConsult: (id: string, reason?: string) =>
post<null>(`/consult/${id}/cancel`, { reason }),
// v13: 上传多媒体文件(支持 consult_id 或 serial_number)
uploadMedia: (idOrSerial: string, file: File) => {
const formData = new FormData();
formData.append('file', file);
return post<{ media_url: string; media_type: string; file_name: string }>(
`/consult/${idOrSerial}/upload`, formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
},
// v13: 转诊(支持 consult_id 或 serial_number)
transferConsult: (idOrSerial: string, data: { to_doctor_id: string; to_department_id?: string; reason: string; transfer_note?: string }) =>
post<null>(`/consult/${idOrSerial}/transfer`, data),
getTransferInbox: () => get<TransferInboxItem[]>('/consult/doctor/transfer-inbox'),
acceptTransfer: (transferId: number) => post<null>(`/consult/transfer/${transferId}/accept`),
rejectTransfer: (transferId: number, reason?: string) =>
post<null>(`/consult/transfer/${transferId}/reject`, { reason }),
// v13: 处方状态
getPrescriptionStatus: (prescriptionId: string) =>
get<PrescriptionStatusResponse>(`/consult/prescription/${prescriptionId}/status`),
// === 医生端 ===
// 获取医生待接诊队列
......@@ -146,14 +283,17 @@ export const consultApi = {
// 获取统一患者列表(待接诊+进行中+已完成)
getPatientList: () => get<PatientListItem[]>('/consult/doctor/patients'),
// 获取患者完整画像
getPatientProfile: (patientId: string) => get<PatientProfile>(`/consult/doctor/patient-profile/${patientId}`),
// 获取医生工作台统计
getDoctorWorkbenchStats: () => get<DoctorWorkbenchStats>('/consult/doctor/workbench-stats'),
// 接诊
acceptConsult: (id: string) => post<null>(`/consult/${id}/accept`),
// 接诊(支持 consult_id 或 serial_number)
acceptConsult: (idOrSerial: string) => post<null>(`/consult/${idOrSerial}/accept`),
// 拒诊
rejectConsult: (id: string, reason?: string) =>
post<null>(`/consult/${id}/reject`, { reason }),
// 拒诊(支持 consult_id 或 serial_number)
rejectConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${idOrSerial}/reject`, { reason }),
};
import { get, post, put } from './request';
import { get, post, put, del } from './request';
import type { Consultation, ConsultMessage } from './consult';
import type { ToolCall } from './agent';
......@@ -14,6 +14,13 @@ export interface DoctorWorkbenchStats {
rating: number;
income_today: number;
income_month: number;
// v13 效率指标
avg_wait_minutes: number;
avg_consult_minutes: number;
prescription_rate: number;
avg_satisfaction: number;
transfer_count: number;
ai_usage_rate: number;
}
// 接诊队列患者
......@@ -130,24 +137,24 @@ export const doctorPortalApi = {
getWaitingQueue: () =>
get<WaitingPatient[]>('/doctor-portal/queue/waiting'),
acceptConsult: (consultId: string) =>
post<Consultation>(`/doctor-portal/consult/${consultId}/accept`),
acceptConsult: (idOrSerial: string) =>
post<Consultation>(`/doctor-portal/consult/${idOrSerial}/accept`),
// === 问诊操作 ===
getConsultDetail: (consultId: string) =>
get<Consultation>(`/doctor-portal/consult/${consultId}`),
// === 问诊操作(支持 consult_id 或 serial_number) ===
getConsultDetail: (idOrSerial: string) =>
get<Consultation>(`/doctor-portal/consult/${idOrSerial}`),
getConsultMessages: (consultId: string) =>
get<ConsultMessage[]>(`/doctor-portal/consult/${consultId}/messages`),
getConsultMessages: (idOrSerial: string) =>
get<ConsultMessage[]>(`/doctor-portal/consult/${idOrSerial}/messages`),
sendMessage: (consultId: string, content: string, contentType: string = 'text') =>
post<ConsultMessage>(`/doctor-portal/consult/${consultId}/message`, {
sendMessage: (idOrSerial: string, content: string, contentType: string = 'text') =>
post<ConsultMessage>(`/doctor-portal/consult/${idOrSerial}/message`, {
content,
content_type: contentType,
}),
endConsult: (consultId: string, summary?: string) =>
post<null>(`/doctor-portal/consult/${consultId}/end`, { summary }),
endConsult: (idOrSerial: string, summary?: string) =>
post<null>(`/doctor-portal/consult/${idOrSerial}/end`, { summary }),
// === 历史问诊 ===
getConsultHistory: (params?: { page?: number; page_size?: number; status?: string }) =>
......@@ -192,4 +199,37 @@ export const doctorPortalApi = {
has_warning: boolean;
has_contraindication: boolean;
}>('/doctor-portal/prescription/check', params, { timeout: 120000 }),
// === v13: 快捷回复 ===
listQuickReplies: (category?: string) =>
get<QuickReplyTemplate[]>('/doctor-portal/quick-replies', { params: { category } }),
createQuickReply: (data: { category: string; title: string; content: string; sort_order?: number }) =>
post<QuickReplyTemplate>('/doctor-portal/quick-replies', data),
updateQuickReply: (id: number, data: Partial<{ category: string; title: string; content: string; sort_order: number }>) =>
put<QuickReplyTemplate>(`/doctor-portal/quick-replies/${id}`, data),
deleteQuickReply: (id: number) =>
del<null>(`/doctor-portal/quick-replies/${id}`),
incrementQuickReplyUse: (id: number) =>
post<null>(`/doctor-portal/quick-replies/${id}/use`),
aiSuggestReplies: (idOrSerial: string) =>
post<{ suggestions: string }>('/doctor-portal/quick-replies/ai-suggest',
{ serial_number: idOrSerial }, { timeout: 60000 }),
};
// 快捷回复模板
export interface QuickReplyTemplate {
id: number;
doctor_id: string;
category: string;
title: string;
content: string;
sort_order: number;
use_count: number;
created_at: string;
updated_at: string;
}
import { get } from './request';
import { sseRequest } from './sse';
// ====== 对话消息统一格式 ======
export interface ChatMessage {
......@@ -65,19 +66,6 @@ export interface ChatDoneInfo {
}
// ====== SSE 流式对话工具函数 ======
const getApiBaseURL = () => {
const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (envUrl && envUrl.includes('/api/v1')) return envUrl;
return (envUrl || 'http://localhost:8080') + '/api/v1';
};
/**
* 发起SSE流式对话请求
* @param url 相对API路径
* @param body POST请求体
* @param callbacks SSE事件回调
* @returns AbortController 用于取消请求
*/
export function sseChat(
url: string,
body: object,
......@@ -88,91 +76,12 @@ export function sseChat(
onError?: (error: string) => void;
}
): AbortController {
const controller = new AbortController();
const token = localStorage.getItem('access_token');
fetch(`${getApiBaseURL()}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const text = await response.text();
callbacks.onError?.(`请求失败 (HTTP ${response.status}): ${text}`);
return;
}
const reader = response.body?.getReader();
if (!reader) {
callbacks.onError?.('无法读取响应流');
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按双换行分割SSE事件
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split('\n');
let eventType = '';
let eventData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}
if (!eventType || !eventData) continue;
try {
const parsed = JSON.parse(eventData);
switch (eventType) {
case 'session':
callbacks.onSession?.(parsed);
break;
case 'chunk':
callbacks.onChunk?.(parsed.content || '');
break;
case 'done':
callbacks.onDone?.(parsed);
break;
case 'error':
callbacks.onError?.(parsed.error || '未知错误');
break;
}
} catch {
// JSON解析失败,忽略
}
}
}
})
.catch((err) => {
if (err.name !== 'AbortError') {
callbacks.onError?.(err.message || '网络请求失败');
}
return sseRequest(url, body, {
session: (data) => callbacks.onSession?.(data as unknown as ChatSessionInfo),
chunk: (data) => callbacks.onChunk?.((data.content as string) || ''),
done: (data) => callbacks.onDone?.(data as unknown as ChatDoneInfo),
error: (data) => callbacks.onError?.((data.error as string) || '未知错误'),
});
return controller;
}
// 预问诊 API
......
......@@ -93,6 +93,7 @@ export interface CreatePrescriptionItemReq {
export interface CreatePrescriptionReq {
consult_id?: string;
serial_number?: string;
patient_id: string;
patient_name: string;
patient_gender?: string;
......
/**
* 通用 SSE 流式请求工具函数
* 支持任意事件类型的回调映射
*/
const getApiBaseURL = () => {
const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
if (envUrl && envUrl.includes('/api/v1')) return envUrl;
return (envUrl || 'http://localhost:8080') + '/api/v1';
};
/**
* 发起 SSE 流式请求
* @param url 相对 API 路径(如 /agent/xxx/chat/stream)
* @param body POST 请求体
* @param handlers 事件类型 → 回调函数 映射
* @returns AbortController 用于取消请求
*/
export function sseRequest(
url: string,
body: object,
handlers: Record<string, (data: Record<string, unknown>) => void>,
): AbortController {
const controller = new AbortController();
const token = localStorage.getItem('access_token');
fetch(`${getApiBaseURL()}${url}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
const text = await response.text();
handlers.error?.({ error: `请求失败 (HTTP ${response.status}): ${text}` });
return;
}
const reader = response.body?.getReader();
if (!reader) {
handlers.error?.({ error: '无法读取响应流' });
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// 按双换行分割 SSE 事件
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.trim()) continue;
const lines = part.split('\n');
let eventType = '';
let eventData = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
eventData = line.slice(6);
}
}
if (!eventType || !eventData) continue;
try {
const parsed = JSON.parse(eventData);
handlers[eventType]?.(parsed);
} catch {
// JSON 解析失败,忽略
}
}
}
})
.catch((err) => {
if (err.name !== 'AbortError') {
handlers.error?.({ error: err.message || '网络请求失败' });
}
});
return controller;
}
......@@ -11,26 +11,46 @@ import {
ReloadOutlined, PlusOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api/agent';
import { agentApi, skillApi } from '@/api/agent';
import type { ToolCall, AgentExecutionLog, AgentDefinition } from '@/api/agent';
const { Text } = Typography;
const { RangePicker } = DatePicker;
const categoryColor: Record<string, string> = {
pre_consult: 'blue', diagnosis: 'purple', prescription: 'orange', follow_up: 'green',
patient: 'green', doctor: 'blue', pharmacy: 'orange', admin: 'purple', general: 'cyan',
};
const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', diagnosis: '诊断辅助', prescription: '处方审核', follow_up: '随访管理',
patient: '患者服务', doctor: '医生辅助', pharmacy: '处方审核', admin: '管理辅助', general: '通用',
};
const CATEGORY_OPTIONS = [
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断辅助' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访管理' },
{ value: 'patient', label: '患者服务' },
{ value: 'doctor', label: '医生辅助' },
{ value: 'pharmacy', label: '处方审核' },
{ value: 'admin', label: '管理辅助' },
{ value: 'general', label: '通用' },
];
// 技能包选择器组件
function SkillSelect(props: { value?: string[]; onChange?: (v: string[]) => void }) {
const { data: skills = [] } = useQuery({
queryKey: ['agent-skills'],
queryFn: () => skillApi.list(),
select: r => (r.data ?? []).map(s => ({ value: s.skill_id, label: `${s.name} - ${s.description}` })),
});
return (
<Select
mode="multiple"
options={skills}
placeholder="选择技能包(可选,技能中的工具会自动合并)"
allowClear
value={props.value}
onChange={props.onChange}
/>
);
}
interface AgentResponse {
response: string;
tool_calls?: ToolCall[];
......@@ -53,7 +73,7 @@ export default function AgentsPage() {
const [editModal, setEditModal] = useState<{ open: boolean; agent: AgentDefinition | null; isNew: boolean }>({ open: false, agent: null, isNew: false });
// 可用工具列表
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [availableTools, setAvailableTools] = useState<{ name: string; description: string; category: string }[]>([]);
// 执行日志
const [logs, setLogs] = useState<AgentExecutionLog[]>([]);
......@@ -73,7 +93,7 @@ export default function AgentsPage() {
useEffect(() => {
agentApi.listTools().then(res => {
if (res.data?.length > 0) {
setAvailableTools(res.data.map((t: { name: string }) => t.name));
setAvailableTools(res.data.map(t => ({ name: t.name, description: t.description, category: t.category })));
}
}).catch(() => {});
fetchLogs();
......@@ -92,6 +112,7 @@ export default function AgentsPage() {
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const toolsArr = values.tools_array as string[] || [];
const skillsArr = values.skills_array as string[] || [];
const params = {
agent_id: values.agent_id as string,
name: values.name as string,
......@@ -99,6 +120,7 @@ export default function AgentsPage() {
category: values.category as string,
system_prompt: values.system_prompt as string,
tools: JSON.stringify(toolsArr),
skills: skillsArr,
max_iterations: values.max_iterations as number,
status: values.status as string,
};
......@@ -136,7 +158,9 @@ export default function AgentsPage() {
const openEdit = (agent?: AgentDefinition) => {
if (agent) {
let toolsArr: string[] = [];
let skillsArr: string[] = [];
try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {}
try { skillsArr = JSON.parse(agent.skills || '[]'); } catch {}
form.setFieldsValue({
agent_id: agent.agent_id,
name: agent.name,
......@@ -144,6 +168,7 @@ export default function AgentsPage() {
category: agent.category,
system_prompt: agent.system_prompt,
tools_array: toolsArr,
skills_array: skillsArr,
max_iterations: agent.max_iterations,
status: agent.status === 'active',
});
......@@ -237,7 +262,7 @@ export default function AgentsPage() {
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '运行中' : '停用'} />,
},
{
title: '操作', key: 'action', width: 200,
title: '操作', key: 'action', width: 260, fixed: 'right' as const,
render: (_: unknown, r: AgentDefinition) => (
<Space>
<Button size="small" icon={<PlayCircleOutlined />} type="link" onClick={() => openTest(r.agent_id, r.name)}>测试</Button>
......@@ -283,7 +308,23 @@ export default function AgentsPage() {
},
];
const toolOptions = availableTools.map(t => ({ value: t, label: t }));
// 按分类分组工具选项
const toolCategoryLabels: Record<string, string> = {
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理',
notification: '消息通知', agent: 'Agent调用', workflow: '工作流',
expression: '表达式', other: '其他',
};
const toolsByCategory = availableTools.reduce((acc, t) => {
const cat = t.category || 'other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(t);
return acc;
}, {} as Record<string, typeof availableTools>);
const toolOptions = Object.entries(toolsByCategory).map(([cat, tools]) => ({
label: toolCategoryLabels[cat] || cat,
options: tools.map(t => ({ value: t.name, label: t.name, title: t.description })),
}));
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
......@@ -309,6 +350,7 @@ export default function AgentsPage() {
loading={agentsLoading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
/>
),
},
......@@ -379,7 +421,7 @@ export default function AgentsPage() {
onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })}
>
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: diagnosis_agent" disabled={!editModal.isNew} />
<Input placeholder="如: doctor_universal_agent" disabled={!editModal.isNew} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" />
......@@ -401,6 +443,9 @@ export default function AgentsPage() {
allowClear
/>
</Form.Item>
<Form.Item name="skills_array" label="技能包">
<SkillSelect />
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item>
......
......@@ -208,10 +208,9 @@ export default function AICenterPage() {
value={agentFilter || undefined}
onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }}
options={[
{ value: 'pre_consult_agent', label: '预问诊 Agent' },
{ value: 'diagnosis_agent', label: '诊断辅助 Agent' },
{ value: 'prescription_agent', label: '处方审核 Agent' },
{ value: 'follow_up_agent', label: '随访管理 Agent' },
{ value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'admin_universal_agent', label: '管理员智能助手' },
]}
/>
<Input.Search
......
'use client';
import React from 'react';
import React, { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
const { Content } = Layout;
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
......@@ -35,9 +36,7 @@ const menuItems = [
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' },
{ key: '/admin/tool-market', icon: <AppstoreOutlined />, label: '工具市场' },
{ key: '/admin/tools', icon: <ApiOutlined />, label: '内置工具' },
{ key: '/admin/http-tools', icon: <CloudOutlined />, label: 'HTTP工具' },
{ key: '/admin/tools', icon: <AppstoreOutlined />, label: '工具中心' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
......@@ -51,6 +50,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const router = useRouter();
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const currentPath = pathname || '';
const userMenuItems = [
......@@ -70,50 +70,98 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (key === 'logout') { logout(); router.push('/login'); }
};
// 选中的菜单项(支持子路径匹配)
const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/tool-market',
'/admin/tools', '/admin/http-tools', '/admin/workflows',
'/admin/ai-config', '/admin/compliance', '/admin/agents',
'/admin/tools', '/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center'];
const match = allKeys.find(k => currentPath.startsWith(k));
return match ? [match] : [];
};
const getOpenKeys = () => {
if (collapsed) return [];
const keys: string[] = [];
if (['/admin/patients', '/admin/doctors', '/admin/admins'].some(k => currentPath.startsWith(k))) {
keys.push('user-mgmt');
}
if (['/admin/agents', '/admin/tools',
'/admin/workflows', '/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center']
.some(k => currentPath.startsWith(k))) {
keys.push('ai-platform');
}
return keys;
};
return (
<Layout style={{ minHeight: '100vh' }}>
<header
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={220}
collapsedWidth={64}
trigger={null}
style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
height: 56, display: 'flex', alignItems: 'center', padding: '0 24px',
background: '#ffffff',
borderBottom: '1px solid #e8f0fc',
boxShadow: '0 1px 4px rgba(0, 82, 204, 0.06)',
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
}}
>
{/* Logo */}
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 20, flexShrink: 0 }}
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/admin/dashboard')}
>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129' }}>互联网医院</span>
<Tag color="purple" style={{ marginLeft: 2, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理后台</Tag>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="purple" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理</Tag>
</>
)}
</div>
{/* Menu */}
<Menu
mode="horizontal"
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
theme="light"
className="patient-nav-menu"
style={{ flex: 1, minWidth: 0, borderBottom: 'none' }}
style={{ border: 'none', padding: '8px 0' }}
/>
</Sider>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<Layout style={{ marginLeft: collapsed ? 64 : 220, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={2} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
......@@ -124,15 +172,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text>
</Space>
</Dropdown>
) : (
<Button size="small" type="primary" onClick={() => router.push('/login')}>登录</Button>
)}
) : null}
</div>
</header>
<Content style={{ marginTop: 56, minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
......@@ -398,7 +398,7 @@ export default function SafetyPage() {
</Col>
<Col span={12}>
<Form.Item name="agent_id" label="限定 Agent(空=全局)">
<Input placeholder="如: prescription_agent" />
<Input placeholder="如: doctor_universal_agent" />
</Form.Item>
</Col>
</Row>
......
'use client';
import { useState } from 'react';
import {
Card, Row, Col, Tag, Button, Input, Space, Typography, Switch, Badge,
Tooltip, Empty, Segmented, message,
} from 'antd';
import {
SearchOutlined, ToolOutlined, ApiOutlined, RobotOutlined,
DeploymentUnitOutlined, BookOutlined, ThunderboltOutlined,
CodeOutlined, BellOutlined, HeartOutlined, SafetyOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
const { Text, Paragraph } = Typography;
type ToolSource = 'builtin' | 'http';
interface UnifiedTool {
id: string;
name: string;
description: string;
category: string;
source: ToolSource;
is_enabled: boolean;
cache_ttl?: number;
timeout?: number;
rawId?: number; // for http tools
}
const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
knowledge: { color: 'blue', label: '知识库', icon: <BookOutlined /> },
recommendation:{ color: 'cyan', label: '智能推荐', icon: <ThunderboltOutlined /> },
medical: { color: 'purple', label: '病历管理', icon: <HeartOutlined /> },
pharmacy: { color: 'green', label: '药品管理', icon: <ToolOutlined /> },
safety: { color: 'red', label: '安全检查', icon: <SafetyOutlined /> },
follow_up: { color: 'orange', label: '随访管理', icon: <HeartOutlined /> },
notification: { color: 'gold', label: '消息通知', icon: <BellOutlined /> },
agent: { color: 'geekblue', label: 'Agent调用', icon: <RobotOutlined /> },
workflow: { color: 'volcano', label: '工作流', icon: <DeploymentUnitOutlined /> },
expression: { color: 'lime', label: '表达式', icon: <CodeOutlined /> },
http: { color: 'magenta', label: 'HTTP服务', icon: <ApiOutlined /> },
other: { color: 'default', label: '其他', icon: <ToolOutlined /> },
};
const SOURCE_CONFIG: Record<ToolSource, { label: string; color: string }> = {
builtin: { label: '内置', color: '#1890ff' },
http: { label: 'HTTP', color: '#fa8c16' },
};
export default function ToolMarketPage() {
const qc = useQueryClient();
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState<'all' | ToolSource>('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [togglingName, setTogglingName] = useState('');
// 加载内置工具
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as {
id: string; name: string; description: string; category: string;
parameters: Record<string, unknown>; is_enabled: boolean; created_at: string;
}[],
});
// 加载 HTTP 工具
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
// 合并为统一格式
const allTools: UnifiedTool[] = [
...(builtinData ?? []).map(t => ({
id: t.name,
name: t.name,
description: t.description,
category: t.category,
source: 'builtin' as ToolSource,
is_enabled: t.is_enabled,
})),
...(httpData ?? []).map(t => ({
id: `http:${t.id}`,
name: t.name,
description: t.description || t.display_name,
category: t.category || 'http',
source: 'http' as ToolSource,
is_enabled: t.status === 'active',
cache_ttl: t.cache_ttl,
timeout: t.timeout,
rawId: t.id,
})),
];
const toggleMut = useMutation({
mutationFn: async ({ tool, checked }: { tool: UnifiedTool; checked: boolean }) => {
if (tool.source === 'builtin') {
return agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
} else {
return httpToolApi.update(tool.rawId!, { status: checked ? 'active' : 'disabled' });
}
},
onSuccess: (_, { tool, checked }) => {
message.success(`${tool.name} 已${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
qc.invalidateQueries({ queryKey: ['http-tools'] });
setTogglingName('');
},
onError: () => { message.error('操作失败'); setTogglingName(''); },
});
const handleToggle = (tool: UnifiedTool, checked: boolean) => {
setTogglingName(tool.id);
toggleMut.mutate({ tool, checked });
};
// 过滤
const filtered = allTools.filter(t => {
const matchSearch = !search || t.name.includes(search) || t.description.includes(search);
const matchSource = sourceFilter === 'all' || t.source === sourceFilter;
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchSource && matchCategory;
});
// 按分类分组
const grouped: Record<string, UnifiedTool[]> = {};
for (const t of filtered) {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
}
const allCategories = [...new Set(allTools.map(t => t.category))];
const enabledCount = allTools.filter(t => t.is_enabled).length;
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工具市场</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
浏览并管理所有 Agent 工具 — 共 <Text strong>{allTools.length}</Text> 个,
已启用 <Text strong style={{ color: '#52c41a' }}>{enabledCount}</Text> 个
</div>
</div>
</div>
{/* 过滤栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }} bodyStyle={{ padding: '12px 16px' }}>
<Space wrap>
<Input
placeholder="搜索工具名称或描述..."
prefix={<SearchOutlined />}
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: 240, borderRadius: 8 }}
allowClear
/>
<Segmented
value={sourceFilter}
onChange={v => setSourceFilter(v as 'all' | ToolSource)}
options={[
{ value: 'all', label: '全部来源' },
{ value: 'builtin', label: '内置工具' },
{ value: 'http', label: 'HTTP工具' },
]}
/>
<Segmented
value={categoryFilter}
onChange={v => setCategoryFilter(v as string)}
options={[
{ value: 'all', label: '全部分类' },
...allCategories.map(c => ({ value: c, label: CATEGORY_CONFIG[c]?.label || c })),
]}
/>
</Space>
</Card>
{/* 工具卡片按分类分组 */}
{Object.keys(grouped).length === 0 ? (
<Empty description="暂无匹配工具" style={{ marginTop: 60 }} />
) : (
Object.entries(grouped).map(([category, tools]) => {
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.other;
return (
<div key={category}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Tag color={cfg.color} icon={cfg.icon} style={{ fontSize: 13, padding: '2px 10px' }}>
{cfg.label}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{tools.length} 个工具</Text>
</div>
<Row gutter={[16, 16]}>
{tools.map(tool => (
<Col key={tool.id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{
borderRadius: 10,
border: `1px solid ${tool.is_enabled ? '#d9f7be' : '#f0f0f0'}`,
background: tool.is_enabled ? '#f6ffed' : '#fafafa',
transition: 'all 0.2s',
}}
bodyStyle={{ padding: 14 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Badge
status={tool.is_enabled ? 'success' : 'default'}
style={{ marginRight: 0 }}
/>
<Text code style={{ fontSize: 12 }}>{tool.name}</Text>
</div>
<div style={{ marginTop: 4, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<Tag
style={{ fontSize: 11, lineHeight: '16px', padding: '0 4px', margin: 0,
background: SOURCE_CONFIG[tool.source].color + '15',
color: SOURCE_CONFIG[tool.source].color,
border: `1px solid ${SOURCE_CONFIG[tool.source].color}40` }}
>
{SOURCE_CONFIG[tool.source].label}
</Tag>
</div>
</div>
<Tooltip title={tool.is_enabled ? '禁用工具' : '启用工具'}>
<Switch
size="small"
checked={tool.is_enabled}
loading={togglingName === tool.id}
onChange={checked => handleToggle(tool, checked)}
/>
</Tooltip>
</div>
<Paragraph
ellipsis={{ rows: 2 }}
style={{ fontSize: 12, color: '#595959', margin: 0 }}
>
{tool.description}
</Paragraph>
{(tool.cache_ttl !== undefined || tool.timeout !== undefined) && (
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
{tool.timeout && (
<Text type="secondary" style={{ fontSize: 11 }}>超时 {tool.timeout}s</Text>
)}
{tool.cache_ttl !== undefined && tool.cache_ttl > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}>缓存 {tool.cache_ttl}s</Text>
)}
</div>
)}
</Card>
</Col>
))}
</Row>
</div>
);
})
)}
</div>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Row, Col, Tag, Switch, Badge, Empty, Tooltip, Typography, message,
} from 'antd';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
import type { CATEGORY_CONFIG_TYPE, SOURCE_CONFIG_TYPE } from './page';
const { Text, Paragraph } = Typography;
type ToolSource = 'builtin' | 'http';
interface UnifiedTool {
id: string;
name: string;
description: string;
category: string;
source: ToolSource;
is_enabled: boolean;
cache_ttl?: number;
timeout?: number;
rawId?: number;
}
interface Props {
search: string;
categoryFilter: string;
CATEGORY_CONFIG: CATEGORY_CONFIG_TYPE;
SOURCE_CONFIG: SOURCE_CONFIG_TYPE;
}
export default function AllToolsTab({ search, categoryFilter, CATEGORY_CONFIG, SOURCE_CONFIG }: Props) {
const qc = useQueryClient();
const [togglingName, setTogglingName] = useState('');
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as {
id: string; name: string; description: string; category: string;
parameters: Record<string, unknown>; is_enabled: boolean; created_at: string;
}[],
});
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const allTools: UnifiedTool[] = [
...(builtinData ?? []).map(t => ({
id: t.name, name: t.name, description: t.description, category: t.category,
source: 'builtin' as ToolSource, is_enabled: t.is_enabled,
})),
...(httpData ?? []).map(t => ({
id: `http:${t.id}`, name: t.name, description: t.description || t.display_name,
category: t.category || 'http', source: 'http' as ToolSource,
is_enabled: t.status === 'active', cache_ttl: t.cache_ttl, timeout: t.timeout, rawId: t.id,
})),
];
const toggleMut = useMutation({
mutationFn: async ({ tool, checked }: { tool: UnifiedTool; checked: boolean }) => {
if (tool.source === 'builtin') {
return agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
} else {
return httpToolApi.update(tool.rawId!, { status: checked ? 'active' : 'disabled' });
}
},
onSuccess: (_, { tool, checked }) => {
message.success(`${tool.name} 已${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
qc.invalidateQueries({ queryKey: ['http-tools'] });
setTogglingName('');
},
onError: () => { message.error('操作失败'); setTogglingName(''); },
});
const filtered = allTools.filter(t => {
const matchSearch = !search || t.name.includes(search) || t.description.includes(search);
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchCategory;
});
const grouped: Record<string, UnifiedTool[]> = {};
for (const t of filtered) {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
}
if (Object.keys(grouped).length === 0) {
return <Empty description="暂无匹配工具" style={{ marginTop: 60 }} />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{Object.entries(grouped).map(([category, tools]) => {
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.other;
return (
<div key={category}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Tag color={cfg.color} icon={cfg.icon} style={{ fontSize: 13, padding: '2px 10px' }}>
{cfg.label}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{tools.length} 个工具</Text>
</div>
<Row gutter={[16, 16]}>
{tools.map(tool => (
<Col key={tool.id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{
borderRadius: 10,
border: `1px solid ${tool.is_enabled ? '#d9f7be' : '#f0f0f0'}`,
background: tool.is_enabled ? '#f6ffed' : '#fafafa',
transition: 'all 0.2s',
}}
styles={{ body: { padding: 14 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Badge status={tool.is_enabled ? 'success' : 'default'} />
<Text code style={{ fontSize: 12 }}>{tool.name}</Text>
</div>
<div style={{ marginTop: 4 }}>
<Tag
style={{
fontSize: 11, lineHeight: '16px', padding: '0 4px', margin: 0,
background: SOURCE_CONFIG[tool.source].color + '15',
color: SOURCE_CONFIG[tool.source].color,
border: `1px solid ${SOURCE_CONFIG[tool.source].color}40`,
}}
>
{SOURCE_CONFIG[tool.source].label}
</Tag>
</div>
</div>
<Tooltip title={tool.is_enabled ? '禁用工具' : '启用工具'}>
<Switch
size="small"
checked={tool.is_enabled}
loading={togglingName === tool.id}
onChange={checked => { setTogglingName(tool.id); toggleMut.mutate({ tool, checked }); }}
/>
</Tooltip>
</div>
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 12, color: '#595959', margin: 0 }}>
{tool.description}
</Paragraph>
{(tool.cache_ttl !== undefined || tool.timeout !== undefined) && (
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
{tool.timeout && <Text type="secondary" style={{ fontSize: 11 }}>超时 {tool.timeout}s</Text>}
{tool.cache_ttl !== undefined && tool.cache_ttl > 0 && <Text type="secondary" style={{ fontSize: 11 }}>缓存 {tool.cache_ttl}s</Text>}
</div>
)}
</Card>
</Col>
))}
</Row>
</div>
);
})}
</div>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Modal, Descriptions, Space, Badge, Typography, Switch, message,
} from 'antd';
import { InfoCircleOutlined, CodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api/agent';
import type { CATEGORY_CONFIG_TYPE } from './page';
const { Text } = Typography;
interface AgentTool {
id: string; name: string; display_name: string; description: string; category: string;
parameters: Record<string, unknown>; status: string; is_enabled: boolean;
cache_ttl: number; timeout: number; max_retries: number; created_at: string;
}
interface Props {
search: string;
categoryFilter: string;
CATEGORY_CONFIG: CATEGORY_CONFIG_TYPE;
}
export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFIG }: Props) {
const qc = useQueryClient();
const [togglingId, setTogglingId] = useState('');
const [detailModal, setDetailModal] = useState<{ open: boolean; tool: AgentTool | null }>({ open: false, tool: null });
const { data: tools = [], isLoading } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as AgentTool[],
});
const handleToggle = async (tool: AgentTool, checked: boolean) => {
setTogglingId(tool.name);
try {
await agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
message.success(`工具 ${tool.name} 已${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
if (detailModal.tool?.name === tool.name) {
setDetailModal(prev => ({ ...prev, tool: prev.tool ? { ...prev.tool, is_enabled: checked } : null }));
}
} catch {
message.error('操作失败');
} finally {
setTogglingId('');
}
};
const filtered = tools.filter(t => {
const matchSearch = !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase());
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchCategory;
});
const columns = [
{
title: '工具名称', dataIndex: 'name', key: 'name',
render: (v: string) => <Space><CodeOutlined style={{ color: '#1890ff' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
title: '分类', dataIndex: 'category', key: 'category', width: 110,
render: (v: string) => {
const cfg = CATEGORY_CONFIG[v] || CATEGORY_CONFIG.other;
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '参数数量', dataIndex: 'parameters', key: 'parameters', width: 90,
render: (v: Record<string, unknown>) => <Text type="secondary">{v ? Object.keys(v).length : 0} 个</Text>,
},
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: AgentTool) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>超时 {r.timeout || 30}s</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{r.cache_ttl > 0 ? `缓存 ${r.cache_ttl}s` : '不缓存'}</Text>
</Space>
),
},
{
title: '启用状态', dataIndex: 'is_enabled', key: 'is_enabled', width: 110,
render: (v: boolean, record: AgentTool) => (
<Switch checked={v} loading={togglingId === record.name} checkedChildren="启用" unCheckedChildren="禁用"
onChange={checked => handleToggle(record, checked)} />
),
},
{
title: '操作', key: 'action', width: 80,
render: (_: unknown, record: AgentTool) => (
<Button type="link" size="small" icon={<InfoCircleOutlined />} onClick={() => setDetailModal({ open: true, tool: record })}>详情</Button>
),
},
];
return (
<>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table dataSource={filtered} columns={columns} rowKey="name" loading={isLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: t => `共 ${t} 个工具` }} />
</Card>
<Modal
title={<Space><CodeOutlined style={{ color: '#1890ff' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, tool: null })}
footer={detailModal.tool && (
<Space>
<Switch checked={detailModal.tool.is_enabled} loading={togglingId === detailModal.tool.name}
checkedChildren="启用" unCheckedChildren="禁用"
onChange={checked => handleToggle(detailModal.tool!, checked)} />
<Text type="secondary" style={{ fontSize: 12 }}>
{detailModal.tool.is_enabled ? '工具已启用,Agent 可正常调用' : '工具已禁用'}
</Text>
</Space>
)}
width={600}
>
{detailModal.tool && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工具名称"><Text code style={{ color: '#1890ff' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="描述">{detailModal.tool.description}</Descriptions.Item>
<Descriptions.Item label="分类"><Tag color={(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).color}>{(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).label}</Tag></Descriptions.Item>
<Descriptions.Item label="状态"><Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} /></Descriptions.Item>
<Descriptions.Item label="执行超时">{detailModal.tool.timeout || 30} 秒</Descriptions.Item>
<Descriptions.Item label="结果缓存">{detailModal.tool.cache_ttl > 0 ? `${detailModal.tool.cache_ttl} 秒` : '不缓存'}</Descriptions.Item>
<Descriptions.Item label="失败重试">{detailModal.tool.max_retries || 0} 次</Descriptions.Item>
</Descriptions>
<Card size="small" title="参数定义" style={{ background: '#fafafa', borderRadius: 8 }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: '#4ade80', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{JSON.stringify(detailModal.tool.parameters, null, 2)}
</pre>
</Card>
</div>
)}
</Modal>
</>
);
}
'use client';
import { useState } from 'react';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber,
Space, Popconfirm, message, Typography, Badge, Tooltip, Switch,
Space, Popconfirm, message, Typography, Badge, Tooltip,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined,
......@@ -18,12 +18,13 @@ const { TextArea } = Input;
const METHOD_COLORS: Record<string, string> = {
GET: 'green', POST: 'blue', PUT: 'orange', DELETE: 'red', PATCH: 'purple',
};
const AUTH_LABELS: Record<string, string> = {
none: '无认证', bearer: 'Bearer Token', basic: 'Basic Auth', apikey: 'API Key',
};
export default function HTTPToolsPage() {
interface Props { search: string }
export default function HTTPToolsTab({ search }: Props) {
const qc = useQueryClient();
const [form] = Form.useForm();
const [testForm] = Form.useForm();
......@@ -37,7 +38,10 @@ export default function HTTPToolsPage() {
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const tools = data ?? [];
const tools = (data ?? []).filter(t =>
!search || t.name.includes(search) || (t.description || '').includes(search)
);
const saveMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
......@@ -45,19 +49,14 @@ export default function HTTPToolsPage() {
onSuccess: () => {
message.success(editingId ? '更新成功' : '创建成功');
qc.invalidateQueries({ queryKey: ['http-tools'] });
setModalOpen(false);
form.resetFields();
setEditingId(null);
setModalOpen(false); form.resetFields(); setEditingId(null);
},
onError: () => message.error('操作失败'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => httpToolApi.delete(id),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['http-tools'] });
},
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['http-tools'] }); },
});
const reloadMut = useMutation({
......@@ -66,65 +65,40 @@ export default function HTTPToolsPage() {
});
const testMut = useMutation({
mutationFn: ({ id, params }: { id: number; params: Record<string, unknown> }) =>
httpToolApi.test(id, params),
mutationFn: ({ id, params }: { id: number; params: Record<string, unknown> }) => httpToolApi.test(id, params),
});
const openCreate = () => {
setEditingId(null);
form.resetFields();
setAuthType('none');
setModalOpen(true);
};
const openCreate = () => { setEditingId(null); form.resetFields(); setAuthType('none'); setModalOpen(true); };
const openEdit = (tool: HTTPToolDefinition) => {
setEditingId(tool.id);
setAuthType(tool.auth_type || 'none');
setEditingId(tool.id); setAuthType(tool.auth_type || 'none');
let authConfig: Record<string, string> = {};
try { authConfig = JSON.parse(tool.auth_config || '{}'); } catch { /* ignore */ }
form.setFieldsValue({
name: tool.name,
display_name: tool.display_name,
description: tool.description,
category: tool.category,
method: tool.method || 'GET',
url: tool.url,
headers: tool.headers,
body_template: tool.body_template,
auth_type: tool.auth_type || 'none',
timeout: tool.timeout || 10,
cache_ttl: tool.cache_ttl || 0,
status: tool.status,
...authConfig,
name: tool.name, display_name: tool.display_name, description: tool.description,
category: tool.category, method: tool.method || 'GET', url: tool.url,
headers: tool.headers, body_template: tool.body_template,
auth_type: tool.auth_type || 'none', timeout: tool.timeout || 10,
cache_ttl: tool.cache_ttl || 0, status: tool.status, ...authConfig,
});
setModalOpen(true);
};
const handleSave = () => {
form.validateFields().then(values => {
// 合并 auth config
const authConfig: Record<string, string> = {};
if (values.auth_type === 'bearer') authConfig.token = values.token || '';
else if (values.auth_type === 'basic') { authConfig.username = values.username || ''; authConfig.password = values.password || ''; }
else if (values.auth_type === 'apikey') { authConfig.key = values.key || ''; authConfig.value = values.value || ''; authConfig.in = values.in || 'header'; }
const payload = {
name: values.name,
display_name: values.display_name || values.name,
description: values.description || '',
category: values.category || 'http',
method: values.method || 'GET',
url: values.url,
headers: values.headers || '{}',
body_template: values.body_template || '',
auth_type: values.auth_type || 'none',
auth_config: JSON.stringify(authConfig),
parameters: '[]',
timeout: values.timeout || 10,
cache_ttl: values.cache_ttl || 0,
status: values.status || 'active',
};
saveMut.mutate(payload);
saveMut.mutate({
name: values.name, display_name: values.display_name || values.name,
description: values.description || '', category: values.category || 'http',
method: values.method || 'GET', url: values.url, headers: values.headers || '{}',
body_template: values.body_template || '', auth_type: values.auth_type || 'none',
auth_config: JSON.stringify(authConfig), parameters: '[]',
timeout: values.timeout || 10, cache_ttl: values.cache_ttl || 0, status: values.status || 'active',
});
});
};
......@@ -132,7 +106,7 @@ export default function HTTPToolsPage() {
if (!testModal.tool) return;
testForm.validateFields().then(values => {
let params: Record<string, unknown> = {};
try { params = JSON.parse(values.params || '{}'); } catch { message.error('参数格式错误,请输入有效 JSON'); return; }
try { params = JSON.parse(values.params || '{}'); } catch { message.error('参数格式错误'); return; }
testMut.mutate({ id: testModal.tool!.id, params });
});
};
......@@ -148,15 +122,9 @@ export default function HTTPToolsPage() {
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 200 },
{
title: '方法', dataIndex: 'method', key: 'method', width: 80,
render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag>,
},
{ title: '方法', dataIndex: 'method', key: 'method', width: 80, render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag> },
{ title: 'URL', dataIndex: 'url', key: 'url', ellipsis: true, width: 200, render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text> },
{
title: '认证', dataIndex: 'auth_type', key: 'auth_type', width: 100,
render: (v: string) => <Tag>{AUTH_LABELS[v] || v}</Tag>,
},
{ title: '认证', dataIndex: 'auth_type', key: 'auth_type', width: 100, render: (v: string) => <Tag>{AUTH_LABELS[v] || v}</Tag> },
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: HTTPToolDefinition) => (
......@@ -174,11 +142,8 @@ export default function HTTPToolsPage() {
title: '操作', key: 'action', width: 160,
render: (_: unknown, record: HTTPToolDefinition) => (
<Space>
<Tooltip title="测试调用">
<Button type="link" size="small" icon={<PlayCircleOutlined />}
onClick={() => { setTestModal({ open: true, tool: record }); testForm.resetFields(); }}
/>
</Tooltip>
<Tooltip title="测试"><Button type="link" size="small" icon={<PlayCircleOutlined />}
onClick={() => { setTestModal({ open: true, tool: record }); testForm.resetFields(); testMut.reset(); }} /></Tooltip>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确定删除?" onConfirm={() => deleteMut.mutate(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
......@@ -189,51 +154,31 @@ export default function HTTPToolsPage() {
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>HTTP 动态工具</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>通过配置 HTTP 接口动态接入外部服务,无需代码即可扩展 Agent 能力</div>
</div>
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => reloadMut.mutate()} loading={reloadMut.isPending}>热重载</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建工具</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建 HTTP 工具</Button>
</Space>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
dataSource={tools}
columns={columns}
rowKey="id"
loading={isLoading}
size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }}
/>
<Table dataSource={tools} columns={columns} rowKey="id" loading={isLoading} size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }} />
</Card>
{/* 创建/编辑 Modal */}
<Modal
title={<Space><ApiOutlined style={{ color: '#1890ff' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSave}
confirmLoading={saveMut.isPending}
width={680}
destroyOnClose
>
{/* Create/Edit Modal */}
<Modal title={<Space><ApiOutlined style={{ color: '#1890ff' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen} onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSave} confirmLoading={saveMut.isPending} width={680} destroyOnHidden>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="工具名称(英文)" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="my_http_tool" disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称">
<Input placeholder="我的 HTTP 工具" />
</Form.Item>
<Form.Item name="display_name" label="显示名称"><Input placeholder="我的 HTTP 工具" /></Form.Item>
</div>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="工具功能说明" />
</Form.Item>
<Form.Item name="description" label="描述"><TextArea rows={2} placeholder="工具功能说明" /></Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '0 12px' }}>
<Form.Item name="method" label="请求方法" initialValue="GET">
<Select options={['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(m => ({ value: m, label: m }))} />
......@@ -242,23 +187,12 @@ export default function HTTPToolsPage() {
<Input placeholder="https://api.example.com/endpoint?q={{keyword}}" />
</Form.Item>
</div>
<Form.Item name="headers" label="请求头 (JSON)" initialValue="{}">
<TextArea rows={2} placeholder='{"Content-Type": "application/json"}' />
</Form.Item>
<Form.Item name="body_template" label="请求体模板(支持 {{variable}} 占位符)">
<TextArea rows={3} placeholder='{"query": "{{keyword}}", "limit": {{limit}}}' />
</Form.Item>
<Form.Item name="headers" label="请求头 (JSON)" initialValue="{}"><TextArea rows={2} /></Form.Item>
<Form.Item name="body_template" label="请求体模板(支持 {{variable}})"><TextArea rows={3} /></Form.Item>
<Form.Item name="auth_type" label="认证方式" initialValue="none">
<Select
options={Object.entries(AUTH_LABELS).map(([v, l]) => ({ value: v, label: l }))}
onChange={v => setAuthType(v)}
/>
</Form.Item>
{authType === 'bearer' && (
<Form.Item name="token" label="Bearer Token" rules={[{ required: true }]}>
<Input.Password placeholder="your-bearer-token" />
<Select options={Object.entries(AUTH_LABELS).map(([v, l]) => ({ value: v, label: l }))} onChange={v => setAuthType(v)} />
</Form.Item>
)}
{authType === 'bearer' && <Form.Item name="token" label="Bearer Token" rules={[{ required: true }]}><Input.Password /></Form.Item>}
{authType === 'basic' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}><Input /></Form.Item>
......@@ -267,7 +201,7 @@ export default function HTTPToolsPage() {
)}
{authType === 'apikey' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0 12px' }}>
<Form.Item name="key" label="参数名" rules={[{ required: true }]}><Input placeholder="X-API-Key" /></Form.Item>
<Form.Item name="key" label="参数名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="value" label="参数值" rules={[{ required: true }]}><Input.Password /></Form.Item>
<Form.Item name="in" label="位置" initialValue="header">
<Select options={[{ value: 'header', label: 'Header' }, { value: 'query', label: 'Query' }]} />
......@@ -275,12 +209,8 @@ export default function HTTPToolsPage() {
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0 16px' }}>
<Form.Item name="timeout" label="超时(秒)" initialValue={10}>
<InputNumber min={1} max={120} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="cache_ttl" label="缓存TTL(秒,0=不缓存)" initialValue={0}>
<InputNumber min={0} max={86400} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="timeout" label="超时(秒)" initialValue={10}><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="cache_ttl" label="缓存TTL(秒)" initialValue={0}><InputNumber min={0} max={86400} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
......@@ -288,19 +218,12 @@ export default function HTTPToolsPage() {
</Form>
</Modal>
{/* 测试 Modal */}
<Modal
title={<Space><ThunderboltOutlined style={{ color: '#fa8c16' }} />测试工具: <Text code>{testModal.tool?.name}</Text></Space>}
open={testModal.open}
onCancel={() => setTestModal({ open: false, tool: null })}
onOk={handleTest}
confirmLoading={testMut.isPending}
width={600}
>
{/* Test Modal */}
<Modal title={<Space><ThunderboltOutlined style={{ color: '#fa8c16' }} />测试工具: <Text code>{testModal.tool?.name}</Text></Space>}
open={testModal.open} onCancel={() => setTestModal({ open: false, tool: null })}
onOk={handleTest} confirmLoading={testMut.isPending} width={600}>
<Form form={testForm} layout="vertical">
<Form.Item name="params" label="输入参数 (JSON)">
<TextArea rows={4} placeholder='{"keyword": "高血压", "limit": 5}' />
</Form.Item>
<Form.Item name="params" label="输入参数 (JSON)"><TextArea rows={4} placeholder='{"keyword": "高血压"}' /></Form.Item>
</Form>
{testMut.data && (
<Card size="small" title="调用结果" style={{ background: '#fafafa' }}>
......@@ -310,19 +233,6 @@ export default function HTTPToolsPage() {
</Card>
)}
</Modal>
{/* URL 模板提示 */}
<Card size="small" style={{ background: '#fffbe6', borderColor: '#ffe58f', borderRadius: 12 }}>
<Space>
<CodeOutlined style={{ color: '#fa8c16' }} />
<Text style={{ fontSize: 13 }}>
<Text strong>URL/Body 模板语法:</Text>
使用 <Text code>{'{{'}</Text><Text code>param_name</Text><Text code>{'}}'}</Text> 引用工具参数,
如 <Text code>https://api.example.com/search?q=&#123;&#123;keyword&#125;&#125;</Text>。
Agent 调用时会自动替换为实际参数值。
</Text>
</Space>
</Card>
</div>
</>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Row, Col, Tag, Button, Modal, Form, Input, Select, Empty, Space,
Popconfirm, message, Typography, Badge,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined,
BookOutlined, HeartOutlined, SafetyOutlined, CalendarOutlined,
MedicineBoxOutlined, SettingOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { skillApi, agentApi, type AgentSkill } from '@/api/agent';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const SKILL_ICONS: Record<string, React.ReactNode> = {
heart: <HeartOutlined />, book: <BookOutlined />, safety: <SafetyOutlined />,
calendar: <CalendarOutlined />, 'medicine-box': <MedicineBoxOutlined />,
'file-text': <BookOutlined />, setting: <SettingOutlined />,
};
const CATEGORY_COLORS: Record<string, string> = {
patient: 'green', doctor: 'blue', admin: 'purple', general: 'cyan',
};
const CATEGORY_LABELS: Record<string, string> = {
patient: '患者', doctor: '医生', admin: '管理', general: '通用',
};
interface Props { search: string }
export default function SkillsTab({ search }: Props) {
const qc = useQueryClient();
const [form] = Form.useForm();
const [modalOpen, setModalOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<AgentSkill | null>(null);
const { data: skills = [] } = useQuery({
queryKey: ['agent-skills'],
queryFn: () => skillApi.list(),
select: r => r.data ?? [],
});
const { data: toolList = [] } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []).map((t: { name: string; description: string }) => ({ value: t.name, label: `${t.name} - ${t.description}` })),
});
const saveMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
editingSkill
? skillApi.update(editingSkill.skill_id, values as Partial<AgentSkill>)
: skillApi.create(values as Partial<AgentSkill>),
onSuccess: () => {
message.success(editingSkill ? '更新成功' : '创建成功');
qc.invalidateQueries({ queryKey: ['agent-skills'] });
setModalOpen(false); form.resetFields(); setEditingSkill(null);
},
onError: () => message.error('操作失败'),
});
const deleteMut = useMutation({
mutationFn: (skillId: string) => skillApi.delete(skillId),
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['agent-skills'] }); },
});
const openCreate = () => { setEditingSkill(null); form.resetFields(); setModalOpen(true); };
const openEdit = (skill: AgentSkill) => {
setEditingSkill(skill);
let parsedTools: string[] = [];
let parsedQR: string[] = [];
try { parsedTools = JSON.parse(skill.tools || '[]'); } catch { /* */ }
try { parsedQR = JSON.parse(skill.quick_replies || '[]'); } catch { /* */ }
form.setFieldsValue({
skill_id: skill.skill_id, name: skill.name, description: skill.description,
category: skill.category, tools: parsedTools, system_prompt_addon: skill.system_prompt_addon,
quick_replies: parsedQR.join('\n'), icon: skill.icon,
});
setModalOpen(true);
};
const handleSave = () => {
form.validateFields().then(values => {
const quickReplies = (values.quick_replies || '').split('\n').filter((s: string) => s.trim());
saveMut.mutate({
skill_id: values.skill_id,
name: values.name,
description: values.description || '',
category: values.category || 'general',
tools: values.tools || [],
system_prompt_addon: values.system_prompt_addon || '',
quick_replies: quickReplies,
icon: values.icon || 'setting',
});
});
};
const filtered = skills.filter(s =>
!search || s.name.includes(search) || s.skill_id.includes(search) || (s.description || '').includes(search)
);
return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建技能包</Button>
</div>
{filtered.length === 0 ? (
<Empty description="暂无技能包" style={{ marginTop: 40 }} />
) : (
<Row gutter={[16, 16]}>
{filtered.map(skill => {
let toolCount = 0;
try { toolCount = JSON.parse(skill.tools || '[]').length; } catch { /* */ }
const icon = SKILL_ICONS[skill.icon] || <ThunderboltOutlined />;
return (
<Col key={skill.skill_id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{ borderRadius: 12, border: '1px solid #edf2fc', transition: 'all 0.2s' }}
styles={{ body: { padding: 16 } }}
hoverable
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 10 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 18, flexShrink: 0,
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#1d2129' }}>{skill.name}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<Tag color={CATEGORY_COLORS[skill.category] || 'default'} style={{ fontSize: 11, margin: 0 }}>
{CATEGORY_LABELS[skill.category] || skill.category}
</Tag>
<Tag style={{ fontSize: 11, margin: 0 }}>{toolCount} 个工具</Tag>
</div>
</div>
</div>
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 12, color: '#595959', margin: '0 0 10px' }}>
{skill.description || '暂无描述'}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4 }}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(skill)} />
<Popconfirm title="确定删除该技能包?" onConfirm={() => deleteMut.mutate(skill.skill_id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</Card>
</Col>
);
})}
</Row>
)}
{/* Create/Edit Modal */}
<Modal
title={editingSkill ? '编辑技能包' : '新建技能包'}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); setEditingSkill(null); }}
onOk={handleSave}
confirmLoading={saveMut.isPending}
width={640}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="skill_id" label="技能ID(英文)" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="symptom_analysis" disabled={!!editingSkill} />
</Form.Item>
<Form.Item name="name" label="技能名称" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="症状分析" />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<TextArea rows={2} placeholder="技能功能说明" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="category" label="分类" initialValue="general">
<Select options={Object.entries(CATEGORY_LABELS).map(([v, l]) => ({ value: v, label: l }))} />
</Form.Item>
<Form.Item name="icon" label="图标" initialValue="setting">
<Select options={Object.keys(SKILL_ICONS).map(k => ({ value: k, label: k }))} />
</Form.Item>
</div>
<Form.Item name="tools" label="关联工具">
<Select mode="multiple" placeholder="选择工具" options={toolList} allowClear
filterOption={(input, option) => (option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())} />
</Form.Item>
<Form.Item name="system_prompt_addon" label="系统提示词追加">
<TextArea rows={3} placeholder="加载此技能时追加到Agent系统提示的内容" />
</Form.Item>
<Form.Item name="quick_replies" label="快捷回复(每行一个)">
<TextArea rows={3} placeholder={"头痛\n发热\n咳嗽"} />
</Form.Item>
</Form>
</Modal>
</>
);
}
'use client';
import { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Modal, Descriptions, Space, Input, Badge, Typography, Switch, message } from 'antd';
import React, { useState, useMemo } from 'react';
import { Input, Space, Segmented, Tabs, Typography } from 'antd';
import {
ToolOutlined, SearchOutlined, InfoCircleOutlined, ApiOutlined,
CheckCircleOutlined, CodeOutlined, ReloadOutlined,
SearchOutlined, ToolOutlined, ApiOutlined, RobotOutlined,
DeploymentUnitOutlined, BookOutlined, ThunderboltOutlined,
CodeOutlined, BellOutlined, HeartOutlined, SafetyOutlined,
AppstoreOutlined, CloudOutlined,
} from '@ant-design/icons';
import { agentApi } from '@/api/agent';
import { useQuery } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
import AllToolsTab from './AllToolsTab';
import BuiltinToolsTab from './BuiltinToolsTab';
import HTTPToolsTab from './HTTPToolsTab';
import SkillsTab from './SkillsTab';
const { Text } = Typography;
interface AgentTool {
id: string;
name: string;
display_name: string;
description: string;
category: string;
parameters: Record<string, unknown>;
status: string;
is_enabled: boolean;
cache_ttl: number;
timeout: number;
max_retries: number;
created_at: string;
}
const categoryColors: Record<string, string> = {
knowledge: 'blue', recommendation: 'cyan', medical: 'purple',
pharmacy: 'green', safety: 'red', follow_up: 'orange', notification: 'gold',
agent: 'geekblue', workflow: 'volcano', expression: 'lime', other: 'default',
};
const categoryLabels: Record<string, string> = {
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理', notification: '消息通知',
agent: 'Agent调用', workflow: '工作流', expression: '表达式', other: '其他',
export const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
knowledge: { color: 'blue', label: '知识库', icon: <BookOutlined /> },
recommendation: { color: 'cyan', label: '智能推荐', icon: <ThunderboltOutlined /> },
medical: { color: 'purple', label: '病历管理', icon: <HeartOutlined /> },
pharmacy: { color: 'green', label: '药品管理', icon: <ToolOutlined /> },
safety: { color: 'red', label: '安全检查', icon: <SafetyOutlined /> },
follow_up: { color: 'orange', label: '随访管理', icon: <HeartOutlined /> },
notification: { color: 'gold', label: '消息通知', icon: <BellOutlined /> },
agent: { color: 'geekblue', label: 'Agent调用', icon: <RobotOutlined /> },
workflow: { color: 'volcano', label: '工作流', icon: <DeploymentUnitOutlined /> },
expression: { color: 'lime', label: '表达式', icon: <CodeOutlined /> },
http: { color: 'magenta', label: 'HTTP服务', icon: <ApiOutlined /> },
other: { color: 'default', label: '其他', icon: <ToolOutlined /> },
};
const statCards = [
{ label: '工具总数', color: '#1890ff', icon: <ToolOutlined />, key: 'total' },
{ label: '已启用', color: '#52c41a', icon: <CheckCircleOutlined />, key: 'enabled' },
{ label: '分类数', color: '#722ed1', icon: <ApiOutlined />, key: 'categories' },
{ label: '安全工具', color: '#fa8c16', icon: <ToolOutlined />, key: 'safety' },
];
export default function ToolsPage() {
const [tools, setTools] = useState<AgentTool[]>([]);
const [loading, setLoading] = useState(false);
const [togglingId, setTogglingId] = useState<string>('');
const [searchText, setSearchText] = useState('');
const [detailModal, setDetailModal] = useState<{ open: boolean; tool: AgentTool | null }>({ open: false, tool: null });
useEffect(() => { fetchTools(); }, []);
const fetchTools = async () => {
setLoading(true);
try {
const res = await agentApi.listTools();
if (res.data?.length > 0) setTools(res.data as AgentTool[]);
} catch {
message.error('加载工具列表失败');
} finally {
setLoading(false);
}
};
const handleToggle = async (tool: AgentTool, checked: boolean) => {
setTogglingId(tool.name);
try {
await agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
message.success(`工具 ${tool.name} 已${checked ? '启用' : '禁用'}`);
setTools(prev => prev.map(t => t.name === tool.name ? { ...t, is_enabled: checked } : t));
// 同步更新 detailModal 中的状态
if (detailModal.tool?.name === tool.name) {
setDetailModal(prev => ({ ...prev, tool: prev.tool ? { ...prev.tool, is_enabled: checked } : null }));
}
} catch {
message.error('操作失败');
} finally {
setTogglingId('');
}
};
const filteredTools = tools.filter(t =>
t.name.toLowerCase().includes(searchText.toLowerCase()) ||
t.description.toLowerCase().includes(searchText.toLowerCase()) ||
t.category.toLowerCase().includes(searchText.toLowerCase())
);
const categoryStats = tools.reduce((acc, t) => { acc[t.category] = (acc[t.category] || 0) + 1; return acc; }, {} as Record<string, number>);
const statValues: Record<string, number> = {
total: tools.length,
enabled: tools.filter(t => t.is_enabled).length,
categories: Object.keys(categoryStats).length,
safety: categoryStats['safety'] || 0,
};
const columns = [
{
title: '工具名称', dataIndex: 'name', key: 'name',
render: (v: string) => (
<Space>
<CodeOutlined style={{ color: '#1890ff' }} />
<Text code style={{ fontSize: 13 }}>{v}</Text>
</Space>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
title: '分类', dataIndex: 'category', key: 'category', width: 110,
render: (v: string) => <Tag color={categoryColors[v] || 'default'}>{categoryLabels[v] || v}</Tag>,
},
export type CATEGORY_CONFIG_TYPE = typeof CATEGORY_CONFIG;
export const SOURCE_CONFIG = {
builtin: { label: '内置', color: '#1890ff' },
http: { label: 'HTTP', color: '#fa8c16' },
} as const;
export type SOURCE_CONFIG_TYPE = typeof SOURCE_CONFIG;
export default function UnifiedToolsPage() {
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [activeTab, setActiveTab] = useState('all');
// Stats
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as { name: string; is_enabled: boolean; category: string }[],
});
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const totalCount = (builtinData?.length || 0) + (httpData?.length || 0);
const enabledCount = (builtinData?.filter(t => t.is_enabled).length || 0) +
(httpData?.filter(t => t.status === 'active').length || 0);
const allCategories = useMemo(() => {
const cats = new Set<string>();
builtinData?.forEach(t => cats.add(t.category));
httpData?.forEach(t => cats.add(t.category || 'http'));
return [...cats];
}, [builtinData, httpData]);
const tabItems = [
{
title: '参数数量', dataIndex: 'parameters', key: 'parameters', width: 90,
render: (v: Record<string, unknown>) => <Text type="secondary">{v ? Object.keys(v).length : 0} 个</Text>,
key: 'all',
label: <span><AppstoreOutlined /> 全部工具</span>,
children: <AllToolsTab search={search} categoryFilter={categoryFilter} CATEGORY_CONFIG={CATEGORY_CONFIG} SOURCE_CONFIG={SOURCE_CONFIG} />,
},
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: AgentTool) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>超时 {r.timeout || 30}s</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{r.cache_ttl > 0 ? `缓存 ${r.cache_ttl}s` : '不缓存'}
</Text>
</Space>
),
key: 'builtin',
label: <span><ToolOutlined /> 内置工具</span>,
children: <BuiltinToolsTab search={search} categoryFilter={categoryFilter} CATEGORY_CONFIG={CATEGORY_CONFIG} />,
},
{
title: '启用状态', dataIndex: 'is_enabled', key: 'is_enabled', width: 110,
render: (v: boolean, record: AgentTool) => (
<Switch
checked={v}
loading={togglingId === record.name}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => handleToggle(record, checked)}
/>
),
key: 'http',
label: <span><CloudOutlined /> HTTP 工具</span>,
children: <HTTPToolsTab search={search} />,
},
{
title: '操作', key: 'action', width: 80,
render: (_: unknown, record: AgentTool) => (
<Button type="link" size="small" icon={<InfoCircleOutlined />} onClick={() => setDetailModal({ open: true, tool: record })}>详情</Button>
),
key: 'skills',
label: <span><ThunderboltOutlined /> 技能包</span>,
children: <SkillsTab search={search} />,
},
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>Agent 工具管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理 Agent 工具启停状态,工具禁用后 Agent 将无法调用</div>
</div>
{/* 统计卡片 */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16 }}>
{statCards.map(s => (
<Card key={s.key} size="small" style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: `4px solid ${s.color}` }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{s.label}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: s.color }}>{statValues[s.key]}</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工具中心</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
管理所有 Agent 工具与技能包 — 共 <Text strong>{totalCount}</Text> 个工具,
已启用 <Text strong style={{ color: '#52c41a' }}>{enabledCount}</Text> 个
</div>
<div style={{ fontSize: 28, color: s.color, opacity: 0.3 }}>{s.icon}</div>
</div>
</Card>
))}
</div>
{/* 搜索栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{/* Filter bar */}
<div style={{
padding: '10px 16px', borderRadius: 12, border: '1px solid #edf2fc', background: '#fff',
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<Input
placeholder="搜索工具名称、描述或分类..."
placeholder="搜索工具名称或描述..."
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
style={{ width: 280, borderRadius: 8 }}
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: 260, borderRadius: 8 }}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={() => { setSearchText(''); fetchTools(); }}>刷新</Button>
<Text type="secondary" style={{ marginLeft: 8 }}>
已启用 <Text strong style={{ color: '#52c41a' }}>{statValues.enabled}</Text> / 共 {statValues.total} 个工具
</Text>
</div>
</Card>
{/* 工具列表 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
dataSource={filteredTools}
columns={columns}
rowKey="name"
loading={loading}
size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: (t) => `共 ${t} 个工具` }}
{(activeTab === 'all' || activeTab === 'builtin') && (
<Segmented
value={categoryFilter}
onChange={v => setCategoryFilter(v as string)}
options={[
{ value: 'all', label: '全部分类' },
...allCategories.map(c => ({ value: c, label: CATEGORY_CONFIG[c]?.label || c })),
]}
/>
</Card>
)}
</div>
{/* 工具详情 Modal */}
<Modal
title={<Space><CodeOutlined style={{ color: '#1890ff' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, tool: null })}
footer={
detailModal.tool && (
<Space>
<Switch
checked={detailModal.tool.is_enabled}
loading={togglingId === detailModal.tool.name}
checkedChildren="启用"
unCheckedChildren="禁用"
onChange={(checked) => handleToggle(detailModal.tool!, checked)}
{/* Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
style={{ marginTop: -4 }}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{detailModal.tool.is_enabled ? '工具已启用,Agent 可正常调用' : '工具已禁用,Agent 将跳过此工具'}
</Text>
</Space>
)
}
width={600}
>
{detailModal.tool && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工具名称">
<Text code style={{ color: '#1890ff' }}>{detailModal.tool.name}</Text>
</Descriptions.Item>
<Descriptions.Item label="描述">{detailModal.tool.description}</Descriptions.Item>
<Descriptions.Item label="分类">
<Tag color={categoryColors[detailModal.tool.category]}>{categoryLabels[detailModal.tool.category] || detailModal.tool.category}</Tag>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} />
</Descriptions.Item>
<Descriptions.Item label="执行超时">{detailModal.tool.timeout || 30} 秒</Descriptions.Item>
<Descriptions.Item label="结果缓存">
{detailModal.tool.cache_ttl > 0 ? `${detailModal.tool.cache_ttl} 秒` : '不缓存'}
</Descriptions.Item>
<Descriptions.Item label="失败重试">{detailModal.tool.max_retries || 0} 次</Descriptions.Item>
</Descriptions>
<Card size="small" title="参数定义" style={{ background: '#fafafa', borderRadius: 8 }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: '#4ade80', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{JSON.stringify(detailModal.tool.parameters, null, 2)}
</pre>
</Card>
<Card size="small" title="调用示例" style={{ background: '#fafafa', borderRadius: 8 }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: '#fde047', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{`// 调用 ${detailModal.tool.name}
const result = await agent.callTool('${detailModal.tool.name}', {
${Object.entries(detailModal.tool.parameters || {}).map(([k, v]) => ` ${k}: <${v}>`).join(',\n')}
});`}
</pre>
</Card>
</div>
)}
</Modal>
</div>
);
}
......@@ -2,16 +2,16 @@
import React, { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Switch, Typography, Tag } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag } from 'antd';
import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
RobotOutlined, DollarOutlined, FolderOpenOutlined, SafetyCertificateOutlined,
CheckCircleOutlined,
DollarOutlined, FolderOpenOutlined, SafetyCertificateOutlined,
CheckCircleOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
const { Content } = Layout;
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
......@@ -30,6 +30,7 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
const pathname = usePathname();
const { user, logout } = useUserStore();
const [isOnline, setIsOnline] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const currentPath = pathname || '';
const userMenuItems = [
......@@ -56,42 +57,72 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
};
return (
<Layout style={{ minHeight: '100vh', background: '#f5f8ff' }}>
<header
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={200}
collapsedWidth={64}
trigger={null}
style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
height: 56, display: 'flex', alignItems: 'center', padding: '0 24px',
background: '#ffffff',
borderBottom: '1px solid #e8f0fc',
boxShadow: '0 1px 4px rgba(0, 82, 204, 0.06)',
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
}}
>
{/* Logo */}
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 24, flexShrink: 0 }}
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/doctor/workbench')}
>
<div style={{
width: 32, height: 32, borderRadius: 8,
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', letterSpacing: '-0.3px' }}>互联网医院</span>
<Tag color="green" style={{ marginLeft: 2, fontSize: 10, lineHeight: '18px', padding: '0 6px', borderRadius: 4 }}>医生工作站</Tag>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="green" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>医生</Tag>
</>
)}
</div>
{/* Menu */}
<Menu
mode="horizontal"
mode="inline"
selectedKeys={getSelectedKeys()}
items={menuItems}
onClick={handleMenuClick}
className="patient-nav-menu"
style={{ flex: 1, minWidth: 0, border: 'none' }}
style={{ border: 'none', padding: '8px 0' }}
/>
</Sider>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<Layout style={{ marginLeft: collapsed ? 64 : 200, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Switch
checked={isOnline}
onChange={setIsOnline}
......@@ -100,7 +131,7 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
style={{ backgroundColor: isOnline ? '#52c41a' : undefined }}
/>
<Badge count={5} size="small">
<BellOutlined style={{ fontSize: 18, color: '#595959', cursor: 'pointer' }} />
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
......@@ -109,15 +140,14 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>{user.real_name || user.phone}</Text>
</Space>
</Dropdown>
) : (
<Button type="primary" size="small" onClick={() => router.push('/login')} style={{ borderRadius: 8 }}>登录</Button>
)}
) : null}
</div>
</header>
<Content style={{ marginTop: 56, minHeight: 'calc(100vh - 56px)', background: '#f5f8ff' }}>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f8ff' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
'use client';
import React from 'react';
import React, { useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd';
import {
HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined,
HeartOutlined, SettingOutlined, LogoutOutlined, BellOutlined,
RobotOutlined, VideoCameraOutlined, PayCircleOutlined,
VideoCameraOutlined, PayCircleOutlined,
ShoppingCartOutlined, FolderOpenOutlined, SearchOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
const { Content } = Layout;
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
......@@ -51,6 +52,7 @@ export default function PatientLayout({ children }: { children: React.ReactNode
const router = useRouter();
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const currentPath = pathname || '';
const userMenuItems = [
......@@ -74,84 +76,111 @@ export default function PatientLayout({ children }: { children: React.ReactNode
return match ? [match] : [];
};
const getOpenKeys = () => {
if (collapsed) return [];
const keys: string[] = [];
if (['/patient/doctors', '/patient/consult'].some(k => currentPath.startsWith(k))) {
keys.push('consult-services');
}
if (['/patient/prescription', '/patient/pharmacy/order', '/patient/payment'].some(k => currentPath.startsWith(k))) {
keys.push('rx-pharmacy');
}
if (['/patient/chronic', '/patient/health-records'].some(k => currentPath.startsWith(k))) {
keys.push('health-mgmt');
}
return keys;
};
return (
<Layout style={{ minHeight: '100vh', background: '#f5f8ff' }}>
{/* 顶部导航 - 白色现代风格 */}
<header
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
width={200}
collapsedWidth={64}
trigger={null}
style={{
position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1000,
height: 56, display: 'flex', alignItems: 'center', padding: '0 24px',
background: '#ffffff',
borderBottom: '1px solid #e8f0fc',
boxShadow: '0 1px 4px rgba(0, 82, 204, 0.06)',
position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: '#fff',
borderRight: '1px solid #f0f0f0',
overflow: 'auto',
}}
>
{/* Logo */}
<div
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', marginRight: 24, flexShrink: 0 }}
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
padding: collapsed ? '0' : '0 16px', cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
}}
onClick={() => router.push('/patient/home')}
>
<div style={{
width: 32, height: 32, borderRadius: 8,
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
</div>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', letterSpacing: '-0.3px' }}>互联网医院</span>
<Tag color="blue" style={{ marginLeft: 2, fontSize: 10, lineHeight: '18px', padding: '0 6px', borderRadius: 4 }}>患者端</Tag>
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="blue" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>患者</Tag>
</>
)}
</div>
{/* 导航菜单 */}
{/* Menu */}
<Menu
mode="horizontal"
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
className="patient-nav-menu"
style={{ flex: 1, minWidth: 0, border: 'none' }}
style={{ border: 'none', padding: '8px 0' }}
/>
</Sider>
{/* 右侧用户区 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<Layout style={{ marginLeft: collapsed ? 64 : 200, transition: 'margin-left 0.2s' }}>
{/* Top bar */}
<header
style={{
height: 56, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', background: '#fff',
borderBottom: '1px solid #f0f0f0',
position: 'sticky', top: 0, zIndex: 99,
}}
>
<div
style={{ cursor: 'pointer', fontSize: 18, color: '#595959' }}
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={3} size="small">
<BellOutlined style={{ fontSize: 18, color: '#595959', cursor: 'pointer' }} />
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar
size={30}
src={user.avatar || null}
icon={<UserOutlined />}
style={{ backgroundColor: '#1890ff' }}
/>
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>
{user.real_name || user.phone}
</Text>
<Avatar size={30} src={user.avatar || null} icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<Text style={{ color: '#1d2129', fontSize: 13, fontWeight: 500 }}>{user.real_name || user.phone}</Text>
</Space>
</Dropdown>
) : (
<Button
type="primary"
size="small"
onClick={() => router.push('/login')}
style={{ borderRadius: 8, fontWeight: 500 }}
>
<Button type="primary" size="small" onClick={() => router.push('/login')} style={{ borderRadius: 8, fontWeight: 500 }}>
登录 / 注册
</Button>
)}
</div>
</header>
{/* 内容区 - 去掉 padding,让各页面自己控制,避免左右留白过大 */}
<Content style={{
marginTop: 56,
minHeight: 'calc(100vh - 56px)',
background: '#f5f8ff',
}}>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f8ff' }}>
{children}
</Content>
</Layout>
</Layout>
);
}
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Drawer, Input, Button, Space, Tag, Spin, Typography, Collapse, Timeline, Divider } from 'antd';
import {
RobotOutlined,
SendOutlined,
BulbOutlined,
MedicineBoxOutlined,
FileTextOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ToolOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import { useUserStore } from '../../store/userStore';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const API = process.env.NEXT_PUBLIC_API_URL || '';
interface ToolCall {
tool_name: string;
call_id: string;
arguments: string;
result: { success: boolean; data?: unknown; error?: string };
success: boolean;
}
interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
toolCalls?: ToolCall[];
meta?: { iterations?: number; tokens?: number };
timestamp?: Date;
}
interface AIAssistDrawerProps {
open: boolean;
onClose: () => void;
patientContext?: {
patientId?: string;
patientName?: string;
symptoms?: string;
medicalHistory?: string;
};
}
const AIAssistDrawer: React.FC<AIAssistDrawerProps> = ({ open, onClose, patientContext }) => {
const { accessToken: token } = useUserStore();
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open && messages.length === 0) {
// 初始化欢迎消息
setMessages([{
role: 'system',
content: '您好,我是AI诊断助手。请描述患者症状或输入您的问题,我将为您提供诊断建议。',
timestamp: new Date(),
}]);
}
}, [open]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!inputValue.trim() || loading) return;
const userMessage = inputValue.trim();
setInputValue('');
setMessages(prev => [...prev, { role: 'user', content: userMessage, timestamp: new Date() }]);
setLoading(true);
try {
const requestBody: Record<string, unknown> = {
session_id: sessionId,
message: userMessage,
};
// 如果有患者上下文,添加到请求中
if (patientContext) {
requestBody.context = patientContext;
}
const res = await fetch(`${API}/api/v1/agent/diagnosis_agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(requestBody),
});
if (res.ok) {
const data = await res.json();
const agentData = data.data;
setMessages(prev => [...prev, {
role: 'assistant',
content: agentData?.response || '抱歉,我暂时无法回答这个问题。',
toolCalls: agentData?.tool_calls,
meta: { iterations: agentData?.iterations, tokens: agentData?.total_tokens },
timestamp: new Date(),
}]);
} else {
setMessages(prev => [...prev, {
role: 'assistant',
content: '请求失败,请稍后重试。',
timestamp: new Date(),
}]);
}
} catch {
setMessages(prev => [...prev, {
role: 'assistant',
content: '网络错误,请检查网络连接。',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
}
};
const quickQuestions = [
{ icon: <BulbOutlined />, text: '鉴别诊断建议' },
{ icon: <MedicineBoxOutlined />, text: '用药方案推荐' },
{ icon: <FileTextOutlined />, text: '检查项目建议' },
];
const renderToolCalls = (toolCalls?: ToolCall[]) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<Collapse
size="small"
className="mt-2 bg-gray-50"
items={[{
key: 'tools',
label: (
<span className="text-xs text-gray-600">
<ToolOutlined className="mr-1" />
调用了 {toolCalls.length} 个工具
</span>
),
children: (
<Timeline
className="mt-2"
items={toolCalls.map((tc, idx) => ({
color: tc.success ? 'green' : 'red',
dot: tc.success ? <CheckCircleOutlined className="text-xs" /> : <CloseCircleOutlined className="text-xs" />,
children: (
<div key={idx} className="text-xs">
<div className="font-medium text-gray-700">{tc.tool_name}</div>
<div className="text-gray-400 truncate" style={{ maxWidth: 250 }}>
参数: {tc.arguments}
</div>
</div>
),
}))}
/>
),
}]}
/>
);
};
const renderMessage = (msg: Message, index: number) => {
if (msg.role === 'system') {
return (
<div key={index} className="flex justify-center my-3">
<div className="bg-blue-50 text-blue-600 text-xs px-4 py-2 rounded-full">
<RobotOutlined className="mr-1" />
{msg.content}
</div>
</div>
);
}
const isUser = msg.role === 'user';
return (
<div key={index} className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
<div className={`max-w-[85%] ${isUser ? 'order-2' : 'order-1'}`}>
<div
className={`px-4 py-3 rounded-2xl text-sm ${
isUser
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-br-md'
: 'bg-white border border-gray-100 text-gray-700 rounded-bl-md shadow-sm'
}`}
>
<Paragraph className={`mb-0 ${isUser ? 'text-white' : 'text-gray-700'}`} style={{ marginBottom: 0 }}>
{msg.content}
</Paragraph>
</div>
{!isUser && msg.meta && (
<div className="text-xs text-gray-400 mt-1 px-2">
<ThunderboltOutlined className="mr-1" />
{msg.meta.iterations || 0} 次推理 · {msg.meta.tokens || 0} tokens
</div>
)}
{!isUser && renderToolCalls(msg.toolCalls)}
</div>
</div>
);
};
return (
<Drawer
title={
<Space>
<div className="w-8 h-8 bg-gradient-to-r from-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
<RobotOutlined className="text-white text-lg" />
</div>
<div>
<div className="font-semibold text-gray-800">AI诊断助手</div>
<div className="text-xs text-gray-400 font-normal">智能辅助诊断 · 实时响应</div>
</div>
</Space>
}
placement="right"
width={420}
open={open}
onClose={onClose}
styles={{
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
}}
>
{/* 患者上下文信息 */}
{patientContext?.patientName && (
<div className="px-4 py-2 bg-blue-50 border-b border-blue-100">
<div className="text-xs text-blue-600">
<span className="font-medium">当前患者:</span>
{patientContext.patientName}
{patientContext.symptoms && (
<span className="ml-2 text-gray-500">主诉:{patientContext.symptoms}</span>
)}
</div>
</div>
)}
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.map((msg, idx) => renderMessage(msg, idx))}
{loading && (
<div className="flex justify-start mb-4">
<div className="bg-white border border-gray-100 px-4 py-3 rounded-2xl rounded-bl-md shadow-sm">
<Spin size="small" />
<span className="ml-2 text-gray-400 text-sm">AI正在思考...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 快捷问题 */}
<div className="px-4 py-2 border-t border-gray-100 bg-white">
<div className="flex gap-2 overflow-x-auto pb-2">
{quickQuestions.map((q, idx) => (
<Tag
key={idx}
className="cursor-pointer hover:bg-blue-50 hover:border-blue-300 transition-colors shrink-0"
onClick={() => setInputValue(q.text)}
>
{q.icon}
<span className="ml-1">{q.text}</span>
</Tag>
))}
</div>
</div>
{/* 输入区域 */}
<div className="p-4 border-t border-gray-200 bg-white">
<div className="flex gap-2">
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="描述症状或输入问题..."
autoSize={{ minRows: 1, maxRows: 4 }}
className="flex-1"
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={sendMessage}
loading={loading}
className="self-end"
>
发送
</Button>
</div>
<div className="text-xs text-gray-400 mt-2 text-center">
AI建议仅供参考,请结合临床实际情况做出诊断
</div>
</div>
</Drawer>
);
};
export default AIAssistDrawer;
......@@ -127,7 +127,7 @@ const ConsultCreateModal: React.FC<ConsultCreateModalProps> = ({
onCancel={handleCancel}
footer={null}
width={700}
destroyOnClose
destroyOnHidden
>
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
......
'use client';
import React from 'react';
import { FloatButton, Tooltip } from 'antd';
import { RobotOutlined, MessageOutlined, CustomerServiceOutlined } from '@ant-design/icons';
import { useAIAssistStore } from '../../store/aiAssistStore';
import { usePreConsultStore } from '../../store/preConsultStore';
import { useUserStore } from '../../store/userStore';
const GlobalAIButton: React.FC = () => {
const { openDrawer: openAIAssist } = useAIAssistStore();
const { openDrawer: openPreConsult } = usePreConsultStore();
const { user } = useUserStore();
const isDoctor = user?.role === 'doctor';
const isPatient = user?.role === 'patient';
if (!user) return null;
return (
<FloatButton.Group
trigger="hover"
type="primary"
style={{ right: 24, bottom: 24 }}
icon={<CustomerServiceOutlined />}
tooltip="AI助手"
>
{isDoctor && (
<Tooltip title="AI诊断助手" placement="left">
<FloatButton
icon={<RobotOutlined />}
onClick={() => openAIAssist()}
style={{
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
}}
/>
</Tooltip>
)}
{isPatient && (
<Tooltip title="智能预问诊" placement="left">
<FloatButton
icon={<MessageOutlined />}
onClick={() => openPreConsult()}
style={{
background: 'linear-gradient(135deg, #52c41a 0%, #389e0d 100%)',
}}
/>
</Tooltip>
)}
</FloatButton.Group>
);
};
export default GlobalAIButton;
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Input, Button, Tag, Spin, Collapse, Timeline, Select } from 'antd';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Input, Button, Tag, Spin, Collapse, Timeline, Tooltip } from 'antd';
import {
SendOutlined,
RobotOutlined,
......@@ -11,18 +11,23 @@ import {
BulbOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
LoadingOutlined,
ToolOutlined,
ThunderboltOutlined,
SwapOutlined,
SettingOutlined,
ClearOutlined,
CompassOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import { agentApi } from '../../api/agent';
import type { ChatMessage, ToolCall, AgentOption } from './types';
import { PATIENT_AGENTS, DOCTOR_AGENTS } from './types';
import MarkdownRenderer from '../MarkdownRenderer';
import type { ChatMessage, ToolCall, WidgetRole } from './types';
import { ROLE_AGENT_ID, ROLE_AGENT_NAME, ROLE_THEME, QUICK_ITEMS } from './types';
const { TextArea } = Input;
interface ChatPanelProps {
role: 'patient' | 'doctor';
role: WidgetRole;
patientContext?: {
patientId?: string;
patientName?: string;
......@@ -30,28 +35,41 @@ interface ChatPanelProps {
};
}
const ROLE_ICONS: Record<WidgetRole, React.ReactNode> = {
patient: <HeartOutlined style={{ color: '#fff', fontSize: 14 }} />,
doctor: <RobotOutlined style={{ color: '#fff', fontSize: 14 }} />,
admin: <SettingOutlined style={{ color: '#fff', fontSize: 14 }} />,
};
const QUICK_ICON: Record<WidgetRole, React.ReactNode> = {
patient: <HeartOutlined style={{ marginRight: 2 }} />,
doctor: <BulbOutlined style={{ marginRight: 2 }} />,
admin: <CompassOutlined style={{ marginRight: 2 }} />,
};
const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState('');
const [selectedAgent, setSelectedAgent] = useState<string>(
role === 'patient' ? 'pre_consult_agent' : 'diagnosis_agent'
);
const [streamingStatus, setStreamingStatus] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const agents: AgentOption[] = role === 'patient' ? PATIENT_AGENTS : DOCTOR_AGENTS;
const agentId = ROLE_AGENT_ID[role];
const agentName = ROLE_AGENT_NAME[role];
const quickItems = QUICK_ITEMS[role];
const theme = ROLE_THEME[role];
// mount时恢复最近会话
// Mount: restore latest session
useEffect(() => {
const restoreSession = async () => {
try {
const res = await agentApi.getSessions(selectedAgent);
const res = await agentApi.getSessions(agentId);
const sessions = res.data;
if (sessions && sessions.length > 0) {
const latest = sessions[0];
setSessionId(latest.session_id);
// 恢复历史消息
try {
const history = JSON.parse(latest.history || '[]') as { role: string; content: string }[];
if (history.length > 0) {
......@@ -63,112 +81,162 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
setMessages(restored);
return;
}
} catch { /* 历史解析失败,使用默认欢迎消息 */ }
} catch { /* parse failed, show welcome */ }
}
} catch { /* 会话恢复失败,使用默认欢迎消息 */ }
// 没有历史会话,展示欢迎消息
const agent = agents.find(a => a.id === selectedAgent);
setMessages([{
role: 'system',
content: role === 'patient'
? `您好!我是${agent?.name || '智能助手'}。请告诉我您目前有什么不舒服的症状?`
: `您好,我是${agent?.name || 'AI诊断助手'}。请描述患者症状或输入您的问题。`,
timestamp: new Date(),
}]);
} catch { /* restore failed, show welcome */ }
showWelcome();
};
restoreSession();
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const showWelcome = () => {
const welcomeMap: Record<WidgetRole, string> = {
patient: `您好!我是${agentName}。请告诉我您目前有什么不舒服的症状?`,
doctor: `您好,我是${agentName}。请描述患者症状或输入您的问题。`,
admin: `您好,我是${agentName}。可以帮您查询运营数据、Agent状态或平台配置问题。`,
};
setMessages([{ role: 'system', content: welcomeMap[role], timestamp: new Date() }]);
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
const stopStreaming = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setLoading(false);
setStreamingStatus('');
setMessages(prev => prev.map(m => m.streaming ? { ...m, streaming: false } : m));
}, []);
const sendMessage = () => {
if (!inputValue.trim() || loading) return;
const userMessage = inputValue.trim();
setInputValue('');
setMessages(prev => [...prev, { role: 'user', content: userMessage, timestamp: new Date() }]);
setLoading(true);
setStreamingStatus('连接中...');
try {
const requestBody: Record<string, unknown> = {
session_id: sessionId,
const msgId = Date.now();
// 添加用户消息 + 占位 AI 消息
setMessages(prev => [
...prev,
{ role: 'user', content: userMessage, timestamp: new Date() },
{ role: 'assistant', content: '', toolCalls: [], streaming: true, _id: msgId, timestamp: new Date() },
]);
const requestBody: { session_id?: string; message: string; context?: Record<string, unknown> } = {
message: userMessage,
};
if (patientContext) requestBody.context = patientContext;
const res = await agentApi.chat(selectedAgent, requestBody as Parameters<typeof agentApi.chat>[1]);
const agentData = res.data;
if (sessionId) requestBody.session_id = sessionId;
if (patientContext) requestBody.context = patientContext as Record<string, unknown>;
// 如果服务端分配了新 session_id 就更新
if (agentData?.session_id && !sessionId) {
setSessionId(agentData.session_id);
abortRef.current = agentApi.chatStream(agentId, requestBody, {
onSession: ({ session_id }) => {
if (!sessionId) setSessionId(session_id);
},
onThinking: ({ iteration }) => {
setStreamingStatus(`第${iteration}轮推理中...`);
},
onToolCall: ({ tool_name, arguments: args, call_id }) => {
setStreamingStatus(`调用工具: ${tool_name}`);
setMessages(prev => prev.map(m =>
m._id === msgId
? {
...m,
toolCalls: [...(m.toolCalls || []), {
tool_name,
call_id,
arguments: args,
result: { success: false },
success: false,
}],
}
setMessages(prev => [...prev, {
role: 'assistant',
content: agentData?.response || '暂时无法回答,请稍后重试。',
toolCalls: agentData?.tool_calls,
meta: {
iterations: agentData?.iterations,
tokens: agentData?.total_tokens,
agent_id: selectedAgent,
: m
));
},
timestamp: new Date(),
}]);
} catch {
setMessages(prev => [...prev, {
role: 'assistant',
content: '请求失败,请稍后重试。',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
onToolResult: ({ call_id, success }) => {
setMessages(prev => prev.map(m =>
m._id === msgId
? {
...m,
toolCalls: (m.toolCalls || []).map(tc =>
tc.call_id === call_id ? { ...tc, success, result: { success } } : tc
),
}
: m
));
},
onChunk: (content) => {
setStreamingStatus('');
setMessages(prev => prev.map(m =>
m._id === msgId ? { ...m, content: m.content + content } : m
));
},
onDone: ({ iterations, total_tokens }) => {
setMessages(prev => prev.map(m =>
m._id === msgId
? {
...m,
streaming: false,
meta: { iterations, tokens: total_tokens, agent_id: agentId },
}
: m
));
setLoading(false);
setStreamingStatus('');
abortRef.current = null;
},
onError: (error) => {
setMessages(prev => prev.map(m =>
m._id === msgId
? { ...m, content: m.content || error || '请求失败,请稍后重试。', streaming: false }
: m
));
setLoading(false);
setStreamingStatus('');
abortRef.current = null;
},
});
};
const handleAgentChange = (agentId: string) => {
setSelectedAgent(agentId);
setSessionId(''); // 切换 Agent 时重置会话,由服务端创建新会话
const agent = agents.find(a => a.id === agentId);
setMessages([{
role: 'system',
content: `已切换到「${agent?.name}」。${agent?.description}`,
timestamp: new Date(),
}]);
const handleNewChat = () => {
stopStreaming();
setSessionId('');
showWelcome();
};
const quickItems = role === 'patient'
? ['头痛', '发热', '咳嗽', '腹痛', '乏力', '失眠']
: ['鉴别诊断建议', '用药方案推荐', '检查项目建议'];
const renderToolCalls = (toolCalls?: ToolCall[]) => {
const renderToolCalls = (toolCalls?: ToolCall[], isStreaming?: boolean) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<Collapse
size="small"
className="mt-2"
defaultActiveKey={isStreaming ? ['tools'] : undefined}
style={{ background: '#f9fafb', borderRadius: 8 }}
items={[{
key: 'tools',
label: (
<span style={{ fontSize: 12, color: '#6b7280' }}>
<ToolOutlined style={{ marginRight: 4 }} />
调用了 {toolCalls.length} 个工具
{isStreaming ? '正在调用工具...' : `调用了 ${toolCalls.length} 个工具`}
</span>
),
children: (
<Timeline
style={{ marginTop: 8, marginBottom: 0 }}
items={toolCalls.map((tc, idx) => ({
color: tc.success ? 'green' : 'red',
color: tc.success ? 'green' : isStreaming ? 'blue' : 'red',
dot: tc.success
? <CheckCircleOutlined style={{ fontSize: 12 }} />
: isStreaming && idx === toolCalls.length - 1
? <LoadingOutlined style={{ fontSize: 12 }} />
: <CloseCircleOutlined style={{ fontSize: 12 }} />,
children: (
<div key={idx} style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500, color: '#374151' }}>{tc.tool_name}</div>
<div style={{ color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 240 }}>
<div style={{ color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>
参数: {tc.arguments}
</div>
</div>
......@@ -183,12 +251,13 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const renderMessage = (msg: ChatMessage, index: number) => {
if (msg.role === 'system') {
const sysBg = role === 'patient' ? '#f0fdf4' : role === 'doctor' ? '#eff6ff' : '#faf5ff';
const sysColor = role === 'patient' ? '#16a34a' : role === 'doctor' ? '#2563eb' : '#7c3aed';
return (
<div key={index} style={{ display: 'flex', justifyContent: 'center', margin: '12px 0' }}>
<div style={{
background: role === 'patient' ? '#f0fdf4' : '#eff6ff',
color: role === 'patient' ? '#16a34a' : '#2563eb',
fontSize: 12, padding: '6px 16px', borderRadius: 20,
background: sysBg, color: sysColor,
fontSize: 12, padding: '6px 16px', borderRadius: 20, maxWidth: '90%', textAlign: 'center',
}}>
<RobotOutlined style={{ marginRight: 4 }} />{msg.content}
</div>
......@@ -198,7 +267,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const isUser = msg.role === 'user';
const bubbleColor = isUser
? (role === 'patient' ? 'linear-gradient(135deg, #3b82f6, #2563eb)' : 'linear-gradient(135deg, #3b82f6, #1d4ed8)')
? 'linear-gradient(135deg, #3b82f6, #2563eb)'
: '#ffffff';
return (
......@@ -206,18 +275,13 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
{!isUser && (
<div style={{
width: 32, height: 32, borderRadius: '50%', flexShrink: 0, marginRight: 8,
background: role === 'patient'
? 'linear-gradient(135deg, #22c55e, #06b6d4)'
: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
background: theme.gradient,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{role === 'patient'
? <HeartOutlined style={{ color: '#fff', fontSize: 14 }} />
: <RobotOutlined style={{ color: '#fff', fontSize: 14 }} />
}
{ROLE_ICONS[role]}
</div>
)}
<div style={{ maxWidth: '78%' }}>
<div style={{ maxWidth: '78%', minWidth: 0 }}>
<div style={{
padding: '10px 14px', borderRadius: 16, fontSize: 13, lineHeight: 1.6,
background: bubbleColor, color: isUser ? '#fff' : '#374151',
......@@ -225,19 +289,36 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
boxShadow: isUser ? 'none' : '0 1px 3px rgba(0,0,0,0.06)',
borderBottomRightRadius: isUser ? 4 : 16,
borderBottomLeftRadius: isUser ? 16 : 4,
overflow: 'hidden',
}}>
{msg.content}
{isUser ? msg.content : (
msg.content ? (
<>
<MarkdownRenderer content={msg.content} fontSize={13} lineHeight={1.6} />
{msg.streaming && (
<span style={{
display: 'inline-block', width: 6, height: 14,
background: '#3b82f6', marginLeft: 2,
animation: 'blink 1s step-end infinite',
verticalAlign: 'text-bottom',
}} />
)}
</>
) : msg.streaming ? (
<span style={{ color: '#9ca3af', fontSize: 13 }}>
<Spin size="small" style={{ marginRight: 8 }} />
{streamingStatus || 'AI思考中...'}
</span>
) : null
)}
</div>
{!isUser && msg.meta && (
{!isUser && msg.meta && !msg.streaming && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4, paddingLeft: 4 }}>
<ThunderboltOutlined style={{ marginRight: 2 }} />
{msg.meta.iterations || 0}次推理 · {msg.meta.tokens || 0} tokens
{msg.meta.agent_id && (
<Tag style={{ marginLeft: 4, fontSize: 10 }} color="processing">{msg.meta.agent_id}</Tag>
)}
</div>
)}
{!isUser && renderToolCalls(msg.toolCalls)}
{!isUser && renderToolCalls(msg.toolCalls, msg.streaming)}
</div>
{isUser && (
<div style={{
......@@ -253,26 +334,25 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Agent选择器 */}
{/* Header */}
<div style={{
padding: '8px 12px', borderBottom: '1px solid #f3f4f6',
display: 'flex', alignItems: 'center', gap: 8,
background: role === 'patient' ? '#f0fdf4' : '#eff6ff',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
background: role === 'patient' ? '#f0fdf4' : role === 'doctor' ? '#eff6ff' : '#faf5ff',
}}>
<SwapOutlined style={{ color: '#6b7280', fontSize: 12 }} />
<Select
<span style={{ fontSize: 13, fontWeight: 500, color: '#374151' }}>{agentName}</span>
<Tooltip title="新对话">
<Button
size="small"
value={selectedAgent}
onChange={handleAgentChange}
style={{ flex: 1 }}
options={agents.map(a => ({
value: a.id,
label: <span style={{ fontSize: 12 }}>{a.name} - {a.description}</span>,
}))}
type="text"
icon={<ClearOutlined style={{ fontSize: 13 }} />}
onClick={handleNewChat}
style={{ color: '#6b7280' }}
/>
</Tooltip>
</div>
{/* 患者上下文 */}
{/* Patient context (doctor only) */}
{role === 'doctor' && patientContext?.patientName && (
<div style={{
padding: '6px 12px', background: '#eff6ff', borderBottom: '1px solid #dbeafe',
......@@ -284,33 +364,13 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
</div>
)}
{/* 消息列表 */}
{/* Messages */}
<div style={{ flex: 1, overflowY: 'auto', padding: 12, background: '#fafafa' }}>
{messages.map((msg, idx) => renderMessage(msg, idx))}
{loading && (
<div style={{ display: 'flex', justifyContent: 'flex-start', marginBottom: 16 }}>
<div style={{
width: 32, height: 32, borderRadius: '50%', flexShrink: 0, marginRight: 8,
background: role === 'patient'
? 'linear-gradient(135deg, #22c55e, #06b6d4)'
: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<RobotOutlined style={{ color: '#fff', fontSize: 14 }} />
</div>
<div style={{
background: '#fff', border: '1px solid #f3f4f6', padding: '10px 14px',
borderRadius: 16, borderBottomLeftRadius: 4, boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
}}>
<Spin size="small" />
<span style={{ marginLeft: 8, color: '#9ca3af', fontSize: 13 }}>AI思考中...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 快捷回复 */}
{/* Quick replies */}
<div style={{ padding: '6px 12px', borderTop: '1px solid #f3f4f6', background: '#fff' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{quickItems.map((text, idx) => (
......@@ -319,31 +379,44 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
style={{ cursor: 'pointer', fontSize: 12, margin: 0 }}
onClick={() => setInputValue(prev => prev ? `${prev}、${text}` : text)}
>
{role === 'patient' ? <HeartOutlined style={{ marginRight: 2 }} /> : <BulbOutlined style={{ marginRight: 2 }} />}
{QUICK_ICON[role]}
{text}
</Tag>
))}
</div>
</div>
{/* 输入区域 */}
{/* Input area */}
<div style={{ padding: '10px 12px', borderTop: '1px solid #e5e7eb', background: '#fff' }}>
<div style={{ display: 'flex', gap: 8 }}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onPressEnter={e => { if (!e.shiftKey) { e.preventDefault(); sendMessage(); } }}
placeholder={role === 'patient' ? '请描述您的症状...' : '描述症状或输入问题...'}
placeholder={
role === 'patient' ? '请描述您的症状...'
: role === 'doctor' ? '描述症状或输入问题...'
: '输入您的问题...'
}
autoSize={{ minRows: 1, maxRows: 3 }}
style={{ flex: 1, borderRadius: 8 }}
disabled={loading}
/>
{loading ? (
<Button
danger
icon={<PauseCircleOutlined />}
onClick={stopStreaming}
style={{ alignSelf: 'flex-end', borderRadius: 8 }}
/>
) : (
<Button
type="primary"
icon={<SendOutlined />}
onClick={sendMessage}
loading={loading}
style={{ alignSelf: 'flex-end', borderRadius: 8 }}
/>
)}
</div>
{role === 'doctor' && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4, textAlign: 'center' }}>
......@@ -351,6 +424,13 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
</div>
)}
</div>
{/* Blinking cursor CSS */}
<style>{`
@keyframes blink {
50% { opacity: 0; }
}
`}</style>
</div>
);
};
......
'use client';
import React from 'react';
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { Tooltip } from 'antd';
import {
RobotOutlined,
CloseOutlined,
HeartOutlined,
MessageOutlined,
MinusOutlined,
ExpandOutlined,
CompressOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { usePathname } from 'next/navigation';
import { Rnd } from 'react-rnd';
import { useUserStore } from '../../store/userStore';
import { useAIAssistStore } from '../../store/aiAssistStore';
import ChatPanel from './ChatPanel';
import type { WidgetRole } from './types';
import { ROLE_THEME } from './types';
const DRAG_HANDLE_CLASS = 'ai-widget-drag-handle';
const FloatContainer: React.FC = () => {
const { user } = useUserStore();
const { isOpen, patientContext, openDrawer, closeDrawer } = useAIAssistStore();
const pathname = usePathname();
const {
isOpen, isFullscreen, bounds,
patientContext,
openWidget, closeWidget, minimizeWidget, toggleFullscreen, setBounds,
} = useAIAssistStore();
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
const role: WidgetRole | null = useMemo(() => {
if (!user) return null;
if (user.role === 'patient') return 'patient';
if (user.role === 'doctor') return 'doctor';
if (user.role === 'admin') return 'admin';
return null;
}, [user]);
const hidden = useMemo(() => {
if (!role) return true;
return false;
}, [role]);
const theme = role ? ROLE_THEME[role] : ROLE_THEME.patient;
const handleDragStop = useCallback((_e: unknown, d: { x: number; y: number }) => {
setBounds({ x: d.x, y: d.y });
}, [setBounds]);
const isDoctor = user.role === 'doctor';
const isPatient = user.role === 'patient';
if (!isDoctor && !isPatient) return null;
const handleResizeStop = useCallback((_e: unknown, _dir: unknown, ref: HTMLElement, _delta: unknown, position: { x: number; y: number }) => {
setBounds({
width: parseInt(ref.style.width, 10),
height: parseInt(ref.style.height, 10),
x: position.x,
y: position.y,
});
}, [setBounds]);
// Hide on doctor consult page — it has its own integrated AI panel
if (isDoctor && pathname?.includes('/doctor/consult')) return null;
const roleIcon = useMemo(() => {
if (role === 'patient') return <HeartOutlined style={{ color: '#fff', fontSize: 22 }} />;
if (role === 'admin') return <SettingOutlined style={{ color: '#fff', fontSize: 22 }} />;
return <RobotOutlined style={{ color: '#fff', fontSize: 22 }} />;
}, [role]);
const primaryColor = isPatient
? 'linear-gradient(135deg, #22c55e, #06b6d4)'
: 'linear-gradient(135deg, #3b82f6, #8b5cf6)';
const roleIconSmall = useMemo(() => {
if (role === 'patient') return <HeartOutlined style={{ color: '#fff', fontSize: 18 }} />;
if (role === 'admin') return <SettingOutlined style={{ color: '#fff', fontSize: 18 }} />;
return <RobotOutlined style={{ color: '#fff', fontSize: 18 }} />;
}, [role]);
const title = isPatient ? 'AI智能助手' : 'AI诊断助手';
const subtitle = isPatient ? '智能预问诊 · 健康咨询' : '辅助诊断 · 处方审核';
if (!mounted || hidden) return null;
// ==================== Minimized: floating circle button ====================
if (!isOpen) {
return (
<>
{/* 悬浮按钮 - 右侧固定 */}
{!isOpen && (
<Tooltip title={title} placement="left">
<Tooltip title={theme.label} placement="left">
<div
onClick={() => openDrawer(patientContext || undefined)}
onClick={() => openWidget(patientContext || undefined)}
className="ai-float-btn"
style={{
position: 'fixed',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
right: 24,
bottom: 32,
zIndex: 1001,
width: 48,
height: 96,
background: primaryColor,
borderRadius: '12px 0 0 12px',
width: 56,
height: 56,
borderRadius: '50%',
background: theme.gradient,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
boxShadow: '-2px 0 12px rgba(0,0,0,0.15)',
transition: 'all 0.3s ease',
boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
transition: 'transform 0.3s cubic-bezier(0.4,0,0.2,1), box-shadow 0.3s',
}}
onMouseEnter={e => {
e.currentTarget.style.transform = 'scale(1.1)';
e.currentTarget.style.boxShadow = '0 6px 28px rgba(0,0,0,0.28)';
}}
onMouseLeave={e => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(0,0,0,0.2)';
}}
onMouseEnter={e => { e.currentTarget.style.width = '56px'; }}
onMouseLeave={e => { e.currentTarget.style.width = '48px'; }}
>
{isPatient
? <HeartOutlined style={{ color: '#fff', fontSize: 20 }} />
: <RobotOutlined style={{ color: '#fff', fontSize: 20 }} />
}
<div style={{
color: '#fff', fontSize: 10, writingMode: 'vertical-rl',
letterSpacing: 2, fontWeight: 500,
}}>
{isPatient ? 'AI助手' : 'AI诊断'}
</div>
{roleIcon}
</div>
</Tooltip>
)}
);
}
{/* 展开的对话面板 */}
{isOpen && (
// ==================== Fullscreen mode ====================
if (isFullscreen) {
return (
<div style={{
position: 'fixed',
right: 0,
top: 0,
bottom: 0,
width: 400,
zIndex: 1002,
inset: 0,
zIndex: 1050,
background: '#fff',
boxShadow: '-4px 0 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.3s ease',
borderLeft: '1px solid #e5e7eb',
transition: 'all 0.3s cubic-bezier(0.4,0,0.2,1)',
}}>
{/* 头部 */}
{/* Header */}
<div style={{
padding: '12px 16px',
background: primaryColor,
padding: '10px 16px',
background: theme.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
......@@ -107,55 +138,117 @@ const FloatContainer: React.FC = () => {
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isPatient
? <MessageOutlined style={{ color: '#fff', fontSize: 18 }} />
: <RobotOutlined style={{ color: '#fff', fontSize: 18 }} />
}
{roleIconSmall}
</div>
<div>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 14 }}>{title}</div>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 11 }}>{subtitle}</div>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 15 }}>{theme.label}</div>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 11 }}>{theme.subtitle}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<HeaderBtn icon={<CompressOutlined />} onClick={toggleFullscreen} title="退出全屏" />
<HeaderBtn icon={<MinusOutlined />} onClick={minimizeWidget} title="最小化" />
<HeaderBtn icon={<CloseOutlined />} onClick={closeWidget} title="关闭" />
</div>
</div>
{/* Chat */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<ChatPanel role={role!} patientContext={role === 'doctor' ? (patientContext || undefined) : undefined} />
</div>
</div>
);
}
// ==================== Expanded: draggable + resizable ====================
return (
<Rnd
style={{
zIndex: 1050,
display: 'flex',
flexDirection: 'column',
borderRadius: 12,
overflow: 'hidden',
boxShadow: '0 8px 40px rgba(0,0,0,0.18)',
border: '1px solid #e5e7eb',
background: '#fff',
transition: 'box-shadow 0.2s',
}}
size={{ width: bounds.width, height: bounds.height }}
position={{ x: bounds.x < 0 ? (typeof window !== 'undefined' ? window.innerWidth - bounds.width - 20 : 800) : bounds.x, y: bounds.y }}
minWidth={320}
minHeight={400}
dragHandleClassName={DRAG_HANDLE_CLASS}
onDragStop={handleDragStop}
onResizeStop={handleResizeStop}
bounds="window"
enableResizing={{
top: true, right: true, bottom: true, left: true,
topRight: true, bottomRight: true, bottomLeft: true, topLeft: true,
}}
>
{/* Header — drag handle */}
<div
onClick={() => closeDrawer()}
className={DRAG_HANDLE_CLASS}
style={{
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'background 0.2s',
padding: '10px 14px',
background: theme.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'grab',
flexShrink: 0,
userSelect: 'none',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.3)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.2)'; }}
>
<CloseOutlined style={{ color: '#fff', fontSize: 14 }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 34, height: 34, borderRadius: 10,
background: 'rgba(255,255,255,0.2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{roleIconSmall}
</div>
<div>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 14 }}>{theme.label}</div>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 11 }}>{theme.subtitle}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<HeaderBtn icon={<ExpandOutlined />} onClick={toggleFullscreen} title="全屏" />
<HeaderBtn icon={<MinusOutlined />} onClick={minimizeWidget} title="最小化" />
<HeaderBtn icon={<CloseOutlined />} onClick={closeWidget} title="关闭" />
</div>
</div>
{/* 聊天面板 */}
<div style={{ flex: 1, overflow: 'hidden' }}>
{/* Chat panel */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<ChatPanel
role={isPatient ? 'patient' : 'doctor'}
patientContext={isDoctor ? (patientContext || undefined) : undefined}
role={role!}
patientContext={role === 'doctor' ? (patientContext || undefined) : undefined}
/>
</div>
</div>
)}
</Rnd>
);
};
{/* 遮罩层 */}
{isOpen && (
// ---------- Small header button ----------
const HeaderBtn: React.FC<{ icon: React.ReactNode; onClick: () => void; title: string }> = ({ icon, onClick, title }) => (
<Tooltip title={title} mouseEnterDelay={0.4}>
<div
onClick={() => closeDrawer()}
onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{
position: 'fixed',
inset: 0,
zIndex: 1001,
background: 'rgba(0,0,0,0.1)',
width: 28, height: 28, borderRadius: '50%',
background: 'rgba(255,255,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', transition: 'background 0.2s',
fontSize: 13, color: '#fff',
}}
/>
)}
</>
);
};
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.3)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
>
{icon}
</div>
</Tooltip>
);
export default FloatContainer;
......@@ -12,21 +12,51 @@ export interface ChatMessage {
toolCalls?: ToolCall[];
meta?: { iterations?: number; tokens?: number; agent_id?: string; workflow_id?: string };
timestamp: Date;
streaming?: boolean;
_id?: number;
}
export interface AgentOption {
id: string;
name: string;
description: string;
icon: string;
export interface PageContext {
pathname: string;
pageTitle?: string;
pageData?: Record<string, unknown>;
}
export const PATIENT_AGENTS: AgentOption[] = [
{ id: 'pre_consult_agent', name: '预问诊助手', description: '智能收集症状,生成预问诊报告', icon: 'heart' },
{ id: 'follow_up_agent', name: '随访助手', description: '用药提醒、复诊管理', icon: 'calendar' },
];
export type WidgetRole = 'patient' | 'doctor' | 'admin';
export const DOCTOR_AGENTS: AgentOption[] = [
{ id: 'diagnosis_agent', name: '诊断辅助', description: '鉴别诊断、检查建议', icon: 'robot' },
{ id: 'prescription_agent', name: '处方审核', description: '药物相互作用、禁忌症检查', icon: 'medicine' },
];
// 每个角色对应唯一的通用智能体,无需手动选择
export const ROLE_AGENT_ID: Record<WidgetRole, string> = {
patient: 'patient_universal_agent',
doctor: 'doctor_universal_agent',
admin: 'admin_universal_agent',
};
export const ROLE_AGENT_NAME: Record<WidgetRole, string> = {
patient: '患者智能助手',
doctor: '医生智能助手',
admin: '管理员智能助手',
};
export const ROLE_THEME: Record<WidgetRole, { gradient: string; label: string; subtitle: string }> = {
patient: {
gradient: 'linear-gradient(135deg, #22c55e, #06b6d4)',
label: 'AI智能助手',
subtitle: '预问诊 · 找医生 · 健康咨询',
},
doctor: {
gradient: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
label: 'AI诊断助手',
subtitle: '辅助诊断 · 处方审核 · 病历生成',
},
admin: {
gradient: 'linear-gradient(135deg, #8b5cf6, #ec4899)',
label: 'AI管理助手',
subtitle: '运营分析 · 工作流 · 平台管理',
},
};
export const QUICK_ITEMS: Record<WidgetRole, string[]> = {
patient: ['头痛', '发热', '咳嗽', '腹痛', '乏力', '失眠'],
doctor: ['鉴别诊断建议', '用药方案推荐', '检查项目建议'],
admin: ['今日运营概况', 'Agent运行状态', '异常告警排查'],
};
'use client';
import React from 'react';
import AIAssistDrawer from '../AIAssistDrawer';
import PreConsultDrawer from '../PreConsultDrawer';
import GlobalAIButton from '../GlobalAIButton';
import { useAIAssistStore } from '../../store/aiAssistStore';
import { usePreConsultStore } from '../../store/preConsultStore';
interface GlobalProvidersProps {
children: React.ReactNode;
}
const GlobalProviders: React.FC<GlobalProvidersProps> = ({ children }) => {
const { isOpen: aiAssistOpen, closeDrawer: closeAIAssist, patientContext } = useAIAssistStore();
const { isOpen: preConsultOpen, closeDrawer: closePreConsult, setReport } = usePreConsultStore();
return (
<>
{children}
{/* 全局AI助手浮动按钮 */}
<GlobalAIButton />
{/* AI诊断助手抽屉(医生端) */}
<AIAssistDrawer
open={aiAssistOpen}
onClose={closeAIAssist}
patientContext={patientContext || undefined}
/>
{/* 智能预问诊抽屉(患者端) */}
<PreConsultDrawer
open={preConsultOpen}
onClose={closePreConsult}
onComplete={(report) => {
setReport(report);
closePreConsult();
}}
/>
</>
);
};
export default GlobalProviders;
'use client';
import React, { useState, useRef, useEffect } from 'react';
import { Drawer, Input, Button, Space, Tag, Spin, Typography, Progress, Steps } from 'antd';
import {
MessageOutlined,
SendOutlined,
HeartOutlined,
MedicineBoxOutlined,
FileTextOutlined,
CheckCircleOutlined,
UserOutlined,
} from '@ant-design/icons';
import { useUserStore } from '../../store/userStore';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const API = process.env.NEXT_PUBLIC_API_URL || '';
interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: Date;
}
interface PreConsultDrawerProps {
open: boolean;
onClose: () => void;
onComplete?: (report: PreConsultReport) => void;
}
interface PreConsultReport {
symptoms: string[];
duration: string;
severity: string;
medical_history: string;
recommended_department: string;
summary: string;
}
const PreConsultDrawer: React.FC<PreConsultDrawerProps> = ({ open, onClose, onComplete }) => {
const { accessToken: token } = useUserStore();
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [sessionId] = useState(() => crypto.randomUUID());
const [progress, setProgress] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const steps = [
{ title: '症状描述', icon: <HeartOutlined /> },
{ title: '病史采集', icon: <FileTextOutlined /> },
{ title: '生成报告', icon: <CheckCircleOutlined /> },
];
useEffect(() => {
if (open && messages.length === 0) {
setMessages([{
role: 'assistant',
content: '您好!我是智能预问诊助手。请告诉我您目前有什么不舒服的症状?',
timestamp: new Date(),
}]);
}
}, [open]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const sendMessage = async () => {
if (!inputValue.trim() || loading) return;
const userMessage = inputValue.trim();
setInputValue('');
setMessages(prev => [...prev, { role: 'user', content: userMessage, timestamp: new Date() }]);
setLoading(true);
try {
const res = await fetch(`${API}/api/v1/agent/pre_consult_agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
session_id: sessionId,
message: userMessage,
}),
});
if (res.ok) {
const data = await res.json();
const agentData = data.data;
// 更新进度
const newProgress = Math.min(progress + 20, 100);
setProgress(newProgress);
if (newProgress >= 40 && currentStep < 1) setCurrentStep(1);
if (newProgress >= 80 && currentStep < 2) setCurrentStep(2);
// 检查是否完成预问诊
if (agentData?.report) {
onComplete?.(agentData.report);
}
setMessages(prev => [...prev, {
role: 'assistant',
content: agentData?.response || '请继续描述您的症状。',
timestamp: new Date(),
}]);
} else {
setMessages(prev => [...prev, {
role: 'assistant',
content: '请求失败,请稍后重试。',
timestamp: new Date(),
}]);
}
} catch {
setMessages(prev => [...prev, {
role: 'assistant',
content: '网络错误,请检查网络连接。',
timestamp: new Date(),
}]);
} finally {
setLoading(false);
}
};
const quickReplies = [
'头痛',
'发热',
'咳嗽',
'腹痛',
'乏力',
'失眠',
];
const renderMessage = (msg: Message, index: number) => {
const isUser = msg.role === 'user';
return (
<div key={index} className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`}>
{!isUser && (
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-green-400 to-cyan-500 flex items-center justify-center mr-2 shrink-0">
<MedicineBoxOutlined className="text-white text-sm" />
</div>
)}
<div className={`max-w-[75%]`}>
<div
className={`px-4 py-3 rounded-2xl text-sm ${
isUser
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-br-md'
: 'bg-white border border-gray-100 text-gray-700 rounded-bl-md shadow-sm'
}`}
>
{msg.content}
</div>
</div>
{isUser && (
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center ml-2 shrink-0">
<UserOutlined className="text-blue-500 text-sm" />
</div>
)}
</div>
);
};
return (
<Drawer
title={
<Space>
<div className="w-8 h-8 bg-gradient-to-r from-green-400 to-cyan-500 rounded-lg flex items-center justify-center">
<MessageOutlined className="text-white text-lg" />
</div>
<div>
<div className="font-semibold text-gray-800">智能预问诊</div>
<div className="text-xs text-gray-400 font-normal">AI助手帮您快速描述症状</div>
</div>
</Space>
}
placement="right"
width={400}
open={open}
onClose={onClose}
styles={{
body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
}}
>
{/* 进度指示 */}
<div className="px-4 py-3 bg-gradient-to-r from-green-50 to-cyan-50 border-b">
<Steps
size="small"
current={currentStep}
items={steps.map(s => ({ title: s.title, icon: s.icon }))}
className="mb-2"
/>
<Progress percent={progress} size="small" strokeColor={{ from: '#52c41a', to: '#13c2c2' }} />
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 bg-gray-50">
{messages.map((msg, idx) => renderMessage(msg, idx))}
{loading && (
<div className="flex justify-start mb-4">
<div className="w-8 h-8 rounded-full bg-gradient-to-r from-green-400 to-cyan-500 flex items-center justify-center mr-2">
<MedicineBoxOutlined className="text-white text-sm" />
</div>
<div className="bg-white border border-gray-100 px-4 py-3 rounded-2xl rounded-bl-md shadow-sm">
<Spin size="small" />
<span className="ml-2 text-gray-400 text-sm">正在分析...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 快捷回复 */}
<div className="px-4 py-2 border-t border-gray-100 bg-white">
<div className="text-xs text-gray-400 mb-2">常见症状:</div>
<div className="flex flex-wrap gap-2">
{quickReplies.map((text, idx) => (
<Tag
key={idx}
className="cursor-pointer hover:bg-green-50 hover:border-green-300 transition-colors"
onClick={() => setInputValue(prev => prev ? `${prev}、${text}` : text)}
>
{text}
</Tag>
))}
</div>
</div>
{/* 输入区域 */}
<div className="p-4 border-t border-gray-200 bg-white">
<div className="flex gap-2">
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onPressEnter={e => {
if (!e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="请描述您的症状..."
autoSize={{ minRows: 1, maxRows: 3 }}
className="flex-1"
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={sendMessage}
loading={loading}
className="self-end bg-gradient-to-r from-green-500 to-cyan-500 border-none"
>
发送
</Button>
</div>
</div>
</Drawer>
);
};
export default PreConsultDrawer;
......@@ -57,10 +57,9 @@ const NODE_CONFIGS: { type: NodeType; label: string; icon: React.ReactNode; colo
];
const AGENTS = [
{ value: 'pre_consult_agent', label: '预问诊Agent' },
{ value: 'diagnosis_agent', label: '诊断辅助Agent' },
{ value: 'prescription_agent', label: '处方审核Agent' },
{ value: 'follow_up_agent', label: '随访管理Agent' },
{ value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'admin_universal_agent', label: '管理员智能助手' },
];
const TOOLS = [
......
......@@ -62,10 +62,9 @@ const NODE_TYPES: { type: NodeType; label: string; icon: React.ReactNode; color:
];
const AGENTS = [
{ value: 'pre_consult_agent', label: '预问诊Agent' },
{ value: 'diagnosis_agent', label: '诊断辅助Agent' },
{ value: 'prescription_agent', label: '处方审核Agent' },
{ value: 'follow_up_agent', label: '随访管理Agent' },
{ value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'admin_universal_agent', label: '管理员智能助手' },
];
const TOOLS = [
......
import { useEffect, useRef, useState, useCallback } from 'react';
import type { WSMessage } from '../api/websocket';
import type { ConsultMessage } from '../api/consult';
interface UseConsultWebSocketOptions {
consultId: string | undefined; // 支持 consult_id 或 serial_number
userId: string;
userType: 'patient' | 'doctor';
token: string;
onNewMessage?: (msg: ConsultMessage) => void;
onConsultUpdate?: (status: string) => void;
onNewPatient?: (consultId: string, patientName: string) => void;
}
interface UseConsultWebSocketReturn {
isConnected: boolean;
typingUser: string | null;
sendMessage: (content: string, contentType?: string) => void;
sendTyping: () => void;
markRead: (messageId: string) => void;
}
export function useConsultWebSocket({
consultId,
userId,
userType,
token,
onNewMessage,
onConsultUpdate,
onNewPatient,
}: UseConsultWebSocketOptions): UseConsultWebSocketReturn {
const wsRef = useRef<WebSocket | null>(null);
const reconnectRef = useRef<NodeJS.Timeout | null>(null);
const pingRef = useRef<NodeJS.Timeout | null>(null);
const typingTimerRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [typingUser, setTypingUser] = useState<string | null>(null);
// 稳定化回调引用
const onNewMessageRef = useRef(onNewMessage);
const onConsultUpdateRef = useRef(onConsultUpdate);
const onNewPatientRef = useRef(onNewPatient);
useEffect(() => { onNewMessageRef.current = onNewMessage; }, [onNewMessage]);
useEffect(() => { onConsultUpdateRef.current = onConsultUpdate; }, [onConsultUpdate]);
useEffect(() => { onNewPatientRef.current = onNewPatient; }, [onNewPatient]);
const connect = useCallback(() => {
if (!consultId || !userId) return;
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = process.env.NEXT_PUBLIC_WS_URL || `${wsProtocol}//${window.location.host}`;
const wsUrl = `${wsHost}/api/v1/ws/consult/${consultId}?user_id=${userId}&user_type=${userType}&token=${token}`;
try {
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
// 心跳
pingRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
};
ws.onmessage = (event) => {
try {
const msg: WSMessage = JSON.parse(event.data);
switch (msg.type) {
case 'message': {
const cm: ConsultMessage = {
id: msg.message_id || '',
consult_id: msg.consult_id,
sender_type: msg.sender_type as ConsultMessage['sender_type'],
content: msg.content || '',
content_type: (msg.content_type || 'text') as ConsultMessage['content_type'],
created_at: msg.timestamp,
};
onNewMessageRef.current?.(cm);
break;
}
case 'system': {
const cm: ConsultMessage = {
id: msg.message_id || `sys-${Date.now()}`,
consult_id: msg.consult_id,
sender_type: 'system',
content: msg.content || '',
content_type: 'text',
created_at: msg.timestamp,
};
onNewMessageRef.current?.(cm);
break;
}
case 'typing':
if (msg.sender_id !== userId) {
setTypingUser(msg.sender_type === 'doctor' ? '医生' : '患者');
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => setTypingUser(null), 3000);
}
break;
case 'read':
// 可扩展:更新消息已读状态
break;
case 'consult_update':
onConsultUpdateRef.current?.(msg.content || '');
break;
case 'new_patient':
onNewPatientRef.current?.(msg.consult_id, msg.content || '');
break;
}
} catch {
// 解析失败静默
}
};
ws.onclose = () => {
setIsConnected(false);
if (pingRef.current) clearInterval(pingRef.current);
};
ws.onerror = () => {
// 将在 onclose 后触发重连
};
} catch {
// 连接失败
}
}, [consultId, userId, userType, token]);
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (pingRef.current) clearInterval(pingRef.current);
if (reconnectRef.current) clearTimeout(reconnectRef.current);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
setIsConnected(false);
};
}, [connect]);
const sendMessage = useCallback((content: string, contentType: string = 'text') => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'message',
content,
content_type: contentType,
}));
}
}, []);
const sendTyping = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'typing' }));
}
}, []);
const markRead = useCallback((messageId: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'read', message_id: messageId }));
}
}, []);
return { isConnected, typingUser, sendMessage, sendTyping, markRead };
}
......@@ -230,7 +230,7 @@ const AdminPatientsPage: React.FC = () => {
</Card>
<Modal title={modalType === 'add' ? '添加患者' : '编辑患者'} open={modalVisible}
onOk={handleModalSubmit} onCancel={() => setModalVisible(false)} confirmLoading={modalLoading} destroyOnClose width={440}>
onOk={handleModalSubmit} onCancel={() => setModalVisible(false)} confirmLoading={modalLoading} destroyOnHidden width={440}>
<Form form={modalForm} layout="vertical" size="small">
<Form.Item name="real_name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="患者姓名" />
......
......@@ -99,7 +99,7 @@ const AIAssistPage: React.FC = () => {
try {
// 调用后端诊断Agent
const res = await fetch(`${API}/api/v1/agent/diagnosis_agent/chat`, {
const res = await fetch(`${API}/api/v1/agent/doctor_universal_agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
......
import React, { useState, useEffect } from 'react';
import {
Tabs, Typography, Space, Empty, Tag, Alert, Spin, Badge, Button,
Collapse, Timeline, Divider,
Collapse, Timeline, Divider, message,
} from 'antd';
import {
RobotOutlined, FileTextOutlined, UserOutlined,
MessageOutlined, MedicineBoxOutlined, ThunderboltOutlined,
ToolOutlined, CheckCircleOutlined, CloseCircleOutlined,
ExperimentOutlined, FormOutlined, CalendarOutlined, OrderedListOutlined,
CopyOutlined, SendOutlined,
} from '@ant-design/icons';
import type { PreConsultResponse, ChatMessage } from '../../../api/preConsult';
import type { ToolCall } from '../../../api/agent';
......@@ -15,6 +17,15 @@ import MarkdownRenderer from '../../../components/MarkdownRenderer';
const { Text } = Typography;
type AIScene = 'consult_diagnosis' | 'consult_medication' | 'consult_lab_advice'
| 'consult_medical_record' | 'consult_follow_up' | 'consult_order_template';
interface SceneState {
content: string;
loading: boolean;
toolCalls: ToolCall[];
}
interface AIPanelProps {
hasActiveConsult: boolean;
activeConsultId?: string;
......@@ -34,39 +45,36 @@ const AIPanel: React.FC<AIPanelProps> = ({
onMedicationChange,
patientMessageTrigger,
}) => {
const [diagnosisContent, setDiagnosisContent] = useState('');
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [diagnosisToolCalls, setDiagnosisToolCalls] = useState<ToolCall[]>([]);
const [medicationContent, setMedicationContent] = useState('');
const [medicationLoading, setMedicationLoading] = useState(false);
const [medicationToolCalls, setMedicationToolCalls] = useState<ToolCall[]>([]);
const [scenes, setScenes] = useState<Record<string, SceneState>>({});
const [preConsultSubTab, setPreConsultSubTab] = useState('chat');
const handleAIAssist = async (scene: 'consult_diagnosis' | 'consult_medication') => {
if (!activeConsultId) return;
const isDiag = scene === 'consult_diagnosis';
const setLoading = isDiag ? setDiagnosisLoading : setMedicationLoading;
const setContent = isDiag ? setDiagnosisContent : setMedicationContent;
const setToolCalls = isDiag ? setDiagnosisToolCalls : setMedicationToolCalls;
const onChange = isDiag ? onDiagnosisChange : onMedicationChange;
const getScene = (scene: string): SceneState =>
scenes[scene] || { content: '', loading: false, toolCalls: [] };
const updateScene = (scene: string, update: Partial<SceneState>) => {
setScenes(prev => ({
...prev,
[scene]: { ...getScene(scene), ...update },
}));
};
setLoading(true);
const handleAIAssist = async (scene: AIScene) => {
if (!activeConsultId) return;
updateScene(scene, { loading: true });
try {
const res = await consultApi.aiAssist(activeConsultId, scene);
const text = res.data?.response || '暂无分析结果';
setContent(text);
setToolCalls(res.data?.tool_calls || []);
onChange?.(text);
const toolCalls = res.data?.tool_calls || [];
updateScene(scene, { content: text, toolCalls, loading: false });
if (scene === 'consult_diagnosis') onDiagnosisChange?.(text);
if (scene === 'consult_medication') onMedicationChange?.(text);
} catch (err: unknown) {
const text = 'AI分析失败: ' + ((err as Error)?.message || '请稍后重试');
setContent(text);
setToolCalls([]);
} finally {
setLoading(false);
updateScene(scene, { content: text, toolCalls: [], loading: false });
}
};
// Auto-trigger both AI analyses whenever patient sends a new message
// Auto-trigger diagnosis + medication when patient sends new message
useEffect(() => {
if (!patientMessageTrigger || !activeConsultId) return;
handleAIAssist('consult_diagnosis');
......@@ -120,74 +128,65 @@ const AIPanel: React.FC<AIPanelProps> = ({
);
};
const handleCopyContent = (text: string) => {
navigator.clipboard.writeText(text).then(() => message.success('已复制到剪贴板'));
};
const renderAIAssistContent = (
loading: boolean,
content: string,
toolCalls: ToolCall[],
scene: 'consult_diagnosis' | 'consult_medication',
scene: AIScene,
extra?: React.ReactNode,
) => {
if (loading) {
const state = getScene(scene);
if (state.loading) {
return (
<div style={{ textAlign: 'center', padding: '32px 16px' }}>
<Spin tip="AI分析中,请稍候..." />
</div>
);
}
if (content) {
if (state.content) {
return (
<div>
<div style={{ maxHeight: 360, overflow: 'auto', padding: '4px 0' }}>
<MarkdownRenderer content={content} fontSize={12} lineHeight={1.6} />
<MarkdownRenderer content={state.content} fontSize={12} lineHeight={1.6} />
</div>
{renderToolCalls(toolCalls)}
{toolCalls.length > 0 && (
{renderToolCalls(state.toolCalls)}
{state.toolCalls.length > 0 && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, display: 'flex', alignItems: 'center', gap: 3 }}>
<ThunderboltOutlined />
通过 {toolCalls.length} 次工具调用生成
通过 {state.toolCalls.length} 次工具调用生成
</div>
)}
<Divider style={{ margin: '10px 0' }} />
<Space size={6}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => handleAIAssist(scene)}
>
<Space size={6} wrap>
<Button size="small" icon={<ThunderboltOutlined />} onClick={() => handleAIAssist(scene)}>
重新分析
</Button>
{scene === 'consult_medication' && (
<Button
size="small"
type="primary"
ghost
icon={<MedicineBoxOutlined />}
onClick={() => {
// Medication content is already synced to parent via onMedicationChange
// The prescription modal reads it from aiMedication prop
}}
>
已同步至处方
<Button size="small" icon={<CopyOutlined />} onClick={() => handleCopyContent(state.content)}>
复制
</Button>
)}
{extra}
</Space>
</div>
);
}
const sceneLabels: Record<string, { emptyText: string; buttonText: string; icon: React.ReactNode }> = {
consult_diagnosis: { emptyText: '患者回复后将自动生成鉴别诊断', buttonText: '立即分析', icon: <ThunderboltOutlined /> },
consult_medication: { emptyText: '患者回复后将自动生成用药建议', buttonText: '立即分析', icon: <MedicineBoxOutlined /> },
consult_lab_advice: { emptyText: '点击按钮获取检查建议', buttonText: '生成检查建议', icon: <ExperimentOutlined /> },
consult_medical_record: { emptyText: '根据对话记录自动生成门诊病历', buttonText: '生成病历', icon: <FormOutlined /> },
consult_follow_up: { emptyText: '根据诊疗情况制定随访计划', buttonText: '生成随访计划', icon: <CalendarOutlined /> },
consult_order_template: { emptyText: '生成结构化医嘱模板', buttonText: '生成医嘱', icon: <OrderedListOutlined /> },
};
const info = sceneLabels[scene] || { emptyText: '点击按钮分析', buttonText: '立即分析', icon: <ThunderboltOutlined /> };
return (
<div style={{ textAlign: 'center', padding: '28px 16px' }}>
<RobotOutlined style={{ fontSize: 36, color: '#d9d9d9', display: 'block', marginBottom: 12 }} />
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 14, lineHeight: 1.6 }}>
{scene === 'consult_diagnosis'
? '患者回复后将自动生成鉴别诊断,也可手动触发'
: '患者回复后将自动生成用药建议,也可手动触发'}
{info.emptyText}
</Text>
<Button
size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
style={scene === 'consult_medication' ? { color: '#722ed1', borderColor: '#722ed1' } : {}}
>
立即分析
<Button size="small" icon={info.icon} onClick={() => handleAIAssist(scene)}>
{info.buttonText}
</Button>
</div>
);
......@@ -229,7 +228,6 @@ const AIPanel: React.FC<AIPanelProps> = ({
if (!preConsultReport) return null;
return (
<div>
{/* 患者信息 */}
<div style={{ padding: '8px 10px', background: '#f9f0ff', borderRadius: 6, marginBottom: 8 }}>
<Text strong style={{ fontSize: 13, color: '#722ed1' }}>
<UserOutlined style={{ marginRight: 4 }} />
......@@ -242,8 +240,6 @@ const AIPanel: React.FC<AIPanelProps> = ({
</Text>
)}
</div>
{/* 严重程度 + 推荐科室 */}
{(preConsultReport.ai_severity || preConsultReport.ai_department) && (
<Space size={4} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
{preConsultReport.ai_severity && (
......@@ -258,16 +254,12 @@ const AIPanel: React.FC<AIPanelProps> = ({
{preConsultReport.ai_department && <Tag color="blue">{preConsultReport.ai_department}</Tag>}
</Space>
)}
{/* 主诉 */}
{preConsultReport.chief_complaint && (
<div style={{ fontSize: 12, marginBottom: 10 }}>
<Text type="secondary">主诉:</Text>
{preConsultReport.chief_complaint}
</div>
)}
{/* 子标签:对话 vs 报告 */}
<Tabs
size="small"
activeKey={preConsultSubTab}
......@@ -295,6 +287,9 @@ const AIPanel: React.FC<AIPanelProps> = ({
);
};
const diagState = getScene('consult_diagnosis');
const medState = getScene('consult_medication');
return (
<div style={{
height: '100%',
......@@ -328,6 +323,7 @@ const AIPanel: React.FC<AIPanelProps> = ({
size="small"
defaultActiveKey={hasPreConsultData ? 'preConsult' : 'diagnosis'}
style={{ padding: '0 8px' }}
tabBarStyle={{ marginBottom: 8 }}
items={[
{
key: 'preConsult',
......@@ -348,23 +344,52 @@ const AIPanel: React.FC<AIPanelProps> = ({
},
{
key: 'diagnosis',
label: '鉴别诊断',
children: renderAIAssistContent(
diagnosisLoading, diagnosisContent, diagnosisToolCalls, 'consult_diagnosis'
label: (
<span>
诊断
{diagState.content && <Badge dot offset={[3, -2]} />}
</span>
),
children: renderAIAssistContent('consult_diagnosis'),
},
{
key: 'drugs',
label: (
<span>
用药建议
{medicationContent && <Badge dot offset={[3, -2]} style={{ backgroundColor: '#722ed1' }} />}
用药
{medState.content && <Badge dot offset={[3, -2]} style={{ backgroundColor: '#722ed1' }} />}
</span>
),
children: renderAIAssistContent(
medicationLoading, medicationContent, medicationToolCalls, 'consult_medication'
children: renderAIAssistContent('consult_medication',
<Button size="small" type="primary" ghost icon={<MedicineBoxOutlined />}>
已同步至处方
</Button>
),
},
{
key: 'lab',
label: <span><ExperimentOutlined /> 检查</span>,
children: renderAIAssistContent('consult_lab_advice'),
},
{
key: 'record',
label: <span><FormOutlined /> 病历</span>,
children: renderAIAssistContent('consult_medical_record'),
},
{
key: 'followup',
label: <span><CalendarOutlined /> 随访</span>,
children: renderAIAssistContent('consult_follow_up',
<Button size="small" type="primary" ghost icon={<SendOutlined />}>
发送给患者
</Button>
),
},
{
key: 'order',
label: <span><OrderedListOutlined /> 医嘱</span>,
children: renderAIAssistContent('consult_order_template'),
},
]}
/>
) : (
......
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Avatar, Tag, Button, Typography, Space, Empty, Input, Modal, Tooltip, Popover,
Avatar, Tag, Button, Typography, Space, Empty, Input, Tooltip, Popover, Tabs, Spin,
Image, Upload, message as antdMessage,
} from 'antd';
import {
UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined,
FileTextOutlined, ThunderboltOutlined, SmileOutlined,
FileTextOutlined, ThunderboltOutlined, SmileOutlined, RobotOutlined,
SettingOutlined, PictureOutlined, PaperClipOutlined,
DownloadOutlined, PlayCircleOutlined, SwapOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import type { ConsultMessage } from '../../../api/consult';
import { consultApi } from '../../../api/consult';
import { doctorPortalApi } from '../../../api/doctorPortal';
import type { QuickReplyTemplate } from '../../../api/doctorPortal';
import PrescriptionModal from '../Prescription';
import TransferModal from './TransferModal';
import EndConsultModal from './EndConsultModal';
const { Text } = Typography;
const { TextArea } = Input;
const QUICK_REPLIES = [
'请问您现在的主要症状是什么?',
'症状持续多久了?',
'有没有发烧、咳嗽等其他伴随症状?',
'既往有什么基础疾病吗?',
'目前正在服用什么药物?',
'您好,请详细描述一下您的不适情况。',
'根据您的描述,初步考虑以下方向...',
'建议您先做以下检查,结果出来后我再为您进一步诊疗。',
'您的情况不严重,注意休息、多喝水,一般3-5天会好转。',
'如果症状明显加重,请及时前往线下医院就诊。',
'我这边为您开具处方,请到附近药房取药并按时服用。',
// 系统级默认快捷回复(fallback)
const DEFAULT_REPLIES = [
{ category: 'greeting', content: '您好,请详细描述一下您的不适情况。' },
{ category: 'inquiry', content: '请问您现在的主要症状是什么?' },
{ category: 'inquiry', content: '症状持续多久了?' },
{ category: 'inquiry', content: '有没有发烧、咳嗽等其他伴随症状?' },
{ category: 'inquiry', content: '既往有什么基础疾病吗?' },
{ category: 'inquiry', content: '目前正在服用什么药物?' },
{ category: 'diagnosis', content: '根据您的描述,初步考虑以下方向...' },
{ category: 'diagnosis', content: '建议您先做以下检查,结果出来后我再为您进一步诊疗。' },
{ category: 'closing', content: '您的情况不严重,注意休息、多喝水,一般3-5天会好转。' },
{ category: 'closing', content: '如果症状明显加重,请及时前往线下医院就诊。' },
{ category: 'prescription', content: '我这边为您开具处方,请到附近药房取药并按时服用。' },
];
const CATEGORY_LABELS: Record<string, string> = {
greeting: '问候',
inquiry: '询问',
diagnosis: '诊断',
prescription: '处方',
closing: '结束',
};
interface ActiveConsult {
consult_id: string;
serial_number: string;
patient_id?: string;
patient_name: string;
patient_gender?: string;
......@@ -35,16 +53,20 @@ interface ActiveConsult {
chief_complaint?: string;
type: 'text' | 'video';
status?: string;
started_at?: string | null;
}
interface ChatPanelProps {
activeConsult: ActiveConsult | null;
messages: ConsultMessage[];
onSend: (content: string) => void;
onEndConsult: () => void;
onEndConsult: (data?: { diagnosis: string; summary: string; follow_up_days: number; send_survey: boolean }) => void;
sending: boolean;
aiDiagnosis?: string;
aiMedication?: string;
onOpenProfile?: (patientId: string) => void;
typingUser?: string | null;
onTyping?: () => void;
}
const ChatPanel: React.FC<ChatPanelProps> = ({
......@@ -55,12 +77,60 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
sending,
aiDiagnosis,
aiMedication,
onOpenProfile,
typingUser,
onTyping,
}) => {
const [inputValue, setInputValue] = useState('');
const [prescriptionOpen, setPrescriptionOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [endConsultOpen, setEndConsultOpen] = useState(false);
const [endConsultLoading, setEndConsultLoading] = useState(false);
const [quickReplyOpen, setQuickReplyOpen] = useState(false);
const [quickReplies, setQuickReplies] = useState<QuickReplyTemplate[]>([]);
const [aiSuggestions, setAiSuggestions] = useState<string[]>([]);
const [aiSugLoading, setAiSugLoading] = useState(false);
const [qrCategory, setQrCategory] = useState<string>('all');
const [timerSeconds, setTimerSeconds] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
// 问诊计时器
useEffect(() => {
if (!activeConsult?.started_at || isCompleted) {
setTimerSeconds(0);
return;
}
const startTime = new Date(activeConsult.started_at).getTime();
const updateTimer = () => {
setTimerSeconds(Math.max(0, Math.floor((Date.now() - startTime) / 1000)));
};
updateTimer();
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [activeConsult?.started_at, activeConsult?.status]);
const formatTimer = (seconds: number) => {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
// 加载快捷回复模板
const loadQuickReplies = useCallback(async () => {
try {
const res = await doctorPortalApi.listQuickReplies();
setQuickReplies(res.data || []);
} catch {
// 静默失败,使用默认回复
}
}, []);
useEffect(() => {
loadQuickReplies();
}, [loadQuickReplies]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
......@@ -71,20 +141,48 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
setInputValue('');
};
const handleQuickReply = (text: string) => {
const handleQuickReply = (text: string, templateId?: number) => {
onSend(text);
setQuickReplyOpen(false);
if (templateId) {
doctorPortalApi.incrementQuickReplyUse(templateId).catch(() => {});
}
};
const handleQuickReplyEdit = (text: string) => {
setInputValue(prev => prev ? `${prev}\n${text}` : text);
setQuickReplyOpen(false);
};
const handleAISuggest = async () => {
if (!activeConsult?.serial_number) return;
setAiSugLoading(true);
try {
const res = await doctorPortalApi.aiSuggestReplies(activeConsult.serial_number);
const lines = (res.data?.suggestions || '')
.split('\n')
.map((l: string) => l.trim())
.filter((l: string) => l.length > 0);
setAiSuggestions(lines);
} catch {
setAiSuggestions([]);
} finally {
setAiSugLoading(false);
}
};
const handleEndConsult = () => {
Modal.confirm({
title: '确认结束问诊',
content: '结束后患者将无法继续发送消息,确认结束本次问诊?',
okText: '确认结束',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: onEndConsult,
});
setEndConsultOpen(true);
};
const handleEndConsultConfirm = async (data: { diagnosis: string; summary: string; follow_up_days: number; send_survey: boolean }) => {
setEndConsultLoading(true);
try {
await onEndConsult(data);
setEndConsultOpen(false);
} finally {
setEndConsultLoading(false);
}
};
const formatTime = (timeStr: string) => {
......@@ -97,32 +195,165 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
const isCompleted = activeConsult?.status === 'completed';
const handleUpload = async (file: File) => {
if (!activeConsult?.serial_number) return;
try {
const res = await consultApi.uploadMedia(activeConsult.serial_number, file);
const { media_url, media_type } = res.data;
// 发送媒体消息
await consultApi.sendMessage(activeConsult.serial_number, media_url, media_type === 'image' ? 'image' : media_type === 'audio' ? 'audio' : 'file');
} catch {
antdMessage.error('上传失败');
}
};
const renderMessageContent = (msg: ConsultMessage) => {
const ct = msg.content_type || 'text';
const mediaUrl = msg.media_url || msg.content;
switch (ct) {
case 'image':
return (
<Image
src={mediaUrl}
alt="图片"
style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8 }}
placeholder
/>
);
case 'file':
return (
<div style={{
padding: '8px 12px', background: '#f5f5f5', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>文件</div>
<div style={{ fontSize: 11, color: '#999' }}>点击下载</div>
</div>
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<DownloadOutlined style={{ fontSize: 16, color: '#1890ff' }} />
</a>
</div>
);
case 'audio':
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlayCircleOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<audio controls src={mediaUrl} style={{ height: 32 }} />
</div>
);
case 'prescription':
return (
<div style={{
padding: '8px 12px', background: '#fffbe6', borderRadius: 8,
border: '1px solid #ffe58f',
}}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#d48806' }}>
<FileTextOutlined style={{ marginRight: 4 }} />处方
</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>
{msg.content}
</div>
</div>
);
default:
return msg.content;
}
};
// 合并 API 回复 + 默认回复
const allReplies = quickReplies.length > 0
? quickReplies
: DEFAULT_REPLIES.map((r, i) => ({ id: -(i + 1), doctor_id: '', category: r.category, title: '', content: r.content, sort_order: i, use_count: 0, created_at: '', updated_at: '' } as QuickReplyTemplate));
const filteredReplies = qrCategory === 'all'
? allReplies
: allReplies.filter(r => r.category === qrCategory);
const categories = ['all', ...Array.from(new Set(allReplies.map(r => r.category)))];
const quickReplyContent = (
<div style={{ width: 290 }}>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 8, fontWeight: 500 }}>快捷回复模板</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{QUICK_REPLIES.map((text, i) => (
<div style={{ width: 320, maxHeight: 420, overflow: 'auto' }}>
{/* AI 推荐区 */}
<div style={{ padding: '6px 0 8px', borderBottom: '1px solid #f0f0f0', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 12, color: '#fa8c16', fontWeight: 500 }}>
<RobotOutlined style={{ marginRight: 4 }} />AI 智能推荐
</span>
<Button type="link" size="small" onClick={handleAISuggest} loading={aiSugLoading} style={{ fontSize: 11, padding: 0 }}>
{aiSuggestions.length > 0 ? '刷新' : '生成推荐'}
</Button>
</div>
{aiSugLoading ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : aiSuggestions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{aiSuggestions.map((text, i) => (
<div
key={i}
onClick={() => handleQuickReply(text)}
onDoubleClick={() => handleQuickReplyEdit(text)}
style={{
padding: '7px 10px',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
color: '#374151',
background: '#f9fafb',
border: '1px solid #f0f0f0',
lineHeight: 1.5,
transition: 'background 0.15s',
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#fff7e6', border: '1px solid #ffe58f',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff1b8'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff7e6'; }}
>
<ThunderboltOutlined style={{ color: '#fa8c16', marginRight: 4, fontSize: 10 }} />
{text}
</div>
))}
</div>
) : (
<div style={{ fontSize: 11, color: '#bbb', textAlign: 'center', padding: 4 }}>
点击"生成推荐"获取智能回复
</div>
)}
</div>
{/* 分类标签 */}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{categories.map(cat => (
<Tag
key={cat}
color={qrCategory === cat ? 'blue' : undefined}
style={{ cursor: 'pointer', fontSize: 11, margin: 0 }}
onClick={() => setQrCategory(cat)}
>
{cat === 'all' ? '全部' : (CATEGORY_LABELS[cat] || cat)}
</Tag>
))}
</div>
{/* 模板列表 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{filteredReplies.map(tpl => (
<div
key={tpl.id}
onClick={() => handleQuickReply(tpl.content, tpl.id > 0 ? tpl.id : undefined)}
onDoubleClick={() => handleQuickReplyEdit(tpl.content)}
title="单击发送,双击填入编辑框"
style={{
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#f9fafb', border: '1px solid #f0f0f0',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#e6f7ff'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#f9fafb'; }}
>
{text}
{tpl.content}
</div>
))}
</div>
{/* 提示 */}
<div style={{ fontSize: 10, color: '#bbb', marginTop: 6, textAlign: 'center' }}>
单击直接发送 · 双击填入编辑框
</div>
</div>
);
......@@ -169,6 +400,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
}
</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
<span style={{ color: '#1890ff', fontFamily: 'monospace', marginRight: 4 }}>{activeConsult.serial_number}</span>
{activeConsult.patient_gender && `${activeConsult.patient_gender} · `}
{activeConsult.patient_age && `${activeConsult.patient_age}岁 · `}
{activeConsult.chief_complaint
......@@ -178,11 +410,41 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</div>
</div>
<Space size={6}>
{/* 问诊计时器 */}
{activeConsult.started_at && activeConsult.status === 'in_progress' && (
<div style={{
display: 'flex', alignItems: 'center', gap: 4,
background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 6,
padding: '2px 10px', fontSize: 13, fontWeight: 500, color: '#52c41a',
fontVariantNumeric: 'tabular-nums',
}}>
<ClockCircleOutlined style={{ fontSize: 12 }} />
{formatTimer(timerSeconds)}
</div>
)}
{activeConsult.patient_id && (
<Button
size="small"
icon={<UserOutlined />}
onClick={() => onOpenProfile?.(activeConsult.patient_id!)}
>
画像
</Button>
)}
{activeConsult.type === 'video' && !isCompleted && (
<Button size="small" type="primary" ghost icon={<VideoCameraOutlined />}>
发起视频
</Button>
)}
{!isCompleted && (
<Button
size="small"
icon={<SwapOutlined />}
onClick={() => setTransferOpen(true)}
>
转诊
</Button>
)}
<Button
size="small"
icon={<FileTextOutlined />}
......@@ -262,8 +524,14 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
fontSize: 14,
lineHeight: 1.6,
}}>
{msg.content}
{renderMessageContent(msg)}
</div>
{/* 已读标记 */}
{msg.sender_type === 'doctor' && msg.read_at && (
<div style={{ fontSize: 10, color: '#52c41a', marginTop: 2, textAlign: 'right' }}>
✓✓ 已读
</div>
)}
</div>
)}
</div>
......@@ -287,6 +555,13 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</div>
) : (
<>
{/* 对方正在输入提示 */}
{typingUser && (
<div style={{ padding: '4px 12px', fontSize: 11, color: '#1890ff', background: '#e6f7ff' }}>
{typingUser}正在输入...
</div>
)}
{/* 工具栏 */}
<div style={{
padding: '6px 12px 0',
......@@ -310,6 +585,24 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
/>
</Tooltip>
</Popover>
<Upload
accept="image/jpeg,image/png,image/gif"
showUploadList={false}
beforeUpload={file => { handleUpload(file); return false; }}
>
<Tooltip title="发送图片">
<Button type="text" size="small" icon={<PictureOutlined />} style={{ color: '#1890ff', fontSize: 13 }} />
</Tooltip>
</Upload>
<Upload
accept="application/pdf"
showUploadList={false}
beforeUpload={file => { handleUpload(file); return false; }}
>
<Tooltip title="发送文件">
<Button type="text" size="small" icon={<PaperClipOutlined />} style={{ color: '#8c8c8c', fontSize: 13 }} />
</Tooltip>
</Upload>
<Tooltip title="表情(开发中)">
<Button
type="text"
......@@ -324,7 +617,10 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
<div style={{ padding: '8px 12px 10px', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<TextArea
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onChange={e => {
setInputValue(e.target.value);
onTyping?.();
}}
placeholder="输入消息,Enter 发送,Shift+Enter 换行..."
autoSize={{ minRows: 2, maxRows: 5 }}
onPressEnter={e => {
......@@ -362,7 +658,7 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
<PrescriptionModal
open={prescriptionOpen}
onClose={() => setPrescriptionOpen(false)}
consultId={activeConsult?.consult_id}
consultId={activeConsult?.serial_number}
patientId={activeConsult?.patient_id}
patientName={activeConsult?.patient_name}
patientGender={activeConsult?.patient_gender}
......@@ -370,6 +666,22 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
diagnosis={aiDiagnosis}
medicationSuggestion={aiMedication}
/>
{activeConsult && (
<TransferModal
open={transferOpen}
onClose={() => setTransferOpen(false)}
consultId={activeConsult.serial_number}
/>
)}
<EndConsultModal
open={endConsultOpen}
onClose={() => setEndConsultOpen(false)}
onConfirm={handleEndConsultConfirm}
loading={endConsultLoading}
aiDiagnosis={aiDiagnosis}
/>
</>
);
};
......
import React, { useState } from 'react';
import { Modal, Form, Input, InputNumber, Switch, Divider, Typography } from 'antd';
import { CheckCircleOutlined } from '@ant-design/icons';
const { Text } = Typography;
interface EndConsultModalProps {
open: boolean;
onClose: () => void;
onConfirm: (data: {
diagnosis: string;
summary: string;
follow_up_days: number;
send_survey: boolean;
}) => void;
loading?: boolean;
aiDiagnosis?: string;
}
const EndConsultModal: React.FC<EndConsultModalProps> = ({
open,
onClose,
onConfirm,
loading,
aiDiagnosis,
}) => {
const [form] = Form.useForm();
const [sendSurvey, setSendSurvey] = useState(true);
const handleOk = async () => {
try {
const values = await form.validateFields();
onConfirm({
diagnosis: values.diagnosis || '',
summary: values.summary || '',
follow_up_days: values.follow_up_days || 0,
send_survey: sendSurvey,
});
} catch {
// validation errors
}
};
const handleCancel = () => {
form.resetFields();
setSendSurvey(true);
onClose();
};
return (
<Modal
title={
<span>
<CheckCircleOutlined style={{ color: '#52c41a', marginRight: 8 }} />
结束问诊
</span>
}
open={open}
onOk={handleOk}
onCancel={handleCancel}
confirmLoading={loading}
okText="确认结束"
okButtonProps={{ danger: true }}
cancelText="取消"
width={520}
destroyOnHidden
>
<div style={{ marginBottom: 12, padding: '8px 12px', background: '#fffbe6', borderRadius: 6, fontSize: 12, color: '#ad8b00' }}>
结束后患者将无法继续发送消息。请填写诊断结论和问诊小结,方便后续复查。
</div>
<Form
form={form}
layout="vertical"
initialValues={{
diagnosis: aiDiagnosis || '',
follow_up_days: 0,
}}
>
<Form.Item
label="诊断结论"
name="diagnosis"
rules={[{ required: true, message: '请填写诊断结论' }]}
>
<Input.TextArea
rows={3}
placeholder="请输入诊断结论,如:上呼吸道感染"
maxLength={500}
showCount
/>
</Form.Item>
<Form.Item
label="问诊小结"
name="summary"
>
<Input.TextArea
rows={3}
placeholder="简要记录本次问诊要点(可选)"
maxLength={1000}
showCount
/>
</Form.Item>
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
label="随访天数"
name="follow_up_days"
style={{ flex: 1 }}
>
<InputNumber
min={0}
max={365}
style={{ width: '100%' }}
placeholder="0 表示不需要随访"
adapterLocale="zh-CN"
/>
</Form.Item>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 14 }}>发送满意度评价</Text>
</div>
<Switch
checked={sendSurvey}
onChange={setSendSurvey}
checkedChildren="发送"
unCheckedChildren="不发送"
/>
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 4 }}>
结束后向患者发送评价邀请
</div>
</div>
</div>
</Form>
</Modal>
);
};
export default EndConsultModal;
import React from 'react';
import { Typography, Tag, Button, Badge } from 'antd';
import React, { useState, useMemo } from 'react';
import { Typography, Tag, Button, Badge, Input, Select, Tooltip, Tabs } from 'antd';
import {
UserOutlined, MessageOutlined, VideoCameraOutlined,
CheckOutlined, CloseOutlined, ClockCircleOutlined,
AlertOutlined, ThunderboltOutlined, SearchOutlined,
MedicineBoxOutlined, ProfileOutlined,
} from '@ant-design/icons';
import type { PatientListItem } from '../../../api/consult';
......@@ -16,12 +18,21 @@ const formatWaitTime = (seconds: number): string => {
return `${seconds}秒`;
};
const riskColorMap: Record<string, string> = {
high: '#ff4d4f',
medium: '#fa8c16',
low: '#52c41a',
};
type SortMode = 'default' | 'wait_time' | 'risk' | 'revisit';
interface PatientListProps {
patients: PatientListItem[];
onSelectPatient: (patient: PatientListItem) => void;
onAccept: (patient: PatientListItem) => void;
onReject: (patient: PatientListItem) => void;
selectedConsultId?: string;
onOpenProfile?: (patientId: string) => void;
}
const PatientCard: React.FC<{
......@@ -30,13 +41,15 @@ const PatientCard: React.FC<{
onSelect: () => void;
onAccept: () => void;
onReject: () => void;
}> = ({ patient, isSelected, onSelect, onAccept, onReject }) => {
onOpenProfile?: () => void;
}> = ({ patient, isSelected, onSelect, onAccept, onReject, onOpenProfile }) => {
const isWaiting = patient.status === 'pending' || patient.status === 'waiting';
const isInProgress = patient.status === 'in_progress';
const isCompleted = patient.status === 'completed';
const avatarColor = isInProgress ? '#52c41a' : isWaiting ? '#fa8c16' : '#d9d9d9';
const avatarBg = isInProgress ? '#f6ffed' : isWaiting ? '#fff7e6' : '#fafafa';
const riskColor = riskColorMap[patient.risk_level] || '#52c41a';
return (
<div
......@@ -44,7 +57,7 @@ const PatientCard: React.FC<{
style={{
padding: '10px 12px',
background: isSelected ? '#e6f7ff' : '#fff',
borderLeft: `3px solid ${isSelected ? '#1890ff' : 'transparent'}`,
borderLeft: `3px solid ${isSelected ? '#1890ff' : riskColor}`,
borderBottom: '1px solid #f5f5f5',
cursor: isWaiting ? 'default' : 'pointer',
transition: 'background 0.15s',
......@@ -70,15 +83,32 @@ const PatientCard: React.FC<{
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 3 }}>
<Text strong style={{ fontSize: 13 }}>{patient.patient_name || '患者'}</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Text
strong
style={{ fontSize: 13, cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onOpenProfile?.(); }}
>
{patient.patient_name || '患者'}
</Text>
{patient.is_revisit && (
<Tooltip title={`复诊 · 第${patient.visit_count + 1}次就诊`}>
<ThunderboltOutlined style={{ fontSize: 11, color: '#1890ff' }} />
</Tooltip>
)}
</div>
{patient.type === 'video'
? <VideoCameraOutlined style={{ fontSize: 12, color: '#722ed1' }} />
: <MessageOutlined style={{ fontSize: 12, color: '#52c41a' }} />
}
</div>
<div style={{ fontSize: 10, color: '#1890ff', fontFamily: 'monospace', marginBottom: 2 }}>
{patient.serial_number}
</div>
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>
{patient.patient_gender && `${patient.patient_gender} · `}
{patient.patient_age ? `${patient.patient_age}岁` : ''}
{patient.visit_count > 0 && ` · 第${patient.visit_count + 1}次`}
</div>
<div style={{
fontSize: 11, color: '#666',
......@@ -86,6 +116,31 @@ const PatientCard: React.FC<{
}}>
{patient.chief_complaint || '暂无主诉'}
</div>
{/* 标签区域:过敏 + 慢病 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 3, marginTop: 4 }}>
{patient.allergy_history && (
<Tag color="red" style={{ fontSize: 10, lineHeight: '16px', margin: 0, padding: '0 4px' }}>
<AlertOutlined style={{ marginRight: 2 }} />过敏
</Tag>
)}
{patient.chronic_diseases?.slice(0, 2).map(d => (
<Tag key={d} color="orange" style={{ fontSize: 10, lineHeight: '16px', margin: 0, padding: '0 4px' }}>
{d.length > 4 ? d.slice(0, 4) + '..' : d}
</Tag>
))}
{patient.chronic_diseases?.length > 2 && (
<Tag style={{ fontSize: 10, lineHeight: '16px', margin: 0, padding: '0 4px' }}>
+{patient.chronic_diseases.length - 2}
</Tag>
)}
{patient.pre_consult_summary && (
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', margin: 0, padding: '0 4px' }}>
<ProfileOutlined style={{ marginRight: 2 }} />预问诊
</Tag>
)}
</div>
{isWaiting && patient.waiting_seconds > 0 && (
<div style={{ fontSize: 11, color: '#fa8c16', marginTop: 4, display: 'flex', alignItems: 'center', gap: 3 }}>
<ClockCircleOutlined />
......@@ -94,7 +149,7 @@ const PatientCard: React.FC<{
)}
{isCompleted && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 4 }}>
已完成
已完成{patient.last_visit_date ? ` · ${patient.last_visit_date}` : ''}
</div>
)}
</div>
......@@ -127,27 +182,71 @@ const PatientCard: React.FC<{
);
};
interface SectionProps { label: string; count: number; color: string }
const SectionHeader: React.FC<SectionProps> = ({ label, count, color }) => (
<div style={{
padding: '5px 12px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<Badge count={count} style={{ backgroundColor: color }} showZero />
<Text style={{ fontSize: 11, color: '#8c8c8c', fontWeight: 500 }}>{label}</Text>
</div>
);
const PatientList: React.FC<PatientListProps> = ({
patients, onSelectPatient, onAccept, onReject, selectedConsultId,
patients, onSelectPatient, onAccept, onReject, selectedConsultId, onOpenProfile,
}) => {
const waiting = patients.filter(p => p.status === 'pending' || p.status === 'waiting');
const inProgress = patients.filter(p => p.status === 'in_progress');
const completed = patients.filter(p => p.status === 'completed');
const [searchText, setSearchText] = useState('');
const [sortMode, setSortMode] = useState<SortMode>('default');
const [activeTab, setActiveTab] = useState('waiting');
// 筛选
const filtered = useMemo(() => {
if (!searchText.trim()) return patients;
const keyword = searchText.trim().toLowerCase();
return patients.filter(p =>
(p.patient_name || '').toLowerCase().includes(keyword) ||
(p.chief_complaint || '').toLowerCase().includes(keyword)
);
}, [patients, searchText]);
// 排序
const sorted = useMemo(() => {
if (sortMode === 'default') return filtered;
return [...filtered].sort((a, b) => {
if (sortMode === 'wait_time') return (b.waiting_seconds || 0) - (a.waiting_seconds || 0);
if (sortMode === 'risk') {
const riskOrder: Record<string, number> = { high: 3, medium: 2, low: 1 };
return (riskOrder[b.risk_level] || 0) - (riskOrder[a.risk_level] || 0);
}
if (sortMode === 'revisit') return (b.is_revisit ? 1 : 0) - (a.is_revisit ? 1 : 0);
return 0;
});
}, [filtered, sortMode]);
const waiting = sorted.filter(p => p.status === 'pending' || p.status === 'waiting');
const inProgress = sorted.filter(p => p.status === 'in_progress');
const completed = sorted.filter(p => p.status === 'completed');
const tabListMap: Record<string, PatientListItem[]> = {
waiting,
inProgress,
completed,
};
const currentList = tabListMap[activeTab] || [];
const renderCardList = (list: PatientListItem[]) => {
if (list.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#bbb' }}>
<UserOutlined style={{ fontSize: 28, display: 'block', marginBottom: 8 }} />
<div style={{ fontSize: 12 }}>
{searchText ? '没有匹配的患者' : '暂无患者'}
</div>
</div>
);
}
return list.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
onOpenProfile={() => onOpenProfile?.(p.patient_id)}
/>
));
};
return (
<div style={{
......@@ -159,76 +258,71 @@ const PatientList: React.FC<PatientListProps> = ({
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* 标题栏 */}
<div style={{
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
}}>
<UserOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 13 }}>患者列表</Text>
<Tag style={{ marginLeft: 'auto', fontSize: 11, margin: 0 }}>{patients.length} 人</Tag>
{/* Tabs */}
<div style={{ flexShrink: 0 }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
size="small"
centered
style={{ marginBottom: 0 }}
items={[
{
key: 'waiting',
label: (
<span style={{ fontSize: 12 }}>
候诊 <Badge count={waiting.length} size="small" style={{ backgroundColor: '#fa8c16' }} />
</span>
),
},
{
key: 'inProgress',
label: (
<span style={{ fontSize: 12 }}>
接诊 <Badge count={inProgress.length} size="small" style={{ backgroundColor: '#52c41a' }} />
</span>
),
},
{
key: 'completed',
label: (
<span style={{ fontSize: 12 }}>
已完成 <Badge count={completed.length} size="small" style={{ backgroundColor: '#8c8c8c' }} />
</span>
),
},
]}
/>
</div>
{/* 列表区域 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{patients.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0', color: '#bbb' }}>
<UserOutlined style={{ fontSize: 30, display: 'block', marginBottom: 10 }} />
<div style={{ fontSize: 12 }}>暂无患者</div>
</div>
) : (
<>
{waiting.length > 0 && (
<>
<SectionHeader label="候诊中" count={waiting.length} color="#fa8c16" />
{waiting.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
/>
))}
</>
)}
{inProgress.length > 0 && (
<>
<SectionHeader label="接诊中" count={inProgress.length} color="#52c41a" />
{inProgress.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
{/* 搜索 + 排序栏 */}
<div style={{ padding: '6px 10px', borderBottom: '1px solid #f5f5f5', flexShrink: 0 }}>
<Input
size="small"
placeholder="搜索姓名/主诉..."
prefix={<SearchOutlined style={{ color: '#bbb' }} />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
style={{ marginBottom: 4, borderRadius: 6 }}
/>
))}
</>
)}
{completed.length > 0 && (
<>
<SectionHeader label="已完成" count={completed.length} color="#8c8c8c" />
{completed.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
<Select
size="small"
value={sortMode}
onChange={setSortMode}
style={{ width: '100%', fontSize: 11 }}
options={[
{ value: 'default', label: '默认排序' },
{ value: 'wait_time', label: '等待时长优先' },
{ value: 'risk', label: '风险等级优先' },
{ value: 'revisit', label: '复诊优先' },
]}
/>
))}
</>
)}
</>
)}
</div>
{/* 列表区域 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{renderCardList(currentList)}
</div>
</div>
);
......
import React, { useEffect, useState } from 'react';
import {
Drawer, Tabs, Descriptions, Tag, Timeline, Table, Empty, Spin, Typography, Space, Alert,
} from 'antd';
import {
UserOutlined, FileTextOutlined, MedicineBoxOutlined, ExperimentOutlined,
HeartOutlined, TeamOutlined, AlertOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import { consultApi } from '../../../api/consult';
import type { PatientProfile } from '../../../api/consult';
const { Text } = Typography;
interface PatientProfileDrawerProps {
open: boolean;
patientId: string | null;
onClose: () => void;
}
const controlStatusMap: Record<string, { text: string; color: string }> = {
stable: { text: '稳定', color: 'green' },
unstable: { text: '不稳定', color: 'orange' },
worsening: { text: '恶化', color: 'red' },
};
const prescriptionStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待审核', color: 'orange' },
signed: { text: '已签名', color: 'blue' },
approved: { text: '已审核', color: 'green' },
rejected: { text: '已驳回', color: 'red' },
dispensed: { text: '已取药', color: 'cyan' },
completed: { text: '已完成', color: 'default' },
};
const metricTypeMap: Record<string, string> = {
blood_pressure: '血压',
blood_sugar: '血糖',
weight: '体重',
heart_rate: '心率',
temperature: '体温',
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleDateString('zh-CN');
} catch {
return dateStr;
}
};
const formatAmount = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
const PatientProfileDrawer: React.FC<PatientProfileDrawerProps> = ({ open, patientId, onClose }) => {
const [loading, setLoading] = useState(false);
const [profile, setProfile] = useState<PatientProfile | null>(null);
useEffect(() => {
if (!open || !patientId) {
setProfile(null);
return;
}
setLoading(true);
consultApi.getPatientProfile(patientId)
.then(res => setProfile(res.data))
.catch(() => setProfile(null))
.finally(() => setLoading(false));
}, [open, patientId]);
const renderBasicInfo = () => {
if (!profile) return null;
const { basic_info } = profile;
return (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="姓名">{basic_info.name || '-'}</Descriptions.Item>
<Descriptions.Item label="性别">{basic_info.gender || '-'}</Descriptions.Item>
<Descriptions.Item label="年龄">{basic_info.age ? `${basic_info.age}岁` : '-'}</Descriptions.Item>
<Descriptions.Item label="联系电话">{basic_info.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="医保类型">{basic_info.insurance_type || '-'}</Descriptions.Item>
<Descriptions.Item label="医保号">{basic_info.insurance_no || '-'}</Descriptions.Item>
</Descriptions>
{basic_info.allergy_history && (
<Alert
message={<><AlertOutlined /> 过敏史</>}
description={basic_info.allergy_history}
type="error"
showIcon={false}
style={{ marginBottom: 12, fontSize: 13 }}
/>
)}
{basic_info.medical_history && (
<Alert
message="既往病史"
description={basic_info.medical_history}
type="info"
showIcon={false}
style={{ fontSize: 13 }}
/>
)}
</div>
);
};
const renderConsultHistory = () => {
if (!profile?.consult_history?.length) return <Empty description="暂无就诊记录" />;
return (
<Timeline
items={profile.consult_history.map(c => ({
color: 'blue',
dot: <ClockCircleOutlined style={{ fontSize: 14 }} />,
children: (
<div style={{ fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 2 }}>
<Text strong>{formatDate(c.created_at)}</Text>
<Tag color={c.type === 'video' ? 'purple' : 'green'} style={{ fontSize: 10 }}>
{c.type === 'video' ? '视频' : '图文'}
</Tag>
</div>
<div style={{ color: '#666' }}>主诉:{c.chief_complaint || '-'}</div>
<div style={{ color: '#999' }}>医生:{c.doctor_name || '-'}</div>
</div>
),
}))}
/>
);
};
const renderPrescriptions = () => {
if (!profile?.prescriptions?.length) return <Empty description="暂无处方记录" />;
return (
<Table
dataSource={profile.prescriptions}
rowKey="id"
size="small"
pagination={false}
columns={[
{ title: '处方号', dataIndex: 'prescription_no', width: 120, ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11 }}>{v}</Text> },
{ title: '诊断', dataIndex: 'diagnosis', ellipsis: true,
render: (v: string) => <Text style={{ fontSize: 11 }}>{v || '-'}</Text> },
{ title: '药品', dataIndex: 'item_count', width: 50, align: 'center' as const,
render: (v: number) => `${v}种` },
{ title: '金额', dataIndex: 'total_amount', width: 70, align: 'right' as const,
render: (v: number) => formatAmount(v) },
{ title: '状态', dataIndex: 'status', width: 70,
render: (v: string) => {
const s = prescriptionStatusMap[v] || { text: v, color: 'default' };
return <Tag color={s.color} style={{ fontSize: 10, margin: 0 }}>{s.text}</Tag>;
} },
]}
/>
);
};
const renderChronicRecords = () => {
if (!profile?.chronic_records?.length) return <Empty description="暂无慢病记录" />;
return (
<Space direction="vertical" style={{ width: '100%' }} size={8}>
{profile.chronic_records.map(cr => {
const statusInfo = controlStatusMap[cr.control_status] || { text: cr.control_status, color: 'default' };
return (
<div key={cr.id} style={{
padding: '10px 12px', border: '1px solid #f0f0f0', borderRadius: 8, background: '#fafafa',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text strong style={{ fontSize: 13 }}>{cr.disease_name}</Text>
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
</div>
<div style={{ fontSize: 12, color: '#666' }}>
确诊时间:{cr.diagnosis_date ? formatDate(cr.diagnosis_date) : '未知'}
</div>
{cr.current_meds && (
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>
当前用药:{cr.current_meds}
</div>
)}
</div>
);
})}
</Space>
);
};
const renderLabReports = () => {
if (!profile?.lab_reports?.length) return <Empty description="暂无检查报告" />;
return (
<Table
dataSource={profile.lab_reports}
rowKey="id"
size="small"
pagination={false}
columns={[
{ title: '报告名称', dataIndex: 'title', ellipsis: true },
{ title: '类别', dataIndex: 'category', width: 70,
render: (v: string) => <Tag style={{ fontSize: 10, margin: 0 }}>{v}</Tag> },
{ title: '日期', dataIndex: 'report_date', width: 90,
render: (v: string | null) => formatDate(v) },
]}
/>
);
};
const renderHealthMetrics = () => {
if (!profile?.health_metrics?.length) return <Empty description="暂无健康指标" />;
// 按类型分组
const grouped: Record<string, typeof profile.health_metrics> = {};
profile.health_metrics.forEach(hm => {
if (!grouped[hm.metric_type]) grouped[hm.metric_type] = [];
grouped[hm.metric_type].push(hm);
});
return (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
{Object.entries(grouped).map(([type, metrics]) => (
<div key={type}>
<Text strong style={{ fontSize: 13, marginBottom: 6, display: 'block' }}>
{metricTypeMap[type] || type}
</Text>
<Table
dataSource={metrics.slice(0, 10)}
rowKey={(_, i) => `${type}-${i}`}
size="small"
pagination={false}
columns={[
{ title: '时间', dataIndex: 'recorded_at', width: 100,
render: (v: string) => formatDate(v) },
{ title: '数值', key: 'value', render: (_, r) =>
type === 'blood_pressure'
? `${r.value1}/${r.value2} ${r.unit}`
: `${r.value1} ${r.unit}`,
},
]}
/>
</div>
))}
</Space>
);
};
const renderFamilyHistory = () => {
if (!profile?.family_history?.length) return <Empty description="暂无家族史记录" />;
return (
<Space direction="vertical" style={{ width: '100%' }} size={6}>
{profile.family_history.map((fm, i) => (
<div key={i} style={{
padding: '8px 12px', border: '1px solid #f0f0f0', borderRadius: 6, background: '#fafafa',
}}>
<Text strong style={{ fontSize: 12 }}>{fm.name}</Text>
<Tag style={{ marginLeft: 6, fontSize: 10 }}>{fm.relation}</Tag>
{fm.medical_history && (
<div style={{ fontSize: 12, color: '#666', marginTop: 2 }}>
病史:{fm.medical_history}
</div>
)}
</div>
))}
</Space>
);
};
return (
<Drawer
title={
<Space>
<UserOutlined style={{ color: '#1890ff' }} />
<span>患者画像</span>
{profile?.basic_info?.name && (
<Tag color="blue">{profile.basic_info.name}</Tag>
)}
</Space>
}
open={open}
onClose={onClose}
width={480}
destroyOnHidden
>
{loading ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="加载患者信息..." />
</div>
) : !profile ? (
<Empty description="暂无患者信息" />
) : (
<Tabs
size="small"
defaultActiveKey="basic"
items={[
{
key: 'basic',
label: <span><UserOutlined /> 基本信息</span>,
children: renderBasicInfo(),
},
{
key: 'consult',
label: <span><FileTextOutlined /> 就诊记录</span>,
children: renderConsultHistory(),
},
{
key: 'prescription',
label: <span><MedicineBoxOutlined /> 处方历史</span>,
children: renderPrescriptions(),
},
{
key: 'lab',
label: <span><ExperimentOutlined /> 检查报告</span>,
children: renderLabReports(),
},
{
key: 'chronic',
label: <span><HeartOutlined /> 慢病管理</span>,
children: renderChronicRecords(),
},
{
key: 'health',
label: <span><HeartOutlined /> 健康指标</span>,
children: renderHealthMetrics(),
},
{
key: 'family',
label: <span><TeamOutlined /> 家族史</span>,
children: renderFamilyHistory(),
},
]}
/>
)}
</Drawer>
);
};
export default PatientProfileDrawer;
import React, { useState, useEffect } from 'react';
import { Modal, Form, Select, Input, message, Spin } from 'antd';
import { doctorApi, type Doctor, type Department } from '../../../api/doctor';
import { consultApi } from '../../../api/consult';
interface TransferModalProps {
open: boolean;
onClose: () => void;
consultId: string;
}
const TransferModal: React.FC<TransferModalProps> = ({ open, onClose, consultId }) => {
const [form] = Form.useForm();
const [departments, setDepartments] = useState<Department[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [selectedDept, setSelectedDept] = useState<string | undefined>();
useEffect(() => {
if (!open) return;
doctorApi.getDepartments().then(res => {
setDepartments(res.data || []);
}).catch(() => {});
}, [open]);
useEffect(() => {
if (!selectedDept) {
setDoctors([]);
return;
}
setLoading(true);
doctorApi.getDoctorList({ department_id: selectedDept, page: 1, page_size: 50 }).then(res => {
setDoctors(res.data?.list || []);
}).catch(() => {
setDoctors([]);
}).finally(() => setLoading(false));
}, [selectedDept]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
await consultApi.transferConsult(consultId, {
to_doctor_id: values.to_doctor_id,
to_department_id: values.to_department_id,
reason: values.reason,
transfer_note: values.transfer_note,
});
message.success('转诊请求已发送');
form.resetFields();
setSelectedDept(undefined);
onClose();
} catch (err: unknown) {
if ((err as { errorFields?: unknown })?.errorFields) return;
message.error((err as Error)?.message || '转诊失败');
} finally {
setSubmitting(false);
}
};
const handleCancel = () => {
form.resetFields();
setSelectedDept(undefined);
onClose();
};
return (
<Modal
title="发起转诊"
open={open}
onOk={handleSubmit}
onCancel={handleCancel}
confirmLoading={submitting}
okText="确认转诊"
cancelText="取消"
width={480}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item label="目标科室" name="to_department_id">
<Select
placeholder="选择科室(可选)"
allowClear
onChange={(val) => setSelectedDept(val)}
options={departments.map(d => ({ label: d.name, value: d.id }))}
/>
</Form.Item>
<Form.Item
label="目标医生"
name="to_doctor_id"
rules={[{ required: true, message: '请选择目标医生' }]}
>
<Select
placeholder={selectedDept ? '选择医生' : '请先选择科室'}
showSearch
optionFilterProp="label"
loading={loading}
notFoundContent={loading ? <Spin size="small" /> : '暂无医生'}
options={doctors.map(d => ({
label: `${d.name} · ${d.title}`,
value: d.id,
}))}
/>
</Form.Item>
<Form.Item
label="转诊原因"
name="reason"
rules={[{ required: true, message: '请填写转诊原因' }]}
>
<Input.TextArea rows={3} placeholder="请说明转诊原因" maxLength={500} showCount />
</Form.Item>
<Form.Item label="转诊备注" name="transfer_note">
<Input.TextArea rows={2} placeholder="可补充患者病情描述等信息(可选)" maxLength={500} showCount />
</Form.Item>
</Form>
</Modal>
);
};
export default TransferModal;
......@@ -2,16 +2,26 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { message, Modal } from 'antd';
import { ClockCircleOutlined, MessageOutlined, CheckCircleOutlined } from '@ant-design/icons';
import {
ClockCircleOutlined, MessageOutlined, CheckCircleOutlined,
DashboardOutlined, DownOutlined, UpOutlined,
FieldTimeOutlined, MedicineBoxOutlined, StarOutlined,
SwapOutlined, RobotOutlined,
} from '@ant-design/icons';
import { consultApi, type PatientListItem, type ConsultMessage } from '../../../api/consult';
import type { DoctorWorkbenchStats } from '../../../api/doctorPortal';
import { preConsultApi } from '../../../api/preConsult';
import type { PreConsultResponse } from '../../../api/preConsult';
import { useConsultWebSocket } from '../../../hooks/useConsultWebSocket';
import { useUserStore } from '../../../store/userStore';
import PatientList from './PatientList';
import ChatPanel from './ChatPanel';
import AIPanel from './AIPanel';
import PatientProfileDrawer from './PatientProfileDrawer';
interface ActiveConsult {
consult_id: string;
serial_number: string;
patient_id?: string;
patient_name: string;
patient_gender?: string;
......@@ -19,6 +29,7 @@ interface ActiveConsult {
chief_complaint?: string;
type: 'text' | 'video';
status?: string;
started_at?: string | null;
}
const ConsultPage: React.FC = () => {
......@@ -36,9 +47,17 @@ const ConsultPage: React.FC = () => {
const [patientMessageTrigger, setPatientMessageTrigger] = useState(0);
const lastPatientMsgIdRef = useRef<string | number | null>(null);
const waitingCount = patientList.filter(p => p.status === 'pending' || p.status === 'waiting').length;
const inProgressCount = patientList.filter(p => p.status === 'in_progress').length;
const completedCount = patientList.filter(p => p.status === 'completed').length;
// 患者画像抽屉
const [profileDrawerOpen, setProfileDrawerOpen] = useState(false);
const [profilePatientId, setProfilePatientId] = useState<string | null>(null);
// 效率看板
const [workbenchStats, setWorkbenchStats] = useState<DoctorWorkbenchStats | null>(null);
const [showEfficiency, setShowEfficiency] = useState(false);
// WebSocket
const { user, accessToken } = useUserStore();
const typingDebounceRef = useRef<NodeJS.Timeout | null>(null);
const fetchPatientList = useCallback(async () => {
try {
......@@ -49,6 +68,15 @@ const ConsultPage: React.FC = () => {
}
}, []);
const fetchWorkbenchStats = useCallback(async () => {
try {
const res = await consultApi.getDoctorWorkbenchStats();
setWorkbenchStats(res.data || null);
} catch {
// 静默失败
}
}, []);
const fetchMessages = useCallback(async (consultId: string) => {
try {
const res = await consultApi.getConsultMessages(consultId);
......@@ -58,18 +86,55 @@ const ConsultPage: React.FC = () => {
}
}, []);
const waitingCount = patientList.filter(p => p.status === 'pending' || p.status === 'waiting').length;
const inProgressCount = patientList.filter(p => p.status === 'in_progress').length;
const completedCount = patientList.filter(p => p.status === 'completed').length;
const handleWsNewMessage = useCallback((msg: ConsultMessage) => {
setMessages(prev => {
// 避免重复
if (prev.find(m => m.id === msg.id)) return prev;
return [...prev, msg];
});
}, []);
const handleWsConsultUpdate = useCallback(() => {
fetchPatientList();
}, [fetchPatientList]);
const { isConnected, typingUser, sendMessage: wsSendMessage, sendTyping, markRead } = useConsultWebSocket({
consultId: activeConsult?.serial_number,
userId: user?.id || '',
userType: 'doctor',
token: accessToken || '',
onNewMessage: handleWsNewMessage,
onConsultUpdate: handleWsConsultUpdate,
});
const handleTyping = useCallback(() => {
if (typingDebounceRef.current) clearTimeout(typingDebounceRef.current);
typingDebounceRef.current = setTimeout(() => {
sendTyping();
}, 1000);
}, [sendTyping]);
useEffect(() => {
fetchPatientList();
fetchWorkbenchStats();
const timer = setInterval(() => fetchPatientList(), 5000);
return () => clearInterval(timer);
}, [fetchPatientList]);
// 效率统计每30秒刷新一次
const statsTimer = setInterval(() => fetchWorkbenchStats(), 30000);
return () => { clearInterval(timer); clearInterval(statsTimer); };
}, [fetchPatientList, fetchWorkbenchStats]);
useEffect(() => {
if (!activeConsult) return;
fetchMessages(activeConsult.consult_id);
const timer = setInterval(() => fetchMessages(activeConsult.consult_id), 3000);
fetchMessages(activeConsult.serial_number);
// WebSocket 连接时使用较长的轮询间隔作为 fallback
const interval = isConnected ? 15000 : 3000;
const timer = setInterval(() => fetchMessages(activeConsult.serial_number), interval);
return () => clearInterval(timer);
}, [activeConsult, fetchMessages]);
}, [activeConsult, fetchMessages, isConnected]);
// Detect new patient messages and auto-trigger AI analysis
useEffect(() => {
......@@ -112,10 +177,11 @@ const ConsultPage: React.FC = () => {
const handleAccept = async (patient: PatientListItem) => {
try {
await consultApi.acceptConsult(patient.consult_id);
await consultApi.acceptConsult(patient.serial_number);
resetConsultState();
setActiveConsult({
consult_id: patient.consult_id,
serial_number: patient.serial_number,
patient_id: patient.patient_id,
patient_name: patient.patient_name || '患者',
patient_gender: patient.patient_gender,
......@@ -123,11 +189,12 @@ const ConsultPage: React.FC = () => {
chief_complaint: patient.chief_complaint,
type: patient.type,
status: 'in_progress',
started_at: new Date().toISOString(),
});
message.success(`已接诊患者 ${patient.patient_name || '未知'}`);
fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchPatientList();
fetchMessages(patient.consult_id);
fetchMessages(patient.serial_number);
} catch (err: unknown) {
message.error((err as Error)?.message || '接诊失败');
}
......@@ -137,6 +204,7 @@ const ConsultPage: React.FC = () => {
resetConsultState();
setActiveConsult({
consult_id: patient.consult_id,
serial_number: patient.serial_number,
patient_id: patient.patient_id,
patient_name: patient.patient_name || '患者',
patient_gender: patient.patient_gender,
......@@ -144,9 +212,10 @@ const ConsultPage: React.FC = () => {
chief_complaint: patient.chief_complaint,
type: patient.type,
status: patient.status,
started_at: patient.started_at,
});
fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchMessages(patient.consult_id);
fetchMessages(patient.serial_number);
};
const handleReject = (patient: PatientListItem) => {
......@@ -155,7 +224,7 @@ const ConsultPage: React.FC = () => {
content: `确定拒绝接诊患者 ${patient.patient_name || '未知'}?`,
onOk: async () => {
try {
await consultApi.rejectConsult(patient.consult_id);
await consultApi.rejectConsult(patient.serial_number);
message.info('已拒诊');
fetchPatientList();
} catch {
......@@ -169,8 +238,14 @@ const ConsultPage: React.FC = () => {
if (!activeConsult) return;
setSending(true);
try {
await consultApi.sendMessage(activeConsult.consult_id, content);
fetchMessages(activeConsult.consult_id);
if (isConnected) {
// WebSocket 发送,消息会通过 onNewMessage 回调自动追加
wsSendMessage(content);
} else {
// HTTP fallback
await consultApi.sendMessage(activeConsult.serial_number, content);
fetchMessages(activeConsult.serial_number);
}
} catch {
message.error('发送失败');
} finally {
......@@ -178,10 +253,10 @@ const ConsultPage: React.FC = () => {
}
};
const handleEndConsult = async () => {
const handleEndConsult = async (data?: { diagnosis: string; summary: string; follow_up_days: number; send_survey: boolean }) => {
if (!activeConsult) return;
try {
await consultApi.endConsult(activeConsult.consult_id);
await consultApi.endConsult(activeConsult.serial_number, data);
message.success('问诊已结束');
setActiveConsult(null);
resetConsultState();
......@@ -191,16 +266,32 @@ const ConsultPage: React.FC = () => {
}
};
const handleOpenProfile = (patientId: string) => {
setProfilePatientId(patientId);
setProfileDrawerOpen(true);
};
const statItems = [
{ label: '候诊中', value: waitingCount, color: '#fa8c16', icon: <ClockCircleOutlined /> },
{ label: '接诊中', value: inProgressCount, color: '#52c41a', icon: <MessageOutlined /> },
{ label: '今日完成', value: completedCount, color: '#1890ff', icon: <CheckCircleOutlined /> },
{ label: '候诊中', value: waitingCount, color: '#fa8c16', icon: <ClockCircleOutlined />, unit: '人' },
{ label: '接诊中', value: inProgressCount, color: '#52c41a', icon: <MessageOutlined />, unit: '人' },
{ label: '今日完成', value: completedCount, color: '#1890ff', icon: <CheckCircleOutlined />, unit: '人' },
{ label: '今日收入', value: workbenchStats ? (workbenchStats.income_today / 100).toFixed(0) : '—', color: '#722ed1', icon: <DashboardOutlined />, unit: '元' },
];
const efficiencyItems = workbenchStats ? [
{ label: '平均候诊', value: workbenchStats.avg_wait_minutes.toFixed(1), unit: '分钟', icon: <FieldTimeOutlined />, color: '#fa8c16' },
{ label: '平均问诊', value: workbenchStats.avg_consult_minutes.toFixed(1), unit: '分钟', icon: <ClockCircleOutlined />, color: '#1890ff' },
{ label: '处方率', value: workbenchStats.prescription_rate.toFixed(0), unit: '%', icon: <MedicineBoxOutlined />, color: '#52c41a' },
{ label: '满意度', value: workbenchStats.avg_satisfaction > 0 ? workbenchStats.avg_satisfaction.toFixed(1) : '—', unit: workbenchStats.avg_satisfaction > 0 ? '分' : '', icon: <StarOutlined />, color: '#faad14' },
{ label: '今日转诊', value: workbenchStats.transfer_count, unit: '次', icon: <SwapOutlined />, color: '#13c2c2' },
{ label: 'AI使用率', value: workbenchStats.ai_usage_rate.toFixed(0), unit: '%', icon: <RobotOutlined />, color: '#722ed1' },
] : [];
return (
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 12, padding: '4px 24px 16px' }}>
{/* 顶部标题 + 统计卡片 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<div style={{ flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<div>
<div style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageOutlined style={{ color: '#1890ff' }} />
......@@ -208,8 +299,8 @@ const ConsultPage: React.FC = () => {
</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>实时接诊管理,AI辅助诊断</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
{statItems.map(({ label, value, color, icon }) => (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>
{statItems.map(({ label, value, color, icon, unit }) => (
<div
key={label}
style={{
......@@ -217,7 +308,7 @@ const ConsultPage: React.FC = () => {
borderRadius: 10,
padding: '8px 18px',
border: '1px solid #edf2fc',
minWidth: 110,
minWidth: 100,
boxShadow: '0 1px 4px rgba(0,0,0,0.04)',
}}
>
......@@ -225,13 +316,57 @@ const ConsultPage: React.FC = () => {
{icon}
{label}
</div>
<div style={{ fontSize: 24, fontWeight: 700, color, lineHeight: 1.2 }}>
<div style={{ fontSize: 22, fontWeight: 700, color, lineHeight: 1.2 }}>
{value}
<span style={{ fontSize: 11, fontWeight: 400, marginLeft: 2 }}>{unit}</span>
</div>
</div>
))}
<div
onClick={() => setShowEfficiency(!showEfficiency)}
style={{
cursor: 'pointer', padding: '6px 10px', borderRadius: 8,
background: showEfficiency ? '#e6f7ff' : '#f5f5f5',
border: `1px solid ${showEfficiency ? '#91d5ff' : '#e8e8e8'}`,
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 12, color: showEfficiency ? '#1890ff' : '#8c8c8c',
transition: 'all 0.2s',
}}
>
<DashboardOutlined />
效率
{showEfficiency ? <UpOutlined style={{ fontSize: 10 }} /> : <DownOutlined style={{ fontSize: 10 }} />}
</div>
</div>
</div>
{/* 可折叠效率指标行 */}
{showEfficiency && efficiencyItems.length > 0 && (
<div style={{
display: 'flex', gap: 8, marginTop: 8, padding: '8px 12px',
background: '#fafafa', borderRadius: 10, border: '1px solid #f0f0f0',
overflow: 'auto',
}}>
{efficiencyItems.map(({ label, value, unit, icon, color }) => (
<div
key={label}
style={{
flex: 1, minWidth: 90, textAlign: 'center',
padding: '4px 8px',
}}
>
<div style={{ fontSize: 11, color: '#8c8c8c', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3, marginBottom: 2 }}>
{React.cloneElement(icon as React.ReactElement, { style: { color, fontSize: 11 } })}
{label}
</div>
<div style={{ fontSize: 18, fontWeight: 600, color, lineHeight: 1.3 }}>
{value}
<span style={{ fontSize: 12, fontWeight: 400, marginLeft: 2 }}>人</span>
<span style={{ fontSize: 10, fontWeight: 400, marginLeft: 1 }}>{unit}</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 主体三栏 */}
......@@ -244,6 +379,7 @@ const ConsultPage: React.FC = () => {
onAccept={handleAccept}
onReject={handleReject}
selectedConsultId={activeConsult?.consult_id}
onOpenProfile={handleOpenProfile}
/>
</div>
......@@ -257,6 +393,9 @@ const ConsultPage: React.FC = () => {
sending={sending}
aiDiagnosis={aiDiagnosis}
aiMedication={aiMedication}
onOpenProfile={handleOpenProfile}
typingUser={typingUser}
onTyping={handleTyping}
/>
</div>
......@@ -264,7 +403,7 @@ const ConsultPage: React.FC = () => {
<div style={{ width: 340, flexShrink: 0 }}>
<AIPanel
hasActiveConsult={!!activeConsult}
activeConsultId={activeConsult?.consult_id}
activeConsultId={activeConsult?.serial_number}
preConsultReport={preConsultReport}
preConsultLoading={preConsultLoading}
onDiagnosisChange={setAiDiagnosis}
......@@ -273,6 +412,13 @@ const ConsultPage: React.FC = () => {
/>
</div>
</div>
{/* 患者画像抽屉 */}
<PatientProfileDrawer
open={profileDrawerOpen}
patientId={profilePatientId}
onClose={() => setProfileDrawerOpen(false)}
/>
</div>
);
};
......
......@@ -184,7 +184,7 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
try {
const values = form.getFieldsValue();
await prescriptionDoctorApi.create({
consult_id: consultId || '',
serial_number: consultId || '',
patient_id: patientId || '',
patient_name: values.patient_name,
patient_gender: values.patient_gender,
......@@ -366,7 +366,7 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
onCancel={handleClose}
width={980}
footer={null}
destroyOnClose
destroyOnHidden
>
{/* AI用药建议参考 */}
{medicationSuggestion && (
......
......@@ -7,18 +7,99 @@ interface PatientContext {
medicalHistory?: string;
}
interface PageContext {
pathname: string;
pageTitle?: string;
pageData?: Record<string, unknown>;
}
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
interface AIAssistState {
isOpen: boolean;
isFullscreen: boolean;
isMinimized: boolean;
bounds: Bounds;
patientContext: PatientContext | null;
pageContext: PageContext | null;
// actions
openWidget: (context?: PatientContext) => void;
closeWidget: () => void;
minimizeWidget: () => void;
toggleFullscreen: () => void;
setBounds: (b: Partial<Bounds>) => void;
setPageContext: (ctx: PageContext | null) => void;
setPatientContext: (context: PatientContext | null) => void;
// compat aliases
openDrawer: (context?: PatientContext) => void;
closeDrawer: () => void;
setPatientContext: (context: PatientContext | null) => void;
}
export const useAIAssistStore = create<AIAssistState>((set) => ({
const DEFAULT_BOUNDS: Bounds = { x: -1, y: 80, width: 420, height: 600 };
function loadBounds(role?: string): Bounds {
if (typeof window === 'undefined') return DEFAULT_BOUNDS;
try {
const key = `ai_widget_bounds_${role || 'default'}`;
const raw = localStorage.getItem(key);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return { ...DEFAULT_BOUNDS, x: window.innerWidth - 440 };
}
function saveBounds(bounds: Bounds, role?: string) {
if (typeof window === 'undefined') return;
try {
const key = `ai_widget_bounds_${role || 'default'}`;
localStorage.setItem(key, JSON.stringify(bounds));
} catch { /* ignore */ }
}
export const useAIAssistStore = create<AIAssistState>((set, get) => ({
isOpen: false,
isFullscreen: false,
isMinimized: true,
bounds: DEFAULT_BOUNDS,
patientContext: null,
openDrawer: (context) => set({ isOpen: true, patientContext: context || null }),
closeDrawer: () => set({ isOpen: false }),
pageContext: null,
openWidget: (context) => set({
isOpen: true,
isMinimized: false,
isFullscreen: false,
patientContext: context || get().patientContext,
bounds: loadBounds(),
}),
closeWidget: () => set({ isOpen: false, isMinimized: true, isFullscreen: false }),
minimizeWidget: () => set({ isOpen: false, isMinimized: true, isFullscreen: false }),
toggleFullscreen: () => {
const prev = get().isFullscreen;
if (!prev) {
// entering fullscreen — save current bounds first
saveBounds(get().bounds);
}
set({ isFullscreen: !prev });
},
setBounds: (b) => {
const newBounds = { ...get().bounds, ...b };
set({ bounds: newBounds });
saveBounds(newBounds);
},
setPageContext: (ctx) => set({ pageContext: ctx }),
setPatientContext: (context) => set({ patientContext: context }),
// compat aliases for existing code
openDrawer: (context) => {
const { openWidget } = get();
openWidget(context);
},
closeDrawer: () => {
const { closeWidget } = get();
closeWidget();
},
}));
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment