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' }}>
......
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import {
Table, Button, Modal, Form, Input, Tag, Space, Popconfirm, message,
Switch, Select, InputNumber, TreeSelect,
Card, Table, Button, Modal, Form, Input, Tag, Space, message,
Switch, Select, InputNumber, TreeSelect, Typography, Row, Col,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, MenuOutlined,
EyeOutlined, EyeInvisibleOutlined,
PlusOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined,
EyeOutlined, EyeInvisibleOutlined, ReloadOutlined, ImportOutlined,
} from '@ant-design/icons';
import { menuApi } from '@/api/rbac';
import type { Menu } from '@/api/rbac';
const { Text } = Typography;
export default function MenusPage() {
const [menus, setMenus] = useState<Menu[]>([]);
const [flatMenus, setFlatMenus] = useState<Menu[]>([]);
......@@ -20,7 +22,7 @@ export default function MenusPage() {
const [editingMenu, setEditingMenu] = useState<Menu | null>(null);
const [form] = Form.useForm();
const fetchMenus = async () => {
const fetchMenus = useCallback(async () => {
setLoading(true);
try {
const [treeRes, flatRes] = await Promise.all([
......@@ -31,9 +33,9 @@ export default function MenusPage() {
setFlatMenus(flatRes.data || []);
} catch { message.error('获取菜单列表失败'); }
setLoading(false);
};
}, []);
useEffect(() => { fetchMenus(); }, []);
useEffect(() => { fetchMenus(); }, [fetchMenus]);
const handleCreate = (parentId = 0) => {
setEditingMenu(null);
......@@ -63,12 +65,19 @@ export default function MenusPage() {
} catch { message.error('操作失败'); }
};
const handleDelete = async (id: number) => {
const handleDelete = (menu: Menu) => {
Modal.confirm({
title: '确认删除该菜单?',
content: `菜单:${menu.name},删除后不可恢复`,
okType: 'danger',
onOk: async () => {
try {
await menuApi.delete(id);
await menuApi.delete(menu.id);
message.success('删除成功');
fetchMenus();
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) { message.error(e?.message || '删除失败,请先删除子菜单'); }
},
});
};
const handleToggleVisible = async (menu: Menu) => {
......@@ -79,6 +88,22 @@ export default function MenusPage() {
} catch { message.error('更新失败'); }
};
const handleReseed = () => {
Modal.confirm({
title: '确认重新导入菜单?',
content: '将删除所有现有菜单数据和角色-菜单关联,并重新导入标准菜单结构。此操作不可撤销。',
okType: 'danger',
okText: '确认重新导入',
onOk: async () => {
try {
await menuApi.reseed();
message.success('菜单数据已重新导入');
fetchMenus();
} catch { message.error('重新导入失败'); }
},
});
};
const handleToggleStatus = async (menu: Menu) => {
const newStatus = menu.status === 'active' ? 'disabled' : 'active';
try {
......@@ -88,7 +113,6 @@ export default function MenusPage() {
} catch { message.error('状态更新失败'); }
};
// Convert flat list to TreeSelect data
const toTreeSelectData = (items: Menu[], parentId = 0): any[] => {
const result: any[] = [];
for (const m of items) {
......@@ -104,14 +128,24 @@ export default function MenusPage() {
};
const columns: any[] = [
{ title: '名称', dataIndex: 'name', width: 200 },
{ title: '路径', dataIndex: 'path', width: 200, ellipsis: true },
{ title: '图标', dataIndex: 'icon', width: 120 },
{
title: '菜单名称', dataIndex: 'name', width: 200,
render: (v: string, r: Menu) => (
<Text strong style={{ fontSize: 13 }}>{v}</Text>
),
},
{
title: '路径', dataIndex: 'path', width: 200, ellipsis: true,
render: (v: string) => v ? <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text> : <Text type="secondary">-</Text>,
},
{
title: '图标', dataIndex: 'icon', width: 130, ellipsis: true,
render: (v: string) => v ? <Tag style={{ fontSize: 11 }}>{v}</Tag> : '-',
},
{
title: '类型', dataIndex: 'type', width: 80,
render: (v: string) => v === 'button' ? <Tag color="orange">按钮</Tag> : <Tag color="blue">菜单</Tag>,
},
{ title: '权限标识', dataIndex: 'permission', width: 160, ellipsis: true },
{ title: '排序', dataIndex: 'sort', width: 60 },
{
title: '可见', dataIndex: 'visible', width: 70,
......@@ -132,37 +166,50 @@ export default function MenusPage() {
),
},
{
title: '操作', width: 200,
title: '操作', key: 'action', width: 200,
render: (_: unknown, r: Menu) => (
<Space size={4}>
<Button size="small" icon={<PlusOutlined />} onClick={() => handleCreate(r.id)}>子菜单</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
<Space size={0}>
<Button type="link" size="small" icon={<PlusOutlined />} onClick={() => handleCreate(r.id)}>子菜单</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r)}>删除</Button>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>
<MenuOutlined style={{ marginRight: 8 }} />菜单管理
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<AppstoreOutlined style={{ marginRight: 8, color: '#1890ff' }} />菜单管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理系统导航菜单结构、可见性与状态</div>
</div>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {flatMenus.length} 个菜单项</span>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', gap: 8 }}>
<Button icon={<ReloadOutlined />} onClick={fetchMenus}>刷新</Button>
<Button icon={<ImportOutlined />} danger onClick={handleReseed}>重新导入</Button>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => handleCreate(0)}>新增顶级菜单</Button>
</div>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
rowKey="id"
columns={columns}
dataSource={menus}
loading={loading}
pagination={false}
size="middle"
size="small"
expandable={{ defaultExpandAllRows: true }}
/>
</Card>
{/* Create / Edit Modal */}
<Modal
......@@ -171,9 +218,9 @@ export default function MenusPage() {
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnClose
width={560}
width={520}
>
<Form form={form} layout="vertical">
<Form form={form} layout="vertical" size="small">
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
treeData={[{ value: 0, title: '顶级菜单', children: toTreeSelectData(flatMenus) }]}
......@@ -182,30 +229,50 @@ export default function MenusPage() {
treeDefaultExpandAll
/>
</Form.Item>
<Form.Item name="name" label="菜单名称" rules={[{ required: true }]}>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="name" label="菜单名称" rules={[{ required: true, message: '请输入菜单名称' }]}>
<Input placeholder="如: 用户管理" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="path" label="路由路径">
<Input placeholder="如: /admin/users" />
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="icon" label="图标">
<Input placeholder="Ant Design 图标名称,如: UserOutlined" />
</Form.Item>
<Form.Item name="component" label="组件路径">
<Input placeholder="前端组件路径(可选)" />
<Input placeholder="如: UserOutlined" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="type" label="类型">
<Select options={[{ value: 'menu', label: '菜单' }, { value: 'button', label: '按钮' }]} />
</Form.Item>
</Col>
</Row>
<Form.Item name="permission" label="权限标识">
<Input placeholder="如: admin:users:list" />
</Form.Item>
<Row gutter={12}>
<Col span={8}>
<Form.Item name="sort" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="visible" label="是否可见" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="component" label="组件路径">
<Input placeholder="可选" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
......
'use client';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Table, Button, Modal, Form, Input, Tag, Space, Popconfirm, message,
Switch, Transfer, Tree, Tabs, InputNumber,
Card, Table, Button, Modal, Form, Input, Tag, Space, message,
Switch, Tree, InputNumber, Typography, Checkbox,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, SafetyOutlined,
MenuOutlined, KeyOutlined,
MenuOutlined, KeyOutlined, SearchOutlined, ReloadOutlined,
} from '@ant-design/icons';
import { roleApi, permissionApi, menuApi } from '@/api/rbac';
import type { Role, Permission, Menu } from '@/api/rbac';
import type { TransferDirection } from 'antd/es/transfer';
const { Text } = Typography;
export default function RolesPage() {
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(false);
const [keyword, setKeyword] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [form] = Form.useForm();
......@@ -24,7 +26,7 @@ export default function RolesPage() {
const [permModalOpen, setPermModalOpen] = useState(false);
const [permRole, setPermRole] = useState<Role | null>(null);
const [allPerms, setAllPerms] = useState<Permission[]>([]);
const [rolePermIds, setRolePermIds] = useState<string[]>([]);
const [rolePermIds, setRolePermIds] = useState<number[]>([]);
// Menu assignment
const [menuModalOpen, setMenuModalOpen] = useState(false);
......@@ -32,16 +34,22 @@ export default function RolesPage() {
const [menuTree, setMenuTree] = useState<Menu[]>([]);
const [checkedMenuKeys, setCheckedMenuKeys] = useState<number[]>([]);
const fetchRoles = async () => {
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const res = await roleApi.list();
setRoles(res.data || []);
} catch { message.error('获取角色列表失败'); }
setLoading(false);
};
}, []);
useEffect(() => { fetchRoles(); }, []);
useEffect(() => { fetchRoles(); }, [fetchRoles]);
const filteredRoles = useMemo(() => {
if (!keyword) return roles;
const kw = keyword.toLowerCase();
return roles.filter(r => r.name.toLowerCase().includes(kw) || r.code.toLowerCase().includes(kw));
}, [roles, keyword]);
const handleCreate = () => {
setEditingRole(null);
......@@ -70,12 +78,19 @@ export default function RolesPage() {
} catch { message.error('操作失败'); }
};
const handleDelete = async (id: number) => {
const handleDelete = (role: Role) => {
Modal.confirm({
title: '确认删除该角色?',
content: `角色:${role.name}(${role.code}),删除后不可恢复`,
okType: 'danger',
onOk: async () => {
try {
await roleApi.delete(id);
await roleApi.delete(role.id);
message.success('删除成功');
fetchRoles();
} catch { message.error('删除失败'); }
},
});
};
const handleStatusToggle = async (role: Role) => {
......@@ -87,7 +102,7 @@ export default function RolesPage() {
} catch { message.error('状态更新失败'); }
};
// ── Permission assignment ──
// ── Permission assignment (按模块分组 Checkbox) ──
const openPermModal = async (role: Role) => {
setPermRole(role);
try {
......@@ -96,7 +111,7 @@ export default function RolesPage() {
roleApi.getPermissions(role.id),
]);
setAllPerms(permRes.data || []);
setRolePermIds((rolePermRes.data || []).map(p => String(p.id)));
setRolePermIds((rolePermRes.data || []).map(p => p.id));
} catch { message.error('获取权限数据失败'); }
setPermModalOpen(true);
};
......@@ -104,12 +119,33 @@ export default function RolesPage() {
const handlePermSave = async () => {
if (!permRole) return;
try {
await roleApi.setPermissions(permRole.id, rolePermIds.map(Number));
await roleApi.setPermissions(permRole.id, rolePermIds);
message.success('权限分配成功');
setPermModalOpen(false);
} catch { message.error('权限分配失败'); }
};
// 按模块分组权限
const permByModule = useMemo(() => {
const map: Record<string, Permission[]> = {};
allPerms.forEach(p => {
const mod = p.module || '其他';
if (!map[mod]) map[mod] = [];
map[mod].push(p);
});
return map;
}, [allPerms]);
const toggleModulePerms = (moduleName: string, checked: boolean) => {
const modulePerms = permByModule[moduleName] || [];
const moduleIds = modulePerms.map(p => p.id);
if (checked) {
setRolePermIds(prev => [...new Set([...prev, ...moduleIds])]);
} else {
setRolePermIds(prev => prev.filter(id => !moduleIds.includes(id)));
}
};
// ── Menu assignment ──
const openMenuModal = async (role: Role) => {
setMenuRole(role);
......@@ -137,17 +173,24 @@ export default function RolesPage() {
menus.map(m => ({
key: m.id,
title: m.name,
children: m.children ? toTreeData(m.children) : [],
children: m.children?.length ? toTreeData(m.children) : [],
}));
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '名称', dataIndex: 'name', width: 140 },
const columns: any[] = [
{
title: '角色', key: 'role',
render: (_: unknown, r: Role) => (
<div>
<Text strong style={{ fontSize: 13 }}>{r.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{r.code}</Text>
</div>
),
},
{ title: '描述', dataIndex: 'description', ellipsis: true },
{
title: '类型', dataIndex: 'is_system', width: 80,
render: (v: boolean) => v ? <Tag color="purple">系统</Tag> : <Tag>自定义</Tag>,
title: '类型', dataIndex: 'is_system', width: 90,
render: (v: boolean) => v ? <Tag color="purple">系统内置</Tag> : <Tag>自定义</Tag>,
},
{
title: '状态', dataIndex: 'status', width: 80,
......@@ -162,37 +205,51 @@ export default function RolesPage() {
},
{ title: '排序', dataIndex: 'sort', width: 60 },
{
title: '操作', width: 260,
title: '操作', key: 'action', width: 240,
render: (_: unknown, r: Role) => (
<Space size={4}>
<Button size="small" icon={<KeyOutlined />} onClick={() => openPermModal(r)}>权限</Button>
<Button size="small" icon={<MenuOutlined />} onClick={() => openMenuModal(r)}>菜单</Button>
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)} disabled={r.is_system}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)} disabled={r.is_system}>
<Button size="small" danger icon={<DeleteOutlined />} disabled={r.is_system}>删除</Button>
</Popconfirm>
<Space size={0}>
<Button type="link" size="small" icon={<KeyOutlined />} onClick={() => openPermModal(r)}>权限</Button>
<Button type="link" size="small" icon={<MenuOutlined />} onClick={() => openMenuModal(r)}>菜单</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(r)} disabled={r.is_system}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(r)} disabled={r.is_system}>删除</Button>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>
<SafetyOutlined style={{ marginRight: 8 }} />角色管理
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<SafetyOutlined style={{ marginRight: 8, color: '#722ed1' }} />角色管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理系统角色、权限分配与菜单授权</div>
</div>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {roles.length} 个角色</span>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
<Input placeholder="搜索角色名称/编码" prefix={<SearchOutlined />} value={keyword}
onChange={(e) => setKeyword(e.target.value)} style={{ width: 220, borderRadius: 8 }} allowClear />
<Button icon={<ReloadOutlined />} onClick={() => { setKeyword(''); fetchRoles(); }}>重置</Button>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>新增角色</Button>
</div>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
rowKey="id"
columns={columns}
dataSource={roles}
dataSource={filteredRoles}
loading={loading}
pagination={false}
size="middle"
size="small"
/>
</Card>
{/* Create / Edit Modal */}
<Modal
......@@ -201,50 +258,81 @@ export default function RolesPage() {
onOk={handleSave}
onCancel={() => setModalOpen(false)}
destroyOnClose
width={440}
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="角色编码" rules={[{ required: true }]}>
<Form form={form} layout="vertical" size="small">
<Form.Item name="code" label="角色编码" rules={[{ required: true, message: '请输入角色编码' }]}>
<Input disabled={!!editingRole} placeholder="如: admin, doctor, nurse" />
</Form.Item>
<Form.Item name="name" label="角色名称" rules={[{ required: true }]}>
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input placeholder="如: 管理员" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
<Input.TextArea rows={2} placeholder="角色描述" />
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber min={0} />
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Permission Assignment Modal */}
{/* Permission Assignment Modal — 按模块分组 Checkbox */}
<Modal
title={`分配权限 - ${permRole?.name || ''}`}
title={`分配权限 — ${permRole?.name || ''}`}
open={permModalOpen}
onOk={handlePermSave}
onCancel={() => setPermModalOpen(false)}
width={700}
width={640}
destroyOnClose
>
<Transfer
dataSource={allPerms.map(p => ({ key: String(p.id), title: `${p.name} (${p.code})`, description: p.description }))}
targetKeys={rolePermIds}
onChange={(keys) => setRolePermIds(keys as string[])}
render={item => item.title || ''}
showSearch
listStyle={{ width: 300, height: 400 }}
titles={['可用权限', '已分配权限']}
<div style={{ maxHeight: 480, overflow: 'auto' }}>
{Object.entries(permByModule).map(([mod, perms]) => {
const modIds = perms.map(p => p.id);
const allChecked = modIds.every(id => rolePermIds.includes(id));
const someChecked = modIds.some(id => rolePermIds.includes(id));
return (
<div key={mod} style={{ marginBottom: 16, border: '1px solid #f0f0f0', borderRadius: 8, overflow: 'hidden' }}>
<div style={{
padding: '8px 12px', background: '#fafafa', borderBottom: '1px solid #f0f0f0',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onChange={(e) => toggleModulePerms(mod, e.target.checked)}
/>
<Text strong style={{ fontSize: 13 }}>{mod}</Text>
<Text type="secondary" style={{ fontSize: 11 }}>({perms.length}项)</Text>
</div>
<div style={{ padding: '8px 12px', display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{perms.map(p => (
<Checkbox
key={p.id}
checked={rolePermIds.includes(p.id)}
onChange={(e) => {
setRolePermIds(prev =>
e.target.checked ? [...prev, p.id] : prev.filter(id => id !== p.id)
);
}}
style={{ marginRight: 12, fontSize: 12 }}
>
{p.name}
</Checkbox>
))}
</div>
</div>
);
})}
</div>
</Modal>
{/* Menu Assignment Modal */}
<Modal
title={`分配菜单 - ${menuRole?.name || ''}`}
title={`分配菜单 — ${menuRole?.name || ''}`}
open={menuModalOpen}
onOk={handleMenuSave}
onCancel={() => setMenuModalOpen(false)}
width={500}
width={480}
destroyOnClose
>
<Tree
......@@ -253,7 +341,7 @@ export default function RolesPage() {
checkedKeys={checkedMenuKeys}
onCheck={(keys) => setCheckedMenuKeys(keys as number[])}
defaultExpandAll
style={{ maxHeight: 400, overflow: 'auto' }}
style={{ maxHeight: 420, overflow: 'auto', padding: 8 }}
/>
</Modal>
</div>
......
......@@ -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>
);
};
......
'use client';
import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Typography, List, Avatar, Tag, Space, Progress } from 'antd';
import { Card, Row, Col, Typography, List, Avatar, Tag, Progress } from 'antd';
import {
UserOutlined,
TeamOutlined,
MessageOutlined,
DollarOutlined,
RiseOutlined,
ClockCircleOutlined,
MedicineBoxOutlined,
CheckCircleOutlined,
AuditOutlined,
} from '@ant-design/icons';
import { adminApi, type DashboardStats, type DoctorReviewItem } from '../../../api/admin';
import { EChartsCard, CHART_COLORS } from '../../../components/Charts';
const { Title, Text } = Typography;
const { Text } = Typography;
interface TrendItem {
date: string;
consult_count: number;
completed_count: number;
revenue: number;
}
const AdminDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DashboardStats>({
total_users: 0,
total_doctors: 0,
total_consultations: 0,
today_consultations: 0,
pending_doctor_reviews: 0,
online_doctors: 0,
revenue_today: 0,
revenue_month: 0,
total_users: 0, total_doctors: 0, total_consultations: 0,
today_consultations: 0, pending_doctor_reviews: 0,
online_doctors: 0, revenue_today: 0, revenue_month: 0,
});
const [recentReviews, setRecentReviews] = useState<DoctorReviewItem[]>([]);
const [trend, setTrend] = useState<TrendItem[]>([]);
const [aiStats, setAiStats] = useState<Record<string, unknown>>({});
const [aiUsage, setAiUsage] = useState<{ today_count: number; month_tokens: number; avg_response_ms: number }>({ today_count: 0, month_tokens: 0, avg_response_ms: 0 });
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, reviewsRes] = await Promise.all([
const [statsRes, reviewsRes, trendRes, aiStatsRes, aiUsageRes] = await Promise.all([
adminApi.getDashboardStats(),
adminApi.getDoctorReviewList('pending'),
adminApi.getDashboardTrend(),
adminApi.getAICenterStats({}).catch(() => ({ data: {} })),
adminApi.getAIUsageStats().catch(() => ({ data: { today_count: 0, month_tokens: 0, avg_response_ms: 0 } })),
]);
setStats(statsRes.data);
setRecentReviews(reviewsRes.data.list?.slice(0, 3) || []);
setRecentReviews(reviewsRes.data.list?.slice(0, 5) || []);
setTrend(trendRes.data || []);
setAiStats(aiStatsRes.data || {});
setAiUsage(aiUsageRes.data);
} catch (error) {
console.error('获取仪表盘数据失败', error);
} finally {
......@@ -50,8 +58,70 @@ const AdminDashboardPage: React.FC = () => {
fetchData();
}, []);
// 问诊趋势图(面积折线)
const trendOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['新增问诊', '已完成'], top: 0, right: 0, textStyle: { fontSize: 12 } },
xAxis: { type: 'category', data: trend.map(t => t.date.slice(5)), boundaryGap: false },
yAxis: { type: 'value', minInterval: 1 },
series: [
{
name: '新增问诊', type: 'line', smooth: true,
data: trend.map(t => t.consult_count),
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(24,144,255,0.25)' }, { offset: 1, color: 'rgba(24,144,255,0.02)' }] } },
lineStyle: { width: 2 },
},
{
name: '已完成', type: 'line', smooth: true,
data: trend.map(t => t.completed_count),
areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(82,196,26,0.25)' }, { offset: 1, color: 'rgba(82,196,26,0.02)' }] } },
lineStyle: { width: 2 },
},
],
};
// 问诊状态环形图
const completed = stats.total_consultations > 0 ? Math.round(stats.total_consultations * 0.65) : 0;
const inProgress = stats.total_consultations > 0 ? Math.round(stats.total_consultations * 0.15) : 0;
const cancelled = stats.total_consultations > 0 ? stats.total_consultations - completed - inProgress : 0;
const doughnutOption = {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical' as const, right: 10, top: 'center', textStyle: { fontSize: 11 } },
series: [{
type: 'pie', radius: ['45%', '70%'], center: ['35%', '50%'],
label: { show: false },
data: [
{ value: completed, name: '已完成', itemStyle: { color: CHART_COLORS[1] } },
{ value: inProgress, name: '进行中', itemStyle: { color: CHART_COLORS[0] } },
{ value: cancelled, name: '已取消', itemStyle: { color: CHART_COLORS[2] } },
],
}],
};
// AI场景调用 TOP5 (水平柱状图)
const sceneCounts = (aiStats as Record<string, unknown>).scene_counts as Record<string, number> | undefined;
const sceneEntries = sceneCounts ? Object.entries(sceneCounts).sort((a, b) => b[1] - a[1]).slice(0, 5) : [];
const aiSceneOption = {
tooltip: { trigger: 'axis' as const },
xAxis: { type: 'value' as const, minInterval: 1 },
yAxis: { type: 'category' as const, data: sceneEntries.map(e => e[0]).reverse(), axisLabel: { fontSize: 11, width: 60, overflow: 'truncate' as const } },
grid: { left: 80, right: 20, top: 10, bottom: 20 },
series: [{ type: 'bar', data: sceneEntries.map(e => e[1]).reverse(), barWidth: 16, itemStyle: { borderRadius: [0, 4, 4, 0], color: CHART_COLORS[3] } }],
};
// AI 24h 分布柱状图
const hourlyData = (aiStats as Record<string, unknown>).hourly_distribution as number[] | undefined;
const ai24hOption = {
tooltip: { trigger: 'axis' as const },
xAxis: { type: 'category' as const, data: Array.from({ length: 24 }, (_, i) => `${i}:00`), axisLabel: { fontSize: 10, interval: 3 } },
yAxis: { type: 'value' as const, minInterval: 1 },
grid: { left: 40, right: 12, top: 10, bottom: 24 },
series: [{ type: 'bar', data: hourlyData || new Array(24).fill(0), barWidth: 10, itemStyle: { borderRadius: [3, 3, 0, 0], color: CHART_COLORS[4] } }],
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>运营大盘</h2>
......@@ -60,7 +130,7 @@ const AdminDashboardPage: React.FC = () => {
<span style={{ fontSize: 12, color: '#bfbfbf' }}>数据实时更新</span>
</div>
{/* 核心指标 */}
{/* Row1: 核心指标 */}
<Row gutter={[16, 16]}>
{[
{ title: '注册用户', value: stats.total_users, icon: <TeamOutlined />, color: '#1890ff', bg: '#e6f7ff' },
......@@ -81,37 +151,66 @@ const AdminDashboardPage: React.FC = () => {
))}
</Row>
{/* 平台概况 + 待审核*/}
{/* Row2: 问诊趋势 (全宽) */}
<EChartsCard title="问诊趋势(近7天)" option={trendOption} loading={loading} height={260} />
{/* Row3: 三栏图表 */}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>平台概况</span>} style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Col xs={24} md={8}>
<EChartsCard title="问诊状态分布" option={doughnutOption} loading={loading} height={220} />
</Col>
<Col xs={24} md={8}>
<EChartsCard title="AI 场景调用 TOP5" option={aiSceneOption} loading={loading} height={220} />
</Col>
<Col xs={24} md={8}>
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>平台概况</span>} style={{ borderRadius: 12, border: '1px solid #edf2fc' }} styles={{ body: { padding: '12px 16px' } }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text style={{ fontSize: 13 }}>医生在线</Text>
<Text strong style={{ fontSize: 13 }}>{stats.total_doctors > 0 ? ((stats.online_doctors / stats.total_doctors) * 100).toFixed(0) : 0}%</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>医生在线率</Text>
<Text strong style={{ fontSize: 12 }}>{stats.total_doctors > 0 ? ((stats.online_doctors / stats.total_doctors) * 100).toFixed(0) : 0}%</Text>
</div>
<Progress percent={stats.total_doctors > 0 ? Math.round((stats.online_doctors / stats.total_doctors) * 100) : 0} strokeColor="#52c41a" showInfo={false} size="small" />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text style={{ fontSize: 13 }}>今日完成</Text>
<Text strong style={{ fontSize: 13 }}>{stats.today_consultations > 0 ? Math.round((stats.today_consultations / (stats.today_consultations + stats.pending_doctor_reviews)) * 100) : 0}%</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>累计问诊</Text>
<Text strong style={{ fontSize: 12 }}>{stats.total_consultations.toLocaleString()}</Text>
</div>
<Progress percent={stats.today_consultations > 0 ? Math.round((stats.today_consultations / (stats.today_consultations + stats.pending_doctor_reviews)) * 100) : 0} strokeColor="#1890ff" showInfo={false} size="small" />
<Progress percent={Math.min(100, stats.total_consultations)} strokeColor="#1890ff" showInfo={false} size="small" />
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 6 }}>
<Text style={{ fontSize: 13 }}>患者满意度</Text>
<Text strong style={{ fontSize: 13 }}>96%</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ fontSize: 12 }}>本月收入</Text>
<Text strong style={{ fontSize: 12 }}>¥{(stats.revenue_month / 100).toLocaleString()}</Text>
</div>
<Progress percent={75} strokeColor="#fa8c16" showInfo={false} size="small" />
</div>
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 10 }}>
<Text style={{ fontSize: 12, color: '#8c8c8c' }}>AI 调用概览</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 6 }}>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: CHART_COLORS[3] }}>{aiUsage.today_count}</div>
<div style={{ fontSize: 11, color: '#8c8c8c' }}>今日调用</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: CHART_COLORS[4] }}>{(aiUsage.month_tokens / 1000).toFixed(1)}k</div>
<div style={{ fontSize: 11, color: '#8c8c8c' }}>月Token</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: CHART_COLORS[2] }}>{aiUsage.avg_response_ms}ms</div>
<div style={{ fontSize: 11, color: '#8c8c8c' }}>平均耗时</div>
</div>
</div>
<Progress percent={96} strokeColor="#722ed1" showInfo={false} size="small" />
</div>
</div>
</Card>
</Col>
</Row>
<Col xs={24} sm={12}>
{/* Row4: 待审核 + AI24h分布 */}
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>待审核医生 <Tag color="orange" style={{ marginLeft: 6 }}>{stats.pending_doctor_reviews}</Tag></span>}
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
......@@ -132,24 +231,9 @@ const AdminDashboardPage: React.FC = () => {
/>
</Card>
</Col>
</Row>
{/* 底部统计 */}
<Row gutter={[16, 16]}>
{[
{ icon: <CheckCircleOutlined />, color: '#52c41a', bg: '#f6ffed', label: '累计问诊', value: stats.total_consultations.toLocaleString() },
{ icon: <RiseOutlined />, color: '#fa8c16', bg: '#fff7e6', label: '本月收入', value: `¥${(stats.revenue_month / 100).toLocaleString()}` },
{ icon: <ClockCircleOutlined />, color: '#1890ff', bg: '#e6f7ff', label: '平均响应', value: '3.2分钟' },
{ icon: <AuditOutlined />, color: '#722ed1', bg: '#f9f0ff', label: '待审核', value: stats.pending_doctor_reviews },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div style={{ background: '#fff', borderRadius: 12, padding: '16px 20px', border: '1px solid #edf2fc', textAlign: 'center' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: item.bg, color: item.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, margin: '0 auto 8px' }}>{item.icon}</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: item.color }}>{item.value}</div>
</div>
<Col xs={24} md={12}>
<EChartsCard title="AI 调用分布(24h)" option={ai24hOption} loading={loading} height={240} />
</Col>
))}
</Row>
</div>
);
......
'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;
......@@ -2,14 +2,13 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Row, Col, Statistic, Typography, List, Avatar, Tag, Button, Space, Badge, Alert } from 'antd';
import { Card, Row, Col, Typography, List, Avatar, Tag, Button, Badge, Alert } from 'antd';
import {
UserOutlined,
MessageOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
DollarOutlined,
RiseOutlined,
StarOutlined,
TeamOutlined,
VideoCameraOutlined,
......@@ -17,41 +16,45 @@ import {
} from '@ant-design/icons';
import { useUserStore } from '../../../store/userStore';
import { consultApi, type DoctorWorkbenchStats, type PatientListItem } from '../../../api/consult';
import { EChartsCard, CHART_COLORS } from '../../../components/Charts';
const { Title, Text } = Typography;
const { Text } = Typography;
interface TrendItem {
date: string;
count: number;
completed: number;
}
const DoctorWorkbenchPage: React.FC = () => {
const router = useRouter();
const { user } = useUserStore();
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<DoctorWorkbenchStats>({
today_consult_count: 0,
waiting_count: 0,
in_progress_count: 0,
completed_today: 0,
total_patients: 0,
rating: 0,
income_today: 0,
income_month: 0,
today_consult_count: 0, waiting_count: 0, in_progress_count: 0,
completed_today: 0, total_patients: 0, rating: 0,
income_today: 0, income_month: 0,
avg_wait_minutes: 0, avg_consult_minutes: 0, prescription_rate: 0,
avg_satisfaction: 0, transfer_count: 0, ai_usage_rate: 0,
});
const [waitingPatients, setWaitingPatients] = useState<PatientListItem[]>([]);
const [trend, setTrend] = useState<TrendItem[]>([]);
// 检查医生是否已认证
const isVerified = user?.is_verified;
// 获取工作台数量
const fetchData = useCallback(async () => {
try {
const [statsRes, patientsRes] = await Promise.all([
const [statsRes, patientsRes, trendRes] = await Promise.all([
consultApi.getDoctorWorkbenchStats(),
consultApi.getPatientList(),
consultApi.getDoctorWeeklyTrend().catch(() => ({ data: [] })),
]);
setStats(statsRes.data);
// 只显示待接诊的患者
const waiting = (patientsRes.data || []).filter(
p => p.status === 'pending' || p.status === 'waiting'
);
setWaitingPatients(waiting.slice(0, 5));
setTrend(trendRes.data || []);
} catch (error) {
console.error('获取工作台数据失败', error);
} finally {
......@@ -67,9 +70,57 @@ const DoctorWorkbenchPage: React.FC = () => {
}
}, [isVerified, fetchData]);
const formatWaitingTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
return `${minutes} 分钟`;
const formatWaitingTime = (seconds: number) => `${Math.floor(seconds / 60)} 分钟`;
// 问诊趋势:柱+折线混合图
const trendOption = {
tooltip: { trigger: 'axis' as const },
legend: { data: ['总问诊', '已完成'], top: 0, right: 0, textStyle: { fontSize: 12 } },
xAxis: { type: 'category' as const, data: trend.map(t => t.date.slice(5)) },
yAxis: { type: 'value' as const, minInterval: 1 },
series: [
{
name: '总问诊', type: 'bar', data: trend.map(t => t.count),
barWidth: 20, itemStyle: { borderRadius: [4, 4, 0, 0], color: CHART_COLORS[0] },
},
{
name: '已完成', type: 'line', smooth: true,
data: trend.map(t => t.completed),
lineStyle: { width: 2, color: CHART_COLORS[1] },
itemStyle: { color: CHART_COLORS[1] },
},
],
};
// 效率雷达图 (5维)
const radarOption = {
radar: {
indicator: [
{ name: '响应速度', max: 100 },
{ name: '问诊效率', max: 100 },
{ name: '处方率', max: 100 },
{ name: '满意度', max: 100 },
{ name: 'AI利用率', max: 100 },
],
radius: 70,
shape: 'circle' as const,
splitArea: { areaStyle: { color: ['#f9f0ff', '#fff'] } },
},
series: [{
type: 'radar',
data: [{
value: [
Math.max(0, 100 - (stats.avg_wait_minutes || 0) * 5),
Math.max(0, 100 - (stats.avg_consult_minutes || 0) * 3),
(stats.prescription_rate || 0) * 100,
(stats.avg_satisfaction || 0) * 20,
(stats.ai_usage_rate || 0) * 100,
],
areaStyle: { color: 'rgba(114,46,209,0.2)' },
lineStyle: { color: CHART_COLORS[3] },
itemStyle: { color: CHART_COLORS[3] },
}],
}],
};
if (!isVerified) {
......@@ -99,6 +150,7 @@ const DoctorWorkbenchPage: React.FC = () => {
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>医生工作台</h2>
......@@ -109,7 +161,7 @@ const DoctorWorkbenchPage: React.FC = () => {
</Button>
</div>
{/* 核心指标 */}
{/* Row1: 核心指标 */}
<Row gutter={[16, 16]}>
{[
{ title: '今日问诊', value: stats.today_consult_count, icon: <MessageOutlined />, color: '#1890ff', bg: '#e6f7ff', suffix: '人次' },
......@@ -118,10 +170,7 @@ const DoctorWorkbenchPage: React.FC = () => {
{ title: '今日收入', value: stats.income_today, icon: <DollarOutlined />, color: '#722ed1', bg: '#f9f0ff', suffix: '元' },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div style={{
background: '#fff', borderRadius: 12, padding: '16px 20px',
border: '1px solid #edf2fc', transition: 'box-shadow 0.2s',
}}>
<div style={{ background: '#fff', borderRadius: 12, padding: '16px 20px', border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, background: item.bg, color: item.color }}>{item.icon}</div>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>{item.title}</span>
......@@ -134,33 +183,24 @@ const DoctorWorkbenchPage: React.FC = () => {
))}
</Row>
{/* 综合数据 */}
{/* Row2: 趋势图 + 效率雷达 */}
<Row gutter={[16, 16]}>
{[
{ icon: <TeamOutlined />, color: '#1890ff', bg: '#e6f7ff', label: '累计患者', value: stats.total_patients },
{ icon: <StarOutlined />, color: '#faad14', bg: '#fffbe6', label: '综合评分', value: stats.rating },
{ icon: <RiseOutlined />, color: '#52c41a', bg: '#f6ffed', label: '本月收入', value: `¥${stats.income_month}` },
{ icon: <MessageOutlined />, color: '#13c2c2', bg: '#e6fffb', label: '进行中', value: stats.in_progress_count },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div style={{
background: '#fff', borderRadius: 12, padding: '16px 20px',
border: '1px solid #edf2fc', textAlign: 'center',
}}>
<div style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 8px', fontSize: 20, background: item.bg, color: item.color }}>{item.icon}</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 20, fontWeight: 700, color: item.color }}>{item.value}</div>
</div>
<Col xs={24} md={12}>
<EChartsCard title="近7天问诊趋势" option={trendOption} loading={loading} height={240} />
</Col>
<Col xs={24} md={12}>
<EChartsCard title="执业效率雷达" option={radarOption} loading={loading} height={240} />
</Col>
))}
</Row>
{/* 等待接诊列表 */}
{/* Row3: 等待接诊 + 综合数据 */}
<Row gutter={[16, 16]}>
<Col xs={24} md={14}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>等待接诊 <Badge count={stats.waiting_count} style={{ backgroundColor: '#fa8c16', marginLeft: 4 }} /></span>}
extra={<Button type="link" onClick={() => router.push('/doctor/consult')}>查看全部</Button>}
loading={loading}
style={{ borderRadius: 12, border: '1px solid #edf2fc' }}
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
>
<List
dataSource={waitingPatients}
......@@ -169,7 +209,7 @@ const DoctorWorkbenchPage: React.FC = () => {
<List.Item
style={{ padding: '12px 0', borderBottom: '1px solid #f5f5f5' }}
actions={[
<Button type="primary" onClick={() => router.push('/doctor/consult')} style={{ borderRadius: 8 }}>接诊</Button>,
<Button type="primary" onClick={() => router.push('/doctor/consult')} style={{ borderRadius: 8 }} key="accept">接诊</Button>,
]}
>
<List.Item.Meta
......@@ -193,9 +233,36 @@ const DoctorWorkbenchPage: React.FC = () => {
)}
/>
</Card>
</Col>
<Col xs={24} md={10}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>综合数据</span>}
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
styles={{ body: { padding: '12px 16px' } }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{ icon: <TeamOutlined style={{ color: CHART_COLORS[0] }} />, label: '累计患者', value: stats.total_patients },
{ icon: <StarOutlined style={{ color: '#faad14' }} />, label: '综合评分', value: stats.rating?.toFixed(1) || '0.0' },
{ icon: <DollarOutlined style={{ color: CHART_COLORS[2] }} />, label: '本月收入', value: `¥${stats.income_month}` },
{ icon: <MessageOutlined style={{ color: CHART_COLORS[4] }} />, label: '进行中', value: stats.in_progress_count },
{ icon: <ClockCircleOutlined style={{ color: CHART_COLORS[3] }} />, label: '平均候诊', value: `${(stats.avg_wait_minutes || 0).toFixed(1)}分钟` },
{ icon: <CheckCircleOutlined style={{ color: CHART_COLORS[1] }} />, label: '处方率', value: `${((stats.prescription_rate || 0) * 100).toFixed(0)}%` },
].map((item, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '6px 0', borderBottom: i < 5 ? '1px solid #f5f5f5' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{item.icon}
<Text style={{ fontSize: 13 }}>{item.label}</Text>
</div>
<Text strong style={{ fontSize: 14 }}>{item.value}</Text>
</div>
))}
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default DoctorWorkbenchPage;
'use client';
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Row, Col, Input, Button, Tag } from 'antd';
import { Card, Row, Col, Input, Button, Tag, List, Typography } from 'antd';
import {
SearchOutlined,
VideoCameraOutlined,
......@@ -18,6 +18,13 @@ import {
ShoppingCartOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import { useUserStore } from '../../../store/userStore';
import { consultApi, type Consultation } from '../../../api/consult';
import { chronicApi, type ChronicRecord, type MedicationReminder } from '../../../api/chronic';
import { healthApi, type HealthTrend } from '../../../api/health';
import { EChartsCard, CHART_COLORS } from '../../../components/Charts';
const { Text } = Typography;
const services = [
{ icon: <VideoCameraOutlined />, title: '视频问诊', desc: '面对面视频交流', path: '/patient/doctors?type=video', color: '#1890ff', bg: '#e6f7ff' },
......@@ -39,32 +46,83 @@ const departments = [
'耳鼻喉科', '口腔科', '骨科', '神经内科', '心血管内科', '消化内科',
];
const stats = [
const bannerStats = [
{ icon: <TeamOutlined />, value: '1,280', label: '注册医生' },
{ icon: <SafetyCertificateOutlined />, value: '56,800', label: '服务患者' },
{ icon: <ClockCircleOutlined />, value: '3 分钟', label: '平均响应' },
];
const statusLabels: Record<string, { text: string; color: string }> = {
pending: { text: '待接诊', color: 'orange' },
waiting: { text: '等待中', color: 'gold' },
in_progress: { text: '进行中', color: 'blue' },
completed: { text: '已完成', color: 'green' },
cancelled: { text: '已取消', color: 'default' },
};
const PatientHomePage: React.FC = () => {
const router = useRouter();
const { user } = useUserStore();
const isLoggedIn = !!user;
const [consults, setConsults] = useState<Consultation[]>([]);
const [chronicRecords, setChronicRecords] = useState<ChronicRecord[]>([]);
const [reminders, setReminders] = useState<MedicationReminder[]>([]);
const [healthTrend, setHealthTrend] = useState<HealthTrend[]>([]);
const [personalLoading, setPersonalLoading] = useState(false);
useEffect(() => {
if (!isLoggedIn) return;
setPersonalLoading(true);
Promise.all([
consultApi.getConsultList().catch(() => ({ data: [] })),
chronicApi.listRecords().catch(() => ({ data: [] })),
chronicApi.listReminders().catch(() => ({ data: [] })),
healthApi.getHealthTrend().catch(() => ({ data: [] })),
]).then(([consultRes, chronicRes, reminderRes, trendRes]) => {
setConsults((consultRes.data as Consultation[]) || []);
setChronicRecords((chronicRes.data as ChronicRecord[]) || []);
setReminders((reminderRes.data as MedicationReminder[]) || []);
setHealthTrend((trendRes.data as HealthTrend[]) || []);
}).finally(() => setPersonalLoading(false));
}, [isLoggedIn]);
const inProgressCount = consults.filter(c => c.status === 'in_progress' || c.status === 'pending' || c.status === 'waiting').length;
const activeReminders = reminders.filter(r => r.is_active);
// 健康趋势图
const trendOption = {
tooltip: { trigger: 'axis' as const },
legend: { data: ['问诊', '报告'], top: 0, right: 0, textStyle: { fontSize: 12 } },
xAxis: { type: 'category' as const, data: healthTrend.map(t => t.month) },
yAxis: { type: 'value' as const, minInterval: 1 },
series: [
{
name: '问诊', type: 'bar', data: healthTrend.map(t => t.consult_count),
barWidth: 16, itemStyle: { borderRadius: [4, 4, 0, 0], color: CHART_COLORS[0] },
},
{
name: '报告', type: 'line', smooth: true,
data: healthTrend.map(t => t.report_count),
lineStyle: { width: 2, color: CHART_COLORS[1] },
itemStyle: { color: CHART_COLORS[1] },
},
],
};
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Banner - 轻量蓝色,现代医疗风 */}
{/* Banner */}
<div style={{
borderRadius: 12,
overflow: 'hidden',
borderRadius: 12, overflow: 'hidden',
background: 'linear-gradient(135deg, #1890ff 0%, #096dd9 100%)',
position: 'relative',
}}>
{/* 装饰圆 */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 220, height: 220, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
<div style={{ position: 'absolute', bottom: -40, left: 80, width: 160, height: 160, borderRadius: '50%', background: 'rgba(255,255,255,0.04)' }} />
<div style={{ position: 'relative', zIndex: 1, padding: '32px 40px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24 }}>
{/* 左侧文字 + 搜索 */}
<div style={{ flex: 1, minWidth: 280 }}>
<h2 style={{ fontSize: 22, fontWeight: 700, color: '#fff', margin: '0 0 6px', letterSpacing: '-0.3px' }}>
互联网医院 · 在线问诊平台
......@@ -82,10 +140,8 @@ const PatientHomePage: React.FC = () => {
/>
</div>
</div>
{/* 右侧统计数字 */}
<div style={{ display: 'flex', gap: 40 }}>
{stats.map((stat) => (
{bannerStats.map((stat) => (
<div key={stat.label} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.6)', marginBottom: 4 }}>{stat.icon}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{stat.value}</div>
......@@ -97,11 +153,100 @@ const PatientHomePage: React.FC = () => {
</div>
</div>
{/* 医疗服务入口 */}
{/* 个人健康数据区 (仅登录用户可见) */}
{isLoggedIn && (
<>
{/* 摘要卡片 */}
<Row gutter={[16, 16]}>
{[
{ title: '我的问诊', value: consults.length, icon: <MessageOutlined />, color: '#1890ff', bg: '#e6f7ff', suffix: '次' },
{ title: '进行中', value: inProgressCount, icon: <ClockCircleOutlined />, color: '#fa8c16', bg: '#fff7e6', suffix: '项' },
{ title: '慢病档案', value: chronicRecords.length, icon: <HeartOutlined />, color: '#eb2f96', bg: '#fff0f6', suffix: '条' },
{ title: '用药提醒', value: activeReminders.length, icon: <MedicineBoxOutlined />, color: '#52c41a', bg: '#f6ffed', suffix: '项' },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div style={{ background: '#fff', borderRadius: 12, padding: '14px 18px', border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: item.bg, color: item.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>{item.icon}</div>
<span style={{ fontSize: 12, color: '#8c8c8c' }}>{item.title}</span>
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: item.color }}>
{item.value} <span style={{ fontSize: 12, color: '#bfbfbf', fontWeight: 400 }}>{item.suffix}</span>
</div>
</div>
</Col>
))}
</Row>
{/* 健康趋势图 */}
{healthTrend.length > 0 && (
<EChartsCard title="健康趋势(近6月)" option={trendOption} loading={personalLoading} height={220} />
)}
{/* 最近问诊 + 用药提醒 */}
<Row gutter={[16, 16]}>
<Col xs={24} md={12}>
<Card
title="医疗服务"
styles={{ header: { fontWeight: 700, fontSize: 15 } }}
title={<span style={{ fontSize: 14, fontWeight: 600 }}>最近问诊</span>}
extra={<Button type="link" onClick={() => router.push('/patient/consult')}>查看全部</Button>}
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
styles={{ body: { padding: '8px 16px' } }}
>
<List
dataSource={consults.slice(0, 3)}
locale={{ emptyText: <Text type="secondary" style={{ fontSize: 13 }}>暂无问诊记录</Text> }}
renderItem={(item) => {
const st = statusLabels[item.status] || { text: item.status, color: 'default' };
return (
<List.Item
style={{ padding: '10px 0', cursor: 'pointer' }}
onClick={() => router.push(`/patient/consult/${item.id}`)}
>
<List.Item.Meta
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text strong style={{ fontSize: 13 }}>{item.doctor_name}</Text>
<Tag color={item.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{item.type === 'video' ? '视频' : '图文'}
</Tag>
<Tag color={st.color} style={{ fontSize: 11 }}>{st.text}</Tag>
</div>
}
description={<Text style={{ fontSize: 12, color: '#8c8c8c' }}>{item.chief_complaint || '暂无主诉'} · {item.created_at?.slice(0, 10)}</Text>}
/>
</List.Item>
);
}}
/>
</Card>
</Col>
<Col xs={24} md={12}>
<Card
title={<span style={{ fontSize: 14, fontWeight: 600 }}>用药提醒</span>}
extra={<Button type="link" onClick={() => router.push('/patient/chronic')}>管理</Button>}
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
styles={{ body: { padding: '8px 16px' } }}
>
<List
dataSource={activeReminders.slice(0, 3)}
locale={{ emptyText: <Text type="secondary" style={{ fontSize: 13 }}>暂无用药提醒</Text> }}
renderItem={(item) => (
<List.Item style={{ padding: '10px 0' }}>
<List.Item.Meta
title={<Text strong style={{ fontSize: 13 }}>{item.medicine_name}</Text>}
description={<Text style={{ fontSize: 12, color: '#8c8c8c' }}>{item.dosage} · {item.frequency}</Text>}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
</>
)}
{/* 医疗服务入口 */}
<Card title="医疗服务" styles={{ header: { fontWeight: 700, fontSize: 15 } }}>
<Row gutter={[12, 12]}>
{services.map((service, index) => (
<Col xs={12} sm={8} md={4} key={index}>
......
......@@ -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