Commit 671cf8df authored by yuguo's avatar yuguo

fix

parent 9b315e10
......@@ -41,7 +41,8 @@
"Bash(claude mcp:*)",
"WebSearch",
"Bash(go env:*)",
"Bash(go install:*)"
"Bash(go install:*)",
"Bash(where psql:*)"
]
}
}
......@@ -133,8 +133,6 @@ func main() {
&model.UserRole{},
&model.Menu{},
&model.RoleMenu{},
// 路由注册表(AI 导航工具)
&model.RouteEntry{},
}
failCount := 0
for _, m := range allModels {
......@@ -175,21 +173,16 @@ func main() {
ai.InitClient(&cfg.AI)
}
// 初始化超级管理员账户
// 初始化超级管理员账户(唯一自动导入的种子数据)
if err := user.InitAdminUser(); err != nil {
log.Printf("Warning: Failed to init admin user: %v", err)
}
// 初始化 RBAC 种子数据(角色/权限/菜单,v12新增)
admin.SeedRBAC()
// 初始化科室和医生数据
if err := doctor.InitDepartmentsAndDoctors(); err != nil {
log.Printf("Warning: Failed to init departments and doctors: %v", err)
}
// 初始化药品种子数据
admin.SeedMedicines()
// 注意:RBAC种子数据、科室医生、药品数据不再自动导入
// 请通过管理端API手动导入:
// POST /api/v1/admin/seed/rbac — 导入角色/权限/菜单
// POST /api/v1/admin/seed/departments — 导入科室和医生
// POST /api/v1/admin/seed/medicines — 导入药品数据
// 初始化Agent工具(内置工具 + HTTP 动态工具)
internalagent.InitTools()
......@@ -251,10 +244,12 @@ func main() {
adminApi.Use(middleware.RequireRole("admin"))
adminHandler := admin.NewHandler()
adminHandler.RegisterRoutes(adminApi)
adminHandler.RegisterUserRoutes(authApi) // /my/menus — 所有登录用户可访问
// Agent路由(需要认证
// Agent路由:聊天/会话(全角色)+ 管理(仅admin
agentHandler := internalagent.NewHandler()
agentHandler.RegisterRoutes(authApi)
agentHandler.RegisterAdminRoutes(adminApi)
// 工作流路由(需要认证)
workflowHandler := workflowsvc.NewHandler()
......
......@@ -55,7 +55,8 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 所有医疗建议仅供参考,请以专业医生判断为准
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如找医生、我的问诊、处方、健康档案等
- 你可以使用 navigate_page 工具打开患者端页面,如找医生、我的问诊、处方、健康档案等
- 你只能导航到 patient_* 开头的页面,不能访问管理端或医生端页面
- 当用户想查看某个页面时,直接调用 navigate_page 工具导航到对应页面`,
Tools: string(patientTools),
Config: "{}",
......@@ -87,7 +88,8 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 所有建议仅供医生参考,请结合临床实际情况
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如工作台、问诊大厅、患者档案、排班管理等
- 你可以使用 navigate_page 工具打开医生端页面,如工作台、问诊大厅、患者档案、排班管理等
- 你只能导航到 doctor_* 开头的页面,不能访问管理端或患者端页面
- 当医生想查看某个页面时,直接调用 navigate_page 工具导航到对应页面`,
Tools: string(doctorTools),
Config: "{}",
......@@ -117,7 +119,7 @@ func defaultAgentDefinitions() []model.AgentDefinition {
- 用中文回答
页面导航能力:
- 你可以使用 navigate_page 工具打开系统页面,如医生管理、患者管理、科室管理、问诊管理等
- 你可以使用 navigate_page 工具打开所有系统页面,包括管理端、患者端、医生端
- 当管理员想查看或操作某个页面时,直接调用 navigate_page 工具导航到对应页面
- 支持 open_add 操作自动打开新增弹窗(如新增医生、新增科室等)`,
Tools: string(adminTools),
......
......@@ -27,6 +27,7 @@ func NewHandler() *Handler {
return &Handler{svc: GetService()}
}
// RegisterRoutes 用户侧路由(全角色可用,仅需 JWT)
func (h *Handler) RegisterRoutes(r gin.IRouter) {
g := r.Group("/agent")
g.POST("/:agent_id/chat", h.Chat)
......@@ -34,6 +35,11 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.GET("/sessions", h.ListSessions)
g.DELETE("/session/:session_id", h.DeleteSession)
g.GET("/list", h.ListAgents)
}
// RegisterAdminRoutes 管理侧路由(仅 admin,需挂在 adminApi 组下)
func (h *Handler) RegisterAdminRoutes(r gin.IRouter) {
g := r.Group("/agent")
g.GET("/tools", h.ListTools)
// HTTP 动态工具管理
......@@ -44,7 +50,7 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.POST("/http-tools/:id/test", h.TestHTTPTool)
g.POST("/http-tools/reload", h.ReloadHTTPToolsAPI)
// Agent 配置管理(管理端使用)
// Agent 配置管理
g.GET("/definitions", h.ListDefinitions)
g.GET("/definitions/:agent_id", h.GetDefinition)
g.POST("/definitions", h.CreateDefinition)
......@@ -73,8 +79,10 @@ func (h *Handler) Chat(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
role, _ := c.Get("role")
userRole, _ := role.(string)
output, err := h.svc.Chat(c.Request.Context(), agentID, uid, req.SessionID, req.Message, req.Context)
output, err := h.svc.Chat(c.Request.Context(), agentID, uid, userRole, req.SessionID, req.Message, req.Context)
if err != nil {
response.Error(c, 500, err.Error())
return
......@@ -113,12 +121,14 @@ func (h *Handler) ChatStream(c *gin.Context) {
userID, _ := c.Get("user_id")
uid, _ := userID.(string)
role, _ := c.Get("role")
userRole, _ := role.(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)
h.svc.ChatStream(c.Request.Context(), agentID, uid, userRole, req.SessionID, req.Message, req.Context, emit)
}
func (h *Handler) ListAgents(c *gin.Context) {
......
......@@ -81,7 +81,9 @@ func WireCallbacks() {
svc := GetService()
tools.AgentCallFn = func(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (string, error) {
output, err := svc.Chat(ctx, agentID, userID, sessionID, message, ctxData)
// 从 context 继承 userRole(agent-to-agent 调用)
userRole, _ := ctx.Value(agent.ContextKeyUserRole).(string)
output, err := svc.Chat(ctx, agentID, userID, userRole, sessionID, message, ctxData)
if err != nil {
return "", err
}
......@@ -97,7 +99,8 @@ func WireCallbacks() {
// v15: 初始化 SkillExecutor(多Agent编排引擎)
agent.InitSkillExecutor(func(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (string, error) {
output, err := svc.Chat(ctx, agentID, userID, sessionID, message, ctxData)
userRole, _ := ctx.Value(agent.ContextKeyUserRole).(string)
output, err := svc.Chat(ctx, agentID, userID, userRole, sessionID, message, ctxData)
if err != nil {
return "", err
}
......
......@@ -69,7 +69,12 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
systemPrompt := def.SystemPrompt
var skillIDs []string
if def.Skills != "" {
json.Unmarshal([]byte(def.Skills), &skillIDs)
if err := json.Unmarshal([]byte(def.Skills), &skillIDs); err != nil {
var raw string
if json.Unmarshal([]byte(def.Skills), &raw) == nil {
json.Unmarshal([]byte(raw), &skillIDs)
}
}
}
if len(skillIDs) > 0 {
db := database.GetDB()
......@@ -83,7 +88,12 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
for _, skill := range skills {
// 合并工具(去重)
var skillTools []string
json.Unmarshal([]byte(skill.Tools), &skillTools)
if err := json.Unmarshal([]byte(skill.Tools), &skillTools); err != nil {
var raw string
if json.Unmarshal([]byte(skill.Tools), &raw) == nil {
json.Unmarshal([]byte(raw), &skillTools)
}
}
for _, st := range skillTools {
if !toolSet[st] {
tools = append(tools, st)
......@@ -116,7 +126,12 @@ func buildAgentFromDef(def model.AgentDefinition) *agent.ReActAgent {
func getOrchestrationSkills(def model.AgentDefinition) []model.AgentSkill {
var skillIDs []string
if def.Skills != "" {
json.Unmarshal([]byte(def.Skills), &skillIDs)
if err := json.Unmarshal([]byte(def.Skills), &skillIDs); err != nil {
var raw string
if json.Unmarshal([]byte(def.Skills), &raw) == nil {
json.Unmarshal([]byte(raw), &skillIDs)
}
}
}
if len(skillIDs) == 0 {
return nil
......@@ -212,7 +227,7 @@ func (s *AgentService) ListAgents() []map[string]interface{} {
}
// Chat 执行Agent对话并持久化会话
func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, message string, contextData map[string]interface{}) (*agent.AgentOutput, error) {
func (s *AgentService) Chat(ctx context.Context, agentID, userID, userRole, sessionID, message string, contextData map[string]interface{}) (*agent.AgentOutput, error) {
a, ok := s.GetAgent(agentID)
if !ok {
return nil, nil
......@@ -236,6 +251,7 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
input := agent.AgentInput{
SessionID: sessionID,
UserID: userID,
UserRole: userRole,
Message: message,
Context: contextData,
History: history,
......@@ -297,7 +313,7 @@ func (s *AgentService) Chat(ctx context.Context, agentID, userID, sessionID, mes
}
// ChatStream 流式执行Agent对话(SSE),完成后持久化
func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, sessionID, message string, contextData map[string]interface{}, emit func(event, data string)) {
func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, userRole, 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"})
......@@ -378,6 +394,7 @@ func (s *AgentService) ChatStream(ctx context.Context, agentID, userID, sessionI
input := agent.AgentInput{
SessionID: sessionID,
UserID: userID,
UserRole: userRole,
Message: message,
Context: contextData,
History: history,
......
package model
import "time"
// RouteEntry 前端路由注册表(供 AI 导航工具查询)
type RouteEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
PageCode string `gorm:"type:varchar(100);uniqueIndex" json:"page_code"`
PageName string `gorm:"type:varchar(200)" json:"page_name"`
Module string `gorm:"type:varchar(50)" json:"module"` // admin/patient/doctor
Route string `gorm:"type:varchar(200)" json:"route"`
EditRoute string `gorm:"type:varchar(200)" json:"edit_route"`
AddRoute string `gorm:"type:varchar(200)" json:"add_route"`
Operations string `gorm:"type:jsonb;default:'[]'" json:"operations"` // ["view","create","edit","delete"]
RoleAccess string `gorm:"type:varchar(100)" json:"role_access"` // "admin"
Description string `gorm:"type:text" json:"description"`
Status string `gorm:"type:varchar(20);default:'active'" json:"status"`
SortOrder int `gorm:"default:0" json:"sort_order"`
Embeddable bool `gorm:"default:false" json:"embeddable"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// GetAgentExecutionLogs 获取 Agent 执行日志(分页)
......@@ -48,12 +48,12 @@ func (h *Handler) GetAgentExecutionLogs(c *gin.Context) {
Limit(pageSize).
Find(&logs)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
response.Success(c, gin.H{
"list": logs,
"total": total,
"page": page,
"page_size": pageSize,
}})
})
}
// GetAgentStats 获取 Agent 执行统计
......@@ -83,5 +83,5 @@ func (h *Handler) GetAgentStats(c *gin.Context) {
Group("agent_id").
Scan(&stats)
c.JSON(http.StatusOK, gin.H{"data": stats})
response.Success(c, stats)
}
package admin
import (
"fmt"
"log"
"internet-hospital/pkg/database"
"github.com/gin-gonic/gin"
"internet-hospital/pkg/response"
)
// expectedTables 所有已注册 GORM model 对应的数据库表名(白名单)
var expectedTables = map[string]bool{
// 用户相关
"users": true,
"user_verifications": true,
"patient_profiles": true,
// 医生相关
"departments": true,
"doctors": true,
"doctor_schedules": true,
"doctor_reviews": true,
// 问诊相关
"consultations": true,
"consult_messages": true,
"video_rooms": true,
"consult_transfers": true,
// 预问诊
"pre_consultations": true,
// 处方相关
"medicines": true,
"prescriptions": true,
"prescription_items": true,
// 健康档案
"lab_reports": true,
"family_members": true,
// 慢病管理
"chronic_records": true,
"renewal_requests": true,
"medication_reminders": true,
"health_metrics": true,
// AI & 系统
"ai_configs": true,
"ai_usage_logs": true,
"prompt_templates": true,
"system_logs": true,
// Agent 相关
"agent_tools": true,
"agent_tool_logs": true,
"agent_definitions": true,
"agent_sessions": true,
"agent_execution_logs": true,
"agent_skills": true,
// 工作流相关
"workflow_definitions": true,
"workflow_executions": true,
"workflow_human_tasks": true,
// 知识库相关
"knowledge_collections": true,
"knowledge_documents": true,
"knowledge_chunks": true,
// 支付相关
"payment_orders": true,
"doctor_incomes": true,
"doctor_withdrawals": true,
// 安全过滤
"safety_word_rules": true,
"safety_filter_logs": true,
// 通知
"notifications": true,
// 合规报告
"compliance_reports": true,
// HTTP / SQL 动态工具
"http_tool_definitions": true,
"sql_tool_definitions": true,
// 快捷回复
"quick_reply_templates": true,
// RBAC
"roles": true,
"permissions": true,
"role_permissions": true,
"user_roles": true,
"menus": true,
"role_menus": true,
}
// ScanExtraTables 扫描数据库中多余的表(不在 model 白名单中)
func ScanExtraTables() ([]string, error) {
db := database.GetDB()
if db == nil {
return nil, fmt.Errorf("数据库未初始化")
}
// 查询当前 schema 下所有用户表
var tables []string
db.Raw(`SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`).Scan(&tables)
var extra []string
for _, t := range tables {
if !expectedTables[t] {
extra = append(extra, t)
}
}
return extra, nil
}
// DropExtraTables 删除多余的数据库表
func DropExtraTables() ([]string, error) {
extra, err := ScanExtraTables()
if err != nil {
return nil, err
}
if len(extra) == 0 {
return nil, nil
}
db := database.GetDB()
var dropped []string
for _, t := range extra {
sql := fmt.Sprintf("DROP TABLE IF EXISTS %q CASCADE", t)
if err := db.Exec(sql).Error; err != nil {
log.Printf("[CleanupTables] 删除表 %s 失败: %v", t, err)
} else {
log.Printf("[CleanupTables] 已删除多余表: %s", t)
dropped = append(dropped, t)
}
}
return dropped, nil
}
// ScanExtraTablesHandler 扫描多余表(仅查看,不删除)
func (h *Handler) ScanExtraTablesHandler(c *gin.Context) {
extra, err := ScanExtraTables()
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, gin.H{
"extra_tables": extra,
"count": len(extra),
})
}
// DropExtraTablesHandler 删除多余的数据库表
func (h *Handler) DropExtraTablesHandler(c *gin.Context) {
dropped, err := DropExtraTables()
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, gin.H{
"dropped_tables": dropped,
"count": len(dropped),
"message": fmt.Sprintf("已删除 %d 张多余表", len(dropped)),
})
}
......@@ -3,6 +3,7 @@ package admin
import (
"github.com/gin-gonic/gin"
"internet-hospital/internal/service/doctor"
"internet-hospital/pkg/response"
)
......@@ -124,31 +125,29 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
adm.GET("/ai-center/trace", h.GetTraceDetail)
adm.GET("/ai-center/stats", h.GetAICenterStats)
// 角色管理(v12新增
// 角色管理(只读
adm.GET("/roles", h.ListRoles)
adm.POST("/roles", h.CreateRole)
adm.PUT("/roles/:id", h.UpdateRole)
adm.DELETE("/roles/:id", h.DeleteRole)
adm.GET("/roles/:id/permissions", h.GetRolePermissions)
adm.PUT("/roles/:id/permissions", h.SetRolePermissions)
adm.GET("/roles/:id/menus", h.GetRoleMenus)
adm.PUT("/roles/:id/menus", h.SetRoleMenus)
// 权限管理(v12新增)
adm.GET("/permissions", h.ListPermissions)
adm.POST("/permissions", h.CreatePermission)
// 用户角色分配(直接更新 User.Role)
adm.PUT("/users/:id/role", h.SetUserRole)
// 菜单管理(v12新增)
// 菜单管理
adm.GET("/menus", h.ListMenus)
adm.GET("/menus/flat", h.ListMenusFlat)
adm.POST("/menus", h.CreateMenu)
adm.PUT("/menus/:id", h.UpdateMenu)
adm.DELETE("/menus/:id", h.DeleteMenu)
adm.POST("/menus/reseed", h.ReseedMenus)
// 用户角色分配(v12新增)
adm.GET("/users/:id/roles", h.GetUserRoles)
adm.PUT("/users/:id/roles", h.SetUserRoles)
// 手动种子数据导入
adm.POST("/seed/rbac", h.SeedRBACHandler)
adm.POST("/seed/departments", h.SeedDepartmentsHandler)
adm.POST("/seed/medicines", h.SeedMedicinesHandler)
// 数据库维护
adm.GET("/db/extra-tables", h.ScanExtraTablesHandler)
adm.DELETE("/db/extra-tables", h.DropExtraTablesHandler)
// 数据导出
adm.POST("/export/:type", h.ExportData)
......@@ -159,7 +158,10 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
adm.POST("/reports/:id/submit", h.SubmitReport)
}
// 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问)
}
// RegisterUserRoutes 注册所有登录用户可访问的路由(不受 admin 角色限制)
func (h *Handler) RegisterUserRoutes(r *gin.RouterGroup) {
r.GET("/my/menus", h.GetMyMenus)
}
......@@ -468,3 +470,24 @@ func (h *Handler) ResetAdminPassword(c *gin.Context) {
response.Success(c, nil)
}
// SeedRBACHandler 手动导入RBAC种子数据(角色、权限、菜单)
func (h *Handler) SeedRBACHandler(c *gin.Context) {
SeedRBAC()
response.Success(c, gin.H{"message": "RBAC种子数据(角色/权限/菜单)已导入"})
}
// SeedDepartmentsHandler 手动导入科室和医生数据
func (h *Handler) SeedDepartmentsHandler(c *gin.Context) {
if err := doctor.InitDepartmentsAndDoctors(); err != nil {
response.Error(c, 500, "导入科室和医生数据失败: "+err.Error())
return
}
response.Success(c, gin.H{"message": "科室和医生种子数据已导入"})
}
// SeedMedicinesHandler 手动导入药品种子数据
func (h *Handler) SeedMedicinesHandler(c *gin.Context) {
SeedMedicines()
response.Success(c, gin.H{"message": "药品种子数据已导入"})
}
......@@ -152,8 +152,3 @@ func (h *Handler) DeleteMenu(c *gin.Context) {
response.Success(c, nil)
}
// ReseedMenus 重新导入菜单数据(删除旧数据,以页面展示结构为准)
func (h *Handler) ReseedMenus(c *gin.Context) {
ReseedMenus()
response.Success(c, gin.H{"message": "菜单数据已重新导入"})
}
......@@ -5,15 +5,13 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"gorm.io/gorm"
)
// SeedRBAC 初始化角色、权限、菜单种子数据(幂等,只在表空时插入)
// SeedRBAC 初始化角色种子数据(幂等,仅在 roles 表为空时插入)
// 菜单数据通过管理端菜单管理页面维护,不在代码中硬编码。
func SeedRBAC() {
db := database.GetDB()
// ── 1. 角色种子 ──
var roleCount int64
db.Model(&model.Role{}).Count(&roleCount)
if roleCount == 0 {
......@@ -27,185 +25,4 @@ func SeedRBAC() {
}
log.Println("[SeedRBAC] 角色种子数据已创建")
}
// ── 2. 权限种子 ──
var permCount int64
db.Model(&model.Permission{}).Count(&permCount)
if permCount == 0 {
perms := []model.Permission{
// 用户管理
{Code: "admin:users:list", Name: "用户列表", Module: "users", Action: "list"},
{Code: "admin:users:create", Name: "创建用户", Module: "users", Action: "create"},
{Code: "admin:users:update", Name: "编辑用户", Module: "users", Action: "update"},
{Code: "admin:users:delete", Name: "删除用户", Module: "users", Action: "delete"},
// 医生管理
{Code: "admin:doctors:list", Name: "医生列表", Module: "doctors", Action: "list"},
{Code: "admin:doctors:create", Name: "创建医生", Module: "doctors", Action: "create"},
{Code: "admin:doctors:update", Name: "编辑医生", Module: "doctors", Action: "update"},
{Code: "admin:doctors:delete", Name: "删除医生", Module: "doctors", Action: "delete"},
{Code: "admin:doctors:review", Name: "医生审核", Module: "doctors", Action: "review"},
// 科室管理
{Code: "admin:departments:list", Name: "科室列表", Module: "departments", Action: "list"},
{Code: "admin:departments:create", Name: "创建科室", Module: "departments", Action: "create"},
{Code: "admin:departments:update", Name: "编辑科室", Module: "departments", Action: "update"},
{Code: "admin:departments:delete", Name: "删除科室", Module: "departments", Action: "delete"},
// 问诊管理
{Code: "admin:consultations:list", Name: "问诊列表", Module: "consultations", Action: "list"},
// 处方管理
{Code: "admin:prescriptions:list", Name: "处方列表", Module: "prescriptions", Action: "list"},
{Code: "admin:prescriptions:review", Name: "处方审核", Module: "prescriptions", Action: "review"},
// 药品管理
{Code: "admin:medicines:list", Name: "药品列表", Module: "medicines", Action: "list"},
{Code: "admin:medicines:create", Name: "创建药品", Module: "medicines", Action: "create"},
{Code: "admin:medicines:update", Name: "编辑药品", Module: "medicines", Action: "update"},
{Code: "admin:medicines:delete", Name: "删除药品", Module: "medicines", Action: "delete"},
// AI配置
{Code: "admin:ai-config:view", Name: "查看AI配置", Module: "ai-config", Action: "view"},
{Code: "admin:ai-config:edit", Name: "编辑AI配置", Module: "ai-config", Action: "edit"},
// Agent管理
{Code: "admin:agents:list", Name: "Agent列表", Module: "agents", Action: "list"},
{Code: "admin:agents:create", Name: "创建Agent", Module: "agents", Action: "create"},
{Code: "admin:agents:update", Name: "编辑Agent", Module: "agents", Action: "update"},
// 工具管理
{Code: "admin:tools:list", Name: "工具列表", Module: "tools", Action: "list"},
{Code: "admin:tools:manage", Name: "工具管理", Module: "tools", Action: "manage"},
// 知识库
{Code: "admin:knowledge:list", Name: "知识库列表", Module: "knowledge", Action: "list"},
{Code: "admin:knowledge:manage", Name: "知识库管理", Module: "knowledge", Action: "manage"},
// 角色管理
{Code: "admin:roles:list", Name: "角色列表", Module: "roles", Action: "list"},
{Code: "admin:roles:create", Name: "创建角色", Module: "roles", Action: "create"},
{Code: "admin:roles:update", Name: "编辑角色", Module: "roles", Action: "update"},
{Code: "admin:roles:delete", Name: "删除角色", Module: "roles", Action: "delete"},
{Code: "admin:roles:assign", Name: "分配权限/菜单", Module: "roles", Action: "assign"},
// 菜单管理
{Code: "admin:menus:list", Name: "菜单列表", Module: "menus", Action: "list"},
{Code: "admin:menus:create", Name: "创建菜单", Module: "menus", Action: "create"},
{Code: "admin:menus:update", Name: "编辑菜单", Module: "menus", Action: "update"},
{Code: "admin:menus:delete", Name: "删除菜单", Module: "menus", Action: "delete"},
// 运营中心
{Code: "admin:dashboard:view", Name: "运营大盘", Module: "dashboard", Action: "view"},
{Code: "admin:ai-center:view", Name: "AI运营中心", Module: "ai-center", Action: "view"},
{Code: "admin:safety:manage", Name: "内容安全管理", Module: "safety", Action: "manage"},
{Code: "admin:compliance:view", Name: "合规报表", Module: "compliance", Action: "view"},
}
for _, p := range perms {
db.Create(&p)
}
log.Println("[SeedRBAC] 权限种子数据已创建")
}
// ── 3. 菜单种子(仅在菜单表为空时导入,避免覆盖手动修改) ──
var menuCount int64
db.Model(&model.Menu{}).Count(&menuCount)
if menuCount == 0 {
ReseedMenus()
}
// ── 4. 为超级管理员角色分配全部权限(菜单已在 ReseedMenus 中分配) ──
var adminRole model.Role
if err := db.Where("code = ?", "admin").First(&adminRole).Error; err == nil {
var rpCount int64
db.Model(&model.RolePermission{}).Where("role_id = ?", adminRole.ID).Count(&rpCount)
if rpCount == 0 {
var allPerms []model.Permission
db.Find(&allPerms)
for _, p := range allPerms {
db.Create(&model.RolePermission{RoleID: adminRole.ID, PermissionID: p.ID})
}
log.Println("[SeedRBAC] 超管角色已分配全部权限")
}
}
// ── 5. 为已有 admin 用户绑定管理员角色 ──
if adminRole.ID > 0 {
var adminUser model.User
if err := db.Where("role = ?", "admin").First(&adminUser).Error; err == nil {
var urCount int64
db.Model(&model.UserRole{}).Where("user_id = ?", adminUser.ID).Count(&urCount)
if urCount == 0 {
db.Create(&model.UserRole{UserID: adminUser.ID, RoleID: adminRole.ID})
log.Printf("[SeedRBAC] 用户 %s 已绑定管理员角色", adminUser.ID)
}
}
}
}
// ReseedMenus 删除旧菜单数据并重新导入(以页面展示的菜单结构为准)
func ReseedMenus() {
db := database.GetDB()
// 清空旧的角色-菜单关联和菜单数据
db.Exec("DELETE FROM role_menus")
db.Exec("DELETE FROM menus")
log.Println("[ReseedMenus] 已清空旧菜单数据和角色-菜单关联")
// 重新创建菜单
seedMenus(db)
// 重新为超管分配全部菜单
var adminRole model.Role
if err := db.Where("code = ?", "admin").First(&adminRole).Error; err == nil {
var allMenus []model.Menu
db.Find(&allMenus)
for _, m := range allMenus {
db.Create(&model.RoleMenu{RoleID: adminRole.ID, MenuID: m.ID})
}
log.Println("[ReseedMenus] 超管角色已重新分配全部菜单")
}
}
// seedMenus 创建与前端路由完全一致的菜单结构(含 permission 字段供导航工具做 RBAC 校验)
func seedMenus(db *gorm.DB) {
m := func(parentID uint, name, path, icon, perm string, sort int) model.Menu {
menu := model.Menu{
ParentID: parentID, Name: name, Path: path, Icon: icon,
Permission: perm, Sort: sort, Type: "menu", Visible: true, Status: "active",
}
db.Create(&menu)
return menu
}
// 1. 运营大盘(顶级)
m(0, "运营大盘", "/admin/dashboard", "DashboardOutlined", "admin:dashboard:view", 1)
// 2. 用户管理(子菜单)
userMgmt := m(0, "用户管理", "", "TeamOutlined", "", 2)
m(userMgmt.ID, "患者管理", "/admin/patients", "UserOutlined", "admin:users:list", 1)
m(userMgmt.ID, "医生管理", "/admin/doctors", "MedicineBoxOutlined", "admin:doctors:list", 2)
m(userMgmt.ID, "管理员管理", "/admin/admins", "SettingOutlined", "admin:users:list", 3)
// 3. 科室管理(顶级)
m(0, "科室管理", "/admin/departments", "ApartmentOutlined", "admin:departments:list", 3)
// 4. 问诊管理(顶级)
m(0, "问诊管理", "/admin/consultations", "FileSearchOutlined", "admin:consultations:list", 4)
// 5. 处方监管(顶级)
m(0, "处方监管", "/admin/prescription", "FileTextOutlined", "admin:prescriptions:list", 5)
// 6. 药品库(顶级)
m(0, "药品库", "/admin/pharmacy", "MedicineBoxOutlined", "admin:medicines:list", 6)
// 7. AI配置(顶级)
m(0, "AI配置", "/admin/ai-config", "RobotOutlined", "admin:ai-config:view", 7)
// 8. 合规报表(顶级)
m(0, "合规报表", "/admin/compliance", "SafetyCertificateOutlined", "admin:compliance:view", 8)
// 9. 智能体平台(子菜单)
aiPlatform := m(0, "智能体平台", "", "ApiOutlined", "", 9)
m(aiPlatform.ID, "Agent管理", "/admin/agents", "RobotOutlined", "admin:agents:list", 1)
m(aiPlatform.ID, "工具中心", "/admin/tools", "AppstoreOutlined", "admin:tools:list", 2)
m(aiPlatform.ID, "工作流", "/admin/workflows", "DeploymentUnitOutlined", "admin:tools:manage", 3)
m(aiPlatform.ID, "人工审核", "/admin/tasks", "CheckCircleOutlined", "admin:tools:manage", 4)
m(aiPlatform.ID, "知识库", "/admin/knowledge", "BookOutlined", "admin:knowledge:list", 5)
m(aiPlatform.ID, "内容安全", "/admin/safety", "SafetyOutlined", "admin:safety:manage", 6)
m(aiPlatform.ID, "AI运营中心", "/admin/ai-center", "FundOutlined", "admin:ai-center:view", 7)
// 10. 系统管理(子菜单)
sysMgmt := m(0, "系统管理", "", "SafetyCertificateOutlined", "", 10)
m(sysMgmt.ID, "角色管理", "/admin/roles", "SafetyOutlined", "admin:roles:list", 1)
m(sysMgmt.ID, "菜单管理", "/admin/menus", "AppstoreOutlined", "admin:menus:list", 2)
log.Println("[SeedRBAC] 菜单种子数据已创建(与前端路由完全一致)")
}
package admin
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
)
// ListWorkflows 工作流列表
func (h *Handler) ListWorkflows(c *gin.Context) {
var workflows []model.WorkflowDefinition
database.GetDB().Find(&workflows)
c.JSON(http.StatusOK, gin.H{"data": workflows})
response.Success(c, workflows)
}
// CreateWorkflow 创建工作流
......@@ -27,7 +27,7 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
Definition string `json:"definition"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
wf := model.WorkflowDefinition{
......@@ -40,7 +40,7 @@ func (h *Handler) CreateWorkflow(c *gin.Context) {
Version: 1,
}
database.GetDB().Create(&wf)
c.JSON(http.StatusOK, gin.H{"data": wf})
response.Success(c, wf)
}
// UpdateWorkflow 更新工作流(名称/描述/定义)
......@@ -51,7 +51,7 @@ func (h *Handler) UpdateWorkflow(c *gin.Context) {
Definition string `json:"definition"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
updates := map[string]interface{}{}
......@@ -67,7 +67,7 @@ func (h *Handler) UpdateWorkflow(c *gin.Context) {
database.GetDB().Model(&model.WorkflowDefinition{}).
Where("id = ?", c.Param("id")).
Updates(updates)
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
// PublishWorkflow 发布工作流
......@@ -75,7 +75,7 @@ func (h *Handler) PublishWorkflow(c *gin.Context) {
database.GetDB().Model(&model.WorkflowDefinition{}).
Where("id = ?", c.Param("id")).
Update("status", "active")
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
// ListWorkflowExecutions 工作流执行记录列表
......@@ -107,10 +107,10 @@ func (h *Handler) ListWorkflowExecutions(c *gin.Context) {
Limit(pageSize).
Find(&executions)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
response.Success(c, gin.H{
"list": executions,
"total": total,
"page": page,
"page_size": pageSize,
}})
})
}
......@@ -131,7 +131,7 @@ 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, "patient_universal_agent", userID, "", msg, agentCtx)
output, err := internalagent.GetService().Chat(ctx, "patient_universal_agent", userID, "patient", "", msg, agentCtx)
if err != nil {
return "", fmt.Errorf("AI续药建议获取失败: %w", err)
}
......
......@@ -380,7 +380,7 @@ func (s *Service) AIAssist(ctx context.Context, consultID string, scene string)
}
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(ctx, agentID, consult.DoctorID, "", message, agentCtx)
output, err := agentSvc.Chat(ctx, agentID, consult.DoctorID, "doctor", "", message, agentCtx)
if err != nil {
return nil, fmt.Errorf("AI分析失败: %w", err)
}
......
......@@ -224,7 +224,7 @@ func (s *Service) CheckPrescriptionSafety(ctx context.Context, userID, patientID
message := fmt.Sprintf("请审核以下处方的安全性:%s,检查药物相互作用和禁忌症", drugList)
agentSvc := internalagent.GetService()
output, err := agentSvc.Chat(ctx, "doctor_universal_agent", userID, "", message, agentCtx)
output, err := agentSvc.Chat(ctx, "doctor_universal_agent", userID, "doctor", "", message, agentCtx)
if err != nil {
return nil, fmt.Errorf("处方安全审核失败: %w", err)
}
......
......@@ -201,6 +201,7 @@ func (h *Handler) AIGenerateReplies(c *gin.Context) {
c.Request.Context(),
"doctor_universal_agent",
userID.(string),
"doctor",
"",
"请根据以上对话上下文,生成3-5条医生可以使用的快捷回复建议。每条回复独立一行,不要编号,直接输出回复内容。回复应该专业、简洁、有针对性。",
agentCtx,
......
package knowledgesvc
import (
"net/http"
"strings"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
......@@ -28,12 +28,12 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
func (h *Handler) Search(c *gin.Context) {
var req struct {
Query string `json:"query" binding:"required"`
CollectionID string `json:"collection_id"`
TopK int `json:"top_k"`
Query string `json:"query" binding:"required"`
CollectionID string `json:"collection_id"`
TopK int `json:"top_k"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
if req.TopK <= 0 {
......@@ -57,13 +57,13 @@ func (h *Handler) Search(c *gin.Context) {
"document_id": ch.DocumentID,
})
}
c.JSON(http.StatusOK, gin.H{"data": results})
response.Success(c, results)
}
func (h *Handler) ListCollections(c *gin.Context) {
var cols []model.KnowledgeCollection
database.GetDB().Find(&cols)
c.JSON(http.StatusOK, gin.H{"data": cols})
response.Success(c, cols)
}
func (h *Handler) CreateCollection(c *gin.Context) {
......@@ -73,7 +73,7 @@ func (h *Handler) CreateCollection(c *gin.Context) {
Category string `json:"category"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
col := model.KnowledgeCollection{
......@@ -83,7 +83,7 @@ func (h *Handler) CreateCollection(c *gin.Context) {
Category: req.Category,
}
database.GetDB().Create(&col)
c.JSON(http.StatusOK, gin.H{"data": col})
response.Success(c, col)
}
func (h *Handler) CreateDocument(c *gin.Context) {
......@@ -94,7 +94,7 @@ func (h *Handler) CreateDocument(c *gin.Context) {
FileType string `json:"file_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
response.BadRequest(c, err.Error())
return
}
......@@ -128,7 +128,7 @@ func (h *Handler) CreateDocument(c *gin.Context) {
Where("collection_id = ?", req.CollectionID).
UpdateColumn("document_count", db.Raw("document_count + 1"))
c.JSON(http.StatusOK, gin.H{"data": doc})
response.Success(c, doc)
}
func (h *Handler) ListDocuments(c *gin.Context) {
......@@ -139,20 +139,19 @@ func (h *Handler) ListDocuments(c *gin.Context) {
q = q.Where("collection_id = ?", collectionID)
}
q.Find(&docs)
c.JSON(http.StatusOK, gin.H{"data": docs})
response.Success(c, docs)
}
func (h *Handler) DeleteCollection(c *gin.Context) {
id := c.Param("id")
db := database.GetDB()
// Delete chunks, documents, then collection
db.Where("collection_id IN (SELECT document_id FROM knowledge_documents WHERE collection_id = ?)", id).Delete(&model.KnowledgeChunk{})
db.Where("collection_id = ?", id).Delete(&model.KnowledgeDocument{})
if err := db.Where("collection_id = ?", id).Delete(&model.KnowledgeCollection{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
response.Error(c, 500, "删除失败")
return
}
c.JSON(http.StatusOK, gin.H{"data": nil, "message": "ok"})
response.Success(c, nil)
}
func (h *Handler) DeleteDocument(c *gin.Context) {
......@@ -160,10 +159,10 @@ func (h *Handler) DeleteDocument(c *gin.Context) {
db := database.GetDB()
db.Where("document_id = ?", id).Delete(&model.KnowledgeChunk{})
if err := db.Where("document_id = ?", id).Delete(&model.KnowledgeDocument{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
response.Error(c, 500, "删除失败")
return
}
c.JSON(http.StatusOK, gin.H{"data": nil, "message": "ok"})
response.Success(c, nil)
}
func splitIntoChunks(text string, chunkSize int) []string {
......
......@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"math/rand"
"strings"
"time"
"github.com/google/uuid"
......@@ -195,7 +196,7 @@ type UserProfileResponse struct {
Menus []model.Menu `json:"menus"`
}
// GetUserProfile 获取用户完整信息(包含基于 RBAC 的菜单树)
// GetUserProfile 获取用户完整信息(包含菜单树)
func (s *Service) GetUserProfile(ctx context.Context, userID string) (*UserProfileResponse, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
......@@ -210,27 +211,79 @@ func (s *Service) GetUserProfile(ctx context.Context, userID string) (*UserProfi
}, nil
}
// getUserMenus 基于 RBAC 查询用户的菜单树
// 流程:user_roles → role_menus → menus → 构建树
// getUserMenus 基于 User.Role 返回对应端菜单树
// admin → /admin/* , patient → /patient/* , doctor → /doctor/*
func (s *Service) getUserMenus(userID string) []model.Menu {
// 通过 user_roles + role_menus 获取该用户可见的菜单 ID
var menuIDs []uint
s.db.Raw(`SELECT DISTINCT rm.menu_id FROM role_menus rm
INNER JOIN user_roles ur ON ur.role_id = rm.role_id
WHERE ur.user_id = ?`, userID).Scan(&menuIDs)
var user model.User
if err := s.db.Select("role").Where("id = ?", userID).First(&user).Error; err != nil {
fmt.Printf("[getUserMenus] 查询用户失败: %v\n", err)
return nil
}
prefix := rolePathPrefix(user.Role)
fmt.Printf("[getUserMenus] userID=%s role=%s prefix=%s\n", userID, user.Role, prefix)
if prefix == "" {
return []model.Menu{}
}
var flatMenus []model.Menu
if len(menuIDs) > 0 {
// RBAC 模式:只返回角色关联的菜单
s.db.Where("id IN ? AND visible = ? AND status = ?", menuIDs, true, "active").
Order("sort ASC, id ASC").Find(&flatMenus)
} else {
// 兼容模式:用户没有角色绑定时返回全部可见菜单
s.db.Where("visible = ? AND status = ?", true, "active").
Order("sort ASC, id ASC").Find(&flatMenus)
s.db.Where("visible = ? AND status = ?", true, "active").
Order("sort ASC, id ASC").Find(&flatMenus)
fmt.Printf("[getUserMenus] 查询到 %d 条可见菜单\n", len(flatMenus))
filtered := filterMenusByPrefix(flatMenus, prefix)
fmt.Printf("[getUserMenus] 过滤后 %d 条菜单 (prefix=%s)\n", len(filtered), prefix)
tree := buildMenuTree(filtered, 0)
fmt.Printf("[getUserMenus] 构建菜单树 %d 个顶级节点\n", len(tree))
return tree
}
// rolePathPrefix 返回角色对应的路由前缀
func rolePathPrefix(role string) string {
switch role {
case "admin":
return "/admin/"
case "patient":
return "/patient/"
case "doctor":
return "/doctor/"
default:
return ""
}
}
return buildMenuTree(flatMenus, 0)
// filterMenusByPrefix 筛选属于指定路径前缀的菜单(包含父级分组节点)
func filterMenusByPrefix(menus []model.Menu, prefix string) []model.Menu {
matchIDs := map[uint]bool{}
parentIDs := map[uint]bool{}
for _, m := range menus {
if m.Path != "" && strings.HasPrefix(m.Path, prefix) {
matchIDs[m.ID] = true
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
}
}
}
for changed := true; changed; {
changed = false
for _, m := range menus {
if parentIDs[m.ID] && !matchIDs[m.ID] {
matchIDs[m.ID] = true
if m.ParentID != 0 {
parentIDs[m.ParentID] = true
}
changed = true
}
}
}
var result []model.Menu
for _, m := range menus {
if matchIDs[m.ID] {
result = append(result, m)
}
}
return result
}
// buildMenuTree 将扁平菜单列表构建为树形结构
......
......@@ -2,10 +2,10 @@ package workflowsvc
import (
"encoding/json"
"net/http"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"internet-hospital/pkg/response"
"internet-hospital/pkg/workflow"
"github.com/gin-gonic/gin"
......@@ -35,25 +35,25 @@ func (h *Handler) Execute(c *gin.Context) {
execID, err := workflow.GetEngine().Execute(c.Request.Context(), workflowID, input, uid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
response.Error(c, 500, err.Error())
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"execution_id": execID}})
response.Success(c, gin.H{"execution_id": execID})
}
func (h *Handler) GetExecution(c *gin.Context) {
var exec model.WorkflowExecution
if err := database.GetDB().Where("execution_id = ?", c.Param("id")).First(&exec).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
response.Error(c, 404, "not found")
return
}
c.JSON(http.StatusOK, gin.H{"data": exec})
response.Success(c, exec)
}
func (h *Handler) ListTasks(c *gin.Context) {
var tasks []model.WorkflowHumanTask
database.GetDB().Where("status = 'pending'").Find(&tasks)
c.JSON(http.StatusOK, gin.H{"data": tasks})
response.Success(c, tasks)
}
func (h *Handler) CompleteTask(c *gin.Context) {
......@@ -65,16 +65,16 @@ func (h *Handler) CompleteTask(c *gin.Context) {
database.GetDB().Model(&model.WorkflowHumanTask{}).
Where("task_id = ?", c.Param("id")).
Updates(map[string]interface{}{"status": "completed", "result": string(resultJSON)})
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
func (h *Handler) DeleteWorkflow(c *gin.Context) {
id := c.Param("workflow_id")
if err := database.GetDB().Where("workflow_id = ?", id).Delete(&model.WorkflowDefinition{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
response.Error(c, 500, "删除失败")
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
func (h *Handler) UpdateWorkflowStatus(c *gin.Context) {
......@@ -83,12 +83,12 @@ func (h *Handler) UpdateWorkflowStatus(c *gin.Context) {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
response.BadRequest(c, "请求参数错误")
return
}
if err := database.GetDB().Model(&model.WorkflowDefinition{}).Where("workflow_id = ?", id).Update("status", req.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
response.Error(c, 500, "更新失败")
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
response.Success(c, nil)
}
......@@ -14,7 +14,8 @@ import (
// contextKey 避免 context key 冲突
type contextKey string
const ContextKeyUserID contextKey = "user_id"
const ContextKeyUserID contextKey = "user_id"
const ContextKeyUserRole contextKey = "user_role"
// StreamEventType SSE 事件类型
type StreamEventType string
......@@ -36,6 +37,7 @@ type StreamEvent struct {
type AgentInput struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
UserRole string `json:"user_role"`
Message string `json:"message"`
Context map[string]interface{} `json:"context"`
History []ai.ChatMessage `json:"history"`
......@@ -123,10 +125,13 @@ func (a *ReActAgent) Description() string { return a.cfg.Description }
// Run 执行Agent(非流式)
func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, error) {
// v15: 将 userID 注入 context,供工具(如 navigate_page)做权限校验
// 将 userID / userRole 注入 context,供工具(如 navigate_page)做权限校验
if input.UserID != "" {
ctx = context.WithValue(ctx, ContextKeyUserID, input.UserID)
}
if input.UserRole != "" {
ctx = context.WithValue(ctx, ContextKeyUserRole, input.UserRole)
}
// 生成链路追踪ID(如果未提供则新建)
traceID := input.TraceID
if traceID == "" {
......@@ -234,10 +239,13 @@ func (a *ReActAgent) Run(ctx context.Context, input AgentInput) (*AgentOutput, e
// RunStream 流式执行Agent,通过 onEvent 实时推送中间过程
// v12改造:最终文本回答使用真流式(ChatWithToolsStream),工具调用阶段仍用非流式。
func (a *ReActAgent) RunStream(ctx context.Context, input AgentInput, onEvent func(StreamEvent) error) (*AgentOutput, error) {
// v15: 将 userID 注入 context,供工具(如 navigate_page)做权限校验
// 将 userID / userRole 注入 context,供工具(如 navigate_page)做权限校验
if input.UserID != "" {
ctx = context.WithValue(ctx, ContextKeyUserID, input.UserID)
}
if input.UserRole != "" {
ctx = context.WithValue(ctx, ContextKeyUserRole, input.UserRole)
}
traceID := input.TraceID
if traceID == "" {
traceID = uuid.New().String()
......
......@@ -10,8 +10,6 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/agent"
"internet-hospital/pkg/database"
"gorm.io/gorm"
)
// menuEntry 缓存的菜单条目(从 menus 表加载)
......@@ -155,18 +153,15 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf
}
}
// RBAC 权限校验
if userID, ok := ctx.Value(agent.ContextKeyUserID).(string); ok && userID != "" {
db := database.GetDB()
allowed, reason := checkPagePermission(db, userID, normalizedCode, entry)
if !allowed {
return map[string]interface{}{
"action": "permission_denied",
"page": normalizedCode,
"page_name": entry.Name,
"message": reason,
}, nil
}
// 基于 User.Role 的权限校验
userRole, _ := ctx.Value(agent.ContextKeyUserRole).(string)
if allowed, reason := checkPagePermission(userRole, normalizedCode, entry); !allowed {
return map[string]interface{}{
"action": "permission_denied",
"page": normalizedCode,
"page_name": entry.Name,
"message": reason,
}, nil
}
// 根据 operation 构建最终路由
......@@ -194,43 +189,38 @@ func (t *NavigatePageTool) Execute(ctx context.Context, params map[string]interf
}, nil
}
// checkPagePermission 基于菜单的权限字段做 RBAC 校验
func checkPagePermission(db *gorm.DB, userID, pageCode string, entry menuEntry) (bool, string) {
if db == nil || userID == "" {
return true, ""
// checkPagePermission 基于 User.Role 的页面导航权限校验
// 规则:
// - admin → 可访问所有页面(admin_*、patient_*、doctor_*)
// - patient → 仅可访问 patient_* 页面
// - doctor → 仅可访问 doctor_* 页面
// - 未知角色 → 拒绝所有
func checkPagePermission(userRole, pageCode string, entry menuEntry) (bool, string) {
if userRole == "" {
return false, fmt.Sprintf("未识别您的角色,无法访问「%s」", entry.Name)
}
// 非 admin 页面不做额外权限校验(依赖 JWT 角色即可)
if !strings.HasPrefix(pageCode, "admin_") {
// admin 拥有全部页面访问权限
if userRole == "admin" {
return true, ""
}
// admin 角色拥有所有权限
var adminCount int64
db.Table("user_roles").
Joins("JOIN roles ON roles.id = user_roles.role_id").
Where("user_roles.user_id = ? AND roles.code = 'admin' AND roles.status = 'active'", userID).
Count(&adminCount)
if adminCount > 0 {
return true, ""
}
// 如果菜单没有配置权限码,放行
if entry.Permission == "" {
return true, ""
}
// 检查用户是否拥有该权限
var permCount int64
db.Table("permissions").
Joins("JOIN role_permissions ON role_permissions.permission_id = permissions.id").
Joins("JOIN user_roles ON user_roles.role_id = role_permissions.role_id").
Where("user_roles.user_id = ? AND permissions.code = ?", userID, entry.Permission).
Count(&permCount)
if permCount > 0 {
// 判断页面所属端:admin_* / patient_* / doctor_*
switch {
case strings.HasPrefix(pageCode, "admin_"):
return false, fmt.Sprintf("您没有访问管理端「%s」页面的权限", entry.Name)
case strings.HasPrefix(pageCode, "patient_"):
if userRole == "patient" {
return true, ""
}
return false, fmt.Sprintf("该页面仅限患者访问", )
case strings.HasPrefix(pageCode, "doctor_"):
if userRole == "doctor" {
return true, ""
}
return false, fmt.Sprintf("该页面仅限医生访问")
default:
// 无明确前缀的页面(如果有),放行
return true, ""
}
return false, fmt.Sprintf("您没有访问「%s」页面的权限,请联系管理员", entry.Name)
}
// Deprecated: 本文件中的 RequirePermission 中间件基于 RBAC 表(roles/permissions/
// user_roles/role_permissions),但系统已简化为以 User.Role 作为唯一权限依据。
// 当前无任何路由挂载此中间件,保留文件仅供参考,请勿在新代码中使用。
package middleware
import (
......
......@@ -70,7 +70,7 @@ type ExecutionContext struct {
// Engine 工作流引擎
type Engine struct {
agentSvc interface {
Chat(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (interface{}, error)
Chat(ctx context.Context, agentID, userID, userRole, sessionID, message string, ctxData map[string]interface{}) (interface{}, error)
}
toolRegistry *agentpkg.ToolRegistry
}
......@@ -85,7 +85,7 @@ func GetEngine() *Engine {
}
func SetAgentService(svc interface {
Chat(ctx context.Context, agentID, userID, sessionID, message string, ctxData map[string]interface{}) (interface{}, error)
Chat(ctx context.Context, agentID, userID, userRole, sessionID, message string, ctxData map[string]interface{}) (interface{}, error)
}) {
GetEngine().agentSvc = svc
}
......@@ -226,7 +226,8 @@ func (e *Engine) executeAgent(ctx context.Context, node *Node, execCtx *Executio
agentID, _ := node.Config["agent_id"].(string)
message, _ := execCtx.Variables["message"].(string)
userID, _ := execCtx.Variables["user_id"].(string)
return e.agentSvc.Chat(ctx, agentID, userID, execCtx.ExecutionID, message, execCtx.Variables)
userRole, _ := execCtx.Variables["user_role"].(string)
return e.agentSvc.Chat(ctx, agentID, userID, userRole, execCtx.ExecutionID, message, execCtx.Variables)
}
func (e *Engine) executeCondition(_ context.Context, node *Node, execCtx *ExecutionContext) (interface{}, error) {
......
import { get, post, put, del } from './request';
import { get, put, del, post } from './request';
// ==================== Types ====================
......@@ -14,16 +14,6 @@ export interface Role {
updated_at: string;
}
export interface Permission {
id: number;
code: string;
name: string;
module: string;
action: string;
description: string;
created_at: string;
}
export interface Menu {
id: number;
parent_id: number;
......@@ -41,28 +31,19 @@ export interface Menu {
children?: Menu[];
}
// ==================== Role API ====================
// ==================== Role API (read-only) ====================
export const roleApi = {
list: () => get<Role[]>('/admin/roles'),
create: (data: Partial<Role>) => post<Role>('/admin/roles', data),
update: (id: number, data: Partial<Role>) => put<Role>(`/admin/roles/${id}`, data),
delete: (id: number) => del<null>(`/admin/roles/${id}`),
getPermissions: (id: number) => get<Permission[]>(`/admin/roles/${id}/permissions`),
setPermissions: (id: number, permissionIds: number[]) =>
put<null>(`/admin/roles/${id}/permissions`, { permission_ids: permissionIds }),
getMenus: (id: number) => get<number[]>(`/admin/roles/${id}/menus`),
setMenus: (id: number, menuIds: number[]) =>
put<null>(`/admin/roles/${id}/menus`, { menu_ids: menuIds }),
getMenus: (roleId: number) => get<{ menu_ids: number[] }>(`/admin/roles/${roleId}/menus`),
setMenus: (roleId: number, menuIds: number[]) => put<null>(`/admin/roles/${roleId}/menus`, { menu_ids: menuIds }),
};
// ==================== Permission API ====================
// ==================== User Role API ====================
export const permissionApi = {
list: () => get<Permission[]>('/admin/permissions'),
create: (data: Partial<Permission>) => post<Permission>('/admin/permissions', data),
export const userRoleApi = {
setUserRole: (userId: number | string, role: 'patient' | 'doctor' | 'admin') =>
put<null>(`/admin/users/${userId}/role`, { role }),
};
// ==================== Menu API ====================
......@@ -73,15 +54,6 @@ export const menuApi = {
create: (data: Partial<Menu>) => post<Menu>('/admin/menus', data),
update: (id: number, data: Partial<Menu>) => put<Menu>(`/admin/menus/${id}`, data),
delete: (id: number) => del<null>(`/admin/menus/${id}`),
reseed: () => post<{ message: string }>('/admin/menus/reseed', {}),
};
// ==================== User Role API ====================
export const userRoleApi = {
getUserRoles: (userId: number) => get<Role[]>(`/admin/users/${userId}/roles`),
setUserRoles: (userId: number, roleIds: number[]) =>
put<null>(`/admin/users/${userId}/roles`, { role_ids: roleIds }),
};
// ==================== My Menus (current user) ====================
......
......@@ -14,6 +14,7 @@ import {
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
......@@ -102,12 +103,20 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams?.get('embed') === '1';
const { user, logout, menus } = useUserStore();
const { user, logout, menus, setMenus } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
// 菜单为空时主动拉取
useEffect(() => {
if (!user || menus.length > 0) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
}).catch(() => {});
}, [user, menus.length, setMenus]);
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
......
This diff is collapsed.
This diff is collapsed.
'use client';
import React, { Suspense, useState, useEffect } from 'react';
import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag, Popover, List } from 'antd';
import {
......@@ -8,24 +8,78 @@ import {
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
DollarOutlined, FolderOpenOutlined, SafetyCertificateOutlined,
CheckCircleOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
HomeOutlined, FileTextOutlined, HeartOutlined, VideoCameraOutlined,
PayCircleOutlined, ShoppingCartOutlined, SearchOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { doctorPortalApi } from '@/api/doctorPortal';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/doctor/workbench', icon: <DashboardOutlined />, label: '工作台' },
{ key: '/doctor/consult', icon: <MessageOutlined />, label: '问诊大厅' },
{ key: '/doctor/tasks', icon: <CheckCircleOutlined />, label: '待办任务' },
{ key: '/doctor/chronic/review', icon: <SafetyCertificateOutlined />, label: '慢病续方' },
{ key: '/doctor/patient', icon: <FolderOpenOutlined />, label: '患者档案' },
{ key: '/doctor/schedule', icon: <CalendarOutlined />, label: '排班管理' },
{ key: '/doctor/income', icon: <DollarOutlined />, label: '收入统计' },
{ key: '/doctor/profile', icon: <UserOutlined />, label: '个人信息' },
];
const ICON_MAP: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
MessageOutlined: <MessageOutlined />,
CalendarOutlined: <CalendarOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
DollarOutlined: <DollarOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
HomeOutlined: <HomeOutlined />,
FileTextOutlined: <FileTextOutlined />,
HeartOutlined: <HeartOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PayCircleOutlined: <PayCircleOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
SearchOutlined: <SearchOutlined />,
SettingOutlined: <SettingOutlined />,
BellOutlined: <BellOutlined />,
};
function convertMenuTree(menus: MenuType[]): any[] {
return menus.map(m => {
const item: any = {
key: m.path || `menu-${m.id}`,
label: m.name,
icon: ICON_MAP[m.icon] || null,
};
if (m.children && m.children.length > 0) {
item.children = convertMenuTree(m.children);
}
return item;
});
}
function collectLeafPaths(menus: MenuType[]): string[] {
const paths: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
paths.push(...collectLeafPaths(m.children));
} else if (m.path) {
paths.push(m.path);
}
}
return paths;
}
function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
const keys: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
const childPaths = collectLeafPaths(m.children);
if (childPaths.some(p => targetPath.startsWith(p))) {
keys.push(m.path || `menu-${m.id}`);
}
keys.push(...findOpenKeys(m.children, targetPath));
}
}
return keys;
}
export default function DoctorLayout({ children }: { children: React.ReactNode }) {
return (
......@@ -40,13 +94,23 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams?.get('embed') === '1';
const { user, logout } = useUserStore();
const { user, logout, menus, setMenus } = useUserStore();
const [isOnline, setIsOnline] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 菜单为空时主动拉取
useEffect(() => {
if (!user || menus.length > 0) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
}).catch(() => {});
}, [user, menus.length, setMenus]);
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
......@@ -108,10 +172,16 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
else if (key === 'settings') { router.push('/doctor/profile'); }
};
const getSelectedKeys = () => {
const match = menuItems.find(item => currentPath.startsWith(item.key));
return match ? [match.key] : [];
};
const getSelectedKeys = useCallback(() => {
const allPaths = collectLeafPaths(menus);
const match = allPaths.find(k => currentPath.startsWith(k));
return match ? [match] : [];
}, [menus, currentPath]);
const getOpenKeys = useCallback(() => {
if (collapsed) return [];
return findOpenKeys(menus, currentPath);
}, [menus, currentPath, collapsed]);
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f8ff' }}>{children}</div>;
......@@ -161,6 +231,7 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
<Menu
mode="inline"
selectedKeys={getSelectedKeys()}
defaultOpenKeys={getOpenKeys()}
items={menuItems}
onClick={handleMenuClick}
style={{ border: 'none', padding: '8px 0' }}
......
import { Suspense } from 'react';
import { Spin } from 'antd';
import PageComponent from '@/pages/patient/ConsultCreate';
export default function Page() {
return (
<Suspense fallback={<div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" tip="加载中..." /></div>}>
<PageComponent />
</Suspense>
);
}
'use client';
import React, { Suspense, useState, useEffect } from 'react';
import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag, Popover, List } from 'antd';
import {
......@@ -8,46 +8,79 @@ import {
HeartOutlined, SettingOutlined, LogoutOutlined, BellOutlined,
VideoCameraOutlined, PayCircleOutlined,
ShoppingCartOutlined, FolderOpenOutlined, SearchOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, DashboardOutlined,
MessageOutlined, CalendarOutlined, DollarOutlined,
SafetyCertificateOutlined, CheckCircleOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/patient/home', icon: <HomeOutlined />, label: '首页' },
{
key: 'consult-services', icon: <VideoCameraOutlined />, label: '问诊服务',
children: [
{ key: '/patient/doctors', icon: <SearchOutlined />, label: '找医生' },
{ key: '/patient/consult', icon: <VideoCameraOutlined />, label: '我的问诊' },
],
},
{
key: 'rx-pharmacy', icon: <MedicineBoxOutlined />, label: '处方购药',
children: [
{ key: '/patient/prescription', icon: <FileTextOutlined />, label: '电子处方' },
{ key: '/patient/pharmacy/order', icon: <ShoppingCartOutlined />, label: '药品配送' },
{ key: '/patient/payment', icon: <PayCircleOutlined />, label: '支付管理' },
],
},
{
key: 'health-mgmt', icon: <HeartOutlined />, label: '健康管理',
children: [
{ key: '/patient/chronic', icon: <HeartOutlined />, label: '慢病管理' },
{ key: '/patient/health-records', icon: <FolderOpenOutlined />, label: '健康档案' },
],
},
{ key: '/patient/profile', icon: <UserOutlined />, label: '个人中心' },
];
// 图标名称 → React 图标组件映射
const ICON_MAP: Record<string, React.ReactNode> = {
HomeOutlined: <HomeOutlined />,
UserOutlined: <UserOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
FileTextOutlined: <FileTextOutlined />,
HeartOutlined: <HeartOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
PayCircleOutlined: <PayCircleOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
FolderOpenOutlined: <FolderOpenOutlined />,
SearchOutlined: <SearchOutlined />,
SettingOutlined: <SettingOutlined />,
DashboardOutlined: <DashboardOutlined />,
MessageOutlined: <MessageOutlined />,
CalendarOutlined: <CalendarOutlined />,
DollarOutlined: <DollarOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
BellOutlined: <BellOutlined />,
};
const allRouteKeys = [
'/patient/home', '/patient/doctors', '/patient/consult',
'/patient/prescription', '/patient/pharmacy/order', '/patient/payment',
'/patient/chronic', '/patient/health-records', '/patient/profile',
];
function convertMenuTree(menus: MenuType[]): any[] {
return menus.map(m => {
const item: any = {
key: m.path || `menu-${m.id}`,
label: m.name,
icon: ICON_MAP[m.icon] || null,
};
if (m.children && m.children.length > 0) {
item.children = convertMenuTree(m.children);
}
return item;
});
}
function collectLeafPaths(menus: MenuType[]): string[] {
const paths: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
paths.push(...collectLeafPaths(m.children));
} else if (m.path) {
paths.push(m.path);
}
}
return paths;
}
function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
const keys: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
const childPaths = collectLeafPaths(m.children);
if (childPaths.some(p => targetPath.startsWith(p))) {
keys.push(m.path || `menu-${m.id}`);
}
keys.push(...findOpenKeys(m.children, targetPath));
}
}
return keys;
}
export default function PatientLayout({ children }: { children: React.ReactNode }) {
return (
......@@ -62,12 +95,22 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams?.get('embed') === '1';
const { user, logout } = useUserStore();
const { user, logout, menus, setMenus } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 菜单为空时主动拉取
useEffect(() => {
if (!user || menus.length > 0) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
}).catch(() => {});
}, [user, menus.length, setMenus]);
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
......@@ -116,25 +159,16 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
else if (key === 'profile') router.push('/patient/profile');
};
const getSelectedKeys = () => {
const match = allRouteKeys.find(k => currentPath.startsWith(k));
const getSelectedKeys = useCallback(() => {
const allPaths = collectLeafPaths(menus);
const match = allPaths.find(k => currentPath.startsWith(k));
return match ? [match] : [];
};
}, [menus, currentPath]);
const getOpenKeys = () => {
const getOpenKeys = useCallback(() => {
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 findOpenKeys(menus, currentPath);
}, [menus, currentPath, collapsed]);
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f8ff' }}>{children}</div>;
......
'use client';
import React, { useCallback, useMemo, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Tooltip, Spin } from 'antd';
import {
RobotOutlined,
......@@ -137,6 +138,9 @@ const FloatContainer: React.FC = () => {
if (!mounted || hidden) return null;
// 使用 Portal 渲染到 document.body,确保不受父级 Layout stacking context 影响
const content = (() => {
// ==================== Minimized: floating circle button ====================
if (!isOpen) {
return (
......@@ -375,6 +379,10 @@ const FloatContainer: React.FC = () => {
</div>
</Rnd>
);
})(); // end content IIFE
return createPortal(content, document.body);
};
// ---------- Small header button ----------
......
......@@ -10,6 +10,7 @@ interface UserState {
isAuthenticated: boolean;
menus: Menu[];
setUser: (user: UserInfo, menus?: Menu[]) => void;
setMenus: (menus: Menu[]) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
}
......@@ -25,6 +26,8 @@ export const useUserStore = create<UserState>()(
setUser: (user, menus) => set({ user, isAuthenticated: true, menus: menus || [] }),
setMenus: (menus) => set({ menus }),
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
......
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