Sfoglia il codice sorgente

用户管理增强

537yaha 3 mesi fa
parent
commit
a65e22c81a
32 ha cambiato i file con 667 aggiunte e 144 eliminazioni
  1. 5 0
      backend/controller/admin_controller.go
  2. 18 2
      backend/controller/ai_config_controller.go
  3. 4 5
      backend/controller/analytics_controller.go
  4. 11 0
      backend/controller/auth_controller.go
  5. 9 0
      backend/controller/conversation_controller.go
  6. 30 1
      backend/controller/document_controller.go
  7. 9 2
      backend/controller/embedding_config_controller.go
  8. 18 2
      backend/controller/faq_controller.go
  9. 17 0
      backend/controller/helper.go
  10. 9 1
      backend/controller/import_controller.go
  11. 21 1
      backend/controller/knowledge_base_controller.go
  12. 9 2
      backend/controller/prompt_config_controller.go
  13. 4 5
      backend/controller/system_log_controller.go
  14. 10 10
      backend/main.go
  15. 3 0
      backend/models/user.go
  16. 102 0
      backend/service/permissions.go
  17. 10 0
      backend/service/profile_service.go
  18. 4 0
      backend/service/types.go
  19. 78 1
      backend/service/user_service.go
  20. 4 0
      frontend/app/agent/login/page.tsx
  21. 83 0
      frontend/app/agent/users/page.tsx
  22. 106 85
      frontend/components/dashboard/NavigationSidebar.tsx
  23. 32 0
      frontend/components/ui/tooltip.tsx
  24. 6 3
      frontend/features/agent/services/aiConfigApi.ts
  25. 6 5
      frontend/features/agent/services/conversationApi.ts
  26. 3 2
      frontend/features/agent/services/embeddingConfigApi.ts
  27. 3 2
      frontend/features/agent/services/promptsApi.ts
  28. 3 0
      frontend/features/agent/services/userApi.ts
  29. 1 0
      frontend/features/agent/types.ts
  30. 16 15
      frontend/lib/constants/agent-pages.tsx
  31. 18 0
      frontend/lib/constants/agent-permissions.ts
  32. 15 0
      frontend/utils/storage.ts

+ 5 - 0
backend/controller/admin_controller.go

@@ -61,6 +61,7 @@ type createAgentRequest struct {
 	Username string `json:"username"`
 	Password string `json:"password"`
 	Role     string `json:"role"`
+	Permissions []string `json:"permissions"`
 }
 
 // CreateAgent 处理创建客服或管理员账号的请求。
@@ -157,6 +158,7 @@ func (a *AdminController) CreateUser(c *gin.Context) {
 		Username string  `json:"username"`
 		Password string  `json:"password"`
 		Role     string  `json:"role"`
+		Permissions []string `json:"permissions"`
 		Nickname *string `json:"nickname"`
 		Email    *string `json:"email"`
 	}
@@ -170,6 +172,7 @@ func (a *AdminController) CreateUser(c *gin.Context) {
 		Username: req.Username,
 		Password: req.Password,
 		Role:     req.Role,
+		Permissions: req.Permissions,
 		Nickname: req.Nickname,
 		Email:    req.Email,
 	})
@@ -209,6 +212,7 @@ func (a *AdminController) UpdateUser(c *gin.Context) {
 
 	var req struct {
 		Role                   *string `json:"role"`
+		Permissions            *[]string `json:"permissions"`
 		Nickname               *string `json:"nickname"`
 		Email                  *string `json:"email"`
 		ReceiveAIConversations *bool   `json:"receive_ai_conversations"`
@@ -222,6 +226,7 @@ func (a *AdminController) UpdateUser(c *gin.Context) {
 	user, err := a.userService.UpdateUser(service.UpdateUserInput{
 		UserID:                 uint(id),
 		Role:                   req.Role,
+		Permissions:            req.Permissions,
 		Nickname:               req.Nickname,
 		Email:                  req.Email,
 		ReceiveAIConversations: req.ReceiveAIConversations,

+ 18 - 2
backend/controller/ai_config_controller.go

@@ -10,11 +10,12 @@ import (
 // AIConfigController 负责处理 AI 配置相关的 HTTP 请求。
 type AIConfigController struct {
 	aiConfigService *service.AIConfigService
+	userService     *service.UserService
 }
 
 // NewAIConfigController 创建 AI 配置控制器实例。
-func NewAIConfigController(aiConfigService *service.AIConfigService) *AIConfigController {
-	return &AIConfigController{aiConfigService: aiConfigService}
+func NewAIConfigController(aiConfigService *service.AIConfigService, userService *service.UserService) *AIConfigController {
+	return &AIConfigController{aiConfigService: aiConfigService, userService: userService}
 }
 
 type createAIConfigRequest struct {
@@ -41,6 +42,9 @@ type updateAIConfigRequest struct {
 
 // CreateAIConfig 创建 AI 配置。
 func (a *AIConfigController) CreateAIConfig(c *gin.Context) {
+	if !requirePermission(c, a.userService, string(service.PermSettings)) {
+		return
+	}
 	userID, err := parseUintParam(c, "user_id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
@@ -74,6 +78,9 @@ func (a *AIConfigController) CreateAIConfig(c *gin.Context) {
 
 // GetAIConfig 获取 AI 配置。
 func (a *AIConfigController) GetAIConfig(c *gin.Context) {
+	if !requirePermission(c, a.userService, string(service.PermSettings)) {
+		return
+	}
 	id, err := parseUintParam(c, "id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})
@@ -91,6 +98,9 @@ func (a *AIConfigController) GetAIConfig(c *gin.Context) {
 
 // ListAIConfigs 获取指定用户的所有 AI 配置。
 func (a *AIConfigController) ListAIConfigs(c *gin.Context) {
+	if !requirePermission(c, a.userService, string(service.PermSettings)) {
+		return
+	}
 	userID, err := parseUintParam(c, "user_id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
@@ -108,6 +118,9 @@ func (a *AIConfigController) ListAIConfigs(c *gin.Context) {
 
 // UpdateAIConfig 更新 AI 配置。
 func (a *AIConfigController) UpdateAIConfig(c *gin.Context) {
+	if !requirePermission(c, a.userService, string(service.PermSettings)) {
+		return
+	}
 	id, err := parseUintParam(c, "id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})
@@ -141,6 +154,9 @@ func (a *AIConfigController) UpdateAIConfig(c *gin.Context) {
 
 // DeleteAIConfig 删除 AI 配置。
 func (a *AIConfigController) DeleteAIConfig(c *gin.Context) {
+	if !requirePermission(c, a.userService, string(service.PermSettings)) {
+		return
+	}
 	id, err := parseUintParam(c, "id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})

+ 4 - 5
backend/controller/analytics_controller.go

@@ -11,17 +11,16 @@ import (
 // AnalyticsController 数据分析报表(客服端查询 + 访客端埋点)
 type AnalyticsController struct {
 	analytics *service.AnalyticsService
+	users     *service.UserService
 }
 
-func NewAnalyticsController(analytics *service.AnalyticsService) *AnalyticsController {
-	return &AnalyticsController{analytics: analytics}
+func NewAnalyticsController(analytics *service.AnalyticsService, users *service.UserService) *AnalyticsController {
+	return &AnalyticsController{analytics: analytics, users: users}
 }
 
 // GetSummary GET /agent/analytics/summary?from=YYYY-MM-DD&to=YYYY-MM-DD
 func (ac *AnalyticsController) GetSummary(c *gin.Context) {
-	userID := getUserIDFromHeader(c)
-	if userID == 0 {
-		c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权,请提供 X-User-Id"})
+	if !requirePermission(c, ac.users, string(service.PermAnalytics)) {
 		return
 	}
 	from := c.Query("from")

+ 11 - 0
backend/controller/auth_controller.go

@@ -46,6 +46,17 @@ func (a *AuthController) Login(c *gin.Context) {
 		"user_id":  user.ID,
 		"username": user.Username,
 		"role":     user.Role,
+		// permissions 用于前端侧边栏显示(后端强校验以 X-User-Id 为准)
+		"permissions": func() []string {
+			if user.Role == "admin" {
+				return service.AllPermissionKeys()
+			}
+			keys := service.DecodePermissions(user.Permissions)
+			if len(keys) == 0 {
+				return service.DefaultAgentPermissions()
+			}
+			return keys
+		}(),
 	})
 }
 

+ 9 - 0
backend/controller/conversation_controller.go

@@ -13,16 +13,19 @@ import (
 type ConversationController struct {
 	conversationService *service.ConversationService
 	aiConfigService     *service.AIConfigService // 用于获取开放的模型列表
+	users               *service.UserService
 }
 
 // NewConversationController 创建 ConversationController 实例。
 func NewConversationController(
 	conversationService *service.ConversationService,
 	aiConfigService *service.AIConfigService,
+	users *service.UserService,
 ) *ConversationController {
 	return &ConversationController{
 		conversationService: conversationService,
 		aiConfigService:     aiConfigService,
+		users:               users,
 	}
 }
 
@@ -88,6 +91,9 @@ func (cc *ConversationController) InitConversation(c *gin.Context) {
 
 // InitInternalConversation 为当前客服创建一条新的内部对话(知识库测试)。需要 query user_id。
 func (cc *ConversationController) InitInternalConversation(c *gin.Context) {
+	if !requirePermission(c, cc.users, string(service.PermKBTest)) {
+		return
+	}
 	userIDStr := c.Query("user_id")
 	if userIDStr == "" {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "需要 user_id"})
@@ -174,6 +180,9 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 	var conversations []service.ConversationSummary
 	var err error
 	if conversationType == "internal" {
+		if !requirePermission(c, cc.users, string(service.PermKBTest)) {
+			return
+		}
 		if userID == 0 {
 			c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"})
 			return

+ 30 - 1
backend/controller/document_controller.go

@@ -13,13 +13,15 @@ import (
 type DocumentController struct {
 	documentService       *service.DocumentService
 	embeddingConfigService *service.EmbeddingConfigService
+	users                 *service.UserService
 }
 
 // NewDocumentController 创建文档控制器实例
-func NewDocumentController(documentService *service.DocumentService, embeddingConfigService *service.EmbeddingConfigService) *DocumentController {
+func NewDocumentController(documentService *service.DocumentService, embeddingConfigService *service.EmbeddingConfigService, users *service.UserService) *DocumentController {
 	return &DocumentController{
 		documentService:       documentService,
 		embeddingConfigService: embeddingConfigService,
+		users:                 users,
 	}
 }
 
@@ -37,6 +39,9 @@ func (c *DocumentController) checkKBAccess(ctx *gin.Context) bool {
 
 // ListDocuments 获取文档列表
 func (c *DocumentController) ListDocuments(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -70,6 +75,9 @@ func (c *DocumentController) ListDocuments(ctx *gin.Context) {
 
 // GetDocument 获取文档详情
 func (c *DocumentController) GetDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -92,6 +100,9 @@ func (c *DocumentController) GetDocument(ctx *gin.Context) {
 
 // CreateDocument 创建文档
 func (c *DocumentController) CreateDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -128,6 +139,9 @@ func (c *DocumentController) CreateDocument(ctx *gin.Context) {
 
 // UpdateDocument 更新文档
 func (c *DocumentController) UpdateDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -169,6 +183,9 @@ func (c *DocumentController) UpdateDocument(ctx *gin.Context) {
 
 // DeleteDocument 删除文档
 func (c *DocumentController) DeleteDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -190,6 +207,9 @@ func (c *DocumentController) DeleteDocument(ctx *gin.Context) {
 
 // SearchDocuments 向量检索搜索文档
 func (c *DocumentController) SearchDocuments(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -236,6 +256,9 @@ func (c *DocumentController) HybridSearchDocuments(ctx *gin.Context) {
 
 // UpdateDocumentStatus 更新文档状态
 func (c *DocumentController) UpdateDocumentStatus(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -266,6 +289,9 @@ func (c *DocumentController) UpdateDocumentStatus(ctx *gin.Context) {
 
 // PublishDocument 发布文档
 func (c *DocumentController) PublishDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -287,6 +313,9 @@ func (c *DocumentController) PublishDocument(ctx *gin.Context) {
 
 // UnpublishDocument 取消发布文档
 func (c *DocumentController) UnpublishDocument(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}

+ 9 - 2
backend/controller/embedding_config_controller.go

@@ -10,16 +10,20 @@ import (
 // EmbeddingConfigController 知识库向量配置控制器
 type EmbeddingConfigController struct {
 	service *service.EmbeddingConfigService
+	users   *service.UserService
 }
 
 // NewEmbeddingConfigController 创建控制器实例
-func NewEmbeddingConfigController(s *service.EmbeddingConfigService) *EmbeddingConfigController {
-	return &EmbeddingConfigController{service: s}
+func NewEmbeddingConfigController(s *service.EmbeddingConfigService, users *service.UserService) *EmbeddingConfigController {
+	return &EmbeddingConfigController{service: s, users: users}
 }
 
 // Get 获取当前配置(API Key 脱敏)
 // GET /agent/embedding-config?user_id=1
 func (e *EmbeddingConfigController) Get(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
 	_, err := parseUintQuery(c, "user_id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
@@ -37,6 +41,9 @@ func (e *EmbeddingConfigController) Get(c *gin.Context) {
 // PUT /agent/embedding-config
 // Body: { "user_id": 1, "embedding_type": "openai", "api_url": "...", "api_key": "...", "model": "...", "customer_can_use_kb": true }
 func (e *EmbeddingConfigController) Update(c *gin.Context) {
+	if !requirePermission(c, e.users, string(service.PermSettings)) {
+		return
+	}
 	var req struct {
 		UserID                  uint   `json:"user_id" binding:"required"`
 		EmbeddingType           *string `json:"embedding_type"`

+ 18 - 2
backend/controller/faq_controller.go

@@ -12,16 +12,20 @@ import (
 // FAQController 负责处理 FAQ(常见问题)相关的 HTTP 请求。
 type FAQController struct {
 	faqService *service.FAQService
+	users      *service.UserService
 }
 
 // NewFAQController 创建 FAQController 实例。
-func NewFAQController(faqService *service.FAQService) *FAQController {
-	return &FAQController{faqService: faqService}
+func NewFAQController(faqService *service.FAQService, users *service.UserService) *FAQController {
+	return &FAQController{faqService: faqService, users: users}
 }
 
 // ListFAQs 获取 FAQ 列表,支持关键词搜索。
 // GET /faqs?query=openai%api%调用
 func (f *FAQController) ListFAQs(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermFAQs)) {
+		return
+	}
 	// 获取查询参数
 	query := c.Query("query")
 
@@ -41,6 +45,9 @@ func (f *FAQController) ListFAQs(c *gin.Context) {
 // GetFAQ 获取 FAQ 详情。
 // GET /faqs/:id
 func (f *FAQController) GetFAQ(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermFAQs)) {
+		return
+	}
 	// 获取 ID
 	idStr := c.Param("id")
 	id, err := strconv.ParseUint(idStr, 10, 64)
@@ -63,6 +70,9 @@ func (f *FAQController) GetFAQ(c *gin.Context) {
 // CreateFAQ 创建新的 FAQ 记录。
 // POST /faqs
 func (f *FAQController) CreateFAQ(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermFAQs)) {
+		return
+	}
 	var req struct {
 		Question string `json:"question" binding:"required"`
 		Answer   string `json:"answer" binding:"required"`
@@ -92,6 +102,9 @@ func (f *FAQController) CreateFAQ(c *gin.Context) {
 // UpdateFAQ 更新 FAQ 记录。
 // PUT /faqs/:id
 func (f *FAQController) UpdateFAQ(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermFAQs)) {
+		return
+	}
 	// 获取 ID
 	idStr := c.Param("id")
 	id, err := strconv.ParseUint(idStr, 10, 64)
@@ -129,6 +142,9 @@ func (f *FAQController) UpdateFAQ(c *gin.Context) {
 // DeleteFAQ 删除 FAQ 记录。
 // DELETE /faqs/:id
 func (f *FAQController) DeleteFAQ(c *gin.Context) {
+	if !requirePermission(c, f.users, string(service.PermFAQs)) {
+		return
+	}
 	// 获取 ID
 	idStr := c.Param("id")
 	id, err := strconv.ParseUint(idStr, 10, 64)

+ 17 - 0
backend/controller/helper.go

@@ -4,6 +4,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
 )
 
@@ -60,3 +61,19 @@ func getTraceID(c *gin.Context) string {
 	}
 	return ""
 }
+
+// requirePermission 统一的权限校验(基于 X-User-Id)。
+// 返回 true 表示允许继续;false 表示已输出错误响应。
+func requirePermission(c *gin.Context, userSvc *service.UserService, perm string) bool {
+	if userSvc == nil {
+		c.JSON(500, gin.H{"error": "权限服务未初始化"})
+		return false
+	}
+	userID := getUserIDFromHeader(c)
+	if err := userSvc.CheckPermission(userID, perm); err != nil {
+		// 未授权/无权限统一 403(避免泄露过多信息)
+		c.JSON(403, gin.H{"error": err.Error()})
+		return false
+	}
+	return true
+}

+ 9 - 1
backend/controller/import_controller.go

@@ -17,13 +17,15 @@ import (
 type ImportController struct {
 	importService          *service.ImportService
 	embeddingConfigService *service.EmbeddingConfigService
+	users                 *service.UserService
 }
 
 // NewImportController 创建导入控制器实例
-func NewImportController(importService *service.ImportService, embeddingConfigService *service.EmbeddingConfigService) *ImportController {
+func NewImportController(importService *service.ImportService, embeddingConfigService *service.EmbeddingConfigService, users *service.UserService) *ImportController {
 	return &ImportController{
 		importService:          importService,
 		embeddingConfigService: embeddingConfigService,
+		users:                 users,
 	}
 }
 
@@ -43,6 +45,9 @@ func (c *ImportController) checkKBAccess(ctx *gin.Context) bool {
 
 // ImportDocuments 批量导入文档(文件上传)
 func (c *ImportController) ImportDocuments(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -138,6 +143,9 @@ func (c *ImportController) ImportDocuments(ctx *gin.Context) {
 
 // ImportFromURLs 批量导入文档(URL 爬取)
 func (c *ImportController) ImportFromURLs(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}

+ 21 - 1
backend/controller/knowledge_base_controller.go

@@ -13,13 +13,15 @@ import (
 type KnowledgeBaseController struct {
 	knowledgeBaseService   *service.KnowledgeBaseService
 	embeddingConfigService *service.EmbeddingConfigService
+	users                  *service.UserService
 }
 
 // NewKnowledgeBaseController 创建知识库控制器实例
-func NewKnowledgeBaseController(knowledgeBaseService *service.KnowledgeBaseService, embeddingConfigService *service.EmbeddingConfigService) *KnowledgeBaseController {
+func NewKnowledgeBaseController(knowledgeBaseService *service.KnowledgeBaseService, embeddingConfigService *service.EmbeddingConfigService, users *service.UserService) *KnowledgeBaseController {
 	return &KnowledgeBaseController{
 		knowledgeBaseService:   knowledgeBaseService,
 		embeddingConfigService: embeddingConfigService,
+		users:                  users,
 	}
 }
 
@@ -38,6 +40,9 @@ func (c *KnowledgeBaseController) checkKBAccess(ctx *gin.Context) bool {
 
 // ListKnowledgeBases 获取知识库列表
 func (c *KnowledgeBaseController) ListKnowledgeBases(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -55,6 +60,9 @@ func (c *KnowledgeBaseController) ListKnowledgeBases(ctx *gin.Context) {
 
 // GetKnowledgeBase 获取知识库详情
 func (c *KnowledgeBaseController) GetKnowledgeBase(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -77,6 +85,9 @@ func (c *KnowledgeBaseController) GetKnowledgeBase(ctx *gin.Context) {
 
 // CreateKnowledgeBase 创建知识库
 func (c *KnowledgeBaseController) CreateKnowledgeBase(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -105,6 +116,9 @@ func (c *KnowledgeBaseController) CreateKnowledgeBase(ctx *gin.Context) {
 
 // UpdateKnowledgeBase 更新知识库
 func (c *KnowledgeBaseController) UpdateKnowledgeBase(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -142,6 +156,9 @@ func (c *KnowledgeBaseController) UpdateKnowledgeBase(ctx *gin.Context) {
 
 // DeleteKnowledgeBase 删除知识库
 func (c *KnowledgeBaseController) DeleteKnowledgeBase(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}
@@ -163,6 +180,9 @@ func (c *KnowledgeBaseController) DeleteKnowledgeBase(ctx *gin.Context) {
 
 // UpdateKnowledgeBaseRAGEnabled 仅更新知识库「参与 RAG」开关。
 func (c *KnowledgeBaseController) UpdateKnowledgeBaseRAGEnabled(ctx *gin.Context) {
+	if !requirePermission(ctx, c.users, string(service.PermKnowledge)) {
+		return
+	}
 	if !c.checkKBAccess(ctx) {
 		return
 	}

+ 9 - 2
backend/controller/prompt_config_controller.go

@@ -10,16 +10,20 @@ import (
 // PromptConfigController 提示词配置控制器(供「提示词」页)
 type PromptConfigController struct {
 	service *service.PromptConfigService
+	users   *service.UserService
 }
 
 // NewPromptConfigController 创建控制器实例
-func NewPromptConfigController(s *service.PromptConfigService) *PromptConfigController {
-	return &PromptConfigController{service: s}
+func NewPromptConfigController(s *service.PromptConfigService, users *service.UserService) *PromptConfigController {
+	return &PromptConfigController{service: s, users: users}
 }
 
 // Get 获取所有提示词项(含默认内容)
 // GET /agent/prompts?user_id=1
 func (p *PromptConfigController) Get(c *gin.Context) {
+	if !requirePermission(c, p.users, string(service.PermPrompts)) {
+		return
+	}
 	_, err := parseUintQuery(c, "user_id")
 	if err != nil {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
@@ -37,6 +41,9 @@ func (p *PromptConfigController) Get(c *gin.Context) {
 // PUT /agent/prompts
 // Body: { "user_id": 1, "key": "rag_prompt", "content": "..." }
 func (p *PromptConfigController) Update(c *gin.Context) {
+	if !requirePermission(c, p.users, string(service.PermPrompts)) {
+		return
+	}
 	var req struct {
 		UserID  uint   `json:"user_id" binding:"required"`
 		Key     string `json:"key" binding:"required"`

+ 4 - 5
backend/controller/system_log_controller.go

@@ -11,17 +11,16 @@ import (
 
 type SystemLogController struct {
 	logs *service.SystemLogService
+	users *service.UserService
 }
 
-func NewSystemLogController(logs *service.SystemLogService) *SystemLogController {
-	return &SystemLogController{logs: logs}
+func NewSystemLogController(logs *service.SystemLogService, users *service.UserService) *SystemLogController {
+	return &SystemLogController{logs: logs, users: users}
 }
 
 // GetLogs 查询日志(客服端)。
 func (lc *SystemLogController) GetLogs(c *gin.Context) {
-	userID := getUserIDFromHeader(c)
-	if userID == 0 {
-		c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权,请提供 X-User-Id"})
+	if !requirePermission(c, lc.users, string(service.PermLogs)) {
 		return
 	}
 	var convID *uint

+ 10 - 10
backend/main.go

@@ -460,24 +460,24 @@ func main() {
 
 	// 初始化控制器
 	authController := controller.NewAuthController(authService)
-	conversationController := controller.NewConversationController(conversationService, aiConfigService)
+	conversationController := controller.NewConversationController(conversationService, aiConfigService, userService)
 	messageController := controller.NewMessageController(messageService, conversationService, storageService)
 	adminController := controller.NewAdminController(authService, userService)
 	profileController := controller.NewProfileController(profileService)
-	aiConfigController := controller.NewAIConfigController(aiConfigService)
-	faqController := controller.NewFAQController(faqService)
-	documentController := controller.NewDocumentController(documentService, embeddingConfigService)
-	embeddingConfigController := controller.NewEmbeddingConfigController(embeddingConfigService)
-	promptConfigController := controller.NewPromptConfigController(promptConfigService)
-	knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService)
-	importController := controller.NewImportController(importService, embeddingConfigService) // 导入控制器
+	aiConfigController := controller.NewAIConfigController(aiConfigService, userService)
+	faqController := controller.NewFAQController(faqService, userService)
+	documentController := controller.NewDocumentController(documentService, embeddingConfigService, userService)
+	embeddingConfigController := controller.NewEmbeddingConfigController(embeddingConfigService, userService)
+	promptConfigController := controller.NewPromptConfigController(promptConfigService, userService)
+	knowledgeBaseController := controller.NewKnowledgeBaseController(knowledgeBaseService, embeddingConfigService, userService)
+	importController := controller.NewImportController(importService, embeddingConfigService, userService) // 导入控制器
 	visitorController := controller.NewVisitorController(visitorService, embeddingConfigService)
 	healthController := controller.NewHealthController(healthChecker, retrievalService) // 健康检查控制器
 
 	widgetOpenRepo := repository.NewWidgetOpenRepository(db)
 	analyticsService := service.NewAnalyticsService(db, widgetOpenRepo)
-	analyticsController := controller.NewAnalyticsController(analyticsService)
-	systemLogController := controller.NewSystemLogController(systemLogService)
+	analyticsController := controller.NewAnalyticsController(analyticsService, userService)
+	systemLogController := controller.NewSystemLogController(systemLogService, userService)
 
 	appRouter.RegisterRoutes(
 		r,

+ 3 - 0
backend/models/user.go

@@ -9,6 +9,9 @@ type User struct {
 	Username  string `json:"username" gorm:"unique"`
 	Password  string `json:"password"`
 	Role      string `json:"role"`
+	// Permissions 功能权限(JSON 数组字符串)。admin 默认视为全权限。
+	// 例:["chat","knowledge"]。为空时:agent 兼容默认仅 chat。
+	Permissions string `json:"permissions" gorm:"type:text"`
 	AvatarURL string `json:"avatar_url" gorm:"type:varchar(500)"` // 头像URL
 	Nickname  string `json:"nickname" gorm:"type:varchar(100)"`   // 昵称
 	Email     string `json:"email" gorm:"type:varchar(255)"`      // 邮箱

+ 102 - 0
backend/service/permissions.go

@@ -0,0 +1,102 @@
+package service
+
+import (
+	"encoding/json"
+	"errors"
+	"sort"
+	"strings"
+)
+
+// PermissionKey 定义客服端菜单/功能的权限键(单级开关:有/无)。
+// 说明:目前系统没有真正的登录态(JWT/Session),所以权限校验依赖 X-User-Id。
+type PermissionKey string
+
+const (
+	PermChat      PermissionKey = "chat"      // 对话
+	PermKBTest    PermissionKey = "kb_test"   // 知识库测试(内部对话)
+	PermKnowledge PermissionKey = "knowledge" // 知识库(含文档/导入)
+	PermFAQs      PermissionKey = "faqs"      // 事件管理
+	PermAnalytics PermissionKey = "analytics" // 数据报表
+	PermLogs      PermissionKey = "logs"      // 日志中心
+	PermPrompts   PermissionKey = "prompts"   // 提示词
+	PermSettings  PermissionKey = "settings"  // AI 配置
+	PermUsers     PermissionKey = "users"     // 用户管理
+)
+
+func AllPermissionKeys() []string {
+	keys := []string{
+		string(PermChat),
+		string(PermKBTest),
+		string(PermKnowledge),
+		string(PermFAQs),
+		string(PermAnalytics),
+		string(PermLogs),
+		string(PermPrompts),
+		string(PermSettings),
+		string(PermUsers),
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+func DefaultAgentPermissions() []string {
+	return []string{string(PermChat)}
+}
+
+func normalizePermissionKeys(keys []string) []string {
+	seen := map[string]bool{}
+	out := make([]string, 0, len(keys))
+	for _, k := range keys {
+		kk := strings.TrimSpace(k)
+		if kk == "" {
+			continue
+		}
+		if seen[kk] {
+			continue
+		}
+		seen[kk] = true
+		out = append(out, kk)
+	}
+	sort.Strings(out)
+	return out
+}
+
+func validatePermissionKeys(keys []string) error {
+	allowed := map[string]bool{}
+	for _, k := range AllPermissionKeys() {
+		allowed[k] = true
+	}
+	for _, k := range keys {
+		if !allowed[k] {
+			return errors.New("存在不支持的权限键: " + k)
+		}
+	}
+	return nil
+}
+
+// EncodePermissions 将权限数组编码为 JSON 字符串,用于存表。
+func EncodePermissions(keys []string) (string, error) {
+	n := normalizePermissionKeys(keys)
+	if err := validatePermissionKeys(n); err != nil {
+		return "", err
+	}
+	b, err := json.Marshal(n)
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
+
+// DecodePermissions 从 JSON 字符串解码权限数组。空串/无效 JSON 视为无权限数组。
+func DecodePermissions(raw string) []string {
+	raw = strings.TrimSpace(raw)
+	if raw == "" {
+		return nil
+	}
+	var keys []string
+	if err := json.Unmarshal([]byte(raw), &keys); err != nil {
+		return nil
+	}
+	return normalizePermissionKeys(keys)
+}
+

+ 10 - 0
backend/service/profile_service.go

@@ -37,6 +37,16 @@ func (s *ProfileService) GetProfile(userID uint) (*ProfileResult, error) {
 		ID:                     user.ID,
 		Username:               user.Username,
 		Role:                   user.Role,
+		Permissions: func() []string {
+			if user.Role == "admin" {
+				return AllPermissionKeys()
+			}
+			keys := DecodePermissions(user.Permissions)
+			if len(keys) == 0 {
+				return DefaultAgentPermissions()
+			}
+			return keys
+		}(),
 		AvatarURL:              user.AvatarURL,
 		Nickname:               user.Nickname,
 		Email:                  user.Email,

+ 4 - 0
backend/service/types.go

@@ -125,6 +125,7 @@ type ProfileResult struct {
 	ID                     uint   `json:"id"`
 	Username               string `json:"username"`
 	Role                   string `json:"role"`
+	Permissions            []string `json:"permissions"`
 	AvatarURL              string `json:"avatar_url"`
 	Nickname               string `json:"nickname"`
 	Email                  string `json:"email"`
@@ -136,6 +137,7 @@ type UserSummary struct {
 	ID                     uint      `json:"id"`
 	Username               string    `json:"username"`
 	Role                   string    `json:"role"`
+	Permissions            []string  `json:"permissions"`
 	Nickname               string    `json:"nickname"`
 	Email                  string    `json:"email"`
 	AvatarURL              string    `json:"avatar_url"`
@@ -149,6 +151,7 @@ type CreateUserInput struct {
 	Username string  // 用户名(必需)
 	Password string  // 密码(必需)
 	Role     string  // 角色:"admin" 或 "agent"(必需)
+	Permissions []string // 功能权限(可选;role=admin 时忽略)
 	Nickname *string // 昵称(可选)
 	Email    *string // 邮箱(可选)
 }
@@ -157,6 +160,7 @@ type CreateUserInput struct {
 type UpdateUserInput struct {
 	UserID                 uint    // 用户ID(必需)
 	Role                   *string // 角色(可选)
+	Permissions            *[]string // 功能权限(可选;role=admin 时忽略)
 	Nickname               *string // 昵称(可选)
 	Email                  *string // 邮箱(可选)
 	ReceiveAIConversations *bool   // 是否接收 AI 对话(可选)

+ 78 - 1
backend/service/user_service.go

@@ -2,6 +2,7 @@ package service
 
 import (
 	"errors"
+	"fmt"
 	"strings"
 
 	"github.com/2930134478/AI-CS/backend/models"
@@ -20,6 +21,43 @@ func NewUserService(users *repository.UserRepository) *UserService {
 	return &UserService{users: users}
 }
 
+// EffectivePermissions 计算用户“有效权限”。
+// - admin:全权限
+// - agent:取 user.Permissions(JSON);若为空则兼容默认仅 chat
+func (s *UserService) EffectivePermissions(user *models.User) []string {
+	if user == nil {
+		return nil
+	}
+	if user.Role == "admin" {
+		return AllPermissionKeys()
+	}
+	keys := DecodePermissions(user.Permissions)
+	if len(keys) == 0 {
+		return DefaultAgentPermissions()
+	}
+	return keys
+}
+
+// CheckPermission 校验用户是否拥有指定权限(用于控制器强校验)。
+func (s *UserService) CheckPermission(userID uint, perm string) error {
+	if userID == 0 {
+		return errors.New("未授权访问,请提供 X-User-Id 请求头")
+	}
+	u, err := s.users.GetByID(userID)
+	if err != nil || u == nil {
+		return errors.New("用户不存在")
+	}
+	if u.Role == "admin" {
+		return nil
+	}
+	for _, p := range s.EffectivePermissions(u) {
+		if p == perm {
+			return nil
+		}
+	}
+	return fmt.Errorf("权限不足:缺少功能权限 %s", perm)
+}
+
 // ListUsers 获取所有用户列表。
 func (s *UserService) ListUsers() ([]UserSummary, error) {
 	users, err := s.users.ListUsers()
@@ -33,6 +71,7 @@ func (s *UserService) ListUsers() ([]UserSummary, error) {
 			ID:                     user.ID,
 			Username:               user.Username,
 			Role:                   user.Role,
+			Permissions:            s.EffectivePermissions(&user),
 			Nickname:               user.Nickname,
 			Email:                  user.Email,
 			AvatarURL:              user.AvatarURL,
@@ -59,6 +98,7 @@ func (s *UserService) GetUser(id uint) (*UserSummary, error) {
 		ID:                     user.ID,
 		Username:               user.Username,
 		Role:                   user.Role,
+		Permissions:            s.EffectivePermissions(user),
 		Nickname:               user.Nickname,
 		Email:                  user.Email,
 		AvatarURL:              user.AvatarURL,
@@ -101,6 +141,19 @@ func (s *UserService) CreateUser(input CreateUserInput) (*UserSummary, error) {
 		ReceiveAIConversations: true, // 默认接收 AI 对话
 	}
 
+	// 权限:admin 默认全开(不存);agent 默认仅 chat
+	if input.Role != "admin" {
+		keys := input.Permissions
+		if len(keys) == 0 {
+			keys = DefaultAgentPermissions()
+		}
+		encoded, err := EncodePermissions(keys)
+		if err != nil {
+			return nil, err
+		}
+		user.Permissions = encoded
+	}
+
 	// 设置可选字段
 	if input.Nickname != nil {
 		user.Nickname = strings.TrimSpace(*input.Nickname)
@@ -117,6 +170,7 @@ func (s *UserService) CreateUser(input CreateUserInput) (*UserSummary, error) {
 		ID:                     user.ID,
 		Username:               user.Username,
 		Role:                   user.Role,
+		Permissions:            s.EffectivePermissions(user),
 		Nickname:               user.Nickname,
 		Email:                  user.Email,
 		AvatarURL:              user.AvatarURL,
@@ -129,17 +183,22 @@ func (s *UserService) CreateUser(input CreateUserInput) (*UserSummary, error) {
 // UpdateUser 更新用户信息。
 func (s *UserService) UpdateUser(input UpdateUserInput) (*UserSummary, error) {
 	// 检查用户是否存在
-	_, err := s.users.GetByID(input.UserID)
+	currentUser, err := s.users.GetByID(input.UserID)
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			return nil, errors.New("用户不存在")
 		}
 		return nil, err
 	}
+	if currentUser == nil {
+		return nil, errors.New("用户不存在")
+	}
 
 	// 构建更新字段
 	updates := make(map[string]interface{})
 
+	// 记录本次更新后的角色(用于决定 permissions 写入规则)
+	nextRole := currentUser.Role
 	// 更新角色
 	if input.Role != nil {
 		role := strings.TrimSpace(*input.Role)
@@ -147,6 +206,24 @@ func (s *UserService) UpdateUser(input UpdateUserInput) (*UserSummary, error) {
 			return nil, errors.New("角色只能是 admin 或 agent")
 		}
 		updates["role"] = role
+		nextRole = role
+	}
+
+	// 更新 permissions(仅对 agent 有意义;admin 视为全开,不存权限)
+	if input.Permissions != nil {
+		if nextRole == "admin" {
+			updates["permissions"] = ""
+		} else {
+			keys := *input.Permissions
+			if len(keys) == 0 {
+				keys = DefaultAgentPermissions()
+			}
+			encoded, err := EncodePermissions(keys)
+			if err != nil {
+				return nil, err
+			}
+			updates["permissions"] = encoded
+		}
 	}
 
 	// 更新昵称

+ 4 - 0
frontend/app/agent/login/page.tsx

@@ -38,6 +38,10 @@ export default function AgentLoginPage() {
         localStorage.setItem("agent_user_id", String(data.user_id));
         localStorage.setItem("agent_username", data.username);
         localStorage.setItem("agent_role", data.role);
+        localStorage.setItem(
+          "agent_permissions",
+          JSON.stringify(Array.isArray(data.permissions) ? data.permissions : [])
+        );
 
         // 跳转到客服工作台(三栏布局)
         router.push("/agent/dashboard");

+ 83 - 0
frontend/app/agent/users/page.tsx

@@ -27,6 +27,11 @@ import { Badge } from "@/components/ui/badge";
 import { Card } from "@/components/ui/card";
 import { Label } from "@/components/ui/label";
 import { toast } from "@/hooks/useToast";
+import {
+  PERMISSION_OPTIONS,
+  defaultAgentPermissions,
+  type PermissionKey,
+} from "@/lib/constants/agent-permissions";
 import {
   Plus,
   Edit,
@@ -57,6 +62,7 @@ export default function UsersPage(props: any = {}) {
     username: "",
     password: "",
     role: "agent",
+    permissions: defaultAgentPermissions(),
     nickname: "",
     email: "",
   });
@@ -64,6 +70,7 @@ export default function UsersPage(props: any = {}) {
   // 编辑用户表单
   const [editForm, setEditForm] = useState<UpdateUserRequest>({
     role: "agent",
+    permissions: defaultAgentPermissions(),
     nickname: "",
     email: "",
     receive_ai_conversations: true,
@@ -123,6 +130,7 @@ export default function UsersPage(props: any = {}) {
       username: "",
       password: "",
       role: "agent",
+      permissions: defaultAgentPermissions(),
       nickname: "",
       email: "",
     });
@@ -156,6 +164,11 @@ export default function UsersPage(props: any = {}) {
     setSelectedUser(user);
     setEditForm({
       role: user.role as "admin" | "agent",
+      permissions:
+        user.role === "admin"
+          ? PERMISSION_OPTIONS.map((p) => p.key)
+          : ((user.permissions as PermissionKey[] | undefined) ??
+            defaultAgentPermissions()),
       nickname: user.nickname || "",
       email: user.email || "",
       receive_ai_conversations: user.receive_ai_conversations,
@@ -428,6 +441,10 @@ export default function UsersPage(props: any = {}) {
                   setCreateForm({
                     ...createForm,
                     role: e.target.value as "admin" | "agent",
+                    permissions:
+                      e.target.value === "admin"
+                        ? PERMISSION_OPTIONS.map((p) => p.key)
+                        : defaultAgentPermissions(),
                   })
                 }
                 className="w-full px-3 py-2 border border-border rounded-md bg-background"
@@ -436,6 +453,39 @@ export default function UsersPage(props: any = {}) {
                 <option value="admin">管理员</option>
               </select>
             </div>
+
+            {/* 功能权限(开/关一级;role=admin 默认全开) */}
+            {createForm.role !== "admin" && (
+              <div>
+                <Label>功能权限</Label>
+                <div className="mt-2 grid grid-cols-2 gap-2">
+                  {PERMISSION_OPTIONS.map((p) => {
+                    const checked = (createForm.permissions ?? []).includes(p.key);
+                    return (
+                      <label
+                        key={p.key}
+                        className="flex items-center gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-sm"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={checked}
+                          onChange={(e) => {
+                            const next = new Set(createForm.permissions ?? []);
+                            if (e.target.checked) next.add(p.key);
+                            else next.delete(p.key);
+                            setCreateForm({ ...createForm, permissions: Array.from(next) });
+                          }}
+                        />
+                        <span>{p.label}</span>
+                      </label>
+                    );
+                  })}
+                </div>
+                <p className="mt-1 text-xs text-muted-foreground">
+                  默认仅开启“对话”。关闭后对应菜单不可见且后端接口会返回 403。
+                </p>
+              </div>
+            )}
             <div>
               <Label htmlFor="create-nickname">昵称</Label>
               <Input
@@ -499,6 +549,10 @@ export default function UsersPage(props: any = {}) {
                     setEditForm({
                       ...editForm,
                       role: e.target.value as "admin" | "agent",
+                      permissions:
+                        e.target.value === "admin"
+                          ? PERMISSION_OPTIONS.map((p) => p.key)
+                          : defaultAgentPermissions(),
                     })
                   }
                   className="w-full px-3 py-2 border border-border rounded-md bg-background"
@@ -507,6 +561,35 @@ export default function UsersPage(props: any = {}) {
                   <option value="admin">管理员</option>
                 </select>
               </div>
+
+              {editForm.role !== "admin" && (
+                <div>
+                  <Label>功能权限</Label>
+                  <div className="mt-2 grid grid-cols-2 gap-2">
+                    {PERMISSION_OPTIONS.map((p) => {
+                      const checked = (editForm.permissions ?? []).includes(p.key);
+                      return (
+                        <label
+                          key={p.key}
+                          className="flex items-center gap-2 rounded-md border border-border/70 bg-background px-3 py-2 text-sm"
+                        >
+                          <input
+                            type="checkbox"
+                            checked={checked}
+                            onChange={(e) => {
+                              const next = new Set(editForm.permissions ?? []);
+                              if (e.target.checked) next.add(p.key);
+                              else next.delete(p.key);
+                              setEditForm({ ...editForm, permissions: Array.from(next) });
+                            }}
+                          />
+                          <span>{p.label}</span>
+                        </label>
+                      );
+                    })}
+                  </div>
+                </div>
+              )}
               <div>
                 <Label htmlFor="edit-nickname">昵称</Label>
                 <Input

+ 106 - 85
frontend/components/dashboard/NavigationSidebar.tsx

@@ -6,6 +6,7 @@ import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
 import { Button } from "@/components/ui/button";
 import { websiteConfig } from "@/lib/website-config";
 import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
 import {
   AGENT_PAGES,
   type NavigationPage,
@@ -36,6 +37,7 @@ export function NavigationSidebar({
   const menuRef = useRef<HTMLDivElement>(null);
 
   const isAdmin = agent?.role === "admin";
+  const permissions = agent?.permissions ?? [];
 
   const handleNavigate = (page: NavigationPage) => {
     onNavigate?.(page);
@@ -61,73 +63,86 @@ export function NavigationSidebar({
   const displayInitial = getAvatarInitial(agent?.username || "");
   const fullAvatarUrl = getAvatarUrl(avatarUrl);
 
-  const visiblePages = AGENT_PAGES.filter(
-    (p) => !p.adminOnly || (p.adminOnly && isAdmin)
-  );
+  const visiblePages = AGENT_PAGES.filter((p) => {
+    if (isAdmin) return true;
+    const need = p.requiredPermission;
+    if (!need) return true;
+    return permissions.includes(need);
+  });
 
   return (
-    <div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200 h-full">
-      {visiblePages.map((page) => {
-        const isActive = currentPage === page.id;
-        const Icon = page.Icon;
-        const showUnread = page.id === "dashboard" && unreadChatCount > 0;
-        return (
-          <button
-            key={page.id}
-            className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
-              isActive
-                ? "bg-green-600 hover:bg-green-700"
-                : "bg-white border border-gray-200 hover:bg-gray-100"
-            }`}
-            title={page.title}
-            onClick={() => handleNavigate(page.id as NavigationPage)}
-          >
-            <div className="relative w-full h-full flex items-center justify-center">
-              <Icon
-                className={`w-6 h-6 ${
-                  isActive ? "text-white" : "text-gray-600"
-                }`}
-              />
-              {showUnread && (
-                <Badge
-                  variant="destructive"
-                  className="absolute -top-1 -right-1 px-1 py-0 h-4 min-w-4 rounded-full text-[10px] leading-none flex items-center justify-center"
-                >
-                  {unreadChatCount > 99 ? "99+" : unreadChatCount}
-                </Badge>
-              )}
-            </div>
-          </button>
-        );
-      })}
+    <TooltipProvider delayDuration={0}>
+      <div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200 h-full">
+        <div className="mt-2 px-3 flex flex-col items-center gap-2">
+          {visiblePages.map((page) => {
+            const isActive = currentPage === page.id;
+            const Icon = page.Icon;
+            const showUnread = page.id === "dashboard" && unreadChatCount > 0;
+            return (
+              <Tooltip key={page.id}>
+                <TooltipTrigger asChild>
+                  <button
+                    className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
+                      isActive
+                        ? "bg-green-600 hover:bg-green-700 text-white"
+                        : "bg-white border border-gray-200 hover:bg-gray-100 text-gray-700"
+                    }`}
+                    onClick={() => handleNavigate(page.id as NavigationPage)}
+                    aria-label={page.title}
+                  >
+                    <div className="relative flex items-center justify-center">
+                      <Icon className={`h-5 w-5 ${isActive ? "text-white" : "text-gray-600"}`} />
+                      {showUnread && (
+                        <Badge
+                          variant="destructive"
+                          className="absolute -top-1 -right-1 px-1 py-0 h-4 min-w-4 rounded-full text-[10px] leading-none flex items-center justify-center"
+                        >
+                          {unreadChatCount > 99 ? "99+" : unreadChatCount}
+                        </Badge>
+                      )}
+                    </div>
+                  </button>
+                </TooltipTrigger>
+                <TooltipContent side="right">{page.title}</TooltipContent>
+              </Tooltip>
+            );
+          })}
+        </div>
 
       {/* 个人资料按钮和 GitHub 按钮(固定在底部) */}
-      <div className="mt-auto flex flex-col items-center gap-2">
-        <div className="relative" ref={menuRef}>
-          <button
-            className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
-              profileMenuOpen
-                ? "bg-primary text-primary-foreground"
-                : "bg-white border border-gray-200 hover:bg-gray-100"
-            }`}
-            title="个人资料"
-            onClick={() => setProfileMenuOpen(!profileMenuOpen)}
-          >
-            {fullAvatarUrl ? (
-              <img
-                src={fullAvatarUrl}
-                alt={agent?.username || "用户"}
-                className="w-8 h-8 rounded-full object-cover"
-              />
-            ) : (
-              <div
-                className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold"
-                style={{ backgroundColor: avatarColor }}
-              >
-                {displayInitial}
-              </div>
-            )}
-          </button>
+        <div className="mt-auto flex flex-col items-center gap-2">
+          <div className="relative" ref={menuRef}>
+            <Tooltip>
+              <TooltipTrigger asChild>
+                <button
+                  className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
+                    profileMenuOpen
+                      ? "bg-primary text-primary-foreground"
+                      : "bg-white border border-gray-200 hover:bg-gray-100"
+                  }`}
+                  onClick={() => setProfileMenuOpen(!profileMenuOpen)}
+                  aria-label="个人资料"
+                >
+                  <div className="flex items-center justify-center">
+                    {fullAvatarUrl ? (
+                      <img
+                        src={fullAvatarUrl}
+                        alt={agent?.username || "用户"}
+                        className="w-8 h-8 rounded-full object-cover"
+                      />
+                    ) : (
+                      <div
+                        className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold"
+                        style={{ backgroundColor: avatarColor }}
+                      >
+                        {displayInitial}
+                      </div>
+                    )}
+                  </div>
+                </button>
+              </TooltipTrigger>
+              <TooltipContent side="right">个人资料</TooltipContent>
+            </Tooltip>
 
           {profileMenuOpen && (
             <div className="absolute bottom-12 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
@@ -212,28 +227,34 @@ export function NavigationSidebar({
           )}
         </div>
 
-        <Button
-          variant="ghost"
-          size="sm"
-          asChild
-          className="w-10 h-10 rounded-lg bg-white border border-gray-200 hover:bg-gray-100 transition-colors p-0"
-          title="GitHub"
-        >
-          <a
-            href={websiteConfig.github.repo}
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <svg
-              className="w-5 h-5 text-gray-600"
-              fill="currentColor"
-              viewBox="0 0 24 24"
-            >
-              <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
-            </svg>
-          </a>
-        </Button>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <Button
+                variant="ghost"
+                size="sm"
+                asChild
+                className="w-10 h-10 rounded-lg bg-white border border-gray-200 hover:bg-gray-100 transition-colors p-0"
+              >
+                <a
+                  href={websiteConfig.github.repo}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  aria-label="GitHub"
+                >
+                  <svg
+                    className="w-5 h-5 text-gray-600"
+                    fill="currentColor"
+                    viewBox="0 0 24 24"
+                  >
+                    <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
+                  </svg>
+                </a>
+              </Button>
+            </TooltipTrigger>
+            <TooltipContent side="right">GitHub</TooltipContent>
+          </Tooltip>
+        </div>
       </div>
-    </div>
+    </TooltipProvider>
   );
 }

+ 32 - 0
frontend/components/ui/tooltip.tsx

@@ -0,0 +1,32 @@
+"use client";
+
+import * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "@/lib/utils";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+const Tooltip = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const TooltipContent = React.forwardRef<
+  React.ElementRef<typeof TooltipPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
+>(({ className, sideOffset = 8, ...props }, ref) => (
+  <TooltipPrimitive.Portal>
+    <TooltipPrimitive.Content
+      ref={ref}
+      sideOffset={sideOffset}
+      className={cn(
+        "z-[100] overflow-hidden rounded-md border border-border/60 bg-foreground px-2.5 py-1.5 text-xs text-background shadow-md",
+        "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
+        className
+      )}
+      {...props}
+    />
+  </TooltipPrimitive.Portal>
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
+

+ 6 - 3
frontend/features/agent/services/aiConfigApi.ts

@@ -1,4 +1,4 @@
-import { apiUrl } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 // AI 配置类型定义
 export interface AIConfig {
@@ -43,6 +43,7 @@ export interface UpdateAIConfigRequest {
 export async function fetchAIConfigs(userId: number): Promise<AIConfig[]> {
   const res = await fetch(apiUrl(`/agent/ai-config/${userId}`), {
     cache: "no-store",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("获取 AI 配置失败");
@@ -57,6 +58,7 @@ export async function fetchAIConfig(
 ): Promise<AIConfig> {
   const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     cache: "no-store",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("获取 AI 配置失败");
@@ -71,7 +73,7 @@ export async function createAIConfig(
 ): Promise<AIConfig> {
   const res = await fetch(apiUrl(`/agent/ai-config/${userId}`), {
     method: "POST",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
   });
   if (!res.ok) {
@@ -89,7 +91,7 @@ export async function updateAIConfig(
 ): Promise<AIConfig> {
   const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     method: "PUT",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify(data),
   });
   if (!res.ok) {
@@ -106,6 +108,7 @@ export async function deleteAIConfig(
 ): Promise<void> {
   const res = await fetch(apiUrl(`/agent/ai-config/${userId}/${configId}`), {
     method: "DELETE",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("删除 AI 配置失败");

+ 6 - 5
frontend/features/agent/services/conversationApi.ts

@@ -1,4 +1,4 @@
-import { apiUrl } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 import {
   ConversationDetail,
   ConversationSummary,
@@ -14,7 +14,7 @@ export async function fetchConversations(
   if (userId) params.set("user_id", String(userId));
   if (opts?.type) params.set("type", opts.type);
   const url = `${apiUrl("/conversations")}?${params.toString()}`;
-  const res = await fetch(url, { cache: "no-store" });
+  const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
   if (!res.ok) {
     throw new Error("获取对话列表失败");
   }
@@ -33,7 +33,7 @@ export async function fetchConversations(
 export async function initInternalConversation(userId: number): Promise<{ conversation_id: number }> {
   const res = await fetch(`${apiUrl("/conversations/internal")}?user_id=${userId}`, {
     method: "POST",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
   });
   if (!res.ok) {
     const err = await res.json().catch(() => ({}));
@@ -52,6 +52,7 @@ export async function searchConversations(
     : `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}`;
   const res = await fetch(url, {
     cache: "no-store",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("搜索对话失败");
@@ -74,7 +75,7 @@ export async function fetchConversationDetail(
   const url = userId
     ? `${apiUrl(`/conversations/${conversationId}`)}?user_id=${userId}`
     : apiUrl(`/conversations/${conversationId}`);
-  const res = await fetch(url, { cache: "no-store" });
+  const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
   if (!res.ok) {
     return null;
   }
@@ -105,7 +106,7 @@ export async function updateConversationContact(
     apiUrl(`/conversations/${conversationId}/contact`),
     {
       method: "PUT",
-      headers: { "Content-Type": "application/json" },
+      headers: { "Content-Type": "application/json", ...getAgentHeaders() },
       body: JSON.stringify(payload),
     }
   );

+ 3 - 2
frontend/features/agent/services/embeddingConfigApi.ts

@@ -1,4 +1,4 @@
-import { apiUrl } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 // 知识库向量配置(API 返回,不含明文 API Key)
 export interface EmbeddingConfig {
@@ -35,6 +35,7 @@ export interface UpdateEmbeddingConfigRequest {
 export async function fetchEmbeddingConfig(userId: number): Promise<EmbeddingConfig> {
   const res = await fetch(`${apiUrl("/agent/embedding-config")}?user_id=${userId}`, {
     cache: "no-store",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("获取知识库向量配置失败");
@@ -49,7 +50,7 @@ export async function updateEmbeddingConfig(
 ): Promise<EmbeddingConfig> {
   const res = await fetch(apiUrl("/agent/embedding-config"), {
     method: "PUT",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify({ user_id: userId, ...data }),
   });
   if (!res.ok) {

+ 3 - 2
frontend/features/agent/services/promptsApi.ts

@@ -1,4 +1,4 @@
-import { apiUrl } from "@/lib/config";
+import { apiUrl, getAgentHeaders } from "@/lib/config";
 
 export interface PromptItem {
   key: string;
@@ -15,6 +15,7 @@ export interface PromptsResponse {
 export async function fetchPrompts(userId: number): Promise<PromptItem[]> {
   const res = await fetch(`${apiUrl("/agent/prompts")}?user_id=${userId}`, {
     cache: "no-store",
+    headers: getAgentHeaders(),
   });
   if (!res.ok) {
     throw new Error("获取提示词配置失败");
@@ -37,7 +38,7 @@ export async function updatePrompt(
 ): Promise<void> {
   const res = await fetch(apiUrl("/agent/prompts"), {
     method: "PUT",
-    headers: { "Content-Type": "application/json" },
+    headers: { "Content-Type": "application/json", ...getAgentHeaders() },
     body: JSON.stringify({ user_id: userId, key, content }),
   });
   if (!res.ok) {

+ 3 - 0
frontend/features/agent/services/userApi.ts

@@ -5,6 +5,7 @@ export interface UserSummary {
   id: number;
   username: string;
   role: "admin" | "agent";
+  permissions?: string[];
   nickname: string;
   email: string;
   avatar_url: string;
@@ -18,6 +19,7 @@ export interface CreateUserRequest {
   username: string;
   password: string;
   role: "admin" | "agent";
+  permissions?: string[];
   nickname?: string;
   email?: string;
 }
@@ -25,6 +27,7 @@ export interface CreateUserRequest {
 // 更新用户请求
 export interface UpdateUserRequest {
   role?: "admin" | "agent";
+  permissions?: string[];
   nickname?: string;
   email?: string;
   receive_ai_conversations?: boolean;

+ 1 - 0
frontend/features/agent/types.ts

@@ -62,6 +62,7 @@ export interface AgentUser {
   id: number;
   username: string;
   role: string;
+  permissions?: string[];
 }
 
 // 个人资料信息

+ 16 - 15
frontend/lib/constants/agent-pages.tsx

@@ -50,7 +50,8 @@ export interface AgentPageItem {
   label: string;
   title: string;
   Icon: LucideIcon;
-  adminOnly?: boolean;
+  /** 需要的功能权限键(单级开关)。admin 视为全权限 */
+  requiredPermission?: string;
   /** 对话类页面:展示会话列表 + 聊天区,无独立主内容 */
   isChatPage?: boolean;
   /** 非对话类页面的嵌入组件;对话类不填 */
@@ -64,26 +65,26 @@ export interface AgentPageItem {
 export const AGENT_PAGES = [
   {
     id: "dashboard",
-    label: "对话",
+    label: "会话对话",
     title: "对话",
     Icon: MessageCircle,
-    adminOnly: false,
+    requiredPermission: "chat",
     isChatPage: true,
   },
   {
     id: "internal-chat",
-    label: "知识测试",
+    label: "知识测试",
     title: "知识库测试",
     Icon: Lightbulb,
-    adminOnly: false,
+    requiredPermission: "kb_test",
     isChatPage: true,
   },
   {
     id: "knowledge",
-    label: "知识",
+    label: "知识管理",
     title: "知识库",
     Icon: BookOpen,
-    adminOnly: false,
+    requiredPermission: "knowledge",
     component: KnowledgePage,
   },
   {
@@ -91,7 +92,7 @@ export const AGENT_PAGES = [
     label: "事件管理",
     title: "事件管理",
     Icon: ClipboardList,
-    adminOnly: false,
+    requiredPermission: "faqs",
     component: FAQsPage,
   },
   {
@@ -99,7 +100,7 @@ export const AGENT_PAGES = [
     label: "数据报表",
     title: "数据报表",
     Icon: BarChart3,
-    adminOnly: false,
+    requiredPermission: "analytics",
     component: AnalyticsPage,
   },
   {
@@ -107,7 +108,7 @@ export const AGENT_PAGES = [
     label: "日志中心",
     title: "日志中心",
     Icon: ScrollText,
-    adminOnly: false,
+    requiredPermission: "logs",
     component: LogsPage,
   },
   {
@@ -115,23 +116,23 @@ export const AGENT_PAGES = [
     label: "用户管理",
     title: "用户管理",
     Icon: Users,
-    adminOnly: true,
+    requiredPermission: "users",
     component: UsersPage,
   },
   {
     id: "prompts",
-    label: "提示",
+    label: "提示配置",
     title: "提示词",
     Icon: FileText,
-    adminOnly: true,
+    requiredPermission: "prompts",
     component: PromptsPage,
   },
   {
     id: "settings",
-    label: "AI 配置",
+    label: "AI配置",
     title: "AI 配置",
     Icon: Settings,
-    adminOnly: false,
+    requiredPermission: "settings",
     component: SettingsPage,
   },
 ] as const;

+ 18 - 0
frontend/lib/constants/agent-permissions.ts

@@ -0,0 +1,18 @@
+export const PERMISSION_OPTIONS = [
+  { key: "chat", label: "对话" },
+  { key: "kb_test", label: "知识库测试" },
+  { key: "knowledge", label: "知识库" },
+  { key: "faqs", label: "事件管理" },
+  { key: "analytics", label: "数据报表" },
+  { key: "logs", label: "日志中心" },
+  { key: "prompts", label: "提示词" },
+  { key: "settings", label: "AI 配置" },
+  { key: "users", label: "用户管理" },
+] as const;
+
+export type PermissionKey = (typeof PERMISSION_OPTIONS)[number]["key"];
+
+export function defaultAgentPermissions(): PermissionKey[] {
+  return ["chat"];
+}
+

+ 15 - 0
frontend/utils/storage.ts

@@ -13,6 +13,7 @@ export function getAgentUser(): AgentUser | null {
   const id = window.localStorage.getItem(AGENT_ID_KEY);
   const username = window.localStorage.getItem(AGENT_USERNAME_KEY);
   const role = window.localStorage.getItem(AGENT_ROLE_KEY);
+  const permissionsRaw = window.localStorage.getItem("agent_permissions");
 
   if (!id || !username) {
     return null;
@@ -27,6 +28,15 @@ export function getAgentUser(): AgentUser | null {
     id: parsedId,
     username,
     role: role ?? "",
+    permissions: (() => {
+      if (!permissionsRaw) return undefined;
+      try {
+        const parsed = JSON.parse(permissionsRaw);
+        return Array.isArray(parsed) ? (parsed as string[]) : undefined;
+      } catch {
+        return undefined;
+      }
+    })(),
   };
 }
 
@@ -37,6 +47,11 @@ export function setAgentUser(agent: AgentUser): void {
   window.localStorage.setItem(AGENT_ID_KEY, String(agent.id));
   window.localStorage.setItem(AGENT_USERNAME_KEY, agent.username);
   window.localStorage.setItem(AGENT_ROLE_KEY, agent.role ?? "");
+  if (agent.permissions) {
+    window.localStorage.setItem("agent_permissions", JSON.stringify(agent.permissions));
+  } else {
+    window.localStorage.removeItem("agent_permissions");
+  }
 }
 
 export function clearAgentUser(): void {