Commit beffd7fe authored by yuguo's avatar yuguo

fix

parent 52d22241
......@@ -34,7 +34,9 @@
"Bash(cp:*)",
"Bash(PGPASSWORD=123456 createdb:*)",
"Bash(pkill:*)",
"Bash(bash:*)"
"Bash(bash:*)",
"Bash(make run:*)",
"Bash(rm:*)"
]
}
}
......@@ -46,13 +46,12 @@ func main() {
// 修复:给已有 consultations 记录补充 serial_number(迁移 NOT NULL 前必须)
if db.Migrator().HasTable("consultations") {
if db.Migrator().HasColumn(&model.Consultation{}, "serial_number") {
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
} else {
// 列不存在时先添加为可空,填值后再由 AutoMigrate 加约束
if !db.Migrator().HasColumn(&model.Consultation{}, "serial_number") {
db.Exec("ALTER TABLE consultations ADD COLUMN IF NOT EXISTS serial_number VARCHAR(20)")
db.Exec("UPDATE consultations SET serial_number = 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') WHERE serial_number IS NULL OR serial_number = ''")
}
db.Exec(`UPDATE consultations SET serial_number = t.sn
FROM (SELECT id, 'C00000000-' || LPAD(CAST(ROW_NUMBER() OVER (ORDER BY created_at) AS TEXT), 4, '0') AS sn FROM consultations WHERE serial_number IS NULL OR serial_number = '') t
WHERE consultations.id = t.id`)
}
allModels := []interface{}{
// 用户相关
......@@ -124,6 +123,8 @@ func main() {
&model.UserRole{},
&model.Menu{},
&model.RoleMenu{},
// 路由注册表(AI 导航工具)
&model.RouteEntry{},
}
failCount := 0
for _, m := range allModels {
......@@ -177,6 +178,9 @@ func main() {
log.Printf("Warning: Failed to init departments and doctors: %v", err)
}
// 初始化路由注册表种子数据
internalagent.EnsureRouteSeeds()
// 初始化Agent工具(内置工具 + HTTP 动态工具)
internalagent.InitTools()
internalagent.LoadHTTPTools()
......
......@@ -7,8 +7,8 @@ import (
"internet-hospital/pkg/database"
)
// ensureRouteSeed 初始化路由注册表种子数据
func ensureRouteSeeds() {
// EnsureRouteSeeds 初始化路由注册表种子数据
func EnsureRouteSeeds() {
db := database.GetDB()
if db == nil {
return
......
......@@ -22,6 +22,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
{
// 仪表盘
adm.GET("/dashboard/stats", h.GetDashboardStats)
adm.GET("/dashboard/trend", h.GetDashboardTrend)
// 用户管理(通用)
adm.GET("/users", h.GetUserList)
......@@ -143,6 +144,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
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)
......@@ -163,6 +165,16 @@ func (h *Handler) GetDashboardStats(c *gin.Context) {
response.Success(c, stats)
}
// GetDashboardTrend 7天问诊趋势
func (h *Handler) GetDashboardTrend(c *gin.Context) {
trend, err := h.service.GetDashboardTrend(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取趋势数据失败")
return
}
response.Success(c, trend)
}
// GetUserList 用户列表
func (h *Handler) GetUserList(c *gin.Context) {
var params UserListParams
......
......@@ -151,3 +151,9 @@ func (h *Handler) DeleteMenu(c *gin.Context) {
db.Delete(&menu)
response.Success(c, nil)
}
// ReseedMenus 重新导入菜单数据(删除旧数据,以页面展示结构为准)
func (h *Handler) ReseedMenus(c *gin.Context) {
ReseedMenus()
response.Success(c, gin.H{"message": "菜单数据已重新导入"})
}
......@@ -5,6 +5,8 @@ import (
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
"gorm.io/gorm"
)
// SeedRBAC 初始化角色、权限、菜单种子数据(幂等,只在表空时插入)
......@@ -95,66 +97,11 @@ func SeedRBAC() {
log.Println("[SeedRBAC] 权限种子数据已创建")
}
// ── 3. 菜单种子 ──
// ── 3. 菜单种子(与前端页面展示的菜单结构一致) ──
var menuCount int64
db.Model(&model.Menu{}).Count(&menuCount)
if menuCount == 0 {
// 顶级菜单
dashboard := model.Menu{Name: "运营大盘", Path: "/admin/dashboard", Icon: "DashboardOutlined", Sort: 1, Type: "menu", Visible: true, Status: "active"}
db.Create(&dashboard)
userMgmt := model.Menu{Name: "用户管理", Path: "", Icon: "TeamOutlined", Sort: 2, Type: "menu", Visible: true, Status: "active"}
db.Create(&userMgmt)
for i, sub := range []struct{ name, path, icon string }{
{"患者管理", "/admin/patients", "UserOutlined"},
{"医生管理", "/admin/doctors", "MedicineBoxOutlined"},
{"管理员管理", "/admin/admins", "SettingOutlined"},
} {
db.Create(&model.Menu{ParentID: userMgmt.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
deptMenu := model.Menu{Name: "科室管理", Path: "/admin/departments", Icon: "ApartmentOutlined", Sort: 3, Type: "menu", Visible: true, Status: "active"}
db.Create(&deptMenu)
consultMenu := model.Menu{Name: "问诊管理", Path: "/admin/consultations", Icon: "FileSearchOutlined", Sort: 4, Type: "menu", Visible: true, Status: "active"}
db.Create(&consultMenu)
rxMenu := model.Menu{Name: "处方监管", Path: "/admin/prescription", Icon: "FileTextOutlined", Sort: 5, Type: "menu", Visible: true, Status: "active"}
db.Create(&rxMenu)
pharmaMenu := model.Menu{Name: "药品库", Path: "/admin/pharmacy", Icon: "MedicineBoxOutlined", Sort: 6, Type: "menu", Visible: true, Status: "active"}
db.Create(&pharmaMenu)
aiCfgMenu := model.Menu{Name: "AI配置", Path: "/admin/ai-config", Icon: "RobotOutlined", Sort: 7, Type: "menu", Visible: true, Status: "active"}
db.Create(&aiCfgMenu)
compMenu := model.Menu{Name: "合规报表", Path: "/admin/compliance", Icon: "SafetyCertificateOutlined", Sort: 8, Type: "menu", Visible: true, Status: "active"}
db.Create(&compMenu)
aiPlatform := model.Menu{Name: "智能体平台", Path: "", Icon: "ApiOutlined", Sort: 9, Type: "menu", Visible: true, Status: "active"}
db.Create(&aiPlatform)
for i, sub := range []struct{ name, path, icon string }{
{"Agent管理", "/admin/agents", "RobotOutlined"},
{"工具中心", "/admin/tools", "AppstoreOutlined"},
{"工作流", "/admin/workflows", "DeploymentUnitOutlined"},
{"人工审核", "/admin/tasks", "CheckCircleOutlined"},
{"知识库", "/admin/knowledge", "BookOutlined"},
{"内容安全", "/admin/safety", "SafetyOutlined"},
{"AI运营中心", "/admin/ai-center", "FundOutlined"},
} {
db.Create(&model.Menu{ParentID: aiPlatform.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
sysMgmt := model.Menu{Name: "系统管理", Path: "", Icon: "SafetyCertificateOutlined", Sort: 10, Type: "menu", Visible: true, Status: "active"}
db.Create(&sysMgmt)
for i, sub := range []struct{ name, path, icon string }{
{"角色管理", "/admin/roles", "SafetyOutlined"},
{"菜单管理", "/admin/menus", "AppstoreOutlined"},
} {
db.Create(&model.Menu{ParentID: sysMgmt.ID, Name: sub.name, Path: sub.path, Icon: sub.icon, Sort: i + 1, Type: "menu", Visible: true, Status: "active"})
}
log.Println("[SeedRBAC] 菜单种子数据已创建")
seedMenus(db)
}
// ── 4. 为超级管理员角色分配全部权限和菜单 ──
......@@ -196,3 +143,76 @@ func SeedRBAC() {
}
}
}
// 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 创建与前端页面展示完全一致的菜单结构
func seedMenus(db *gorm.DB) {
m := func(parentID uint, name, path, icon string, sort int) model.Menu {
menu := model.Menu{
ParentID: parentID, Name: name, Path: path, Icon: icon,
Sort: sort, Type: "menu", Visible: true, Status: "active",
}
db.Create(&menu)
return menu
}
// 1. 运营大盘
m(0, "运营大盘", "/admin/dashboard", "DashboardOutlined", 1)
// 2. 用户管理
userMgmt := m(0, "用户管理", "", "TeamOutlined", 2)
m(userMgmt.ID, "患者管理", "/admin/patients", "UserOutlined", 1)
m(userMgmt.ID, "医生管理", "/admin/doctors", "MedicineBoxOutlined", 2)
m(userMgmt.ID, "管理员管理", "/admin/admins", "SettingOutlined", 3)
// 3. 业务管理
bizMgmt := m(0, "业务管理", "", "ShopOutlined", 3)
m(bizMgmt.ID, "科室管理", "/admin/departments", "ApartmentOutlined", 1)
m(bizMgmt.ID, "问诊管理", "/admin/consultations", "FileSearchOutlined", 2)
m(bizMgmt.ID, "处方监管", "/admin/prescription", "FileTextOutlined", 3)
m(bizMgmt.ID, "药品库", "/admin/pharmacy", "MedicineBoxOutlined", 4)
// 4. 智能体平台
aiPlatform := m(0, "智能体平台", "", "ApiOutlined", 4)
m(aiPlatform.ID, "Agent管理", "/admin/agents", "RobotOutlined", 1)
m(aiPlatform.ID, "工具中心", "/admin/tools", "AppstoreOutlined", 2)
m(aiPlatform.ID, "工作流", "/admin/workflows", "DeploymentUnitOutlined", 3)
m(aiPlatform.ID, "人工审核", "/admin/tasks", "CheckCircleOutlined", 4)
m(aiPlatform.ID, "知识库", "/admin/knowledge", "BookOutlined", 5)
// 5. AI运维
aiOps := m(0, "AI运维", "", "RobotOutlined", 5)
m(aiOps.ID, "AI配置", "/admin/ai-config", "SettingOutlined", 1)
m(aiOps.ID, "AI运营中心", "/admin/ai-center", "FundOutlined", 2)
m(aiOps.ID, "内容安全", "/admin/safety", "SafetyOutlined", 3)
// 6. 系统管理
sysMgmt := m(0, "系统管理", "", "SafetyCertificateOutlined", 6)
m(sysMgmt.ID, "角色管理", "/admin/roles", "SafetyOutlined", 1)
m(sysMgmt.ID, "菜单管理", "/admin/menus", "AppstoreOutlined", 2)
m(sysMgmt.ID, "合规报表", "/admin/compliance", "SafetyCertificateOutlined", 3)
log.Println("[SeedRBAC] 菜单种子数据已创建(与页面展示一致)")
}
......@@ -119,6 +119,48 @@ func (s *Service) GetDashboardStats(ctx context.Context) (*DashboardStats, error
return stats, nil
}
// DailyConsultTrend 每日问诊趋势
type DailyConsultTrend struct {
Date string `json:"date"`
ConsultCount int `json:"consult_count"`
CompletedCount int `json:"completed_count"`
Revenue int `json:"revenue"`
}
func (s *Service) GetDashboardTrend(ctx context.Context) ([]DailyConsultTrend, error) {
var results []DailyConsultTrend
sevenDaysAgo := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
rows, err := s.db.Raw(`
SELECT
DATE(created_at)::text AS date,
COUNT(*) AS consult_count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed_count
FROM consultations
WHERE DATE(created_at) >= ?
GROUP BY DATE(created_at)
ORDER BY date
`, sevenDaysAgo).Rows()
if err != nil {
return results, err
}
defer rows.Close()
for rows.Next() {
var r DailyConsultTrend
if err := rows.Scan(&r.Date, &r.ConsultCount, &r.CompletedCount); err != nil {
continue
}
r.Revenue = r.CompletedCount * 5000
results = append(results, r)
}
if results == nil {
results = []DailyConsultTrend{}
}
return results, nil
}
func (s *Service) GetUserList(ctx context.Context, params *UserListParams) (interface{}, error) {
var users []model.User
var total int64
......
......@@ -33,6 +33,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
consult.GET("/doctor/waiting", h.GetWaitingList)
consult.GET("/doctor/patients", h.GetPatientList)
consult.GET("/doctor/workbench-stats", h.GetDoctorWorkbenchStats)
consult.GET("/doctor/weekly-trend", h.GetDoctorWeeklyTrend)
consult.GET("/doctor/patient-profile/:patient_id", h.GetPatientProfile)
// v13: 转诊
consult.POST("/:id/transfer", h.TransferConsult)
......@@ -129,6 +130,18 @@ func (h *Handler) GetDoctorWorkbenchStats(c *gin.Context) {
response.Success(c, stats)
}
func (h *Handler) GetDoctorWeeklyTrend(c *gin.Context) {
userID, _ := c.Get("user_id")
trend, err := h.service.GetDoctorWeeklyTrend(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取问诊趋势失败")
return
}
response.Success(c, trend)
}
func (h *Handler) GetConsultMessages(c *gin.Context) {
id, err := h.service.ResolveConsultID(c.Request.Context(), c.Param("id"))
if err != nil {
......
......@@ -1069,3 +1069,46 @@ func (s *Service) GetPatientProfile(ctx context.Context, patientID string) (*Pat
return profile, nil
}
// DoctorDailyTrend 医生每日问诊趋势
type DoctorDailyTrend struct {
Date string `json:"date"`
Count int `json:"count"`
Completed int `json:"completed"`
}
// GetDoctorWeeklyTrend 获取医生近7天每日问诊趋势
func (s *Service) GetDoctorWeeklyTrend(ctx context.Context, doctorUserID string) ([]DoctorDailyTrend, error) {
var results []DoctorDailyTrend
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return results, nil
}
sevenDaysAgo := time.Now().AddDate(0, 0, -6).Format("2006-01-02")
rows, err := s.db.Raw(`
SELECT DATE(created_at)::text AS date,
COUNT(*) AS count,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
FROM consultations
WHERE doctor_id = ? AND DATE(created_at) >= ?
GROUP BY DATE(created_at)
ORDER BY date
`, doctor.ID, sevenDaysAgo).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var item DoctorDailyTrend
if err := rows.Scan(&item.Date, &item.Count, &item.Completed); err != nil {
return nil, err
}
results = append(results, item)
}
return results, nil
}
......@@ -16,6 +16,8 @@
"antd": "^5.25.3",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"next": "^15.3.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
......@@ -1908,6 +1910,36 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
......@@ -1993,6 +2025,12 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
......@@ -4483,6 +4521,12 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
......@@ -4774,6 +4818,21 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz",
......
......@@ -17,6 +17,8 @@
"antd": "^5.25.3",
"axios": "^1.9.0",
"dayjs": "^1.11.13",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"next": "^15.3.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
......
......@@ -220,6 +220,9 @@ export const adminApi = {
// === 仪表盘 ===
getDashboardStats: () => get<DashboardStats>('/admin/dashboard/stats'),
getDashboardTrend: () =>
get<{ date: string; consult_count: number; completed_count: number; revenue: number }[]>('/admin/dashboard/trend'),
// === 用户管理(通用) ===
getUserList: (params: UserListParams) =>
get<PaginatedResponse<UserInfo>>('/admin/users', { params }),
......@@ -365,4 +368,32 @@ export const adminApi = {
getAILogs: (params: AILogListParams) =>
get<PaginatedResponse<AILogItem>>('/admin/ai-config/logs', { params }),
// === 内容安全 ===
getSafetyRules: (params: { page?: number; page_size?: number }) =>
get<{ list: unknown[]; total: number }>('/admin/safety/rules', { params }),
getSafetyLogs: (params: { page?: number; page_size?: number; action?: string }) =>
get<{ list: unknown[]; total: number }>('/admin/safety/logs', { params }),
getSafetyStats: () => get<Record<string, number>>('/admin/safety/stats'),
createSafetyRule: (data: Record<string, unknown>) =>
post<unknown>('/admin/safety/rules', data),
updateSafetyRule: (id: number, data: Record<string, unknown>) =>
put<unknown>(`/admin/safety/rules/${id}`, data),
deleteSafetyRule: (id: number) =>
del<null>(`/admin/safety/rules/${id}`),
importSafetyPreset: () =>
post<{ created: number }>('/admin/safety/rules/import-preset', {}),
// === AI 运营中心 ===
getAICenterStats: (params: { page?: number; page_size?: number; agent_id?: string }) =>
get<Record<string, unknown>>('/admin/ai-center/stats', { params }),
getAITrace: (traceId: string) =>
get<unknown>('/admin/ai-center/trace', { params: { trace_id: traceId } }),
};
......@@ -292,7 +292,8 @@ export const workflowApi = {
listExecutions: (params?: { workflow_id?: string; page?: number; page_size?: number }) =>
get<{ list: WorkflowExecution[]; total: number }>('/admin/workflow/executions', { params }),
getTasks: () => get<unknown[]>('/workflow/tasks'),
getTasks: (params?: { status?: string; assigned_to?: string }) =>
get<unknown[]>('/workflow/tasks', { params }),
completeTask: (taskId: string, result: Record<string, unknown>) =>
post<null>(`/workflow/task/${taskId}/complete`, result),
......
......@@ -289,6 +289,10 @@ export const consultApi = {
// 获取医生工作台统计
getDoctorWorkbenchStats: () => get<DoctorWorkbenchStats>('/consult/doctor/workbench-stats'),
// 获取医生近7天问诊趋势
getDoctorWeeklyTrend: () =>
get<{ date: string; count: number; completed: number }[]>('/consult/doctor/weekly-trend'),
// 接诊(支持 consult_id 或 serial_number)
acceptConsult: (idOrSerial: string) => post<null>(`/consult/${idOrSerial}/accept`),
......
......@@ -73,6 +73,7 @@ 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 ====================
......
......@@ -12,6 +12,7 @@ import {
import { useQuery } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { adminApi } from '@/api/admin';
const { Title, Text } = Typography;
......@@ -39,26 +40,6 @@ interface TraceDetail {
tool_calls: unknown[];
}
const token = () => localStorage.getItem('access_token') || '';
async function fetchStats(page: number, agentID: string) {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (agentID) params.append('agent_id', agentID);
const res = await fetch(`/api/v1/admin/ai-center/stats?${params}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? {};
}
async function fetchTrace(traceID: string) {
const res = await fetch(`/api/v1/admin/ai-center/trace?trace_id=${traceID}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data as TraceDetail;
}
export default function AICenterPage() {
const [activeTab, setActiveTab] = useState('overview');
const [page, setPage] = useState(1);
......@@ -69,13 +50,21 @@ export default function AICenterPage() {
const { data: stats, isLoading } = useQuery({
queryKey: ['ai-center-stats', page, agentFilter],
queryFn: () => fetchStats(page, agentFilter),
queryFn: async () => {
const res = await adminApi.getAICenterStats({
page, page_size: 20, agent_id: agentFilter || undefined,
});
return (res.data ?? {}) as Record<string, unknown>;
},
refetchInterval: 60000,
});
const { data: traceDetail, isFetching: traceFetching } = useQuery({
queryKey: ['trace-detail', selectedTraceID],
queryFn: () => fetchTrace(selectedTraceID),
queryFn: async () => {
const res = await adminApi.getAITrace(selectedTraceID);
return res.data as TraceDetail;
},
enabled: !!selectedTraceID && traceModalOpen,
});
......
import PageComponent from '@/pages/admin/DoctorReview';
export default function Page() { return <PageComponent />; }
'use client';
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag, Spin } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, CloudOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, ShopOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, CompassOutlined, ToolOutlined,
AuditOutlined, ScheduleOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import { myMenuApi } from '@/api/rbac';
import type { Menu as MenuType } from '@/api/rbac';
const { Sider, Content } = Layout;
const { Text } = Typography;
const menuItems = [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营大盘' },
{
key: 'user-mgmt', icon: <TeamOutlined />, label: '用户管理',
children: [
{ key: '/admin/patients', icon: <UserOutlined />, label: '患者管理' },
{ key: '/admin/doctors', icon: <MedicineBoxOutlined />, label: '医生管理' },
{ key: '/admin/admins', icon: <SettingOutlined />, label: '管理员管理' },
],
},
{ key: '/admin/departments', icon: <ApartmentOutlined />, label: '科室管理' },
{ key: '/admin/consultations', icon: <FileSearchOutlined />, label: '问诊管理' },
{ key: '/admin/prescription', icon: <FileTextOutlined />, label: '处方监管' },
{ key: '/admin/pharmacy', icon: <MedicineBoxOutlined />, label: '药品库' },
{ key: '/admin/ai-config', icon: <RobotOutlined />, label: 'AI配置' },
{ key: '/admin/compliance', icon: <SafetyCertificateOutlined />, label: '合规报表' },
{
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: 'Agent管理' },
{ key: '/admin/tools', icon: <AppstoreOutlined />, label: '工具中心' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/admin/safety', icon: <SafetyOutlined />, label: '内容安全' },
{ key: '/admin/ai-center', icon: <FundOutlined />, label: 'AI运营中心' },
],
},
{
key: 'system-mgmt', icon: <SafetyCertificateOutlined />, label: '系统管理',
children: [
{ key: '/admin/roles', icon: <SafetyOutlined />, label: '角色管理' },
{ key: '/admin/menus', icon: <AppstoreOutlined />, label: '菜单管理' },
],
},
];
// 图标名称 → React 图标组件映射
const ICON_MAP: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
TeamOutlined: <TeamOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
SettingOutlined: <SettingOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
FileSearchOutlined: <FileSearchOutlined />,
FileTextOutlined: <FileTextOutlined />,
RobotOutlined: <RobotOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
ApiOutlined: <ApiOutlined />,
DeploymentUnitOutlined: <DeploymentUnitOutlined />,
BookOutlined: <BookOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
SafetyOutlined: <SafetyOutlined />,
FundOutlined: <FundOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
ShopOutlined: <ShopOutlined />,
CompassOutlined: <CompassOutlined />,
ToolOutlined: <ToolOutlined />,
AuditOutlined: <AuditOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
BellOutlined: <BellOutlined />,
};
// 将数据库菜单树转换为 Ant Design Menu items
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;
}
// 从菜单树中查找包含某路径的父节点key
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 AdminLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [dynamicMenus, setDynamicMenus] = useState<MenuType[]>([]);
const [menuLoading, setMenuLoading] = useState(true);
const currentPath = pathname || '';
// 从 API 获取动态菜单
useEffect(() => {
let cancelled = false;
setMenuLoading(true);
myMenuApi.getMenus()
.then(res => {
if (!cancelled) setDynamicMenus(res.data || []);
})
.catch(() => { /* 静默失败,使用空菜单 */ })
.finally(() => { if (!cancelled) setMenuLoading(false); });
return () => { cancelled = true; };
}, []);
// 转换为 Ant Design menu items
const menuItems = useMemo(() => convertMenuTree(dynamicMenus), [dynamicMenus]);
// 监听 AI 助手导航事件
useEffect(() => {
const handleAIAction = (e: Event) => {
......@@ -91,33 +145,16 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
if (key === 'logout') { logout(); router.push('/login'); }
};
const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents',
'/admin/tools', '/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center',
'/admin/roles', '/admin/menus'];
const match = allKeys.find(k => currentPath.startsWith(k));
const getSelectedKeys = useCallback(() => {
const allPaths = collectLeafPaths(dynamicMenus);
const match = allPaths.find(k => currentPath.startsWith(k));
return match ? [match] : [];
};
}, [dynamicMenus, currentPath]);
const getOpenKeys = () => {
const getOpenKeys = useCallback(() => {
if (collapsed) return [];
const keys: string[] = [];
if (['/admin/patients', '/admin/doctors', '/admin/admins'].some(k => currentPath.startsWith(k))) {
keys.push('user-mgmt');
}
if (['/admin/agents', '/admin/tools',
'/admin/workflows', '/admin/tasks', '/admin/knowledge', '/admin/safety', '/admin/ai-center']
.some(k => currentPath.startsWith(k))) {
keys.push('ai-platform');
}
if (['/admin/roles', '/admin/menus'].some(k => currentPath.startsWith(k))) {
keys.push('system-mgmt');
}
return keys;
};
return findOpenKeys(dynamicMenus, currentPath);
}, [dynamicMenus, currentPath, collapsed]);
return (
<Layout style={{ minHeight: '100vh' }}>
......
This diff is collapsed.
This diff is collapsed.
......@@ -12,6 +12,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { adminApi } from '@/api/admin';
const { Title, Text } = Typography;
......@@ -41,69 +42,6 @@ interface SafetyLog {
created_at: string;
}
const token = () => localStorage.getItem('access_token') || '';
async function fetchRules(page: number) {
const res = await fetch(`/api/v1/admin/safety/rules?page=${page}&page_size=20`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? { list: [], total: 0 };
}
async function fetchLogs(page: number, action: string) {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (action) params.append('action', action);
const res = await fetch(`/api/v1/admin/safety/logs?${params}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? { list: [], total: 0 };
}
async function fetchStats() {
const res = await fetch('/api/v1/admin/safety/stats', {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? {};
}
async function createRule(rule: Partial<SafetyRule>) {
const res = await fetch('/api/v1/admin/safety/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify(rule),
});
if (!res.ok) throw new Error('创建失败');
return res.json();
}
async function updateRule(id: number, rule: Partial<SafetyRule>) {
const res = await fetch(`/api/v1/admin/safety/rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token()}` },
body: JSON.stringify(rule),
});
if (!res.ok) throw new Error('更新失败');
return res.json();
}
async function deleteRule(id: number) {
await fetch(`/api/v1/admin/safety/rules/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token()}` },
});
}
async function importPreset() {
const res = await fetch('/api/v1/admin/safety/rules/import-preset', {
method: 'POST',
headers: { Authorization: `Bearer ${token()}` },
});
return res.json();
}
const CATEGORY_OPTIONS = [
{ value: 'injection', label: '注入攻击' },
{ value: 'medical_claim', label: '医疗断言' },
......@@ -134,13 +72,31 @@ export default function SafetyPage() {
const [editingRule, setEditingRule] = useState<SafetyRule | null>(null);
const [form] = Form.useForm();
const { data: rulesData } = useQuery({ queryKey: ['safety-rules', rulesPage], queryFn: () => fetchRules(rulesPage) });
const { data: logsData } = useQuery({ queryKey: ['safety-logs', logsPage, logsFilter], queryFn: () => fetchLogs(logsPage, logsFilter) });
const { data: stats } = useQuery({ queryKey: ['safety-stats'], queryFn: fetchStats });
const { data: rulesData } = useQuery({
queryKey: ['safety-rules', rulesPage],
queryFn: async () => {
const res = await adminApi.getSafetyRules({ page: rulesPage, page_size: 20 });
return res.data ?? { list: [], total: 0 };
},
});
const { data: logsData } = useQuery({
queryKey: ['safety-logs', logsPage, logsFilter],
queryFn: async () => {
const res = await adminApi.getSafetyLogs({ page: logsPage, page_size: 20, action: logsFilter || undefined });
return res.data ?? { list: [], total: 0 };
},
});
const { data: stats } = useQuery({
queryKey: ['safety-stats'],
queryFn: async () => {
const res = await adminApi.getSafetyStats();
return res.data ?? {};
},
});
const saveMutation = useMutation({
mutationFn: (values: Partial<SafetyRule>) =>
editingRule ? updateRule(editingRule.id, values) : createRule(values),
editingRule ? adminApi.updateSafetyRule(editingRule.id, values) : adminApi.createSafetyRule(values),
onSuccess: () => {
message.success(editingRule ? '更新成功' : '创建成功');
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
......@@ -152,7 +108,7 @@ export default function SafetyPage() {
});
const deleteMutation = useMutation({
mutationFn: deleteRule,
mutationFn: (id: number) => adminApi.deleteSafetyRule(id),
onSuccess: () => {
message.success('删除成功');
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
......@@ -160,9 +116,9 @@ export default function SafetyPage() {
});
const importMutation = useMutation({
mutationFn: importPreset,
onSuccess: (data) => {
message.success(`导入完成,新增 ${data.created} 条规则`);
mutationFn: () => adminApi.importSafetyPreset(),
onSuccess: (res) => {
message.success(`导入完成,新增 ${res.data?.created ?? 0} 条规则`);
queryClient.invalidateQueries({ queryKey: ['safety-rules'] });
},
});
......
import PageComponent from '@/pages/admin/Statistics';
export default function Page() { return <PageComponent />; }
......@@ -12,6 +12,7 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { workflowApi } from '@/api/agent';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
......@@ -31,29 +32,6 @@ interface HumanTask {
updated_at: string;
}
async function fetchAllTasks(status: string, assignedTo: string) {
const token = localStorage.getItem('access_token');
const params = new URLSearchParams();
if (status) params.append('status', status);
if (assignedTo) params.append('assigned_to', assignedTo);
const res = await fetch(`/api/v1/workflow/tasks?${params}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
return data.data ?? [];
}
async function completeTask(id: number, action: 'approve' | 'reject', comment: string) {
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/v1/workflow/task/${id}/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ action, comment }),
});
if (!res.ok) throw new Error('操作失败');
return res.json();
}
export default function AdminTasksPage() {
const queryClient = useQueryClient();
const [filterStatus, setFilterStatus] = useState('pending');
......@@ -64,13 +42,19 @@ export default function AdminTasksPage() {
const { data: tasks = [], isLoading } = useQuery<HumanTask[]>({
queryKey: ['admin-tasks', filterStatus, filterRole],
queryFn: () => fetchAllTasks(filterStatus, filterRole),
queryFn: async () => {
const res = await workflowApi.getTasks({
status: filterStatus || undefined,
assigned_to: filterRole || undefined,
});
return (res.data ?? []) as HumanTask[];
},
refetchInterval: 30000,
});
const mutation = useMutation({
mutationFn: ({ id, action, comment }: { id: number; action: 'approve' | 'reject'; comment: string }) =>
completeTask(id, action, comment),
workflowApi.completeTask(String(id), { action, comment }),
onSuccess: () => {
message.success('操作成功');
queryClient.invalidateQueries({ queryKey: ['admin-tasks'] });
......
import PageComponent from '@/pages/admin/Users';
export default function Page() { return <PageComponent />; }
'use client';
import React from 'react';
import { Card } from 'antd';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts/core';
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
GridComponent, TooltipComponent, LegendComponent,
RadarComponent, TitleComponent,
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([
BarChart, LineChart, PieChart, RadarChart,
GridComponent, TooltipComponent, LegendComponent,
RadarComponent, TitleComponent, CanvasRenderer,
]);
export const CHART_COLORS = ['#1890ff', '#52c41a', '#fa8c16', '#722ed1', '#13c2c2', '#eb2f96'];
interface EChartsCardProps {
title: string;
extra?: React.ReactNode;
height?: number;
option: Record<string, unknown>;
loading?: boolean;
}
export default function EChartsCard({ title, extra, height = 280, option, loading }: EChartsCardProps) {
return (
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>{title}</span>}
extra={extra}
loading={loading}
style={{ borderRadius: 12, border: '1px solid #edf2fc' }}
styles={{ body: { padding: '12px 16px' } }}
>
<ReactECharts
echarts={echarts}
option={{
grid: { containLabel: true, left: 12, right: 12, top: 30, bottom: 8 },
color: CHART_COLORS,
...option,
}}
style={{ height }}
notMerge
/>
</Card>
);
}
export { default as EChartsCard, CHART_COLORS } from './EChartsCard';
......@@ -462,14 +462,14 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext, onEmbed })
{/* Quick replies */}
<div style={{ padding: '6px 12px', borderTop: '1px solid #f3f4f6', background: '#fff' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{quickItems.map((text, idx) => (
{quickItems.map((item, idx) => (
<Tag
key={idx}
style={{ cursor: 'pointer', fontSize: 12, margin: 0 }}
onClick={() => setInputValue(prev => prev ? `${prev}、${text}` : text)}
onClick={() => setInputValue(prev => prev ? `${prev}、${item.label}` : item.label)}
>
{QUICK_ICON[role]}
{text}
{item.label}
</Tag>
))}
</div>
......
......@@ -95,30 +95,6 @@ export const ROUTE_REGISTRY: RouteDefinition[] = [
permissions: ['admin:pharmacy:list'],
operations: { list: '/admin/pharmacy' },
},
{
code: 'admin_users',
path: '/admin/users',
name: '用户管理',
role: 'admin',
permissions: ['admin:users:list'],
operations: { list: '/admin/users' },
},
{
code: 'admin_doctor_review',
path: '/admin/doctor-review',
name: '医生审核',
role: 'admin',
permissions: ['admin:doctor-review:list'],
operations: { list: '/admin/doctor-review' },
},
{
code: 'admin_statistics',
path: '/admin/statistics',
name: '数据统计',
role: 'admin',
permissions: ['admin:statistics:view'],
operations: { list: '/admin/statistics' },
},
// AI 管理
{
code: 'admin_ai_config',
......
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect } from 'react';
import {
Card, Row, Col, Typography, Space, Tabs, Form, Input, Select, Switch,
Button, Table, Tag, Modal, message, Divider, Alert, InputNumber, Spin,
DatePicker, Tooltip,
} from 'antd';
import {
RobotOutlined, SettingOutlined, FileTextOutlined, SafetyOutlined,
PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined,
CheckCircleOutlined, LoadingOutlined, HistoryOutlined, ReloadOutlined,
SettingOutlined, FileTextOutlined,
PlusOutlined, EditOutlined, DeleteOutlined,
CheckCircleOutlined, LoadingOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { adminApi } from '../../../api/admin';
import type { AIUsageStats, PromptTemplateData, AILogItem } from '../../../api/admin';
import type { AIUsageStats, PromptTemplateData } from '../../../api/admin';
const { Title, Text } = Typography;
const { TextArea } = Input;
......@@ -84,17 +82,6 @@ const AdminAIConfigPage: React.FC = () => {
const [editingTemplate, setEditingTemplate] = useState<PromptTemplateData | null>(null);
const [templateSaving, setTemplateSaving] = useState(false);
const [logData, setLogData] = useState<AILogItem[]>([]);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(1);
const [logPageSize, setLogPageSize] = useState(20);
const [logLoading, setLogLoading] = useState(false);
const [logSceneFilter, setLogSceneFilter] = useState<string | undefined>();
const [logSuccessFilter, setLogSuccessFilter] = useState<string | undefined>();
const [logDateRange, setLogDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>([dayjs(), dayjs()]);
const [logDetailVisible, setLogDetailVisible] = useState(false);
const [logDetailItem, setLogDetailItem] = useState<AILogItem | null>(null);
useEffect(() => {
const fetchConfig = async () => {
setLoading(true);
......@@ -131,31 +118,6 @@ const AdminAIConfigPage: React.FC = () => {
fetchConfig();
}, [modelForm]);
const fetchAILogs = useCallback(async () => {
setLogLoading(true);
try {
const res = await adminApi.getAILogs({
scene: logSceneFilter,
success: logSuccessFilter,
start_date: logDateRange?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: logDateRange?.[1]?.format('YYYY-MM-DD') || undefined,
page: logPage,
page_size: logPageSize,
});
const result = res.data as any;
setLogData(result?.list || []);
setLogTotal(result?.total || 0);
} catch {
message.error('获取AI日志失败');
} finally {
setLogLoading(false);
}
}, [logSceneFilter, logSuccessFilter, logDateRange, logPage, logPageSize]);
useEffect(() => {
fetchAILogs();
}, [fetchAILogs]);
const handleProviderChange = (provider: string) => {
setCurrentProvider(provider);
const defaults = providerDefaults[provider];
......@@ -425,77 +387,6 @@ const AdminAIConfigPage: React.FC = () => {
</Card>
),
},
{
key: 'safety',
label: <span><SafetyOutlined /> 安全词过滤</span>,
children: (
<Card style={{ borderRadius: 12 }}>
<Alert message="安全词过滤规则用于防止 AI 输出不安全或不合规的内容" type="info" showIcon style={{ marginBottom: 16 }} />
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
<ExclamationCircleOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>安全词管理功能开发中...</div>
</div>
</Card>
),
},
{
key: 'logs',
label: <span><HistoryOutlined /> 模型日志</span>,
children: (
<Card style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space wrap>
<Select placeholder="场景筛选" style={{ width: 150 }} allowClear value={logSceneFilter} onChange={setLogSceneFilter}
options={Object.entries(sceneLabels).map(([k, v]) => ({ label: v, value: k }))} />
<Select placeholder="状态筛选" style={{ width: 120 }} allowClear value={logSuccessFilter} onChange={setLogSuccessFilter}
options={[{ label: '成功', value: 'true' }, { label: '失败', value: 'false' }]} />
<DatePicker.RangePicker value={logDateRange} onChange={(dates) => setLogDateRange(dates)} />
<Button icon={<ReloadOutlined />} onClick={fetchAILogs}>刷新</Button>
</Space>
</div>
<Table
columns={[
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '场景', dataIndex: 'scene', key: 'scene', width: 130, render: (v: string) => <Tag color="blue">{sceneLabels[v] || v}</Tag> },
{ title: '模型', dataIndex: 'model', key: 'model', width: 140 },
{
title: '请求摘要', dataIndex: 'request_content', key: 'request_content', width: 180,
render: (v: string) => <Tooltip title={v}><Text ellipsis style={{ maxWidth: 180, display: 'block' }}>{v || '-'}</Text></Tooltip>,
},
{ title: 'Tokens', key: 'tokens', width: 100, render: (_: any, record: AILogItem) => <Text type="secondary">{record.total_tokens || 0}</Text> },
{ title: '耗时', dataIndex: 'response_time_ms', key: 'response_time_ms', width: 80, render: (v: number) => <Text type="secondary">{v ? (v / 1000).toFixed(2) + 's' : '-'}</Text> },
{
title: '状态', dataIndex: 'success', key: 'success', width: 80,
render: (v: boolean, record: AILogItem) => (
<Space direction="vertical" size={0}>
<Tag color={v ? 'success' : 'error'}>{v ? '成功' : '失败'}</Tag>
{record.is_mock && <Tag color="default">模拟</Tag>}
</Space>
),
},
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '操作', key: 'action', width: 80,
render: (_: any, record: AILogItem) => (
<Button type="link" size="small" onClick={() => { setLogDetailItem(record); setLogDetailVisible(true); }}>详情</Button>
),
},
]}
dataSource={logData}
rowKey="id"
loading={logLoading}
pagination={{
current: logPage,
pageSize: logPageSize,
total: logTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => { setLogPage(p); setLogPageSize(ps); },
}}
/>
</Card>
),
},
]} />
<Modal title={templateModalType === 'add' ? '新建 Prompt 模板' : '编辑 Prompt 模板'}
......@@ -533,42 +424,6 @@ const AdminAIConfigPage: React.FC = () => {
</Form>
</Modal>
<Modal title="AI 调用详情" open={logDetailVisible} onCancel={() => setLogDetailVisible(false)}
footer={[<Button key="close" onClick={() => setLogDetailVisible(false)}>关闭</Button>]} width={800}>
{logDetailItem && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={16}>
<Col span={8}><Text type="secondary">场景:</Text><Text strong>{sceneLabels[logDetailItem.scene] || logDetailItem.scene}</Text></Col>
<Col span={8}><Text type="secondary">模型:</Text><Text strong>{logDetailItem.model || '-'}</Text></Col>
<Col span={8}><Text type="secondary">状态:</Text><Tag color={logDetailItem.success ? 'success' : 'error'}>{logDetailItem.success ? '成功' : '失败'}</Tag></Col>
</Row>
<Row gutter={16}>
<Col span={8}><Text type="secondary">Prompt Tokens:</Text><Text>{logDetailItem.prompt_tokens || 0}</Text></Col>
<Col span={8}><Text type="secondary">Completion Tokens:</Text><Text>{logDetailItem.completion_tokens || 0}</Text></Col>
<Col span={8}><Text type="secondary">Total Tokens:</Text><Text strong>{logDetailItem.total_tokens || 0}</Text></Col>
</Row>
<Divider style={{ margin: '8px 0' }} />
<div>
<Text type="secondary">系统提示词:</Text>
<Card size="small" style={{ marginTop: 8, maxHeight: 200, overflow: 'auto', backgroundColor: '#f5f5f5' }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logDetailItem.prompt || ''}</pre>
</Card>
</div>
<div>
<Text type="secondary">响应内容:</Text>
<Card size="small" style={{ marginTop: 8, maxHeight: 250, overflow: 'auto' }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logDetailItem.response_content || ''}</pre>
</Card>
</div>
{logDetailItem.error_message && (
<div>
<Text type="secondary">错误信息:</Text>
<Alert message={logDetailItem.error_message} type="error" style={{ marginTop: 8 }} />
</div>
)}
</Space>
)}
</Modal>
</div>
);
};
......
This diff is collapsed.
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message, Descriptions } from 'antd';
import { UserOutlined, CheckCircleOutlined, CloseCircleOutlined, EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { adminApi, type DoctorReviewItem } from '../../../api/admin';
const { Title, Text } = Typography;
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待审核', color: 'orange' },
approved: { text: '已通过', color: 'green' },
rejected: { text: '已拒绝', color: 'red' },
};
const AdminDoctorReviewPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [reviewData, setReviewData] = useState<DoctorReviewItem[]>([]);
const fetchReviews = async () => {
setLoading(true);
try {
const res = await adminApi.getDoctorReviewList();
setReviewData(res.data.list || []);
} catch (error) {
console.error('获取医生审核列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchReviews();
}, []);
const handleApprove = (record: DoctorReviewItem) => {
Modal.confirm({
title: '确认通过',
content: `确认通过 ${record.name} 的医生认证申请?`,
icon: <CheckCircleOutlined style={{ color: '#52c41a' }} />,
onOk: async () => {
try {
await adminApi.approveDoctorReview(record.id);
message.success('审核已通过');
fetchReviews();
} catch (error) {
message.error('操作失败');
}
},
});
};
const handleReject = (record: DoctorReviewItem) => {
Modal.confirm({
title: '拒绝申请',
content: `确认拒绝 ${record.name} 的医生认证申请?`,
icon: <CloseCircleOutlined style={{ color: '#ff4d4f' }} />,
onOk: async () => {
try {
await adminApi.rejectDoctorReview(record.id, '资质不符合要求');
message.success('已拒绝');
fetchReviews();
} catch (error) {
message.error('操作失败');
}
},
});
};
const handleViewDetail = (record: DoctorReviewItem) => {
Modal.info({
title: '医生审核详情',
width: 600,
content: (
<Descriptions column={2} style={{ marginTop: 16 }}>
<Descriptions.Item label="姓名">{record.name}</Descriptions.Item>
<Descriptions.Item label="电话">{record.phone}</Descriptions.Item>
<Descriptions.Item label="执业证号">{record.license_no}</Descriptions.Item>
<Descriptions.Item label="职称">{record.title}</Descriptions.Item>
<Descriptions.Item label="所属医院">{record.hospital}</Descriptions.Item>
<Descriptions.Item label="科室">{record.department_name}</Descriptions.Item>
<Descriptions.Item label="提交时间">{record.submitted_at}</Descriptions.Item>
<Descriptions.Item label="审核状态">
<Tag color={statusMap[record.status].color}>
{statusMap[record.status].text}
</Tag>
</Descriptions.Item>
</Descriptions>
),
});
};
const columns: ColumnsType<DoctorReviewItem> = [
{
title: '医生',
key: 'doctor',
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
<div>
<Text strong>{record.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
</div>
</Space>
),
},
{
title: '职称',
dataIndex: 'title',
key: 'title',
render: (title: string) => <Tag color="blue">{title}</Tag>,
},
{
title: '医院',
dataIndex: 'hospital',
key: 'hospital',
},
{
title: '科室',
dataIndex: 'department_name',
key: 'department_name',
},
{
title: '执业证号',
dataIndex: 'license_no',
key: 'license_no',
},
{
title: '提交时间',
dataIndex: 'submitted_at',
key: 'submitted_at',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => {
const s = statusMap[status];
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
查看
</Button>
{record.status === 'pending' && (
<>
<Button
type="link"
size="small"
style={{ color: '#52c41a' }}
icon={<CheckCircleOutlined />}
onClick={() => handleApprove(record)}
>
通过
</Button>
<Button
type="link"
size="small"
danger
icon={<CloseCircleOutlined />}
onClick={() => handleReject(record)}
>
拒绝
</Button>
</>
)}
</Space>
),
},
];
return (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">医生审核</h4>
<Card size="small">
<Table
columns={columns}
dataSource={reviewData}
rowKey="id"
loading={loading}
size="small"
pagination={{ pageSize: 10, size: 'small', showTotal: (t) => `共 ${t} 条` }}
/>
</Card>
</div>
);
};
export default AdminDoctorReviewPage;
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Typography, Space, Statistic, Select, DatePicker, Table, Tag, Progress, Spin, message } from 'antd';
import {
RiseOutlined, FallOutlined, UserOutlined, MessageOutlined,
DollarOutlined, TeamOutlined, MedicineBoxOutlined,
} from '@ant-design/icons';
import { adminApi, type DashboardStats } from '../../../api/admin';
import dayjs from 'dayjs';
const { Text } = Typography;
interface DepartmentStat {
name: string;
count: number;
percentage: number;
color: string;
}
interface DoctorRank {
rank: number;
name: string;
department: string;
consult_count: number;
rating: number;
income: number;
}
const AdminStatisticsPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [period, setPeriod] = useState('month');
useEffect(() => {
const fetchStats = async () => {
setLoading(true);
try {
const res = await adminApi.getDashboardStats();
setStats(res.data);
} catch {
message.error('获取统计数据失败');
} finally {
setLoading(false);
}
};
fetchStats();
}, [period]);
const departmentStats: DepartmentStat[] = [
{ name: '内科', count: 320, percentage: 28, color: '#1890ff' },
{ name: '外科', count: 245, percentage: 21, color: '#52c41a' },
{ name: '妇产科', count: 198, percentage: 17, color: '#722ed1' },
{ name: '儿科', count: 176, percentage: 15, color: '#fa8c16' },
{ name: '皮肤科', count: 112, percentage: 10, color: '#eb2f96' },
{ name: '其他', count: 109, percentage: 9, color: '#8c8c8c' },
];
const doctorRankData: DoctorRank[] = [
{ rank: 1, name: '陈医生', department: '内科', consult_count: 156, rating: 4.9, income: 78000 },
{ rank: 2, name: '张医生', department: '外科', consult_count: 132, rating: 4.8, income: 66000 },
{ rank: 3, name: '刘医生', department: '妇产科', consult_count: 118, rating: 4.9, income: 59000 },
{ rank: 4, name: '王医生', department: '儿科', consult_count: 105, rating: 4.7, income: 52500 },
{ rank: 5, name: '李医生', department: '内科', consult_count: 98, rating: 4.8, income: 49000 },
];
const doctorColumns = [
{
title: '排名', dataIndex: 'rank', key: 'rank', width: 60,
render: (v: number) => {
const colors = ['#f5222d', '#fa8c16', '#faad14'];
return v <= 3
? <span style={{ color: colors[v - 1], fontWeight: 'bold', fontSize: 16 }}>{v}</span>
: <span style={{ color: '#8c8c8c' }}>{v}</span>;
},
},
{ title: '医生', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '科室', dataIndex: 'department', key: 'department', render: (v: string) => <Tag>{v}</Tag> },
{ title: '问诊量', dataIndex: 'consult_count', key: 'consult_count', render: (v: number) => <Text strong style={{ color: '#1890ff' }}>{v}</Text> },
{
title: '评分', dataIndex: 'rating', key: 'rating',
render: (v: number) => <Tag color={v >= 4.8 ? 'green' : v >= 4.5 ? 'blue' : 'default'}>{v.toFixed(1)}</Tag>,
},
{
title: '收入', dataIndex: 'income', key: 'income',
render: (v: number) => <Text style={{ color: '#52c41a' }}>¥{(v / 100).toFixed(0)}</Text>,
},
];
return (
<Spin spinning={loading}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-gray-800 m-0">数据统计</h4>
<div className="flex items-center gap-2">
<Select defaultValue="month" size="small" style={{ width: 100 }} onChange={setPeriod}
options={[{ label: '本周', value: 'week' }, { label: '本月', value: 'month' }, { label: '本季', value: 'quarter' }, { label: '本年', value: 'year' }]} />
<DatePicker.RangePicker size="small" />
</div>
</div>
<Row gutter={[8, 8]}>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="总用户数" value={stats?.total_users || 0} prefix={<UserOutlined />}
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 12.5%</span>} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="总问诊量" value={stats?.total_consultations || 0} prefix={<MessageOutlined />}
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 8.3%</span>} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="本月收入" value={stats?.revenue_month ? stats.revenue_month / 100 : 0} prefix={<DollarOutlined />} precision={0}
suffix={<span className="text-xs text-green-500"><RiseOutlined /> 15.2%</span>} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="在线医生" value={stats?.online_doctors || 0} prefix={<TeamOutlined />}
suffix={<span className="text-xs text-red-500"><FallOutlined /> 2.1%</span>} />
</Card>
</Col>
</Row>
<Row gutter={[8, 8]}>
<Col span={12}>
<Card title={<span className="text-xs font-semibold">问诊趋势(近7日)</span>} size="small" style={{ minHeight: 280 }}>
<div className="space-y-2 px-2">
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((day, i) => {
const values = [45, 62, 58, 71, 85, 38, 42];
const max = Math.max(...values);
return (
<div key={day} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-8">{day}</span>
<Progress
percent={Math.round(values[i] / max * 100)}
size="small"
strokeColor="#1890ff"
format={() => <span className="text-xs">{values[i]}</span>}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</Card>
</Col>
<Col span={12}>
<Card title={<span className="text-xs font-semibold">收入趋势(近7日)</span>} size="small" style={{ minHeight: 280 }}>
<div className="space-y-2 px-2">
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map((day, i) => {
const values = [2800, 3500, 3200, 4100, 5200, 1800, 2200];
const max = Math.max(...values);
return (
<div key={day} className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-8">{day}</span>
<Progress
percent={Math.round(values[i] / max * 100)}
size="small"
strokeColor="#52c41a"
format={() => <span className="text-xs">¥{values[i]}</span>}
style={{ flex: 1 }}
/>
</div>
);
})}
</div>
</Card>
</Col>
</Row>
<Row gutter={[8, 8]}>
<Col span={12}>
<Card title={<span className="text-xs font-semibold"><MedicineBoxOutlined className="mr-1" />科室问诊分布</span>} size="small" style={{ minHeight: 280 }}>
<div className="space-y-3 px-2">
{departmentStats.map((dept) => (
<div key={dept.name}>
<div className="flex items-center justify-between mb-1">
<Space size={4}>
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: dept.color }} />
<Text className="text-xs!">{dept.name}</Text>
</Space>
<Space size={8}>
<Text strong className="text-xs!">{dept.count}</Text>
<Text type="secondary" className="text-xs!">{dept.percentage}%</Text>
</Space>
</div>
<Progress percent={dept.percentage} size="small" strokeColor={dept.color} showInfo={false} />
</div>
))}
</div>
</Card>
</Col>
<Col span={12}>
<Card title={<span className="text-xs font-semibold"><TeamOutlined className="mr-1" />医生排名 TOP5</span>} size="small" style={{ minHeight: 280 }}>
<Table
dataSource={doctorRankData}
columns={doctorColumns}
rowKey="rank"
size="small"
pagination={false}
/>
</Card>
</Col>
</Row>
</div>
</Spin>
);
};
export default AdminStatisticsPage;
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, Modal, message } from 'antd';
import { SearchOutlined, UserOutlined, LockOutlined, StopOutlined, EditOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { adminApi } from '../../../api/admin';
import type { UserInfo } from '../../../api/user';
const { Title, Text } = Typography;
type UserRecord = UserInfo;
const roleMap: Record<string, { text: string; color: string }> = {
patient: { text: '患者', color: 'blue' },
doctor: { text: '医生', color: 'green' },
admin: { text: '管理员', color: 'purple' },
};
const AdminUsersPage: React.FC = () => {
const [keyword, setKeyword] = useState('');
const [roleFilter, setRoleFilter] = useState<string | undefined>();
const [loading, setLoading] = useState(false);
const [usersData, setUsersData] = useState<UserRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const fetchUsers = async () => {
setLoading(true);
try {
const res = await adminApi.getUserList({
keyword: keyword || undefined,
role: roleFilter,
page,
page_size: pageSize,
});
setUsersData(res.data.list || []);
setTotal(res.data.total || 0);
} catch (error) {
console.error('获取用户列表失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, [page, pageSize]);
const handleSearch = () => {
setPage(1);
fetchUsers();
};
const handleResetPassword = (userId: string) => {
Modal.confirm({
title: '确认重置',
content: '确定要重置该用户的密码吗?重置后密码为 123456',
onOk: async () => {
try {
await adminApi.resetUserPassword(userId);
message.success('密码已重置');
} catch (error) {
message.error('重置密码失败');
}
},
});
};
const handleToggleStatus = (userId: string, currentStatus: string) => {
const action = currentStatus === 'active' ? '禁用' : '启用';
const newStatus = currentStatus === 'active' ? 'disabled' : 'active';
Modal.confirm({
title: `确认${action}`,
content: `确定${action}该用户吗?`,
onOk: async () => {
try {
await adminApi.updateUserStatus(userId, newStatus);
message.success(`已${action}`);
fetchUsers();
} catch (error) {
message.error(`${action}失败`);
}
},
});
};
const columns: ColumnsType<UserRecord> = [
{
title: '用户',
key: 'user',
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} src={record.avatar} />
<div>
<Text strong>{record.real_name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
</div>
</Space>
),
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
render: (role: string) => {
const r = roleMap[role];
return <Tag color={r.color}>{r.text}</Tag>;
},
},
{
title: '实名认证',
dataIndex: 'is_verified',
key: 'is_verified',
render: (v: boolean) => v ? <Tag color="success">已认证</Tag> : <Tag color="warning">未认证</Tag>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>
{status === 'active' ? '正常' : '已禁用'}
</Tag>
),
},
{
title: '注册时间',
dataIndex: 'created_at',
key: 'created_at',
render: (t: string) => dayjs(t).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />}>编辑</Button>
<Button type="link" size="small" icon={<LockOutlined />} onClick={() => handleResetPassword(record.id)}>
重置密码
</Button>
<Button
type="link"
size="small"
danger={record.status === 'active'}
icon={<StopOutlined />}
onClick={() => handleToggleStatus(record.id, record.status || 'active')}
>
{record.status === 'active' ? '禁用' : '启用'}
</Button>
</Space>
),
},
];
return (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">用户管理</h4>
<Card size="small">
<div className="flex flex-wrap items-center gap-2">
<Input placeholder="搜索用户名/手机号" prefix={<SearchOutlined />} value={keyword}
onChange={(e) => setKeyword(e.target.value)} style={{ width: 180 }} size="small" />
<Select placeholder="角色" style={{ width: 100 }} size="small" allowClear value={roleFilter}
onChange={(v) => setRoleFilter(v)} options={[
{ label: '患者', value: 'patient' }, { label: '医生', value: 'doctor' }, { label: '管理员', value: 'admin' },
]} />
<Select placeholder="状态" style={{ width: 100 }} size="small" allowClear
options={[{ label: '正常', value: 'active' }, { label: '已禁用', value: 'disabled' }]} />
<Button type="primary" size="small" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
</div>
</Card>
<Card size="small">
<Table columns={columns} dataSource={usersData} rowKey="id" loading={loading} size="small"
pagination={{
current: page, pageSize, total, size: 'small', showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</Card>
</div>
);
};
export default AdminUsersPage;
This diff is collapsed.
This diff is collapsed.
......@@ -41,14 +41,11 @@ export const routeMap = {
// 管理端
adminDashboard: '/admin/dashboard',
adminUsers: '/admin/users',
adminPatients: '/admin/patients',
adminDoctors: '/admin/doctors',
adminDoctorReview: '/admin/doctor-review',
adminAdmins: '/admin/admins',
adminDepartments: '/admin/departments',
adminConsultations: '/admin/consultations',
adminStatistics: '/admin/statistics',
adminPrescription: '/admin/prescription',
adminPharmacy: '/admin/pharmacy',
adminAIConfig: '/admin/ai-config',
......
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