Commit 4981617b authored by yuguo's avatar yuguo

fix

parent 859fb6ac
...@@ -115,6 +115,8 @@ func main() { ...@@ -115,6 +115,8 @@ func main() {
&model.SafetyFilterLog{}, &model.SafetyFilterLog{},
// 通知 // 通知
&model.Notification{}, &model.Notification{},
// 合规报告
&model.ComplianceReport{},
// HTTP 动态工具 // HTTP 动态工具
&model.HTTPToolDefinition{}, &model.HTTPToolDefinition{},
// v15: SQL 动态工具 // v15: SQL 动态工具
...@@ -231,8 +233,10 @@ func main() { ...@@ -231,8 +233,10 @@ func main() {
authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment) authApi.POST("/doctor/appointment", doctorHandler.MakeAppointment)
// 医生端路由(需要认证 + 医生角色) // 医生端路由(需要认证 + 医生角色)
doctorApi := authApi.Group("")
doctorApi.Use(middleware.RequireRole("doctor"))
doctorPortalHandler := doctorportal.NewHandler() doctorPortalHandler := doctorportal.NewHandler()
doctorPortalHandler.RegisterRoutes(authApi) doctorPortalHandler.RegisterRoutes(doctorApi)
// 健康档案路由(需要认证) // 健康档案路由(需要认证)
healthHandler := health.NewHandler() healthHandler := health.NewHandler()
...@@ -243,8 +247,10 @@ func main() { ...@@ -243,8 +247,10 @@ func main() {
chronicHandler.RegisterRoutes(authApi) chronicHandler.RegisterRoutes(authApi)
// 管理端路由(需要认证 + 管理员角色) // 管理端路由(需要认证 + 管理员角色)
adminApi := authApi.Group("")
adminApi.Use(middleware.RequireRole("admin"))
adminHandler := admin.NewHandler() adminHandler := admin.NewHandler()
adminHandler.RegisterRoutes(authApi) adminHandler.RegisterRoutes(adminApi)
// Agent路由(需要认证) // Agent路由(需要认证)
agentHandler := internalagent.NewHandler() agentHandler := internalagent.NewHandler()
......
package model
import "time"
// ComplianceReport 合规报告
type ComplianceReport struct {
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
Name string `gorm:"type:varchar(200)" json:"name"`
Type string `gorm:"type:varchar(50)" json:"type"` // monthly=月度报告, special=专项报告, security=安全报告
Period string `gorm:"type:varchar(20)" json:"period"` // e.g. 2026-03
Status string `gorm:"type:varchar(20);default:pending" json:"status"` // pending, generated, submitted
FileURL string `gorm:"type:varchar(500)" json:"file_url"`
GeneratedAt *time.Time `json:"generated_at"`
SubmittedAt *time.Time `json:"submitted_at"`
CreatedBy string `gorm:"type:varchar(36)" json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
package admin
import (
"context"
"encoding/csv"
"fmt"
"os"
"time"
"github.com/google/uuid"
"internet-hospital/internal/model"
)
// ListReports 获取合规报告列表
func (s *Service) ListReports(ctx context.Context) ([]model.ComplianceReport, error) {
var reports []model.ComplianceReport
if err := s.db.Order("created_at DESC").Find(&reports).Error; err != nil {
return nil, err
}
return reports, nil
}
// GenerateReport 生成合规报告(查询真实数据并导出为CSV文件)
func (s *Service) GenerateReport(ctx context.Context, reportType, name, period, userID string) (*model.ComplianceReport, error) {
os.MkdirAll("./uploads/reports", 0755)
filename := fmt.Sprintf("report_%s_%s_%s.csv", reportType, period, time.Now().Format("20060102_150405"))
fpath := fmt.Sprintf("./uploads/reports/%s", filename)
file, err := os.Create(fpath)
if err != nil {
return nil, fmt.Errorf("创建报告文件失败")
}
defer file.Close()
// UTF-8 BOM for Excel compatibility
file.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(file)
defer writer.Flush()
switch reportType {
case "monthly":
err = s.generateMonthlyReport(writer, period)
case "special":
err = s.generatePrescriptionComplianceReport(writer, period)
case "security":
err = s.generateSecurityReport(writer, period)
default:
return nil, fmt.Errorf("不支持的报告类型: %s", reportType)
}
if err != nil {
os.Remove(fpath)
return nil, err
}
now := time.Now()
report := model.ComplianceReport{
ID: uuid.New().String(),
Name: name,
Type: reportType,
Period: period,
Status: "generated",
FileURL: fmt.Sprintf("/api/v1/uploads/reports/%s", filename),
GeneratedAt: &now,
CreatedBy: userID,
}
if err := s.db.Create(&report).Error; err != nil {
return nil, err
}
// 记录系统日志
s.db.Create(&model.SystemLog{
ID: uuid.New().String(),
Action: "generate_report",
Resource: reportType,
Detail: fmt.Sprintf("用户 %s 生成了 %s 报告: %s", userID, reportType, name),
})
return &report, nil
}
// SubmitReport 上报合规报告
func (s *Service) SubmitReport(ctx context.Context, reportID, userID string) error {
var report model.ComplianceReport
if err := s.db.Where("id = ?", reportID).First(&report).Error; err != nil {
return fmt.Errorf("报告不存在")
}
if report.Status != "generated" {
return fmt.Errorf("仅已生成的报告可以上报")
}
now := time.Now()
if err := s.db.Model(&report).Updates(map[string]interface{}{
"status": "submitted",
"submitted_at": &now,
}).Error; err != nil {
return err
}
s.db.Create(&model.SystemLog{
ID: uuid.New().String(),
Action: "submit_report",
Resource: "compliance_report",
Detail: fmt.Sprintf("用户 %s 上报了报告: %s", userID, report.Name),
})
return nil
}
// generateMonthlyReport 生成月度运营报告
func (s *Service) generateMonthlyReport(writer *csv.Writer, period string) error {
// 解析期间
startDate, err := time.Parse("2006-01", period)
if err != nil {
return fmt.Errorf("无效的期间格式,请使用 YYYY-MM")
}
endDate := startDate.AddDate(0, 1, 0)
// 汇总区段
writer.Write([]string{"=== 月度运营汇总 ===", period})
writer.Write([]string{""})
// 1. 问诊统计
var consultTotal int64
var consultCompleted int64
var consultCancelled int64
s.db.Model(&model.Consultation{}).Where("created_at >= ? AND created_at < ?", startDate, endDate).Count(&consultTotal)
s.db.Model(&model.Consultation{}).Where("created_at >= ? AND created_at < ? AND status = 'completed'", startDate, endDate).Count(&consultCompleted)
s.db.Model(&model.Consultation{}).Where("created_at >= ? AND created_at < ? AND status = 'cancelled'", startDate, endDate).Count(&consultCancelled)
writer.Write([]string{"指标", "数值"})
writer.Write([]string{"问诊总数", fmt.Sprintf("%d", consultTotal)})
writer.Write([]string{"完成问诊", fmt.Sprintf("%d", consultCompleted)})
writer.Write([]string{"取消问诊", fmt.Sprintf("%d", consultCancelled)})
if consultTotal > 0 {
writer.Write([]string{"完成率", fmt.Sprintf("%.1f%%", float64(consultCompleted)/float64(consultTotal)*100)})
}
writer.Write([]string{""})
// 2. 处方统计
var rxTotal int64
var rxWarning int64
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ?", startDate, endDate).Count(&rxTotal)
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ? AND warning_level != '' AND warning_level != 'normal'", startDate, endDate).Count(&rxWarning)
writer.Write([]string{"处方总数", fmt.Sprintf("%d", rxTotal)})
writer.Write([]string{"处方预警数", fmt.Sprintf("%d", rxWarning)})
writer.Write([]string{""})
// 3. 用户统计
var newPatients int64
var newDoctors int64
s.db.Model(&model.User{}).Where("created_at >= ? AND created_at < ? AND role = 'patient'", startDate, endDate).Count(&newPatients)
s.db.Model(&model.User{}).Where("created_at >= ? AND created_at < ? AND role = 'doctor'", startDate, endDate).Count(&newDoctors)
writer.Write([]string{"新增患者", fmt.Sprintf("%d", newPatients)})
writer.Write([]string{"新增医生", fmt.Sprintf("%d", newDoctors)})
writer.Write([]string{""})
// 4. 收入统计
var totalRevenue float64
s.db.Model(&model.PaymentOrder{}).Where("created_at >= ? AND created_at < ? AND status = 'paid'", startDate, endDate).
Select("COALESCE(SUM(amount), 0)").Scan(&totalRevenue)
writer.Write([]string{"总营收(元)", fmt.Sprintf("%.2f", totalRevenue)})
writer.Write([]string{""})
// 5. 问诊明细
writer.Write([]string{"=== 问诊明细 ==="})
writer.Write([]string{"ID", "患者ID", "医生ID", "类型", "状态", "主诉", "创建时间", "完成时间"})
var consults []model.Consultation
s.db.Where("created_at >= ? AND created_at < ?", startDate, endDate).Order("created_at DESC").Limit(500).Find(&consults)
for _, c := range consults {
endedAt := ""
if c.EndedAt != nil {
endedAt = c.EndedAt.Format("2006-01-02 15:04:05")
}
writer.Write([]string{c.ID, c.PatientID, c.DoctorID, c.Type, c.Status, c.ChiefComplaint, c.CreatedAt.Format("2006-01-02 15:04:05"), endedAt})
}
return nil
}
// generatePrescriptionComplianceReport 生成处方合规专项报告
func (s *Service) generatePrescriptionComplianceReport(writer *csv.Writer, period string) error {
startDate, err := time.Parse("2006-01", period)
if err != nil {
return fmt.Errorf("无效的期间格式")
}
endDate := startDate.AddDate(0, 1, 0)
writer.Write([]string{"=== 处方用药合规专项报告 ===", period})
writer.Write([]string{""})
// 汇总
var total, normal, warning, rejected int64
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ?", startDate, endDate).Count(&total)
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ? AND (warning_level = '' OR warning_level = 'normal')", startDate, endDate).Count(&normal)
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ? AND warning_level NOT IN ('', 'normal') AND status != 'rejected'", startDate, endDate).Count(&warning)
s.db.Model(&model.Prescription{}).Where("created_at >= ? AND created_at < ? AND status = 'rejected'", startDate, endDate).Count(&rejected)
writer.Write([]string{"指标", "数值"})
writer.Write([]string{"处方总数", fmt.Sprintf("%d", total)})
writer.Write([]string{"正常处方", fmt.Sprintf("%d", normal)})
writer.Write([]string{"预警处方", fmt.Sprintf("%d", warning)})
writer.Write([]string{"驳回处方", fmt.Sprintf("%d", rejected)})
if total > 0 {
writer.Write([]string{"合规率", fmt.Sprintf("%.1f%%", float64(normal)/float64(total)*100)})
}
writer.Write([]string{""})
// 处方明细
writer.Write([]string{"=== 处方明细 ==="})
writer.Write([]string{"处方号", "患者ID", "医生ID", "诊断", "预警级别", "状态", "金额(元)", "创建时间"})
var prescriptions []model.Prescription
s.db.Where("created_at >= ? AND created_at < ?", startDate, endDate).Order("created_at DESC").Limit(500).Find(&prescriptions)
for _, p := range prescriptions {
wl := p.WarningLevel
if wl == "" {
wl = "normal"
}
writer.Write([]string{p.PrescriptionNo, p.PatientID, p.DoctorID, p.Diagnosis, wl, p.Status, fmt.Sprintf("%.2f", p.TotalAmount), p.CreatedAt.Format("2006-01-02 15:04:05")})
}
return nil
}
// generateSecurityReport 生成安全自查报告
func (s *Service) generateSecurityReport(writer *csv.Writer, period string) error {
startDate, err := time.Parse("2006-01", period)
if err != nil {
return fmt.Errorf("无效的期间格式")
}
endDate := startDate.AddDate(0, 1, 0)
writer.Write([]string{"=== 信息安全自查报告 ===", period})
writer.Write([]string{""})
// 1. 安全过滤统计
var safetyTotal, blocked, replaced, warned int64
s.db.Model(&model.SafetyFilterLog{}).Where("created_at >= ? AND created_at < ?", startDate, endDate).Count(&safetyTotal)
s.db.Model(&model.SafetyFilterLog{}).Where("created_at >= ? AND created_at < ? AND action = 'blocked'", startDate, endDate).Count(&blocked)
s.db.Model(&model.SafetyFilterLog{}).Where("created_at >= ? AND created_at < ? AND action = 'replaced'", startDate, endDate).Count(&replaced)
s.db.Model(&model.SafetyFilterLog{}).Where("created_at >= ? AND created_at < ? AND action = 'warned'", startDate, endDate).Count(&warned)
writer.Write([]string{"指标", "数值"})
writer.Write([]string{"安全过滤触发总数", fmt.Sprintf("%d", safetyTotal)})
writer.Write([]string{"拦截数", fmt.Sprintf("%d", blocked)})
writer.Write([]string{"替换数", fmt.Sprintf("%d", replaced)})
writer.Write([]string{"警告数", fmt.Sprintf("%d", warned)})
writer.Write([]string{""})
// 2. 安全规则配置
var ruleTotal int64
var ruleActive int64
s.db.Model(&model.SafetyWordRule{}).Count(&ruleTotal)
s.db.Model(&model.SafetyWordRule{}).Where("status = 'active'").Count(&ruleActive)
writer.Write([]string{"安全规则总数", fmt.Sprintf("%d", ruleTotal)})
writer.Write([]string{"启用规则数", fmt.Sprintf("%d", ruleActive)})
writer.Write([]string{""})
// 3. 系统操作日志统计
var logTotal int64
s.db.Model(&model.SystemLog{}).Where("created_at >= ? AND created_at < ?", startDate, endDate).Count(&logTotal)
writer.Write([]string{"系统操作日志", fmt.Sprintf("%d", logTotal)})
writer.Write([]string{""})
// 4. 安全过滤事件明细
writer.Write([]string{"=== 安全过滤事件明细 ==="})
writer.Write([]string{"ID", "方向", "原始内容", "动作", "匹配规则", "创建时间"})
var logs []model.SafetyFilterLog
s.db.Where("created_at >= ? AND created_at < ?", startDate, endDate).Order("created_at DESC").Limit(500).Find(&logs)
for _, l := range logs {
writer.Write([]string{fmt.Sprintf("%d", l.ID), l.Direction, l.OriginalText, l.Action, l.MatchedRules, l.CreatedAt.Format("2006-01-02 15:04:05")})
}
return nil
}
package admin
import (
"context"
"encoding/csv"
"fmt"
"os"
"time"
"github.com/google/uuid"
"internet-hospital/internal/model"
"internet-hospital/pkg/workflow"
)
func (s *Service) GetDoctorReviewList(ctx context.Context, status string) (interface{}, error) {
var reviews []model.DoctorReview
var total int64
query := s.db.Model(&model.DoctorReview{})
// 状态筛选
if status != "" {
query = query.Where("status = ?", status)
}
// 统计总数
query.Count(&total)
// 查询数据
query.Order("created_at DESC").Find(&reviews)
return map[string]interface{}{
"list": reviews,
"total": total,
}, nil
}
func (s *Service) ApproveDoctorReview(ctx context.Context, reviewID string) error {
// 获取审核记录
var review model.DoctorReview
if err := s.db.Where("id = ?", reviewID).First(&review).Error; err != nil {
return fmt.Errorf("审核记录不存在")
}
if review.Status != "pending" {
return fmt.Errorf("该审核已处理")
}
// 开启事务
tx := s.db.Begin()
// 1. 更新审核状态
now := time.Now()
if err := tx.Model(&review).Updates(map[string]interface{}{
"status": "approved",
"reviewed_at": now,
}).Error; err != nil {
tx.Rollback()
return err
}
// 2. 查找对应科室ID
var dept model.Department
tx.Where("name = ?", review.DepartmentName).First(&dept)
// 3. 创建医生档案
doctor := &model.Doctor{
ID: uuid.New().String(),
UserID: review.UserID,
Name: review.Name,
LicenseNo: review.LicenseNo,
Title: review.Title,
DepartmentID: dept.ID,
Hospital: review.Hospital,
Rating: 5.0,
Price: 5000, // 默认50元
Status: "approved",
}
if err := tx.Create(doctor).Error; err != nil {
tx.Rollback()
return err
}
// 4. 更新用户认证状态
if err := tx.Model(&model.User{}).Where("id = ?", review.UserID).Updates(map[string]interface{}{
"is_verified": true,
}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Commit().Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "approved",
})
return nil
}
func (s *Service) RejectDoctorReview(ctx context.Context, reviewID, reason string) error {
var review model.DoctorReview
if err := s.db.Where("id = ?", reviewID).First(&review).Error; err != nil {
return fmt.Errorf("审核记录不存在")
}
if review.Status != "pending" {
return fmt.Errorf("该审核已处理")
}
now := time.Now()
if err := s.db.Model(&review).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": reason,
"reviewed_at": now,
}).Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "rejected",
"reason": reason,
})
return nil
}
// ConsultListParams 管理后台问诊列表参数
type ConsultListParams struct {
Keyword string `form:"keyword"`
Type string `form:"type"`
Status string `form:"status"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// AdminConsultItem 管理后台问诊列表项
type AdminConsultItem struct {
ID string `json:"id"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
DoctorID string `json:"doctor_id"`
DoctorName string `json:"doctor_name"`
Type string `json:"type"`
Status string `json:"status"`
ChiefComplaint string `json:"chief_complaint"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
}
// GetConsultList 管理后台获取问诊列表
func (s *Service) GetConsultList(ctx context.Context, params *ConsultListParams) (interface{}, error) {
var total int64
query := s.db.Table("consultations").
Select("consultations.*, p.real_name as patient_name, d.name as doctor_name").
Joins("LEFT JOIN users p ON consultations.patient_id = p.id").
Joins("LEFT JOIN doctors d ON consultations.doctor_id = d.id").
Where("consultations.deleted_at IS NULL")
if params.Keyword != "" {
query = query.Where("(p.real_name LIKE ? OR d.name LIKE ?)",
"%"+params.Keyword+"%", "%"+params.Keyword+"%")
}
if params.Type != "" {
query = query.Where("consultations.type = ?", params.Type)
}
if params.Status != "" {
query = query.Where("consultations.status = ?", params.Status)
}
if params.StartDate != "" {
query = query.Where("consultations.created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
query = query.Where("consultations.created_at <= ?", params.EndDate+" 23:59:59")
}
// 统计总数(需要单独count避免select干扰)
countQuery := s.db.Table("consultations").
Joins("LEFT JOIN users p ON consultations.patient_id = p.id").
Joins("LEFT JOIN doctors d ON consultations.doctor_id = d.id").
Where("consultations.deleted_at IS NULL")
if params.Keyword != "" {
countQuery = countQuery.Where("(p.real_name LIKE ? OR d.name LIKE ?)",
"%"+params.Keyword+"%", "%"+params.Keyword+"%")
}
if params.Type != "" {
countQuery = countQuery.Where("consultations.type = ?", params.Type)
}
if params.Status != "" {
countQuery = countQuery.Where("consultations.status = ?", params.Status)
}
if params.StartDate != "" {
countQuery = countQuery.Where("consultations.created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
countQuery = countQuery.Where("consultations.created_at <= ?", params.EndDate+" 23:59:59")
}
countQuery.Count(&total)
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 10
}
offset := (params.Page - 1) * params.PageSize
var items []AdminConsultItem
query.Order("consultations.created_at DESC").Offset(offset).Limit(params.PageSize).Scan(&items)
return map[string]interface{}{
"list": items,
"total": total,
"page": params.Page,
"page_size": params.PageSize,
}, nil
}
func (s *Service) GetSystemLogs(ctx context.Context) (interface{}, error) {
var logs []model.SystemLog
var total int64
s.db.Model(&model.SystemLog{}).Count(&total)
s.db.Order("created_at DESC").Limit(100).Find(&logs)
if logs == nil {
logs = []model.SystemLog{}
}
return map[string]interface{}{
"list": logs,
"total": total,
}, nil
}
// SystemLogParams 系统日志查询参数
type SystemLogParams struct {
Keyword string `form:"keyword"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// GetSystemLogsWithParams 带条件的系统日志查询
func (s *Service) GetSystemLogsWithParams(ctx context.Context, params *SystemLogParams) (interface{}, error) {
var logs []model.SystemLog
var total int64
query := s.db.Model(&model.SystemLog{})
if params.Keyword != "" {
query = query.Where("action LIKE ? OR resource LIKE ? OR detail LIKE ?",
"%"+params.Keyword+"%", "%"+params.Keyword+"%", "%"+params.Keyword+"%")
}
if params.StartDate != "" {
query = query.Where("created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
query = query.Where("created_at <= ?", params.EndDate+" 23:59:59")
}
query.Count(&total)
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 10
}
offset := (params.Page - 1) * params.PageSize
query.Order("created_at DESC").Offset(offset).Limit(params.PageSize).Find(&logs)
if logs == nil {
logs = []model.SystemLog{}
}
return map[string]interface{}{
"list": logs,
"total": total,
"page": params.Page,
"page_size": params.PageSize,
}, nil
}
// AILogListParams AI日志查询参数
type AILogListParams struct {
Scene string `form:"scene"`
Success string `form:"success"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// GetAILogs 获取AI调用日志列表
func (s *Service) GetAILogs(ctx context.Context, params *AILogListParams) (interface{}, error) {
var total int64
query := s.db.Model(&model.AIUsageLog{})
if params.Scene != "" {
query = query.Where("scene = ?", params.Scene)
}
if params.Success == "true" {
query = query.Where("success = ?", true)
} else if params.Success == "false" {
query = query.Where("success = ?", false)
}
if params.StartDate != "" {
query = query.Where("created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
query = query.Where("created_at <= ?", params.EndDate+" 23:59:59")
}
query.Count(&total)
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
var logs []model.AIUsageLog
query.Order("created_at DESC").Offset(offset).Limit(params.PageSize).Find(&logs)
return map[string]interface{}{
"list": logs,
"total": total,
"page": params.Page,
"page_size": params.PageSize,
}, nil
}
// ExportData 导出数据为CSV
func (s *Service) ExportData(ctx context.Context, exportType, userID string) (map[string]interface{}, error) {
os.MkdirAll("./uploads/exports", 0755)
filename := fmt.Sprintf("%s_%s.csv", exportType, time.Now().Format("20060102_150405"))
fpath := fmt.Sprintf("./uploads/exports/%s", filename)
file, err := os.Create(fpath)
if err != nil {
return nil, fmt.Errorf("创建文件失败")
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
switch exportType {
case "consultations":
writer.Write([]string{"ID", "患者", "医生", "类型", "状态", "创建时间"})
var items []model.Consultation
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.PatientID, item.DoctorID, item.Type, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "users":
writer.Write([]string{"ID", "姓名", "手机号", "角色", "状态", "注册时间"})
var items []model.User
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.RealName, item.Phone, item.Role, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "prescriptions":
writer.Write([]string{"ID", "处方号", "患者ID", "医生ID", "诊断", "状态", "创建时间"})
var items []model.Prescription
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.PrescriptionNo, item.PatientID, item.DoctorID, item.Diagnosis, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "logs":
writer.Write([]string{"ID", "操作", "资源", "详情", "创建时间"})
var items []model.SystemLog
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.Action, item.Resource, item.Detail, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
default:
return nil, fmt.Errorf("不支持的导出类型: %s", exportType)
}
// Log the export
s.db.Create(&model.SystemLog{
ID: uuid.New().String(),
Action: "export",
Resource: exportType,
Detail: fmt.Sprintf("用户 %s 导出了 %s 数据", userID, exportType),
})
return map[string]interface{}{
"url": fmt.Sprintf("/api/v1/uploads/exports/%s", filename),
"filename": filename,
}, nil
}
...@@ -152,6 +152,11 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -152,6 +152,11 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 数据导出 // 数据导出
adm.POST("/export/:type", h.ExportData) adm.POST("/export/:type", h.ExportData)
// 合规报告
adm.GET("/reports", h.ListReports)
adm.POST("/reports/generate", h.GenerateReport)
adm.POST("/reports/:id/submit", h.SubmitReport)
} }
// 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问) // 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问)
...@@ -463,105 +468,3 @@ func (h *Handler) ResetAdminPassword(c *gin.Context) { ...@@ -463,105 +468,3 @@ func (h *Handler) ResetAdminPassword(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
// GetSystemLogs 系统日志
func (h *Handler) GetSystemLogs(c *gin.Context) {
logs, err := h.service.GetSystemLogs(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取日志失败")
return
}
response.Success(c, logs)
}
// GetConsultList 管理后台获取问诊列表
func (h *Handler) GetConsultList(c *gin.Context) {
var params ConsultListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetConsultList(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取问诊列表失败")
return
}
response.Success(c, result)
}
// GetAIConfig 获取AI配置
func (h *Handler) GetAIConfig(c *gin.Context) {
cfg, err := h.service.GetAIConfig(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取AI配置失败")
return
}
// 对API Key脱敏
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// SaveAIConfig 保存AI配置
func (h *Handler) SaveAIConfig(c *gin.Context) {
var req SaveAIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
cfg, err := h.service.SaveAIConfig(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, "保存AI配置失败: "+err.Error())
return
}
// 脱敏返回
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// GetAIUsageStats 获取AI使用统计
func (h *Handler) GetAIUsageStats(c *gin.Context) {
stats, err := h.service.GetAIUsageStats(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取使用统计失败")
return
}
response.Success(c, stats)
}
// GetAILogs 获取AI调用日志
func (h *Handler) GetAILogs(c *gin.Context) {
var params AILogListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetAILogs(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取AI日志失败")
return
}
response.Success(c, result)
}
// ExportData 数据导出
func (h *Handler) ExportData(c *gin.Context) {
exportType := c.Param("type")
userID, _ := c.Get("user_id")
result, err := h.service.ExportData(c.Request.Context(), exportType, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
package admin
import (
"github.com/gin-gonic/gin"
"internet-hospital/pkg/response"
)
// GetSystemLogs 系统日志
func (h *Handler) GetSystemLogs(c *gin.Context) {
var params SystemLogParams
_ = c.ShouldBindQuery(&params)
logs, err := h.service.GetSystemLogsWithParams(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取日志失败")
return
}
response.Success(c, logs)
}
// GetConsultList 管理后台获取问诊列表
func (h *Handler) GetConsultList(c *gin.Context) {
var params ConsultListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetConsultList(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取问诊列表失败")
return
}
response.Success(c, result)
}
// GetAIConfig 获取AI配置
func (h *Handler) GetAIConfig(c *gin.Context) {
cfg, err := h.service.GetAIConfig(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取AI配置失败")
return
}
// 对API Key脱敏
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// SaveAIConfig 保存AI配置
func (h *Handler) SaveAIConfig(c *gin.Context) {
var req SaveAIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}
cfg, err := h.service.SaveAIConfig(c.Request.Context(), &req)
if err != nil {
response.Error(c, 500, "保存AI配置失败: "+err.Error())
return
}
// 脱敏返回
if cfg.APIKey != "" {
if len(cfg.APIKey) > 8 {
cfg.APIKey = cfg.APIKey[:4] + "****" + cfg.APIKey[len(cfg.APIKey)-4:]
} else {
cfg.APIKey = "****"
}
}
response.Success(c, cfg)
}
// GetAIUsageStats 获取AI使用统计
func (h *Handler) GetAIUsageStats(c *gin.Context) {
stats, err := h.service.GetAIUsageStats(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取使用统计失败")
return
}
response.Success(c, stats)
}
// GetAILogs 获取AI调用日志
func (h *Handler) GetAILogs(c *gin.Context) {
var params AILogListParams
if err := c.ShouldBindQuery(&params); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
result, err := h.service.GetAILogs(c.Request.Context(), &params)
if err != nil {
response.Error(c, 500, "获取AI日志失败")
return
}
response.Success(c, result)
}
// ExportData 数据导出
func (h *Handler) ExportData(c *gin.Context) {
exportType := c.Param("type")
userID, _ := c.Get("user_id")
result, err := h.service.ExportData(c.Request.Context(), exportType, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
}
// ListReports 合规报告列表
func (h *Handler) ListReports(c *gin.Context) {
reports, err := h.service.ListReports(c.Request.Context())
if err != nil {
response.Error(c, 500, "获取报告列表失败")
return
}
response.Success(c, reports)
}
// GenerateReport 生成合规报告
func (h *Handler) GenerateReport(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Period string `json:"period" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请提供报告类型、名称和周期")
return
}
userID, _ := c.Get("user_id")
report, err := h.service.GenerateReport(c.Request.Context(), req.Type, req.Name, req.Period, userID.(string))
if err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, report)
}
// SubmitReport 上报合规报告
func (h *Handler) SubmitReport(c *gin.Context) {
id := c.Param("id")
userID, _ := c.Get("user_id")
if err := h.service.SubmitReport(c.Request.Context(), id, userID.(string)); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
...@@ -2,9 +2,7 @@ package admin ...@@ -2,9 +2,7 @@ package admin
import ( import (
"context" "context"
"encoding/csv"
"fmt" "fmt"
"os"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
...@@ -13,7 +11,6 @@ import ( ...@@ -13,7 +11,6 @@ import (
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/utils" "internet-hospital/pkg/utils"
"internet-hospital/pkg/workflow"
) )
// Service 管理端业务逻辑层 // Service 管理端业务逻辑层
...@@ -253,24 +250,24 @@ type DoctorManageParams struct { ...@@ -253,24 +250,24 @@ type DoctorManageParams struct {
// DoctorManageItem 医生管理列表项 // DoctorManageItem 医生管理列表项
type DoctorManageItem struct { type DoctorManageItem struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DoctorID string `json:"doctor_id"` DoctorID string `json:"doctor_id"`
Name string `json:"name"` Name string `json:"name"`
Phone string `json:"phone"` Phone string `json:"phone"`
Status string `json:"status"` // not_submitted | pending | approved | rejected Status string `json:"status"` // not_submitted | pending | approved | rejected
IsVerified bool `json:"is_verified"` IsVerified bool `json:"is_verified"`
UserStatus string `json:"user_status"` // active | disabled UserStatus string `json:"user_status"` // active | disabled
LicenseNo string `json:"license_no"` LicenseNo string `json:"license_no"`
Title string `json:"title"` Title string `json:"title"`
Hospital string `json:"hospital"` Hospital string `json:"hospital"`
DepartmentName string `json:"department_name"` DepartmentName string `json:"department_name"`
Rating float64 `json:"rating"` Rating float64 `json:"rating"`
ConsultCount int `json:"consult_count"` ConsultCount int `json:"consult_count"`
SubmittedAt *time.Time `json:"submitted_at"` SubmittedAt *time.Time `json:"submitted_at"`
ReviewID string `json:"review_id"` ReviewID string `json:"review_id"`
LicenseImage string `json:"license_image"` LicenseImage string `json:"license_image"`
QualificationImage string `json:"qualification_image"` QualificationImage string `json:"qualification_image"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
func (s *Service) GetDoctorManageList(ctx context.Context, params *DoctorManageParams) (map[string]interface{}, error) { func (s *Service) GetDoctorManageList(ctx context.Context, params *DoctorManageParams) (map[string]interface{}, error) {
...@@ -451,123 +448,6 @@ func (s *Service) CreateDoctor(ctx context.Context, req *CreateDoctorReq) error ...@@ -451,123 +448,6 @@ func (s *Service) CreateDoctor(ctx context.Context, req *CreateDoctorReq) error
return nil return nil
} }
func (s *Service) GetDoctorReviewList(ctx context.Context, status string) (interface{}, error) {
var reviews []model.DoctorReview
var total int64
query := s.db.Model(&model.DoctorReview{})
// 状态筛选
if status != "" {
query = query.Where("status = ?", status)
}
// 统计总数
query.Count(&total)
// 查询数据
query.Order("created_at DESC").Find(&reviews)
return map[string]interface{}{
"list": reviews,
"total": total,
}, nil
}
func (s *Service) ApproveDoctorReview(ctx context.Context, reviewID string) error {
// 获取审核记录
var review model.DoctorReview
if err := s.db.Where("id = ?", reviewID).First(&review).Error; err != nil {
return fmt.Errorf("审核记录不存在")
}
if review.Status != "pending" {
return fmt.Errorf("该审核已处理")
}
// 开启事务
tx := s.db.Begin()
// 1. 更新审核状态
now := time.Now()
if err := tx.Model(&review).Updates(map[string]interface{}{
"status": "approved",
"reviewed_at": now,
}).Error; err != nil {
tx.Rollback()
return err
}
// 2. 查找对应科室ID
var dept model.Department
tx.Where("name = ?", review.DepartmentName).First(&dept)
// 3. 创建医生档案
doctor := &model.Doctor{
ID: uuid.New().String(),
UserID: review.UserID,
Name: review.Name,
LicenseNo: review.LicenseNo,
Title: review.Title,
DepartmentID: dept.ID,
Hospital: review.Hospital,
Rating: 5.0,
Price: 5000, // 默认50元
Status: "approved",
}
if err := tx.Create(doctor).Error; err != nil {
tx.Rollback()
return err
}
// 4. 更新用户认证状态
if err := tx.Model(&model.User{}).Where("id = ?", review.UserID).Updates(map[string]interface{}{
"is_verified": true,
}).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Commit().Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "approved",
})
return nil
}
func (s *Service) RejectDoctorReview(ctx context.Context, reviewID, reason string) error {
var review model.DoctorReview
if err := s.db.Where("id = ?", reviewID).First(&review).Error; err != nil {
return fmt.Errorf("审核记录不存在")
}
if review.Status != "pending" {
return fmt.Errorf("该审核已处理")
}
now := time.Now()
if err := s.db.Model(&review).Updates(map[string]interface{}{
"status": "rejected",
"reject_reason": reason,
"reviewed_at": now,
}).Error; err != nil {
return err
}
// 触发 doctor_review 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "doctor_review", map[string]interface{}{
"review_id": reviewID,
"doctor_id": review.UserID,
"action": "rejected",
"reason": reason,
})
return nil
}
func (s *Service) GetDepartmentList(ctx context.Context) (interface{}, error) { func (s *Service) GetDepartmentList(ctx context.Context) (interface{}, error) {
var departments []model.Department var departments []model.Department
if err := s.db.Order("sort_order ASC").Find(&departments).Error; err != nil { if err := s.db.Order("sort_order ASC").Find(&departments).Error; err != nil {
...@@ -673,225 +553,3 @@ func (s *Service) CreateAdmin(ctx context.Context, req *CreateAdminReq) error { ...@@ -673,225 +553,3 @@ func (s *Service) CreateAdmin(ctx context.Context, req *CreateAdminReq) error {
} }
return s.db.Create(user).Error return s.db.Create(user).Error
} }
// ConsultListParams 管理后台问诊列表参数
type ConsultListParams struct {
Keyword string `form:"keyword"`
Type string `form:"type"`
Status string `form:"status"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// AdminConsultItem 管理后台问诊列表项
type AdminConsultItem struct {
ID string `json:"id"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
DoctorID string `json:"doctor_id"`
DoctorName string `json:"doctor_name"`
Type string `json:"type"`
Status string `json:"status"`
ChiefComplaint string `json:"chief_complaint"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
}
// GetConsultList 管理后台获取问诊列表
func (s *Service) GetConsultList(ctx context.Context, params *ConsultListParams) (interface{}, error) {
var total int64
query := s.db.Table("consultations").
Select("consultations.*, p.real_name as patient_name, d.name as doctor_name").
Joins("LEFT JOIN users p ON consultations.patient_id = p.id").
Joins("LEFT JOIN doctors d ON consultations.doctor_id = d.id").
Where("consultations.deleted_at IS NULL")
if params.Keyword != "" {
query = query.Where("(p.real_name LIKE ? OR d.name LIKE ?)",
"%"+params.Keyword+"%", "%"+params.Keyword+"%")
}
if params.Type != "" {
query = query.Where("consultations.type = ?", params.Type)
}
if params.Status != "" {
query = query.Where("consultations.status = ?", params.Status)
}
if params.StartDate != "" {
query = query.Where("consultations.created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
query = query.Where("consultations.created_at <= ?", params.EndDate+" 23:59:59")
}
// 统计总数(需要单独count避免select干扰)
countQuery := s.db.Table("consultations").
Joins("LEFT JOIN users p ON consultations.patient_id = p.id").
Joins("LEFT JOIN doctors d ON consultations.doctor_id = d.id").
Where("consultations.deleted_at IS NULL")
if params.Keyword != "" {
countQuery = countQuery.Where("(p.real_name LIKE ? OR d.name LIKE ?)",
"%"+params.Keyword+"%", "%"+params.Keyword+"%")
}
if params.Type != "" {
countQuery = countQuery.Where("consultations.type = ?", params.Type)
}
if params.Status != "" {
countQuery = countQuery.Where("consultations.status = ?", params.Status)
}
if params.StartDate != "" {
countQuery = countQuery.Where("consultations.created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
countQuery = countQuery.Where("consultations.created_at <= ?", params.EndDate+" 23:59:59")
}
countQuery.Count(&total)
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 10
}
offset := (params.Page - 1) * params.PageSize
var items []AdminConsultItem
query.Order("consultations.created_at DESC").Offset(offset).Limit(params.PageSize).Scan(&items)
return map[string]interface{}{
"list": items,
"total": total,
"page": params.Page,
"page_size": params.PageSize,
}, nil
}
func (s *Service) GetSystemLogs(ctx context.Context) (interface{}, error) {
var logs []model.SystemLog
var total int64
s.db.Model(&model.SystemLog{}).Count(&total)
s.db.Order("created_at DESC").Limit(100).Find(&logs)
if logs == nil {
logs = []model.SystemLog{}
}
return map[string]interface{}{
"list": logs,
"total": total,
}, nil
}
// AILogListParams AI日志查询参数
type AILogListParams struct {
Scene string `form:"scene"`
Success string `form:"success"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
// GetAILogs 获取AI调用日志列表
func (s *Service) GetAILogs(ctx context.Context, params *AILogListParams) (interface{}, error) {
var total int64
query := s.db.Model(&model.AIUsageLog{})
if params.Scene != "" {
query = query.Where("scene = ?", params.Scene)
}
if params.Success == "true" {
query = query.Where("success = ?", true)
} else if params.Success == "false" {
query = query.Where("success = ?", false)
}
if params.StartDate != "" {
query = query.Where("created_at >= ?", params.StartDate)
}
if params.EndDate != "" {
query = query.Where("created_at <= ?", params.EndDate+" 23:59:59")
}
query.Count(&total)
if params.Page <= 0 {
params.Page = 1
}
if params.PageSize <= 0 {
params.PageSize = 20
}
offset := (params.Page - 1) * params.PageSize
var logs []model.AIUsageLog
query.Order("created_at DESC").Offset(offset).Limit(params.PageSize).Find(&logs)
return map[string]interface{}{
"list": logs,
"total": total,
"page": params.Page,
"page_size": params.PageSize,
}, nil
}
// ExportData 导出数据为CSV
func (s *Service) ExportData(ctx context.Context, exportType, userID string) (map[string]interface{}, error) {
os.MkdirAll("./uploads/exports", 0755)
filename := fmt.Sprintf("%s_%s.csv", exportType, time.Now().Format("20060102_150405"))
fpath := fmt.Sprintf("./uploads/exports/%s", filename)
file, err := os.Create(fpath)
if err != nil {
return nil, fmt.Errorf("创建文件失败")
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
switch exportType {
case "consultations":
writer.Write([]string{"ID", "患者", "医生", "类型", "状态", "创建时间"})
var items []model.Consultation
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.PatientID, item.DoctorID, item.Type, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "users":
writer.Write([]string{"ID", "姓名", "手机号", "角色", "状态", "注册时间"})
var items []model.User
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.RealName, item.Phone, item.Role, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "prescriptions":
writer.Write([]string{"ID", "处方号", "患者ID", "医生ID", "诊断", "状态", "创建时间"})
var items []model.Prescription
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.PrescriptionNo, item.PatientID, item.DoctorID, item.Diagnosis, item.Status, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
case "logs":
writer.Write([]string{"ID", "操作", "资源", "详情", "创建时间"})
var items []model.SystemLog
s.db.Limit(1000).Order("created_at DESC").Find(&items)
for _, item := range items {
writer.Write([]string{item.ID, item.Action, item.Resource, item.Detail, item.CreatedAt.Format("2006-01-02 15:04:05")})
}
default:
return nil, fmt.Errorf("不支持的导出类型: %s", exportType)
}
// Log the export
s.db.Create(&model.SystemLog{
ID: uuid.New().String(),
Action: "export",
Resource: exportType,
Detail: fmt.Sprintf("用户 %s 导出了 %s 数据", userID, exportType),
})
return map[string]interface{}{
"url": fmt.Sprintf("/api/v1/uploads/exports/%s", filename),
"filename": filename,
}, nil
}
...@@ -11,6 +11,7 @@ import ( ...@@ -11,6 +11,7 @@ import (
internalagent "internet-hospital/internal/agent" internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -324,13 +325,29 @@ func (s *Service) DoctorListRenewals(ctx context.Context, doctorUserID string) ( ...@@ -324,13 +325,29 @@ func (s *Service) DoctorListRenewals(ctx context.Context, doctorUserID string) (
} }
func (s *Service) DoctorApproveRenewal(ctx context.Context, doctorUserID, renewalID string) error { func (s *Service) DoctorApproveRenewal(ctx context.Context, doctorUserID, renewalID string) error {
return s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{ if err := s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{
"status": "approved", "status": "approved",
}).Error }).Error; err != nil {
return err
}
// 通知患者续方已批准
var renewal model.RenewalRequest
if s.db.Where("id = ?", renewalID).First(&renewal).Error == nil {
go notification.Notify(renewal.UserID, "续方已批准", "您的续方申请已通过审核", "chronic", renewalID)
}
return nil
} }
func (s *Service) DoctorRejectRenewal(ctx context.Context, doctorUserID, renewalID, reason string) error { func (s *Service) DoctorRejectRenewal(ctx context.Context, doctorUserID, renewalID, reason string) error {
return s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{ if err := s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{
"status": "rejected", "doctor_note": reason, "status": "rejected", "doctor_note": reason,
}).Error }).Error; err != nil {
return err
}
// 通知患者续方被驳回
var renewal model.RenewalRequest
if s.db.Where("id = ?", renewalID).First(&renewal).Error == nil {
go notification.Notify(renewal.UserID, "续方未通过", fmt.Sprintf("您的续方申请未通过审核:%s", reason), "chronic", renewalID)
}
return nil
} }
package consult
import (
"context"
"errors"
"fmt"
"time"
"internet-hospital/internal/model"
)
// ========== 医生端 Service ==========
type WaitingPatientResponse struct {
ID string `json:"id"`
ConsultID string `json:"consult_id"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
PatientAge int `json:"patient_age"`
ChiefComplaint string `json:"chief_complaint"`
MedicalHistory string `json:"medical_history"`
Type string `json:"type"`
WaitingSeconds int `json:"waiting_seconds"`
CreatedAt time.Time `json:"created_at"`
}
// GetDoctorConsultList 医生端获取自己的问诊列表
func (s *Service) GetDoctorConsultList(ctx context.Context, doctorUserID string, status string) ([]DoctorConsultResponse, error) {
// 先通过user_id找到doctor记录
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []DoctorConsultResponse{}, nil
}
query := s.db.Model(&model.Consultation{}).Where("doctor_id = ?", doctor.ID)
if status != "" {
query = query.Where("status = ?", status)
}
var consultations []model.Consultation
if err := query.Order("created_at DESC").Find(&consultations).Error; err != nil {
return nil, err
}
var results []DoctorConsultResponse
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
results = append(results, DoctorConsultResponse{
Consultation: c,
PatientName: patient.RealName,
PatientPhone: patient.Phone,
})
}
return results, nil
}
type DoctorConsultResponse struct {
model.Consultation
PatientName string `json:"patient_name"`
PatientPhone string `json:"patient_phone"`
}
// GetWaitingList 医生端获取待接诊队列
func (s *Service) GetWaitingList(ctx context.Context, doctorUserID string) ([]WaitingPatientResponse, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []WaitingPatientResponse{}, nil
}
var consultations []model.Consultation
if err := s.db.Where("doctor_id = ? AND status IN ?", doctor.ID, []string{"pending", "waiting"}).
Order("created_at ASC").Find(&consultations).Error; err != nil {
return nil, err
}
now := time.Now()
var results []WaitingPatientResponse
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
results = append(results, WaitingPatientResponse{
ID: c.PatientID,
ConsultID: c.ID,
PatientID: c.PatientID,
PatientName: patient.RealName,
PatientGender: patient.Gender,
PatientAge: patient.Age,
ChiefComplaint: c.ChiefComplaint,
MedicalHistory: c.MedicalHistory,
Type: c.Type,
WaitingSeconds: int(now.Sub(c.CreatedAt).Seconds()),
CreatedAt: c.CreatedAt,
})
}
return results, nil
}
// PatientListItem 统一患者列表项
type PatientListItem struct {
ID string `json:"id"`
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
PatientAge int `json:"patient_age"`
ChiefComplaint string `json:"chief_complaint"`
MedicalHistory string `json:"medical_history"`
Type string `json:"type"`
Status string `json:"status"`
WaitingSeconds int `json:"waiting_seconds"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
// v13 新增字段
AllergyHistory string `json:"allergy_history"`
ChronicDiseases []string `json:"chronic_diseases"`
IsRevisit bool `json:"is_revisit"`
VisitCount int `json:"visit_count"`
RiskLevel string `json:"risk_level"`
LastVisitDate string `json:"last_visit_date"`
PreConsultSummary string `json:"pre_consult_summary"`
}
// GetPatientList 医生端获取统一患者列表(待接诊+进行中+已完成)
func (s *Service) GetPatientList(ctx context.Context, doctorUserID string) ([]PatientListItem, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []PatientListItem{}, nil
}
// 查询所有相关问诊
var consultations []model.Consultation
if err := s.db.Where("doctor_id = ? AND status IN ?", doctor.ID,
[]string{"pending", "waiting", "in_progress", "completed"}).
Find(&consultations).Error; err != nil {
return nil, err
}
now := time.Now()
var results []PatientListItem
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
waitingSeconds := 0
if c.Status == "pending" || c.Status == "waiting" {
waitingSeconds = int(now.Sub(c.CreatedAt).Seconds())
}
item := PatientListItem{
ID: c.PatientID,
ConsultID: c.ID,
SerialNumber: c.SerialNumber,
PatientID: c.PatientID,
PatientName: patient.RealName,
PatientGender: patient.Gender,
PatientAge: patient.Age,
ChiefComplaint: c.ChiefComplaint,
MedicalHistory: c.MedicalHistory,
Type: c.Type,
Status: c.Status,
WaitingSeconds: waitingSeconds,
CreatedAt: c.CreatedAt,
StartedAt: c.StartedAt,
EndedAt: c.EndedAt,
}
// 查询过敏史
var profile model.PatientProfile
if err := s.db.Where("user_id = ?", c.PatientID).First(&profile).Error; err == nil {
item.AllergyHistory = profile.AllergyHistory
}
// 查询慢病标签
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", c.PatientID).Find(&chronicRecords)
for _, cr := range chronicRecords {
item.ChronicDiseases = append(item.ChronicDiseases, cr.DiseaseName)
}
if item.ChronicDiseases == nil {
item.ChronicDiseases = []string{}
}
// 查询是否复诊 + 就诊次数
var visitCount int64
s.db.Model(&model.Consultation{}).
Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Count(&visitCount)
item.VisitCount = int(visitCount)
item.IsRevisit = visitCount > 0
// 上次就诊时间
if visitCount > 0 {
var lastConsult model.Consultation
if err := s.db.Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Order("ended_at DESC").First(&lastConsult).Error; err == nil && lastConsult.EndedAt != nil {
item.LastVisitDate = lastConsult.EndedAt.Format("2006-01-02")
}
}
// 计算风险等级
item.RiskLevel = calcRiskLevel(patient.Age, len(chronicRecords), item.AllergyHistory)
// 预问诊摘要
var preConsult model.PreConsultation
if err := s.db.Where("consultation_id = ?", c.ID).First(&preConsult).Error; err == nil {
parts := []string{}
if preConsult.AIDepartment != "" {
parts = append(parts, preConsult.AIDepartment)
}
if preConsult.AISeverity != "" {
parts = append(parts, preConsult.AISeverity)
}
if len(parts) > 0 {
item.PreConsultSummary = fmt.Sprintf("%s", joinStrings(parts, " · "))
}
}
results = append(results, item)
}
// 排序:in_progress > pending/waiting > completed
sortPatientList(results)
return results, nil
}
// calcRiskLevel 根据年龄、慢病数量、过敏史计算风险等级
func calcRiskLevel(age int, chronicCount int, allergyHistory string) string {
score := 0
if age >= 65 {
score += 2
} else if age >= 50 {
score += 1
}
score += chronicCount
if allergyHistory != "" {
score += 1
}
if score >= 4 {
return "high"
}
if score >= 2 {
return "medium"
}
return "low"
}
// joinStrings 用分隔符连接字符串切片
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}
// sortPatientList 对患者列表排序
func sortPatientList(list []PatientListItem) {
// 定义状态优先级
statusPriority := map[string]int{
"in_progress": 1,
"pending": 2,
"waiting": 2,
"completed": 3,
}
// 使用冒泡排序
n := len(list)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
p1 := statusPriority[list[j].Status]
p2 := statusPriority[list[j+1].Status]
// 先按状态优先级排序
if p1 > p2 {
list[j], list[j+1] = list[j+1], list[j]
} else if p1 == p2 {
// 同状态内,进行中和待接诊按创建时间升序,已完成按创建时间降序
if p1 == 3 { // completed
if list[j].CreatedAt.Before(list[j+1].CreatedAt) {
list[j], list[j+1] = list[j+1], list[j]
}
} else {
if list[j].CreatedAt.After(list[j+1].CreatedAt) {
list[j], list[j+1] = list[j+1], list[j]
}
}
}
}
}
}
// AcceptConsult 医生接诊
func (s *Service) AcceptConsult(ctx context.Context, consultID string, doctorUserID string) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
}
if consult.Status != "pending" && consult.Status != "waiting" {
return errors.New("该问诊状态不可接诊")
}
now := time.Now()
if err := s.db.Model(&consult).Updates(map[string]interface{}{
"status": "in_progress",
"started_at": now,
}).Error; err != nil {
return err
}
// 发送系统消息
s.SendMessage(ctx, consultID, "", "system", "医生已接诊,问诊开始", "text")
return nil
}
// RejectConsult 医生拒诊
func (s *Service) RejectConsult(ctx context.Context, consultID string, reason string) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
}
if consult.Status != "pending" && consult.Status != "waiting" {
return errors.New("该问诊状态不可拒诊")
}
return s.db.Model(&consult).Update("status", "cancelled").Error
}
// DoctorWorkbenchStats 医生工作台统计
type DoctorWorkbenchStats struct {
TodayConsultCount int `json:"today_consult_count"`
WaitingCount int `json:"waiting_count"`
InProgressCount int `json:"in_progress_count"`
CompletedToday int `json:"completed_today"`
TotalPatients int `json:"total_patients"`
Rating float64 `json:"rating"`
IncomeToday int `json:"income_today"`
IncomeMonth int `json:"income_month"`
// v13 效率指标
AvgWaitMinutes float64 `json:"avg_wait_minutes"`
AvgConsultMinutes float64 `json:"avg_consult_minutes"`
PrescriptionRate float64 `json:"prescription_rate"`
AvgSatisfaction float64 `json:"avg_satisfaction"`
TransferCount int `json:"transfer_count"`
AIUsageRate float64 `json:"ai_usage_rate"`
}
// GetDoctorWorkbenchStats 获取医生工作台统计数据
func (s *Service) GetDoctorWorkbenchStats(ctx context.Context, doctorUserID string) (*DoctorWorkbenchStats, error) {
stats := &DoctorWorkbenchStats{}
// 获取医生ID
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return stats, nil
}
today := time.Now().Format("2006-01-02")
var count int64
// 今日问诊数(所有状态)
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Count(&count)
stats.TodayConsultCount = int(count)
// 待接诊数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status IN ?", doctor.ID, []string{"pending", "waiting"}).
Count(&count)
stats.WaitingCount = int(count)
// 进行中数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ?", doctor.ID, "in_progress").
Count(&count)
stats.InProgressCount = int(count)
// 今日完成数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ?", doctor.ID, "completed", today).
Count(&count)
stats.CompletedToday = int(count)
// 累计患者数(去重)
s.db.Model(&model.Consultation{}).
Where("doctor_id = ?", doctor.ID).
Distinct("patient_id").
Count(&count)
stats.TotalPatients = int(count)
// 综合评分(从医生表获取真实评分)
stats.Rating = doctor.Rating
// 今日收入(从 DoctorIncome 表查询真实金额,单位:分)
var incomeToday int64
s.db.Model(&model.DoctorIncome{}).
Where("doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Select("COALESCE(SUM(amount), 0)").Scan(&incomeToday)
stats.IncomeToday = int(incomeToday)
// 本月收入
monthStart := time.Now().Format("2006-01") + "-01"
var incomeMonth int64
s.db.Model(&model.DoctorIncome{}).
Where("doctor_id = ? AND created_at >= ?", doctor.ID, monthStart).
Select("COALESCE(SUM(amount), 0)").Scan(&incomeMonth)
stats.IncomeMonth = int(incomeMonth)
// v13: 效率指标
// 平均候诊时长(今日已接诊的,started_at - created_at 的平均值)
var avgWaitResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(EXTRACT(EPOCH FROM (started_at - created_at)) / 60) as avg").
Where("doctor_id = ? AND started_at IS NOT NULL AND DATE(created_at) = ?", doctor.ID, today).
Scan(&avgWaitResult)
if avgWaitResult.Avg != nil {
stats.AvgWaitMinutes = *avgWaitResult.Avg
}
// 平均问诊时长(今日已完成的 duration_minutes 平均)
var avgConsultResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(duration_minutes) as avg").
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND duration_minutes > 0", doctor.ID, "completed", today).
Scan(&avgConsultResult)
if avgConsultResult.Avg != nil {
stats.AvgConsultMinutes = *avgConsultResult.Avg
}
// 处方率(今日完成中有处方的比例)
if stats.CompletedToday > 0 {
var withPrescription int64
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND prescription_id IS NOT NULL",
doctor.ID, "completed", today).
Count(&withPrescription)
stats.PrescriptionRate = float64(withPrescription) / float64(stats.CompletedToday) * 100
}
// 平均满意度(有评分的)
var avgSatResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(satisfaction_score) as avg").
Where("doctor_id = ? AND satisfaction_score IS NOT NULL AND DATE(ended_at) = ?", doctor.ID, today).
Scan(&avgSatResult)
if avgSatResult.Avg != nil {
stats.AvgSatisfaction = *avgSatResult.Avg
}
// 今日转诊数
var transferCount int64
s.db.Model(&model.ConsultTransfer{}).
Where("from_doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Count(&transferCount)
stats.TransferCount = int(transferCount)
// AI 使用率(今日问诊中使用过 AI 辅助的比例)
if stats.CompletedToday > 0 {
var aiUsedCount int64
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND ai_session_id != ''",
doctor.ID, "completed", today).
Count(&aiUsedCount)
stats.AIUsageRate = float64(aiUsedCount) / float64(stats.CompletedToday) * 100
}
return stats, nil
}
// 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
}
...@@ -56,6 +56,12 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) { ...@@ -56,6 +56,12 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 患者端处方 // 患者端处方
consult.GET("/patient/prescriptions", h.GetPatientPrescriptions) consult.GET("/patient/prescriptions", h.GetPatientPrescriptions)
consult.GET("/prescription/:id", h.GetPrescriptionDetail) consult.GET("/prescription/:id", h.GetPrescriptionDetail)
// 标记消息已读
consult.POST("/:id/mark-read", h.MarkMessagesRead)
// 患者评价
consult.POST("/:id/rate", h.RateConsult)
} }
} }
...@@ -544,3 +550,46 @@ func (h *Handler) RejectConsult(c *gin.Context) { ...@@ -544,3 +550,46 @@ func (h *Handler) RejectConsult(c *gin.Context) {
response.Success(c, nil) response.Success(c, nil)
} }
func (h *Handler) RateConsult(c *gin.Context) {
id := c.Param("id")
userID, _ := c.Get("user_id")
var req struct {
Rating int `json:"rating" binding:"required,min=1,max=5"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请提供有效的评分(1-5)")
return
}
if err := h.service.RateConsult(c.Request.Context(), id, fmt.Sprintf("%v", userID), req.Rating, req.Comment); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// MarkMessagesRead 标记问诊中对方发送的消息为已读
func (h *Handler) MarkMessagesRead(c *gin.Context) {
consultID := c.Param("id")
userID, _ := c.Get("user_id")
role, _ := c.Get("role")
// 患者标记医生/AI消息为已读,医生标记患者消息为已读
var senderTypes []string
if role == "patient" {
senderTypes = []string{"doctor", "ai", "system"}
} else {
senderTypes = []string{"patient"}
}
count, err := h.service.MarkMessagesRead(c.Request.Context(), consultID, fmt.Sprintf("%v", userID), senderTypes)
if err != nil {
response.Error(c, 500, "标记已读失败")
return
}
response.Success(c, gin.H{"marked_count": count})
}
package consult
import (
"context"
"fmt"
"time"
"internet-hospital/internal/model"
)
// ========== 患者画像 API (v13) ==========
// PatientBasicInfo 患者基本信息
type PatientBasicInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
AllergyHistory string `json:"allergy_history"`
MedicalHistory string `json:"medical_history"`
InsuranceType string `json:"insurance_type"`
InsuranceNo string `json:"insurance_no"`
}
// ConsultSummary 问诊摘要
type ConsultSummary struct {
ID string `json:"id"`
DoctorName string `json:"doctor_name"`
ChiefComplaint string `json:"chief_complaint"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at"`
}
// PrescriptionBrief 处方摘要
type PrescriptionBrief struct {
ID string `json:"id"`
PrescriptionNo string `json:"prescription_no"`
Diagnosis string `json:"diagnosis"`
ItemCount int `json:"item_count"`
TotalAmount int `json:"total_amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ChronicBrief 慢病摘要
type ChronicBrief struct {
ID string `json:"id"`
DiseaseName string `json:"disease_name"`
DiagnosisDate *time.Time `json:"diagnosis_date"`
ControlStatus string `json:"control_status"`
CurrentMeds string `json:"current_meds"`
}
// LabReportBrief 检查报告摘要
type LabReportBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
ReportDate *time.Time `json:"report_date"`
FileURL string `json:"file_url"`
}
// HealthMetricPoint 健康指标数据点
type HealthMetricPoint struct {
MetricType string `json:"metric_type"`
Value1 float64 `json:"value1"`
Value2 float64 `json:"value2"`
Unit string `json:"unit"`
RecordedAt time.Time `json:"recorded_at"`
}
// FamilyMemberBrief 家族成员摘要
type FamilyMemberBrief struct {
Name string `json:"name"`
Relation string `json:"relation"`
MedicalHistory string `json:"medical_history"`
}
// PatientProfileResponse 患者画像完整响应
type PatientProfileResponse struct {
BasicInfo PatientBasicInfo `json:"basic_info"`
ConsultHistory []ConsultSummary `json:"consult_history"`
Prescriptions []PrescriptionBrief `json:"prescriptions"`
ChronicRecords []ChronicBrief `json:"chronic_records"`
LabReports []LabReportBrief `json:"lab_reports"`
HealthMetrics []HealthMetricPoint `json:"health_metrics"`
FamilyHistory []FamilyMemberBrief `json:"family_history"`
}
// GetPatientProfile 获取患者完整画像
func (s *Service) GetPatientProfile(ctx context.Context, patientID string) (*PatientProfileResponse, error) {
profile := &PatientProfileResponse{}
// 基本信息
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
profile.BasicInfo = PatientBasicInfo{
ID: user.ID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
}
var patientProfile model.PatientProfile
if err := s.db.Where("user_id = ?", patientID).First(&patientProfile).Error; err == nil {
profile.BasicInfo.AllergyHistory = patientProfile.AllergyHistory
profile.BasicInfo.MedicalHistory = patientProfile.MedicalHistory
profile.BasicInfo.InsuranceType = patientProfile.InsuranceType
profile.BasicInfo.InsuranceNo = patientProfile.InsuranceNo
}
// 问诊历史(最近10条)
var consultations []model.Consultation
s.db.Where("patient_id = ? AND status = ?", patientID, "completed").
Order("created_at DESC").Limit(10).Find(&consultations)
profile.ConsultHistory = make([]ConsultSummary, 0, len(consultations))
for _, c := range consultations {
var doc model.Doctor
doctorName := ""
if err := s.db.Where("id = ?", c.DoctorID).First(&doc).Error; err == nil {
doctorName = doc.Name
}
profile.ConsultHistory = append(profile.ConsultHistory, ConsultSummary{
ID: c.ID,
DoctorName: doctorName,
ChiefComplaint: c.ChiefComplaint,
Type: c.Type,
Status: c.Status,
CreatedAt: c.CreatedAt,
EndedAt: c.EndedAt,
})
}
// 处方历史(最近10条)
var prescriptions []model.Prescription
s.db.Where("patient_id = ?", patientID).
Order("created_at DESC").Limit(10).Preload("Items").Find(&prescriptions)
profile.Prescriptions = make([]PrescriptionBrief, 0, len(prescriptions))
for _, p := range prescriptions {
profile.Prescriptions = append(profile.Prescriptions, PrescriptionBrief{
ID: p.ID,
PrescriptionNo: p.PrescriptionNo,
Diagnosis: p.Diagnosis,
ItemCount: len(p.Items),
TotalAmount: p.TotalAmount,
Status: p.Status,
CreatedAt: p.CreatedAt,
})
}
// 慢病档案
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&chronicRecords)
profile.ChronicRecords = make([]ChronicBrief, 0, len(chronicRecords))
for _, cr := range chronicRecords {
profile.ChronicRecords = append(profile.ChronicRecords, ChronicBrief{
ID: cr.ID,
DiseaseName: cr.DiseaseName,
DiagnosisDate: cr.DiagnosisDate,
ControlStatus: cr.ControlStatus,
CurrentMeds: cr.CurrentMeds,
})
}
// 检查报告(最近10条)
var labReports []model.LabReport
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("report_date DESC").Limit(10).Find(&labReports)
profile.LabReports = make([]LabReportBrief, 0, len(labReports))
for _, lr := range labReports {
profile.LabReports = append(profile.LabReports, LabReportBrief{
ID: lr.ID,
Title: lr.Title,
Category: lr.Category,
ReportDate: lr.ReportDate,
FileURL: lr.FileURL,
})
}
// 健康指标(最近30条)
var healthMetrics []model.HealthMetric
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("recorded_at DESC").Limit(30).Find(&healthMetrics)
profile.HealthMetrics = make([]HealthMetricPoint, 0, len(healthMetrics))
for _, hm := range healthMetrics {
profile.HealthMetrics = append(profile.HealthMetrics, HealthMetricPoint{
MetricType: hm.MetricType,
Value1: hm.Value1,
Value2: hm.Value2,
Unit: hm.Unit,
RecordedAt: hm.RecordedAt,
})
}
// 家族史
var familyMembers []model.FamilyMember
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&familyMembers)
profile.FamilyHistory = make([]FamilyMemberBrief, 0, len(familyMembers))
for _, fm := range familyMembers {
profile.FamilyHistory = append(profile.FamilyHistory, FamilyMemberBrief{
Name: fm.Name,
Relation: fm.Relation,
MedicalHistory: fm.MedicalHistory,
})
}
return profile, nil
}
...@@ -397,718 +397,58 @@ func (s *Service) AIAssist(ctx context.Context, consultID string, scene string) ...@@ -397,718 +397,58 @@ func (s *Service) AIAssist(ctx context.Context, consultID string, scene string)
}, nil }, nil
} }
// ========== 医生端 Service ========== // RateConsult 患者评价问诊
func (s *Service) RateConsult(ctx context.Context, consultID, userID string, rating int, comment string) error {
type WaitingPatientResponse struct { consultID, err := s.ResolveConsultID(ctx, consultID)
ID string `json:"id"` if err != nil {
ConsultID string `json:"consult_id"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
PatientAge int `json:"patient_age"`
ChiefComplaint string `json:"chief_complaint"`
MedicalHistory string `json:"medical_history"`
Type string `json:"type"`
WaitingSeconds int `json:"waiting_seconds"`
CreatedAt time.Time `json:"created_at"`
}
// GetDoctorConsultList 医生端获取自己的问诊列表
func (s *Service) GetDoctorConsultList(ctx context.Context, doctorUserID string, status string) ([]DoctorConsultResponse, error) {
// 先通过user_id找到doctor记录
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []DoctorConsultResponse{}, nil
}
query := s.db.Model(&model.Consultation{}).Where("doctor_id = ?", doctor.ID)
if status != "" {
query = query.Where("status = ?", status)
}
var consultations []model.Consultation
if err := query.Order("created_at DESC").Find(&consultations).Error; err != nil {
return nil, err
}
var results []DoctorConsultResponse
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
results = append(results, DoctorConsultResponse{
Consultation: c,
PatientName: patient.RealName,
PatientPhone: patient.Phone,
})
}
return results, nil
}
type DoctorConsultResponse struct {
model.Consultation
PatientName string `json:"patient_name"`
PatientPhone string `json:"patient_phone"`
}
// GetWaitingList 医生端获取待接诊队列
func (s *Service) GetWaitingList(ctx context.Context, doctorUserID string) ([]WaitingPatientResponse, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []WaitingPatientResponse{}, nil
}
var consultations []model.Consultation
if err := s.db.Where("doctor_id = ? AND status IN ?", doctor.ID, []string{"pending", "waiting"}).
Order("created_at ASC").Find(&consultations).Error; err != nil {
return nil, err
}
now := time.Now()
var results []WaitingPatientResponse
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
results = append(results, WaitingPatientResponse{
ID: c.PatientID,
ConsultID: c.ID,
PatientID: c.PatientID,
PatientName: patient.RealName,
PatientGender: patient.Gender,
PatientAge: patient.Age,
ChiefComplaint: c.ChiefComplaint,
MedicalHistory: c.MedicalHistory,
Type: c.Type,
WaitingSeconds: int(now.Sub(c.CreatedAt).Seconds()),
CreatedAt: c.CreatedAt,
})
}
return results, nil
}
// PatientListItem 统一患者列表项
type PatientListItem struct {
ID string `json:"id"`
ConsultID string `json:"consult_id"`
SerialNumber string `json:"serial_number"`
PatientID string `json:"patient_id"`
PatientName string `json:"patient_name"`
PatientGender string `json:"patient_gender"`
PatientAge int `json:"patient_age"`
ChiefComplaint string `json:"chief_complaint"`
MedicalHistory string `json:"medical_history"`
Type string `json:"type"`
Status string `json:"status"`
WaitingSeconds int `json:"waiting_seconds"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
// v13 新增字段
AllergyHistory string `json:"allergy_history"`
ChronicDiseases []string `json:"chronic_diseases"`
IsRevisit bool `json:"is_revisit"`
VisitCount int `json:"visit_count"`
RiskLevel string `json:"risk_level"`
LastVisitDate string `json:"last_visit_date"`
PreConsultSummary string `json:"pre_consult_summary"`
}
// GetPatientList 医生端获取统一患者列表(待接诊+进行中+已完成)
func (s *Service) GetPatientList(ctx context.Context, doctorUserID string) ([]PatientListItem, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return []PatientListItem{}, nil
}
// 查询所有相关问诊
var consultations []model.Consultation
if err := s.db.Where("doctor_id = ? AND status IN ?", doctor.ID,
[]string{"pending", "waiting", "in_progress", "completed"}).
Find(&consultations).Error; err != nil {
return nil, err
}
now := time.Now()
var results []PatientListItem
for _, c := range consultations {
var patient model.User
s.db.Where("id = ?", c.PatientID).First(&patient)
waitingSeconds := 0
if c.Status == "pending" || c.Status == "waiting" {
waitingSeconds = int(now.Sub(c.CreatedAt).Seconds())
}
item := PatientListItem{
ID: c.PatientID,
ConsultID: c.ID,
SerialNumber: c.SerialNumber,
PatientID: c.PatientID,
PatientName: patient.RealName,
PatientGender: patient.Gender,
PatientAge: patient.Age,
ChiefComplaint: c.ChiefComplaint,
MedicalHistory: c.MedicalHistory,
Type: c.Type,
Status: c.Status,
WaitingSeconds: waitingSeconds,
CreatedAt: c.CreatedAt,
StartedAt: c.StartedAt,
EndedAt: c.EndedAt,
}
// 查询过敏史
var profile model.PatientProfile
if err := s.db.Where("user_id = ?", c.PatientID).First(&profile).Error; err == nil {
item.AllergyHistory = profile.AllergyHistory
}
// 查询慢病标签
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", c.PatientID).Find(&chronicRecords)
for _, cr := range chronicRecords {
item.ChronicDiseases = append(item.ChronicDiseases, cr.DiseaseName)
}
if item.ChronicDiseases == nil {
item.ChronicDiseases = []string{}
}
// 查询是否复诊 + 就诊次数
var visitCount int64
s.db.Model(&model.Consultation{}).
Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Count(&visitCount)
item.VisitCount = int(visitCount)
item.IsRevisit = visitCount > 0
// 上次就诊时间
if visitCount > 0 {
var lastConsult model.Consultation
if err := s.db.Where("patient_id = ? AND doctor_id = ? AND id != ? AND status = ?",
c.PatientID, doctor.ID, c.ID, "completed").
Order("ended_at DESC").First(&lastConsult).Error; err == nil && lastConsult.EndedAt != nil {
item.LastVisitDate = lastConsult.EndedAt.Format("2006-01-02")
}
}
// 计算风险等级
item.RiskLevel = calcRiskLevel(patient.Age, len(chronicRecords), item.AllergyHistory)
// 预问诊摘要
var preConsult model.PreConsultation
if err := s.db.Where("consultation_id = ?", c.ID).First(&preConsult).Error; err == nil {
parts := []string{}
if preConsult.AIDepartment != "" {
parts = append(parts, preConsult.AIDepartment)
}
if preConsult.AISeverity != "" {
parts = append(parts, preConsult.AISeverity)
}
if len(parts) > 0 {
item.PreConsultSummary = fmt.Sprintf("%s", joinStrings(parts, " · "))
}
}
results = append(results, item)
}
// 排序:in_progress > pending/waiting > completed
sortPatientList(results)
return results, nil
}
// calcRiskLevel 根据年龄、慢病数量、过敏史计算风险等级
func calcRiskLevel(age int, chronicCount int, allergyHistory string) string {
score := 0
if age >= 65 {
score += 2
} else if age >= 50 {
score += 1
}
score += chronicCount
if allergyHistory != "" {
score += 1
}
if score >= 4 {
return "high"
}
if score >= 2 {
return "medium"
}
return "low"
}
// joinStrings 用分隔符连接字符串切片
func joinStrings(strs []string, sep string) string {
result := ""
for i, s := range strs {
if i > 0 {
result += sep
}
result += s
}
return result
}
// sortPatientList 对患者列表排序
func sortPatientList(list []PatientListItem) {
// 定义状态优先级
statusPriority := map[string]int{
"in_progress": 1,
"pending": 2,
"waiting": 2,
"completed": 3,
}
// 使用冒泡排序
n := len(list)
for i := 0; i < n-1; i++ {
for j := 0; j < n-i-1; j++ {
p1 := statusPriority[list[j].Status]
p2 := statusPriority[list[j+1].Status]
// 先按状态优先级排序
if p1 > p2 {
list[j], list[j+1] = list[j+1], list[j]
} else if p1 == p2 {
// 同状态内,进行中和待接诊按创建时间升序,已完成按创建时间降序
if p1 == 3 { // completed
if list[j].CreatedAt.Before(list[j+1].CreatedAt) {
list[j], list[j+1] = list[j+1], list[j]
}
} else {
if list[j].CreatedAt.After(list[j+1].CreatedAt) {
list[j], list[j+1] = list[j+1], list[j]
}
}
}
}
}
}
// AcceptConsult 医生接诊
func (s *Service) AcceptConsult(ctx context.Context, consultID string, doctorUserID string) error {
var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在")
}
if consult.Status != "pending" && consult.Status != "waiting" {
return errors.New("该问诊状态不可接诊")
}
now := time.Now()
if err := s.db.Model(&consult).Updates(map[string]interface{}{
"status": "in_progress",
"started_at": now,
}).Error; err != nil {
return err return err
} }
// 发送系统消息
s.SendMessage(ctx, consultID, "", "system", "医生已接诊,问诊开始", "text")
return nil
}
// RejectConsult 医生拒诊
func (s *Service) RejectConsult(ctx context.Context, consultID string, reason string) error {
var consult model.Consultation var consult model.Consultation
if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil { if err := s.db.Where("id = ?", consultID).First(&consult).Error; err != nil {
return errors.New("问诊不存在") return errors.New("问诊不存在")
} }
if consult.Status != "pending" && consult.Status != "waiting" { if consult.PatientID != userID {
return errors.New("该问诊状态不可拒诊") return errors.New("无权评价此问诊")
}
return s.db.Model(&consult).Update("status", "cancelled").Error
}
// DoctorWorkbenchStats 医生工作台统计
type DoctorWorkbenchStats struct {
TodayConsultCount int `json:"today_consult_count"`
WaitingCount int `json:"waiting_count"`
InProgressCount int `json:"in_progress_count"`
CompletedToday int `json:"completed_today"`
TotalPatients int `json:"total_patients"`
Rating float64 `json:"rating"`
IncomeToday int `json:"income_today"`
IncomeMonth int `json:"income_month"`
// v13 效率指标
AvgWaitMinutes float64 `json:"avg_wait_minutes"`
AvgConsultMinutes float64 `json:"avg_consult_minutes"`
PrescriptionRate float64 `json:"prescription_rate"`
AvgSatisfaction float64 `json:"avg_satisfaction"`
TransferCount int `json:"transfer_count"`
AIUsageRate float64 `json:"ai_usage_rate"`
}
// GetDoctorWorkbenchStats 获取医生工作台统计数据
func (s *Service) GetDoctorWorkbenchStats(ctx context.Context, doctorUserID string) (*DoctorWorkbenchStats, error) {
stats := &DoctorWorkbenchStats{}
// 获取医生ID
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return stats, nil
} }
today := time.Now().Format("2006-01-02") if consult.Status != "completed" {
var count int64 return errors.New("问诊尚未结束,无法评价")
// 今日问诊数(所有状态)
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Count(&count)
stats.TodayConsultCount = int(count)
// 待接诊数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status IN ?", doctor.ID, []string{"pending", "waiting"}).
Count(&count)
stats.WaitingCount = int(count)
// 进行中数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ?", doctor.ID, "in_progress").
Count(&count)
stats.InProgressCount = int(count)
// 今日完成数
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ?", doctor.ID, "completed", today).
Count(&count)
stats.CompletedToday = int(count)
// 累计患者数(去重)
s.db.Model(&model.Consultation{}).
Where("doctor_id = ?", doctor.ID).
Distinct("patient_id").
Count(&count)
stats.TotalPatients = int(count)
// 综合评分(暂时使用固定值,后续可从评价表获取)
stats.Rating = 4.8
// 今日收入(假设每单50元)
stats.IncomeToday = stats.CompletedToday * 5000
// 本月收入
monthStart := time.Now().Format("2006-01") + "-01"
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND ended_at >= ?", doctor.ID, "completed", monthStart).
Count(&count)
stats.IncomeMonth = int(count) * 5000
// v13: 效率指标
// 平均候诊时长(今日已接诊的,started_at - created_at 的平均值)
var avgWaitResult struct{ Avg *float64 }
s.db.Model(&model.Consultation{}).
Select("AVG(EXTRACT(EPOCH FROM (started_at - created_at)) / 60) as avg").
Where("doctor_id = ? AND started_at IS NOT NULL AND DATE(created_at) = ?", doctor.ID, today).
Scan(&avgWaitResult)
if avgWaitResult.Avg != nil {
stats.AvgWaitMinutes = *avgWaitResult.Avg
} }
// 平均问诊时长(今日已完成的 duration_minutes 平均) if consult.SatisfactionScore != nil {
var avgConsultResult struct{ Avg *float64 } return errors.New("已评价过,不能重复评价")
s.db.Model(&model.Consultation{}).
Select("AVG(duration_minutes) as avg").
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND duration_minutes > 0", doctor.ID, "completed", today).
Scan(&avgConsultResult)
if avgConsultResult.Avg != nil {
stats.AvgConsultMinutes = *avgConsultResult.Avg
} }
// 处方率(今日完成中有处方的比例) // 更新问诊评分
if stats.CompletedToday > 0 { if err := s.db.Model(&consult).Updates(map[string]interface{}{
var withPrescription int64 "satisfaction_score": rating,
s.db.Model(&model.Consultation{}). }).Error; err != nil {
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND prescription_id IS NOT NULL", return fmt.Errorf("评价失败: %v", err)
doctor.ID, "completed", today).
Count(&withPrescription)
stats.PrescriptionRate = float64(withPrescription) / float64(stats.CompletedToday) * 100
} }
// 平均满意度(有评分的) // 更新医生平均评分
var avgSatResult struct{ Avg *float64 } var avgScore float64
s.db.Model(&model.Consultation{}). s.db.Model(&model.Consultation{}).
Select("AVG(satisfaction_score) as avg"). Where("doctor_id = ? AND satisfaction_score IS NOT NULL", consult.DoctorID).
Where("doctor_id = ? AND satisfaction_score IS NOT NULL AND DATE(ended_at) = ?", doctor.ID, today). Select("COALESCE(AVG(satisfaction_score), 0)").Scan(&avgScore)
Scan(&avgSatResult) s.db.Model(&model.Doctor{}).Where("id = ?", consult.DoctorID).
if avgSatResult.Avg != nil { Update("rating", avgScore)
stats.AvgSatisfaction = *avgSatResult.Avg
}
// 今日转诊数
var transferCount int64
s.db.Model(&model.ConsultTransfer{}).
Where("from_doctor_id = ? AND DATE(created_at) = ?", doctor.ID, today).
Count(&transferCount)
stats.TransferCount = int(transferCount)
// AI 使用率(今日问诊中使用过 AI 辅助的比例)
if stats.CompletedToday > 0 {
var aiUsedCount int64
s.db.Model(&model.Consultation{}).
Where("doctor_id = ? AND status = ? AND DATE(ended_at) = ? AND ai_session_id != ''",
doctor.ID, "completed", today).
Count(&aiUsedCount)
stats.AIUsageRate = float64(aiUsedCount) / float64(stats.CompletedToday) * 100
}
return stats, nil
}
// ========== 患者画像 API (v13) ==========
// PatientBasicInfo 患者基本信息
type PatientBasicInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
AllergyHistory string `json:"allergy_history"`
MedicalHistory string `json:"medical_history"`
InsuranceType string `json:"insurance_type"`
InsuranceNo string `json:"insurance_no"`
}
// ConsultSummary 问诊摘要
type ConsultSummary struct {
ID string `json:"id"`
DoctorName string `json:"doctor_name"`
ChiefComplaint string `json:"chief_complaint"`
Type string `json:"type"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
EndedAt *time.Time `json:"ended_at"`
}
// PrescriptionBrief 处方摘要
type PrescriptionBrief struct {
ID string `json:"id"`
PrescriptionNo string `json:"prescription_no"`
Diagnosis string `json:"diagnosis"`
ItemCount int `json:"item_count"`
TotalAmount int `json:"total_amount"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// ChronicBrief 慢病摘要
type ChronicBrief struct {
ID string `json:"id"`
DiseaseName string `json:"disease_name"`
DiagnosisDate *time.Time `json:"diagnosis_date"`
ControlStatus string `json:"control_status"`
CurrentMeds string `json:"current_meds"`
}
// LabReportBrief 检查报告摘要 return nil
type LabReportBrief struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
ReportDate *time.Time `json:"report_date"`
FileURL string `json:"file_url"`
}
// HealthMetricPoint 健康指标数据点
type HealthMetricPoint struct {
MetricType string `json:"metric_type"`
Value1 float64 `json:"value1"`
Value2 float64 `json:"value2"`
Unit string `json:"unit"`
RecordedAt time.Time `json:"recorded_at"`
}
// FamilyMemberBrief 家族成员摘要
type FamilyMemberBrief struct {
Name string `json:"name"`
Relation string `json:"relation"`
MedicalHistory string `json:"medical_history"`
}
// PatientProfileResponse 患者画像完整响应
type PatientProfileResponse struct {
BasicInfo PatientBasicInfo `json:"basic_info"`
ConsultHistory []ConsultSummary `json:"consult_history"`
Prescriptions []PrescriptionBrief `json:"prescriptions"`
ChronicRecords []ChronicBrief `json:"chronic_records"`
LabReports []LabReportBrief `json:"lab_reports"`
HealthMetrics []HealthMetricPoint `json:"health_metrics"`
FamilyHistory []FamilyMemberBrief `json:"family_history"`
}
// GetPatientProfile 获取患者完整画像
func (s *Service) GetPatientProfile(ctx context.Context, patientID string) (*PatientProfileResponse, error) {
profile := &PatientProfileResponse{}
// 基本信息
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
profile.BasicInfo = PatientBasicInfo{
ID: user.ID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
}
var patientProfile model.PatientProfile
if err := s.db.Where("user_id = ?", patientID).First(&patientProfile).Error; err == nil {
profile.BasicInfo.AllergyHistory = patientProfile.AllergyHistory
profile.BasicInfo.MedicalHistory = patientProfile.MedicalHistory
profile.BasicInfo.InsuranceType = patientProfile.InsuranceType
profile.BasicInfo.InsuranceNo = patientProfile.InsuranceNo
}
// 问诊历史(最近10条)
var consultations []model.Consultation
s.db.Where("patient_id = ? AND status = ?", patientID, "completed").
Order("created_at DESC").Limit(10).Find(&consultations)
profile.ConsultHistory = make([]ConsultSummary, 0, len(consultations))
for _, c := range consultations {
var doc model.Doctor
doctorName := ""
if err := s.db.Where("id = ?", c.DoctorID).First(&doc).Error; err == nil {
doctorName = doc.Name
}
profile.ConsultHistory = append(profile.ConsultHistory, ConsultSummary{
ID: c.ID,
DoctorName: doctorName,
ChiefComplaint: c.ChiefComplaint,
Type: c.Type,
Status: c.Status,
CreatedAt: c.CreatedAt,
EndedAt: c.EndedAt,
})
}
// 处方历史(最近10条)
var prescriptions []model.Prescription
s.db.Where("patient_id = ?", patientID).
Order("created_at DESC").Limit(10).Preload("Items").Find(&prescriptions)
profile.Prescriptions = make([]PrescriptionBrief, 0, len(prescriptions))
for _, p := range prescriptions {
profile.Prescriptions = append(profile.Prescriptions, PrescriptionBrief{
ID: p.ID,
PrescriptionNo: p.PrescriptionNo,
Diagnosis: p.Diagnosis,
ItemCount: len(p.Items),
TotalAmount: p.TotalAmount,
Status: p.Status,
CreatedAt: p.CreatedAt,
})
}
// 慢病档案
var chronicRecords []model.ChronicRecord
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&chronicRecords)
profile.ChronicRecords = make([]ChronicBrief, 0, len(chronicRecords))
for _, cr := range chronicRecords {
profile.ChronicRecords = append(profile.ChronicRecords, ChronicBrief{
ID: cr.ID,
DiseaseName: cr.DiseaseName,
DiagnosisDate: cr.DiagnosisDate,
ControlStatus: cr.ControlStatus,
CurrentMeds: cr.CurrentMeds,
})
}
// 检查报告(最近10条)
var labReports []model.LabReport
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("report_date DESC").Limit(10).Find(&labReports)
profile.LabReports = make([]LabReportBrief, 0, len(labReports))
for _, lr := range labReports {
profile.LabReports = append(profile.LabReports, LabReportBrief{
ID: lr.ID,
Title: lr.Title,
Category: lr.Category,
ReportDate: lr.ReportDate,
FileURL: lr.FileURL,
})
}
// 健康指标(最近30条)
var healthMetrics []model.HealthMetric
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).
Order("recorded_at DESC").Limit(30).Find(&healthMetrics)
profile.HealthMetrics = make([]HealthMetricPoint, 0, len(healthMetrics))
for _, hm := range healthMetrics {
profile.HealthMetrics = append(profile.HealthMetrics, HealthMetricPoint{
MetricType: hm.MetricType,
Value1: hm.Value1,
Value2: hm.Value2,
Unit: hm.Unit,
RecordedAt: hm.RecordedAt,
})
}
// 家族史
var familyMembers []model.FamilyMember
s.db.Where("user_id = ? AND deleted_at IS NULL", patientID).Find(&familyMembers)
profile.FamilyHistory = make([]FamilyMemberBrief, 0, len(familyMembers))
for _, fm := range familyMembers {
profile.FamilyHistory = append(profile.FamilyHistory, FamilyMemberBrief{
Name: fm.Name,
Relation: fm.Relation,
MedicalHistory: fm.MedicalHistory,
})
}
return profile, nil
}
// DoctorDailyTrend 医生每日问诊趋势
type DoctorDailyTrend struct {
Date string `json:"date"`
Count int `json:"count"`
Completed int `json:"completed"`
} }
// GetDoctorWeeklyTrend 获取医生近7天每日问诊趋势 // MarkMessagesRead 标记指定发送者类型的消息为已读
func (s *Service) GetDoctorWeeklyTrend(ctx context.Context, doctorUserID string) ([]DoctorDailyTrend, error) { func (s *Service) MarkMessagesRead(ctx context.Context, consultID, userID string, senderTypes []string) (int64, error) {
var results []DoctorDailyTrend consultID, err := s.ResolveConsultID(ctx, consultID)
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 { if err != nil {
return nil, err return 0, err
} }
defer rows.Close()
for rows.Next() { now := time.Now()
var item DoctorDailyTrend result := s.db.Model(&model.ConsultMessage{}).
if err := rows.Scan(&item.Date, &item.Count, &item.Completed); err != nil { Where("consult_id = ? AND sender_type IN ? AND read_at IS NULL", consultID, senderTypes).
return nil, err Update("read_at", now)
} return result.RowsAffected, result.Error
results = append(results, item)
}
return results, nil
} }
package doctorportal
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"internet-hospital/internal/model"
)
// PatientSummary 患者概要
type PatientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Gender string `json:"gender"`
Phone string `json:"phone"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
}
// PatientListItem 患者列表项
type PatientListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
ChronicDiseases []string `json:"chronic_diseases"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
LastConsultAt string `json:"last_consult_at"`
}
// PatientConsultRecord 患者问诊记录
type PatientConsultRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Type string `json:"type"`
ChiefComplaint string `json:"chief_complaint"`
Status string `json:"status"`
}
// PatientPrescriptionRecord 患者处方记录
type PatientPrescriptionRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Diagnosis string `json:"diagnosis"`
Drugs []string `json:"drugs"`
Status string `json:"status"`
PrescriptionNo string `json:"prescription_no"`
}
// PatientDetail 患者详情
type PatientDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
EmergencyContact string `json:"emergency_contact"`
InsuranceType string `json:"insurance_type"`
ConsultationCount int `json:"consultation_count"`
Consults []PatientConsultRecord `json:"consults"`
Prescriptions []PatientPrescriptionRecord `json:"prescriptions"`
}
func (s *Service) GetPatientSummary(ctx context.Context, patientID string) (*PatientSummary, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientSummary{
ID: patientID,
Name: user.RealName,
Age: user.Age,
Gender: user.Gender,
Phone: user.Phone,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(count),
}, nil
}
// GetPatientList 获取医生的患者列表
func (s *Service) GetPatientList(ctx context.Context, doctorID string, keyword string, page, pageSize int) ([]PatientListItem, int64, error) {
// 查询与该医生有过问诊的患者 ID(去重)
var patientIDs []string
query := s.db.Model(&model.Consultation{}).
Where("doctor_id = ?", doctorID).
Distinct("patient_id").
Pluck("patient_id", &patientIDs)
if query.Error != nil {
return nil, 0, query.Error
}
if len(patientIDs) == 0 {
return []PatientListItem{}, 0, nil
}
// 查询用户基本信息
userQuery := s.db.Model(&model.User{}).Where("id IN ?", patientIDs)
if keyword != "" {
userQuery = userQuery.Where("real_name LIKE ? OR phone LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
userQuery.Count(&total)
var users []model.User
offset := (page - 1) * pageSize
if err := userQuery.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
// 批量查询 profile 和问诊统计
userIDList := make([]string, len(users))
for i, u := range users {
userIDList[i] = u.ID
}
var profiles []model.PatientProfile
s.db.Where("user_id IN ?", userIDList).Find(&profiles)
profileMap := make(map[string]model.PatientProfile)
for _, p := range profiles {
profileMap[p.UserID] = p
}
// 每位患者的问诊次数和最近问诊时间
type consultStat struct {
PatientID string
Count int64
LastConsultAt time.Time
}
var stats []consultStat
s.db.Model(&model.Consultation{}).
Select("patient_id, COUNT(*) as count, MAX(created_at) as last_consult_at").
Where("doctor_id = ? AND patient_id IN ?", doctorID, userIDList).
Group("patient_id").
Scan(&stats)
statMap := make(map[string]consultStat)
for _, st := range stats {
statMap[st.PatientID] = st
}
items := make([]PatientListItem, 0, len(users))
for _, u := range users {
profile := profileMap[u.ID]
stat := statMap[u.ID]
lastAt := ""
if !stat.LastConsultAt.IsZero() {
lastAt = stat.LastConsultAt.Format("2006-01-02")
}
items = append(items, PatientListItem{
ID: u.ID,
Name: u.RealName,
Gender: u.Gender,
Age: u.Age,
Phone: u.Phone,
Avatar: u.Avatar,
ChronicDiseases: []string{},
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(stat.Count),
LastConsultAt: lastAt,
})
}
return items, total, nil
}
// GetPatientDetail 获取患者详情(含问诊和处方记录)
func (s *Service) GetPatientDetail(ctx context.Context, doctorID, patientID string) (*PatientDetail, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
// 问诊记录(仅该医生与该患者的)
var consults []model.Consultation
s.db.Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&consults)
consultRecords := make([]PatientConsultRecord, 0, len(consults))
for _, c := range consults {
consultRecords = append(consultRecords, PatientConsultRecord{
ID: c.ID,
Date: c.CreatedAt.Format("2006-01-02"),
Type: c.Type,
ChiefComplaint: c.ChiefComplaint,
Status: c.Status,
})
}
// 处方记录
var prescriptions []model.Prescription
s.db.Preload("Items").
Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&prescriptions)
prescriptionRecords := make([]PatientPrescriptionRecord, 0, len(prescriptions))
for _, p := range prescriptions {
drugs := make([]string, 0, len(p.Items))
for _, item := range p.Items {
drugs = append(drugs, item.MedicineName)
}
prescriptionRecords = append(prescriptionRecords, PatientPrescriptionRecord{
ID: p.ID,
Date: p.CreatedAt.Format("2006-01-02"),
Diagnosis: p.Diagnosis,
Drugs: drugs,
Status: p.Status,
PrescriptionNo: p.PrescriptionNo,
})
}
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientDetail{
ID: patientID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
Avatar: user.Avatar,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
EmergencyContact: profile.EmergencyContact,
InsuranceType: profile.InsuranceType,
ConsultationCount: int(count),
Consults: consultRecords,
Prescriptions: prescriptionRecords,
}, nil
}
// SubmitCertification 提交医生资质认证
func (s *Service) SubmitCertification(ctx context.Context, userID string, req *SubmitCertificationRequest) error {
// 检查是否已经提交过认证
var existingReview model.DoctorReview
if err := s.db.Where("user_id = ? AND status = ?", userID, "pending").First(&existingReview).Error; err == nil {
return fmt.Errorf("您已提交过认证申请,请等待审核")
}
// 获取用户信息
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Errorf("用户不存在")
}
// 创建审核记录
review := &model.DoctorReview{
ID: uuid.New().String(),
UserID: userID,
Name: user.RealName,
Phone: user.Phone,
LicenseNo: req.LicenseNo,
Title: req.Title,
Hospital: req.Hospital,
DepartmentName: req.DepartmentName,
LicenseImage: req.LicenseImage,
QualificationImage: req.QualificationImage,
Status: "pending",
SubmittedAt: time.Now(),
}
return s.db.Create(review).Error
}
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
internalagent "internet-hospital/internal/agent" internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -167,6 +168,9 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req * ...@@ -167,6 +168,9 @@ func (s *Service) CreatePrescription(ctx context.Context, doctorID string, req *
tx.Commit() tx.Commit()
// 通知患者处方已开具
go notification.Notify(prescription.PatientID, "处方已开具", fmt.Sprintf("医生已为您开具处方 %s,请前往处方页面查看", prescriptionNo), "prescription", prescription.ID)
// 触发 prescription_created 工作流(异步) // 触发 prescription_created 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "prescription_created", map[string]interface{}{ workflow.GetEngine().TriggerByCategory(ctx, "prescription_created", map[string]interface{}{
"prescription_id": prescription.ID, "prescription_id": prescription.ID,
......
...@@ -9,6 +9,7 @@ import ( ...@@ -9,6 +9,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
) )
...@@ -55,76 +56,14 @@ type WorkbenchStats struct { ...@@ -55,76 +56,14 @@ type WorkbenchStats struct {
// WaitingPatient 等待中的患者 // WaitingPatient 等待中的患者
type WaitingPatient struct { type WaitingPatient struct {
ID string `json:"id"`
ConsultID string `json:"consult_id"`
PatientName string `json:"patient_name"`
PatientAvatar string `json:"patient_avatar"`
ChiefComplaint string `json:"chief_complaint"`
Type string `json:"type"`
WaitingTime int `json:"waiting_time"`
CreatedAt string `json:"created_at"`
}
// PatientSummary 患者概要
type PatientSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Age int `json:"age"`
Gender string `json:"gender"`
Phone string `json:"phone"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
}
// PatientListItem 患者列表项
type PatientListItem struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
ChronicDiseases []string `json:"chronic_diseases"`
AllergyHistory string `json:"allergy_history"`
ConsultationCount int `json:"consultation_count"`
LastConsultAt string `json:"last_consult_at"`
}
// PatientConsultRecord 患者问诊记录
type PatientConsultRecord struct {
ID string `json:"id"` ID string `json:"id"`
Date string `json:"date"` ConsultID string `json:"consult_id"`
Type string `json:"type"` PatientName string `json:"patient_name"`
PatientAvatar string `json:"patient_avatar"`
ChiefComplaint string `json:"chief_complaint"` ChiefComplaint string `json:"chief_complaint"`
Status string `json:"status"` Type string `json:"type"`
} WaitingTime int `json:"waiting_time"`
CreatedAt string `json:"created_at"`
// PatientPrescriptionRecord 患者处方记录
type PatientPrescriptionRecord struct {
ID string `json:"id"`
Date string `json:"date"`
Diagnosis string `json:"diagnosis"`
Drugs []string `json:"drugs"`
Status string `json:"status"`
PrescriptionNo string `json:"prescription_no"`
}
// PatientDetail 患者详情
type PatientDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Gender string `json:"gender"`
Age int `json:"age"`
Phone string `json:"phone"`
Avatar string `json:"avatar"`
MedicalHistory string `json:"medical_history"`
AllergyHistory string `json:"allergy_history"`
EmergencyContact string `json:"emergency_contact"`
InsuranceType string `json:"insurance_type"`
ConsultationCount int `json:"consultation_count"`
Consults []PatientConsultRecord `json:"consults"`
Prescriptions []PatientPrescriptionRecord `json:"prescriptions"`
} }
func (s *Service) GetWorkbenchStats(ctx context.Context, userID string) (*WorkbenchStats, error) { func (s *Service) GetWorkbenchStats(ctx context.Context, userID string) (*WorkbenchStats, error) {
...@@ -282,6 +221,9 @@ func (s *Service) AcceptConsult(ctx context.Context, consultID, userID string) ( ...@@ -282,6 +221,9 @@ func (s *Service) AcceptConsult(ctx context.Context, consultID, userID string) (
return nil, fmt.Errorf("接诊失败: %v", err) return nil, fmt.Errorf("接诊失败: %v", err)
} }
// 通知患者医生已接诊
go notification.Notify(consult.PatientID, "医生已接诊", fmt.Sprintf("医生已接受您的问诊,请前往问诊页面沟通"), "consult", consult.ID)
return map[string]interface{}{ return map[string]interface{}{
"consult_id": consult.ID, "consult_id": consult.ID,
"status": "in_progress", "status": "in_progress",
...@@ -307,33 +249,33 @@ func (s *Service) GetConsultDetail(ctx context.Context, consultID string) (inter ...@@ -307,33 +249,33 @@ func (s *Service) GetConsultDetail(ctx context.Context, consultID string) (inter
s.db.Where("id = ?", doctor.UserID).First(&doctorUser) s.db.Where("id = ?", doctor.UserID).First(&doctorUser)
return map[string]interface{}{ return map[string]interface{}{
"id": consult.ID, "id": consult.ID,
"serial_number": consult.SerialNumber, "serial_number": consult.SerialNumber,
"type": consult.Type, "type": consult.Type,
"status": consult.Status, "status": consult.Status,
"chief_complaint": consult.ChiefComplaint, "chief_complaint": consult.ChiefComplaint,
"medical_history": consult.MedicalHistory, "medical_history": consult.MedicalHistory,
"diagnosis": consult.Diagnosis, "diagnosis": consult.Diagnosis,
"summary": consult.Summary, "summary": consult.Summary,
"duration_minutes": consult.DurationMinutes, "duration_minutes": consult.DurationMinutes,
"started_at": consult.StartedAt, "started_at": consult.StartedAt,
"ended_at": consult.EndedAt, "ended_at": consult.EndedAt,
"created_at": consult.CreatedAt, "created_at": consult.CreatedAt,
"patient": map[string]interface{}{ "patient": map[string]interface{}{
"id": patient.ID, "id": patient.ID,
"name": patient.RealName, "name": patient.RealName,
"gender": patient.Gender, "gender": patient.Gender,
"age": patient.Age, "age": patient.Age,
"phone": patient.Phone, "phone": patient.Phone,
"avatar": patient.Avatar, "avatar": patient.Avatar,
}, },
"doctor": map[string]interface{}{ "doctor": map[string]interface{}{
"id": doctor.ID, "id": doctor.ID,
"name": doctor.Name, "name": doctor.Name,
"title": doctor.Title, "title": doctor.Title,
"hospital": doctor.Hospital, "hospital": doctor.Hospital,
"avatar": doctor.Avatar, "avatar": doctor.Avatar,
"specialties": doctor.Specialties, "specialties": doctor.Specialties,
}, },
}, nil }, nil
} }
...@@ -410,6 +352,9 @@ func (s *Service) EndConsult(ctx context.Context, consultID, userID, summary str ...@@ -410,6 +352,9 @@ func (s *Service) EndConsult(ctx context.Context, consultID, userID, summary str
return fmt.Errorf("结束问诊失败: %v", err) return fmt.Errorf("结束问诊失败: %v", err)
} }
// 通知患者问诊已结束
go notification.Notify(consult.PatientID, "问诊已结束", "您的问诊已完成,如有处方请前往处方页面查看", "consult", consult.ID)
return nil return nil
} }
...@@ -420,19 +365,19 @@ func (s *Service) GetConsultHistory(ctx context.Context, userID string) (interfa ...@@ -420,19 +365,19 @@ func (s *Service) GetConsultHistory(ctx context.Context, userID string) (interfa
} }
type historyRow struct { type historyRow struct {
ID string `json:"id"` ID string `json:"id"`
SerialNumber string `json:"serial_number"` SerialNumber string `json:"serial_number"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
ChiefComplaint string `json:"chief_complaint"` ChiefComplaint string `json:"chief_complaint"`
Diagnosis string `json:"diagnosis"` Diagnosis string `json:"diagnosis"`
Summary string `json:"summary"` Summary string `json:"summary"`
PatientName string `json:"patient_name"` PatientName string `json:"patient_name"`
PatientAvatar string `json:"patient_avatar"` PatientAvatar string `json:"patient_avatar"`
StartedAt *time.Time `json:"started_at"` StartedAt *time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"` EndedAt *time.Time `json:"ended_at"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
DurationMinutes int `json:"duration_minutes"` DurationMinutes int `json:"duration_minutes"`
} }
var rows []historyRow var rows []historyRow
...@@ -585,207 +530,3 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, data map[str ...@@ -585,207 +530,3 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, data map[str
} }
return nil return nil
} }
func (s *Service) GetPatientSummary(ctx context.Context, patientID string) (*PatientSummary, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientSummary{
ID: patientID,
Name: user.RealName,
Age: user.Age,
Gender: user.Gender,
Phone: user.Phone,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(count),
}, nil
}
// GetPatientList 获取医生的患者列表
func (s *Service) GetPatientList(ctx context.Context, doctorID string, keyword string, page, pageSize int) ([]PatientListItem, int64, error) {
// 查询与该医生有过问诊的患者 ID(去重)
var patientIDs []string
query := s.db.Model(&model.Consultation{}).
Where("doctor_id = ?", doctorID).
Distinct("patient_id").
Pluck("patient_id", &patientIDs)
if query.Error != nil {
return nil, 0, query.Error
}
if len(patientIDs) == 0 {
return []PatientListItem{}, 0, nil
}
// 查询用户基本信息
userQuery := s.db.Model(&model.User{}).Where("id IN ?", patientIDs)
if keyword != "" {
userQuery = userQuery.Where("real_name LIKE ? OR phone LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
var total int64
userQuery.Count(&total)
var users []model.User
offset := (page - 1) * pageSize
if err := userQuery.Offset(offset).Limit(pageSize).Find(&users).Error; err != nil {
return nil, 0, err
}
// 批量查询 profile 和问诊统计
userIDList := make([]string, len(users))
for i, u := range users {
userIDList[i] = u.ID
}
var profiles []model.PatientProfile
s.db.Where("user_id IN ?", userIDList).Find(&profiles)
profileMap := make(map[string]model.PatientProfile)
for _, p := range profiles {
profileMap[p.UserID] = p
}
// 每位患者的问诊次数和最近问诊时间
type consultStat struct {
PatientID string
Count int64
LastConsultAt time.Time
}
var stats []consultStat
s.db.Model(&model.Consultation{}).
Select("patient_id, COUNT(*) as count, MAX(created_at) as last_consult_at").
Where("doctor_id = ? AND patient_id IN ?", doctorID, userIDList).
Group("patient_id").
Scan(&stats)
statMap := make(map[string]consultStat)
for _, st := range stats {
statMap[st.PatientID] = st
}
items := make([]PatientListItem, 0, len(users))
for _, u := range users {
profile := profileMap[u.ID]
stat := statMap[u.ID]
lastAt := ""
if !stat.LastConsultAt.IsZero() {
lastAt = stat.LastConsultAt.Format("2006-01-02")
}
items = append(items, PatientListItem{
ID: u.ID,
Name: u.RealName,
Gender: u.Gender,
Age: u.Age,
Phone: u.Phone,
Avatar: u.Avatar,
ChronicDiseases: []string{},
AllergyHistory: profile.AllergyHistory,
ConsultationCount: int(stat.Count),
LastConsultAt: lastAt,
})
}
return items, total, nil
}
// GetPatientDetail 获取患者详情(含问诊和处方记录)
func (s *Service) GetPatientDetail(ctx context.Context, doctorID, patientID string) (*PatientDetail, error) {
var user model.User
if err := s.db.Where("id = ?", patientID).First(&user).Error; err != nil {
return nil, fmt.Errorf("患者不存在")
}
var profile model.PatientProfile
s.db.Where("user_id = ?", patientID).First(&profile)
// 问诊记录(仅该医生与该患者的)
var consults []model.Consultation
s.db.Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&consults)
consultRecords := make([]PatientConsultRecord, 0, len(consults))
for _, c := range consults {
consultRecords = append(consultRecords, PatientConsultRecord{
ID: c.ID,
Date: c.CreatedAt.Format("2006-01-02"),
Type: c.Type,
ChiefComplaint: c.ChiefComplaint,
Status: c.Status,
})
}
// 处方记录
var prescriptions []model.Prescription
s.db.Preload("Items").
Where("doctor_id = ? AND patient_id = ?", doctorID, patientID).
Order("created_at DESC").Limit(20).Find(&prescriptions)
prescriptionRecords := make([]PatientPrescriptionRecord, 0, len(prescriptions))
for _, p := range prescriptions {
drugs := make([]string, 0, len(p.Items))
for _, item := range p.Items {
drugs = append(drugs, item.MedicineName)
}
prescriptionRecords = append(prescriptionRecords, PatientPrescriptionRecord{
ID: p.ID,
Date: p.CreatedAt.Format("2006-01-02"),
Diagnosis: p.Diagnosis,
Drugs: drugs,
Status: p.Status,
PrescriptionNo: p.PrescriptionNo,
})
}
var count int64
s.db.Model(&model.Consultation{}).Where("patient_id = ?", patientID).Count(&count)
return &PatientDetail{
ID: patientID,
Name: user.RealName,
Gender: user.Gender,
Age: user.Age,
Phone: user.Phone,
Avatar: user.Avatar,
MedicalHistory: profile.MedicalHistory,
AllergyHistory: profile.AllergyHistory,
EmergencyContact: profile.EmergencyContact,
InsuranceType: profile.InsuranceType,
ConsultationCount: int(count),
Consults: consultRecords,
Prescriptions: prescriptionRecords,
}, nil
}
// SubmitCertification 提交医生资质认证
func (s *Service) SubmitCertification(ctx context.Context, userID string, req *SubmitCertificationRequest) error {
// 检查是否已经提交过认证
var existingReview model.DoctorReview
if err := s.db.Where("user_id = ? AND status = ?", userID, "pending").First(&existingReview).Error; err == nil {
return fmt.Errorf("您已提交过认证申请,请等待审核")
}
// 获取用户信息
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return fmt.Errorf("用户不存在")
}
// 创建审核记录
review := &model.DoctorReview{
ID: uuid.New().String(),
UserID: userID,
Name: user.RealName,
Phone: user.Phone,
LicenseNo: req.LicenseNo,
Title: req.Title,
Hospital: req.Hospital,
DepartmentName: req.DepartmentName,
LicenseImage: req.LicenseImage,
QualificationImage: req.QualificationImage,
Status: "pending",
SubmittedAt: time.Now(),
}
return s.db.Create(review).Error
}
...@@ -48,3 +48,9 @@ func (s *Service) Create(ctx context.Context, userID, title, content, nType, rel ...@@ -48,3 +48,9 @@ func (s *Service) Create(ctx context.Context, userID, title, content, nType, rel
} }
return s.db.Create(n).Error return s.db.Create(n).Error
} }
// Notify 全局便捷函数,供其他 service 在业务事件中直接调用
func Notify(userID, title, content, nType, relatedID string) {
svc := NewService()
_ = svc.Create(context.Background(), userID, title, content, nType, relatedID)
}
...@@ -10,6 +10,7 @@ import ( ...@@ -10,6 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/internal/service/notification"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/workflow" "internet-hospital/pkg/workflow"
) )
...@@ -105,6 +106,9 @@ func (s *Service) PayOrder(ctx context.Context, orderID, paymentMethod string) ( ...@@ -105,6 +106,9 @@ func (s *Service) PayOrder(ctx context.Context, orderID, paymentMethod string) (
s.createDoctorIncome(ctx, order.RelatedID, order.Amount, order.OrderType) s.createDoctorIncome(ctx, order.RelatedID, order.Amount, order.OrderType)
} }
// 通知用户支付成功
go notification.Notify(order.UserID, "支付成功", fmt.Sprintf("您的订单 %s 已支付成功", order.OrderNo), "payment", order.ID)
// 触发 payment_completed 工作流(异步) // 触发 payment_completed 工作流(异步)
workflow.GetEngine().TriggerByCategory(ctx, "payment_completed", map[string]interface{}{ workflow.GetEngine().TriggerByCategory(ctx, "payment_completed", map[string]interface{}{
"order_id": order.ID, "order_id": order.ID,
......
...@@ -46,10 +46,11 @@ type LoginRequest struct { ...@@ -46,10 +46,11 @@ type LoginRequest struct {
} }
type LoginResponse struct { type LoginResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"` ExpiresIn int64 `json:"expires_in"`
User *model.User `json:"user"` User *model.User `json:"user"`
Menus []model.Menu `json:"menus"`
} }
func (s *Service) SendCode(ctx context.Context, phone string) error { func (s *Service) SendCode(ctx context.Context, phone string) error {
...@@ -174,11 +175,24 @@ func (s *Service) Login(ctx context.Context, req *LoginRequest) (*LoginResponse, ...@@ -174,11 +175,24 @@ func (s *Service) Login(ctx context.Context, req *LoginRequest) (*LoginResponse,
return nil, err return nil, err
} }
// 查询用户菜单(基于角色)
var menus []model.Menu
s.db.Preload("Children", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "active").Order("sort_order ASC")
}).Where("parent_id IS NULL AND status = ?", "active").Order("sort_order ASC").Find(&menus)
// 根据角色过滤菜单(简化版:admin看全部,其他角色过滤)
if user.Role != "admin" {
// 可以在这里添加基于角色的菜单过滤逻辑
// 暂时保持简单,返回所有菜单
}
return &LoginResponse{ return &LoginResponse{
AccessToken: tokenPair.AccessToken, AccessToken: tokenPair.AccessToken,
RefreshToken: tokenPair.RefreshToken, RefreshToken: tokenPair.RefreshToken,
ExpiresIn: tokenPair.ExpiresIn, ExpiresIn: tokenPair.ExpiresIn,
User: &user, User: &user,
Menus: menus,
}, nil }, nil
} }
......
...@@ -234,695 +234,3 @@ func (c *Client) Chat(ctx context.Context, messages []ChatMessage) (*ChatResult, ...@@ -234,695 +234,3 @@ func (c *Client) Chat(ctx context.Context, messages []ChatMessage) (*ChatResult,
TotalTokens: chatResp.Usage.TotalTokens, TotalTokens: chatResp.Usage.TotalTokens,
}, nil }, nil
} }
// StreamChunk 流式响应数据块
type StreamChunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
// ChatStream 发送流式对话请求,通过channel逐块返回内容
func (c *Client) ChatStream(ctx context.Context, messages []ChatMessage, onChunk func(content string) error) (string, error) {
c.mu.RLock()
reqBody := ChatRequest{
Model: c.model,
Messages: messages,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: true,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
// 逐行读取SSE流
var fullContent string
buf := make([]byte, 4096)
var remainder string
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
data := remainder + string(buf[:n])
remainder = ""
lines := splitSSELines(data)
for i, line := range lines {
// 最后一段可能不完整,留到下次
if i == len(lines)-1 && !isCompleteLine(data) {
remainder = line
continue
}
if line == "" || line == "\n" || line == "\r\n" {
continue
}
// 去除 "data: " 前缀
payload := trimSSEPrefix(line)
if payload == "[DONE]" {
return fullContent, nil
}
if payload == "" {
continue
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
if content != "" {
fullContent += content
if onChunk != nil {
if err := onChunk(content); err != nil {
return fullContent, err
}
}
}
if chunk.Choices[0].FinishReason != nil {
return fullContent, nil
}
}
}
}
if readErr != nil {
if readErr == io.EOF {
return fullContent, nil
}
return fullContent, fmt.Errorf("读取流式响应失败: %w", readErr)
}
}
}
// splitSSELines 按换行分割SSE数据
func splitSSELines(data string) []string {
var lines []string
start := 0
for i := 0; i < len(data); i++ {
if data[i] == '\n' {
lines = append(lines, data[start:i])
start = i + 1
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
return lines
}
// isCompleteLine 检查数据是否以换行结尾
func isCompleteLine(data string) bool {
return len(data) > 0 && data[len(data)-1] == '\n'
}
// trimSSEPrefix 去除SSE的 "data: " 前缀
func trimSSEPrefix(line string) string {
line = trimSpace(line)
if len(line) > 6 && line[:6] == "data: " {
return line[6:]
}
if len(line) > 5 && line[:5] == "data:" {
return line[5:]
}
return ""
}
// trimSpace 简单的trim
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\r' || s[0] == '\n') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r' || s[len(s)-1] == '\n') {
s = s[:len(s)-1]
}
return s
}
// ToolChatRequest 支持 Function Calling 的请求体
type ToolChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
}
// ToolChatResponse Function Calling 响应
type ToolChatResponse struct {
ID string `json:"id"`
Choices []struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"` // "stop" | "tool_calls"
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
// ChatWithTools 支持 Function Calling 的对话
func (c *Client) ChatWithTools(ctx context.Context, messages []ChatMessage, tools []interface{}) (*ToolChatResponse, error) {
c.mu.RLock()
reqBody := ToolChatRequest{
Model: c.model,
Messages: messages,
Tools: tools,
ToolChoice: "auto",
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
var toolResp ToolChatResponse
if err := json.Unmarshal(body, &toolResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if len(toolResp.Choices) == 0 {
return nil, fmt.Errorf("AI未返回有效回复")
}
return &toolResp, nil
}
// ToolStreamChunk 流式 Function Calling 响应数据块
type ToolStreamChunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage,omitempty"`
}
// ChatWithToolsStream 流式调用LLM(支持 Function Calling)
// 通过 onContent 回调推送文本片段,如果 LLM 返回 tool_calls 则收集后整体返回。
// 返回完整的 ToolChatResponse,调用方可据此判断是文本结束还是需要执行工具。
func (c *Client) ChatWithToolsStream(ctx context.Context, messages []ChatMessage, tools []interface{}, onContent func(content string) error) (*ToolChatResponse, error) {
c.mu.RLock()
reqBody := ToolChatRequest{
Model: c.model,
Messages: messages,
Tools: tools,
ToolChoice: "auto",
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: true,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
// 收集流式结果
var fullContent string
// toolCallsMap 按 index 累积增量的 tool_calls
toolCallsMap := make(map[int]*ToolCall)
var finishReason string
totalTokens := 0
promptTokens := 0
completionTokens := 0
buf := make([]byte, 4096)
var remainder string
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
data := remainder + string(buf[:n])
remainder = ""
lines := splitSSELines(data)
for i, line := range lines {
if i == len(lines)-1 && !isCompleteLine(data) {
remainder = line
continue
}
if line == "" || line == "\n" || line == "\r\n" {
continue
}
payload := trimSSEPrefix(line)
if payload == "[DONE]" {
goto done
}
if payload == "" {
continue
}
var chunk ToolStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Usage != nil {
promptTokens = chunk.Usage.PromptTokens
completionTokens = chunk.Usage.CompletionTokens
totalTokens = chunk.Usage.TotalTokens
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
// 累积文本内容
if choice.Delta.Content != "" {
fullContent += choice.Delta.Content
if onContent != nil {
if err := onContent(choice.Delta.Content); err != nil {
goto done
}
}
}
// 累积 tool_calls 增量
for _, tc := range choice.Delta.ToolCalls {
idx := 0
// OpenAI 流式 tool_calls 有 index 字段,但 Go struct 中没有,用 ID 判断
// 实际上流式 delta 中 tool_calls 数组元素的位置即 index
for k, existing := range toolCallsMap {
if existing.ID == tc.ID && tc.ID != "" {
idx = k
break
}
}
if tc.ID != "" {
if _, exists := toolCallsMap[idx]; !exists {
toolCallsMap[len(toolCallsMap)] = &ToolCall{
ID: tc.ID,
Type: tc.Type,
}
idx = len(toolCallsMap) - 1
}
}
if existing, ok := toolCallsMap[idx]; ok {
if tc.Function.Name != "" {
existing.Function.Name += tc.Function.Name
}
if tc.Function.Arguments != "" {
existing.Function.Arguments += tc.Function.Arguments
}
}
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
return nil, fmt.Errorf("读取流式响应失败: %w", readErr)
}
}
done:
// 组装完整响应
msg := ChatMessage{
Role: "assistant",
Content: fullContent,
}
for i := 0; i < len(toolCallsMap); i++ {
if tc, ok := toolCallsMap[i]; ok {
msg.ToolCalls = append(msg.ToolCalls, *tc)
}
}
if finishReason == "" {
if len(msg.ToolCalls) > 0 {
finishReason = "tool_calls"
} else {
finishReason = "stop"
}
}
result := &ToolChatResponse{
Choices: []struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}{
{Message: msg, FinishReason: finishReason},
},
}
result.Usage.PromptTokens = promptTokens
result.Usage.CompletionTokens = completionTokens
result.Usage.TotalTokens = totalTokens
return result, nil
}
// MockChatStream 模拟流式AI回复
func MockChatStream(ctx context.Context, messages []ChatMessage, onChunk func(content string) error) (string, error) {
chatResult, err := MockChat(ctx, messages)
if err != nil {
return "", err
}
fullReply := chatResult.Content
// 模拟逐字输出
runes := []rune(fullReply)
chunkSize := 3
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
if onChunk != nil {
if err := onChunk(chunk); err != nil {
return fullReply, err
}
}
// 模拟打字延迟
select {
case <-ctx.Done():
return string(runes[:end]), ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
return fullReply, nil
}
// MockChat 模拟AI回复(API Key未配置时使用)
func MockChat(ctx context.Context, messages []ChatMessage) (*ChatResult, error) {
// 从最后一条用户消息判断场景
lastUserMsg := ""
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUserMsg = messages[i].Content
break
}
}
_ = lastUserMsg
// 检查system prompt判断是哪个阶段
systemPrompt := ""
for _, m := range messages {
if m.Role == "system" {
systemPrompt = m.Content
break
}
}
var content string
if contains(systemPrompt, "追问") {
content = `{
"questions": [
"您的头痛是持续性的还是间歇性的?",
"头痛时是否伴有恶心、呕吐、视物模糊等症状?",
"您最近的血压测量值是多少?",
"头痛通常在一天中的什么时间加重?"
]
}`
} else if contains(systemPrompt, "综合分析") || contains(systemPrompt, "诊断") || contains(systemPrompt, "分析报告") {
content = `## 综合分析
根据您描述的症状,头痛伴乏力持续数天,这可能与多种原因有关。结合您的描述,初步考虑可能为紧张性头痛或血压相关的头痛。持续性头部胀痛并伴随疲劳乏力,需要进一步检查确认。
## 严重程度
moderate - 症状持续时间较长且影响日常生活,建议尽快就诊。
## 推荐科室
神经内科
## 病历摘要
患者主诉头痛、乏力数天。胀痛为主,偶有恶心,无呕吐。建议神经内科就诊,进一步排查病因。
## 就医建议
1. 建议尽快到神经内科就诊,进行详细检查
2. 注意监测血压变化
3. 避免情绪激动和过度劳累
4. 保证充足睡眠,规律作息`
} else if contains(systemPrompt, "预问诊助手") || contains(systemPrompt, "预问诊对话") {
content = "您好!我是AI预问诊助手,感谢您的描述。为了更好地了解您的情况,我想再问您几个问题:\n\n1. 您的症状大概是从什么时候开始的?是突然出现的还是逐渐加重的?\n2. 目前有没有服用什么药物或者采取什么措施来缓解?\n\n请您详细描述一下,这样我可以更准确地为您提供建议。"
} else {
content = `{"message": "AI分析完成"}`
}
// 模拟 token 计数(粗略估算)
promptTokens := len(systemPrompt) / 4
completionTokens := len(content) / 4
return &ChatResult{
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}, nil
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// CallParams 统一AI调用参数
type CallParams struct {
Scene string // 场景标识
UserID string // 用户ID
Messages []ChatMessage // 消息列表
RequestSummary string // 请求摘要(用于日志)
// 链路追踪字段(Agent调用时填写)
TraceID string // 链路追踪ID
AgentID string // 关联Agent ID
SessionID string // 关联会话ID
Iteration int // Agent第几轮迭代(0=非Agent调用)
}
// CallResult 统一AI调用结果
type CallResult struct {
Content string
IsMock bool
ResponseTimeMs int64
PromptTokens int
CompletionTokens int
TotalTokens int
Error error
}
// Call 统一AI调用接口(自动记录日志)
func Call(ctx context.Context, params CallParams) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
chatResult, err := client.Chat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = false
} else {
chatResult, err := MockChat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
// CallStream 统一AI流式调用接口(自动记录日志)
func CallStream(ctx context.Context, params CallParams, onChunk func(content string) error) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
content, err := client.ChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = false
} else {
content, err := MockChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 流式调用没有返回 token 信息,粗略估算
promptLen := 0
for _, msg := range params.Messages {
promptLen += len(msg.Content)
}
result.PromptTokens = promptLen / 4
result.CompletionTokens = len(result.Content) / 4
result.TotalTokens = result.PromptTokens + result.CompletionTokens
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
package ai
import (
"context"
"time"
)
// MockChatStream 模拟流式AI回复
func MockChatStream(ctx context.Context, messages []ChatMessage, onChunk func(content string) error) (string, error) {
chatResult, err := MockChat(ctx, messages)
if err != nil {
return "", err
}
fullReply := chatResult.Content
// 模拟逐字输出
runes := []rune(fullReply)
chunkSize := 3
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
if end > len(runes) {
end = len(runes)
}
chunk := string(runes[i:end])
if onChunk != nil {
if err := onChunk(chunk); err != nil {
return fullReply, err
}
}
// 模拟打字延迟
select {
case <-ctx.Done():
return string(runes[:end]), ctx.Err()
case <-time.After(20 * time.Millisecond):
}
}
return fullReply, nil
}
// MockChat 模拟AI回复(API Key未配置时使用)
func MockChat(ctx context.Context, messages []ChatMessage) (*ChatResult, error) {
// 从最后一条用户消息判断场景
lastUserMsg := ""
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
lastUserMsg = messages[i].Content
break
}
}
_ = lastUserMsg
// 检查system prompt判断是哪个阶段
systemPrompt := ""
for _, m := range messages {
if m.Role == "system" {
systemPrompt = m.Content
break
}
}
var content string
if contains(systemPrompt, "追问") {
content = `{
"questions": [
"您的头痛是持续性的还是间歇性的?",
"头痛时是否伴有恶心、呕吐、视物模糊等症状?",
"您最近的血压测量值是多少?",
"头痛通常在一天中的什么时间加重?"
]
}`
} else if contains(systemPrompt, "综合分析") || contains(systemPrompt, "诊断") || contains(systemPrompt, "分析报告") {
content = `## 综合分析
根据您描述的症状,头痛伴乏力持续数天,这可能与多种原因有关。结合您的描述,初步考虑可能为紧张性头痛或血压相关的头痛。持续性头部胀痛并伴随疲劳乏力,需要进一步检查确认。
## 严重程度
moderate - 症状持续时间较长且影响日常生活,建议尽快就诊。
## 推荐科室
神经内科
## 病历摘要
患者主诉头痛、乏力数天。胀痛为主,偶有恶心,无呕吐。建议神经内科就诊,进一步排查病因。
## 就医建议
1. 建议尽快到神经内科就诊,进行详细检查
2. 注意监测血压变化
3. 避免情绪激动和过度劳累
4. 保证充足睡眠,规律作息`
} else if contains(systemPrompt, "预问诊助手") || contains(systemPrompt, "预问诊对话") {
content = "您好!我是AI预问诊助手,感谢您的描述。为了更好地了解您的情况,我想再问您几个问题:\n\n1. 您的症状大概是从什么时候开始的?是突然出现的还是逐渐加重的?\n2. 目前有没有服用什么药物或者采取什么措施来缓解?\n\n请您详细描述一下,这样我可以更准确地为您提供建议。"
} else {
content = `{"message": "AI分析完成"}`
}
// 模拟 token 计数(粗略估算)
promptTokens := len(systemPrompt) / 4
completionTokens := len(content) / 4
return &ChatResult{
Content: content,
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: promptTokens + completionTokens,
}, nil
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstr(s, substr))
}
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
// CallParams 统一AI调用参数
type CallParams struct {
Scene string // 场景标识
UserID string // 用户ID
Messages []ChatMessage // 消息列表
RequestSummary string // 请求摘要(用于日志)
// 链路追踪字段(Agent调用时填写)
TraceID string // 链路追踪ID
AgentID string // 关联Agent ID
SessionID string // 关联会话ID
Iteration int // Agent第几轮迭代(0=非Agent调用)
}
// CallResult 统一AI调用结果
type CallResult struct {
Content string
IsMock bool
ResponseTimeMs int64
PromptTokens int
CompletionTokens int
TotalTokens int
Error error
}
// Call 统一AI调用接口(自动记录日志)
func Call(ctx context.Context, params CallParams) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
chatResult, err := client.Chat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = false
} else {
chatResult, err := MockChat(ctx, params.Messages)
if err != nil {
result.Error = err
} else {
result.Content = chatResult.Content
result.PromptTokens = chatResult.PromptTokens
result.CompletionTokens = chatResult.CompletionTokens
result.TotalTokens = chatResult.TotalTokens
}
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
// CallStream 统一AI流式调用接口(自动记录日志)
func CallStream(ctx context.Context, params CallParams, onChunk func(content string) error) CallResult {
start := time.Now()
var result CallResult
client := GetClient()
if client != nil {
content, err := client.ChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = false
} else {
content, err := MockChatStream(ctx, params.Messages, onChunk)
result.Content = content
result.Error = err
result.IsMock = true
}
result.ResponseTimeMs = time.Since(start).Milliseconds()
// 流式调用没有返回 token 信息,粗略估算
promptLen := 0
for _, msg := range params.Messages {
promptLen += len(msg.Content)
}
result.PromptTokens = promptLen / 4
result.CompletionTokens = len(result.Content) / 4
result.TotalTokens = result.PromptTokens + result.CompletionTokens
// 提取系统提示词(通常是第一条 system 消息)
systemPrompt := ""
for _, msg := range params.Messages {
if msg.Role == "system" {
systemPrompt = msg.Content
break
}
}
// 自动记录日志
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
SaveLog(LogParams{
Scene: params.Scene,
UserID: params.UserID,
Prompt: systemPrompt,
RequestContent: params.RequestSummary,
ResponseContent: result.Content,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
TotalTokens: result.TotalTokens,
ResponseTimeMs: int(result.ResponseTimeMs),
Success: result.Error == nil,
ErrorMessage: errMsg,
IsMock: result.IsMock,
TraceID: params.TraceID,
AgentID: params.AgentID,
SessionID: params.SessionID,
Iteration: params.Iteration,
})
return result
}
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// StreamChunk 流式响应数据块
type StreamChunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
}
// ChatStream 发送流式对话请求,通过channel逐块返回内容
func (c *Client) ChatStream(ctx context.Context, messages []ChatMessage, onChunk func(content string) error) (string, error) {
c.mu.RLock()
reqBody := ChatRequest{
Model: c.model,
Messages: messages,
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: true,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
// 逐行读取SSE流
var fullContent string
buf := make([]byte, 4096)
var remainder string
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
data := remainder + string(buf[:n])
remainder = ""
lines := splitSSELines(data)
for i, line := range lines {
// 最后一段可能不完整,留到下次
if i == len(lines)-1 && !isCompleteLine(data) {
remainder = line
continue
}
if line == "" || line == "\n" || line == "\r\n" {
continue
}
// 去除 "data: " 前缀
payload := trimSSEPrefix(line)
if payload == "[DONE]" {
return fullContent, nil
}
if payload == "" {
continue
}
var chunk StreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if len(chunk.Choices) > 0 {
content := chunk.Choices[0].Delta.Content
if content != "" {
fullContent += content
if onChunk != nil {
if err := onChunk(content); err != nil {
return fullContent, err
}
}
}
if chunk.Choices[0].FinishReason != nil {
return fullContent, nil
}
}
}
}
if readErr != nil {
if readErr == io.EOF {
return fullContent, nil
}
return fullContent, fmt.Errorf("读取流式响应失败: %w", readErr)
}
}
}
// splitSSELines 按换行分割SSE数据
func splitSSELines(data string) []string {
var lines []string
start := 0
for i := 0; i < len(data); i++ {
if data[i] == '\n' {
lines = append(lines, data[start:i])
start = i + 1
}
}
if start < len(data) {
lines = append(lines, data[start:])
}
return lines
}
// isCompleteLine 检查数据是否以换行结尾
func isCompleteLine(data string) bool {
return len(data) > 0 && data[len(data)-1] == '\n'
}
// trimSSEPrefix 去除SSE的 "data: " 前缀
func trimSSEPrefix(line string) string {
line = trimSpace(line)
if len(line) > 6 && line[:6] == "data: " {
return line[6:]
}
if len(line) > 5 && line[:5] == "data:" {
return line[5:]
}
return ""
}
// trimSpace 简单的trim
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t' || s[0] == '\r' || s[0] == '\n') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t' || s[len(s)-1] == '\r' || s[len(s)-1] == '\n') {
s = s[:len(s)-1]
}
return s
}
// ToolChatRequest 支持 Function Calling 的请求体
type ToolChatRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Tools []interface{} `json:"tools,omitempty"`
ToolChoice string `json:"tool_choice,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
}
// ToolChatResponse Function Calling 响应
type ToolChatResponse struct {
ID string `json:"id"`
Choices []struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"` // "stop" | "tool_calls"
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
// ChatWithTools 支持 Function Calling 的对话
func (c *Client) ChatWithTools(ctx context.Context, messages []ChatMessage, tools []interface{}) (*ToolChatResponse, error) {
c.mu.RLock()
reqBody := ToolChatRequest{
Model: c.model,
Messages: messages,
Tools: tools,
ToolChoice: "auto",
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: false,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
var toolResp ToolChatResponse
if err := json.Unmarshal(body, &toolResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
if len(toolResp.Choices) == 0 {
return nil, fmt.Errorf("AI未返回有效回复")
}
return &toolResp, nil
}
// ToolStreamChunk 流式 Function Calling 响应数据块
type ToolStreamChunk struct {
ID string `json:"id"`
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage,omitempty"`
}
// ChatWithToolsStream 流式调用LLM(支持 Function Calling)
// 通过 onContent 回调推送文本片段,如果 LLM 返回 tool_calls 则收集后整体返回。
// 返回完整的 ToolChatResponse,调用方可据此判断是文本结束还是需要执行工具。
func (c *Client) ChatWithToolsStream(ctx context.Context, messages []ChatMessage, tools []interface{}, onContent func(content string) error) (*ToolChatResponse, error) {
c.mu.RLock()
reqBody := ToolChatRequest{
Model: c.model,
Messages: messages,
Tools: tools,
ToolChoice: "auto",
MaxTokens: c.maxTokens,
Temperature: c.temperature,
Stream: true,
}
apiKey := c.apiKey
baseURL := c.baseURL
c.mu.RUnlock()
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("序列化请求失败: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/v1/chat/completions", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求AI服务失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("AI服务返回错误 (HTTP %d): %s", resp.StatusCode, string(body))
}
// 收集流式结果
var fullContent string
// toolCallsMap 按 index 累积增量的 tool_calls
toolCallsMap := make(map[int]*ToolCall)
var finishReason string
totalTokens := 0
promptTokens := 0
completionTokens := 0
buf := make([]byte, 4096)
var remainder string
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
data := remainder + string(buf[:n])
remainder = ""
lines := splitSSELines(data)
for i, line := range lines {
if i == len(lines)-1 && !isCompleteLine(data) {
remainder = line
continue
}
if line == "" || line == "\n" || line == "\r\n" {
continue
}
payload := trimSSEPrefix(line)
if payload == "[DONE]" {
goto done
}
if payload == "" {
continue
}
var chunk ToolStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
continue
}
if chunk.Usage != nil {
promptTokens = chunk.Usage.PromptTokens
completionTokens = chunk.Usage.CompletionTokens
totalTokens = chunk.Usage.TotalTokens
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
// 累积文本内容
if choice.Delta.Content != "" {
fullContent += choice.Delta.Content
if onContent != nil {
if err := onContent(choice.Delta.Content); err != nil {
goto done
}
}
}
// 累积 tool_calls 增量
for _, tc := range choice.Delta.ToolCalls {
idx := 0
// OpenAI 流式 tool_calls 有 index 字段,但 Go struct 中没有,用 ID 判断
// 实际上流式 delta 中 tool_calls 数组元素的位置即 index
for k, existing := range toolCallsMap {
if existing.ID == tc.ID && tc.ID != "" {
idx = k
break
}
}
if tc.ID != "" {
if _, exists := toolCallsMap[idx]; !exists {
toolCallsMap[len(toolCallsMap)] = &ToolCall{
ID: tc.ID,
Type: tc.Type,
}
idx = len(toolCallsMap) - 1
}
}
if existing, ok := toolCallsMap[idx]; ok {
if tc.Function.Name != "" {
existing.Function.Name += tc.Function.Name
}
if tc.Function.Arguments != "" {
existing.Function.Arguments += tc.Function.Arguments
}
}
}
if choice.FinishReason != nil {
finishReason = *choice.FinishReason
}
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
return nil, fmt.Errorf("读取流式响应失败: %w", readErr)
}
}
done:
// 组装完整响应
msg := ChatMessage{
Role: "assistant",
Content: fullContent,
}
for i := 0; i < len(toolCallsMap); i++ {
if tc, ok := toolCallsMap[i]; ok {
msg.ToolCalls = append(msg.ToolCalls, *tc)
}
}
if finishReason == "" {
if len(msg.ToolCalls) > 0 {
finishReason = "tool_calls"
} else {
finishReason = "stop"
}
}
result := &ToolChatResponse{
Choices: []struct {
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}{
{Message: msg, FinishReason: finishReason},
},
}
result.Usage.PromptTokens = promptTokens
result.Usage.CompletionTokens = completionTokens
result.Usage.TotalTokens = totalTokens
return result, nil
}
...@@ -14,6 +14,7 @@ import ( ...@@ -14,6 +14,7 @@ import (
"internet-hospital/internal/model" "internet-hospital/internal/model"
"internet-hospital/pkg/database" "internet-hospital/pkg/database"
"internet-hospital/pkg/utils"
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
...@@ -105,8 +106,21 @@ func (h *Handler) HandleConsultWS(c *gin.Context) { ...@@ -105,8 +106,21 @@ func (h *Handler) HandleConsultWS(c *gin.Context) {
return return
} }
// TODO: 验证token // 验证JWT token
_ = token if token == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims, err := utils.ParseToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"})
return
}
// 校验token中的user_id与请求参数一致
if claims.UserID != userID {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token user mismatch"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil { if err != nil {
......
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
...@@ -128,6 +129,295 @@ ...@@ -128,6 +129,295 @@
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ant-design/pro-card": {
"version": "2.10.0",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-card/-/pro-card-2.10.0.tgz",
"integrity": "sha512-sLONn1odmE0Wkbse8pol4WiaEzBV8JU5s3FAMflPpycfUcbSaa1ktXzQ7LCo2SAvOS7gkfmpFjBPtrfbigKh4g==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.4.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-components": {
"version": "2.8.10",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-components/-/pro-components-2.8.10.tgz",
"integrity": "sha512-QHnnIXdmC5GTAtm6i8eeJy5yT9npPlFyxpDm+duiDrTRKRFaAQBduArxlH3DA/hoRCCypzPONxfK9BQNIhIyZA==",
"license": "MIT",
"dependencies": {
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-descriptions": "2.6.10",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-layout": "7.22.7",
"@ant-design/pro-list": "2.6.10",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-skeleton": "2.2.1",
"@ant-design/pro-table": "3.21.0",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.16.3"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-descriptions": {
"version": "2.6.10",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-descriptions/-/pro-descriptions-2.6.10.tgz",
"integrity": "sha512-+4MbiOfumnWlW0Awm4m8JML5o3lR649FD24AaivCmr8BQvIAAXdTITnDMXEg8BqvdP4KOvNsStZrvYfqoev33A==",
"license": "MIT",
"dependencies": {
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-skeleton": "2.2.1",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"rc-resize-observer": "^0.2.3",
"rc-util": "^5.0.6"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-descriptions/node_modules/rc-resize-observer": {
"version": "0.2.6",
"resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-0.2.6.tgz",
"integrity": "sha512-YX6nYnd6fk7zbuvT6oSDMKiZjyngjHoy+fz+vL3Tez38d/G5iGdaDJa2yE7345G6sc4Mm1IGRUIwclvltddhmA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.1",
"rc-util": "^5.0.0",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ant-design/pro-field": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-field/-/pro-field-3.1.0.tgz",
"integrity": "sha512-+Dgp31WjD+iwg9KIRAMgNkfQivkJKMcYBrIBmho1e8ep/O0HgWSp48g70tBIWi/Lfem/Ky2schF7O8XCFouczw==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@chenshuai2144/sketch-color": "^1.0.8",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-util": "^5.4.0",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-form": {
"version": "2.32.0",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-form/-/pro-form-2.32.0.tgz",
"integrity": "sha512-GZnVAMeYv+YHJb17lJ7rX5PYuQPvEA6EotQnPbHi9tGLN3PfexcAd21rqzuO+OrulU2x7TEMDIxtY9MzvvOGbg==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@chenshuai2144/sketch-color": "^1.0.7",
"@umijs/use-params": "^1.0.9",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-resize-observer": "^1.1.0",
"rc-util": "^5.0.6"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"rc-field-form": ">=1.22.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-layout": {
"version": "7.22.7",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-layout/-/pro-layout-7.22.7.tgz",
"integrity": "sha512-fvmtNA1r9SaasVIQIQt611VSlNxtVxDbQ3e+1GhYQza3tVJi/3gCZuDyfMfTnbLmf3PaW/YvLkn7MqDbzAzoLA==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@umijs/route-utils": "^4.0.0",
"@umijs/use-params": "^1.0.9",
"classnames": "^2.3.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"path-to-regexp": "8.2.0",
"rc-resize-observer": "^1.1.0",
"rc-util": "^5.0.6",
"swr": "^2.0.0",
"warning": "^4.0.3"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-list": {
"version": "2.6.10",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-list/-/pro-list-2.6.10.tgz",
"integrity": "sha512-xSWwnqCr+hPEYR4qY7nFUaxO5RQBxNlFaPNmobP2i+Im31slk9JuAusgWeIYO0mNhLJuLbxd8CCma2AZij3fBQ==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-table": "3.21.0",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"rc-resize-observer": "^1.0.0",
"rc-util": "^4.19.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-list/node_modules/rc-util": {
"version": "4.21.1",
"resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-4.21.1.tgz",
"integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==",
"license": "MIT",
"dependencies": {
"add-dom-event-listener": "^1.1.0",
"prop-types": "^15.5.10",
"react-is": "^16.12.0",
"react-lifecycles-compat": "^3.0.4",
"shallowequal": "^1.1.0"
}
},
"node_modules/@ant-design/pro-list/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/@ant-design/pro-provider": {
"version": "2.16.2",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-provider/-/pro-provider-2.16.2.tgz",
"integrity": "sha512-0KmCH1EaOND787Jz6VRMYtLNZmqfT0JPjdUfxhyOxFfnBRfrjyfZgIa6CQoAJLEUMWv57PccWS8wRHVUUk2Yiw==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@babel/runtime": "^7.18.0",
"@ctrl/tinycolor": "^3.4.0",
"dayjs": "^1.11.10",
"rc-util": "^5.0.1",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-skeleton": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-skeleton/-/pro-skeleton-2.2.1.tgz",
"integrity": "sha512-3M2jNOZQZWEDR8pheY00OkHREfb0rquvFZLCa6DypGmiksiuuYuR9Y4iA82ZF+mva2FmpHekdwbje/GpbxqBeg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-table": {
"version": "3.21.0",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-table/-/pro-table-3.21.0.tgz",
"integrity": "sha512-sI81d3FYRv5sXamUc+M5CsHZ9CchuUQgOAPzo5H4oPAVL5h+mkYGRsBzPsxQX7khTNpWjrAtPoRm5ipx3vvWog==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.0.1"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"rc-field-form": ">=1.22.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-utils": {
"version": "2.18.0",
"resolved": "https://registry.npmmirror.com/@ant-design/pro-utils/-/pro-utils-2.18.0.tgz",
"integrity": "sha512-8+ikyrN8L8a8Ph4oeHTOJEiranTj18+9+WHCHjKNdEfukI7Rjn8xpYdLJWb2AUJkb9d4eoAqjd5+k+7w81Df0w==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-util": "^5.0.6",
"safe-stable-stringify": "^2.4.3",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/react-slick": { "node_modules/@ant-design/react-slick": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
...@@ -167,6 +457,95 @@ ...@@ -167,6 +457,95 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@chenshuai2144/sketch-color": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz",
"integrity": "sha512-obzSy26cb7Pm7OprWyVpgMpIlrZpZ0B7vbrU0RMbvRg0YAI890S5Xy02Aj1Nhl4+KTbi1lVYHt6HQP8Hm9s+1w==",
"license": "MIT",
"dependencies": {
"reactcss": "^1.2.3",
"tinycolor2": "^1.4.2"
},
"peerDependencies": {
"react": ">=16.12.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz",
"integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.1",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.0.6",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "7.0.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.0",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.0.7",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz",
...@@ -1404,6 +1783,21 @@ ...@@ -1404,6 +1783,21 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@umijs/route-utils": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/@umijs/route-utils/-/route-utils-4.0.3.tgz",
"integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==",
"license": "MIT"
},
"node_modules/@umijs/use-params": {
"version": "1.0.9",
"resolved": "https://registry.npmmirror.com/@umijs/use-params/-/use-params-1.0.9.tgz",
"integrity": "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
...@@ -1470,6 +1864,15 @@ ...@@ -1470,6 +1864,15 @@
"d3-zoom": "^3.0.0" "d3-zoom": "^3.0.0"
} }
}, },
"node_modules/add-dom-event-listener": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz",
"integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==",
"license": "MIT",
"dependencies": {
"object-assign": "4.x"
}
},
"node_modules/antd": { "node_modules/antd": {
"version": "5.29.3", "version": "5.29.3",
"resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz", "resolved": "https://registry.npmmirror.com/antd/-/antd-5.29.3.tgz",
...@@ -2572,6 +2975,18 @@ ...@@ -2572,6 +2975,18 @@
"url": "https://opencollective.com/parcel" "url": "https://opencollective.com/parcel"
} }
}, },
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT"
},
"node_modules/longest-streak": { "node_modules/longest-streak": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz",
...@@ -3599,6 +4014,15 @@ ...@@ -3599,6 +4014,15 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
...@@ -4323,6 +4747,12 @@ ...@@ -4323,6 +4747,12 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz",
...@@ -4371,6 +4801,15 @@ ...@@ -4371,6 +4801,15 @@
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/reactcss/-/reactcss-1.2.3.tgz",
"integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.0.1"
}
},
"node_modules/remark-gfm": { "node_modules/remark-gfm": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", "resolved": "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz",
...@@ -4441,6 +4880,15 @@ ...@@ -4441,6 +4880,15 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
...@@ -4476,6 +4924,12 @@ ...@@ -4476,6 +4924,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.34.5",
"resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz",
...@@ -4613,6 +5067,19 @@ ...@@ -4613,6 +5067,19 @@
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.2.1", "version": "4.2.1",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz", "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.1.tgz",
...@@ -4641,6 +5108,12 @@ ...@@ -4641,6 +5108,12 @@
"node": ">=12.22" "node": ">=12.22"
} }
}, },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/toggle-selection": { "node_modules/toggle-selection": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz",
...@@ -4818,6 +5291,15 @@ ...@@ -4818,6 +5291,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/zrender": { "node_modules/zrender": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz",
......
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/v5-patch-for-react-19": "^1.0.3", "@ant-design/v5-patch-for-react-19": "^1.0.3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@tanstack/react-query": "^5.80.6", "@tanstack/react-query": "^5.80.6",
......
...@@ -79,6 +79,19 @@ export interface SystemLog { ...@@ -79,6 +79,19 @@ export interface SystemLog {
created_at: string; created_at: string;
} }
export interface ComplianceReport {
id: string;
name: string;
type: string;
period: string;
status: 'pending' | 'generated' | 'submitted';
file_url: string;
generated_at: string | null;
submitted_at: string | null;
created_by: string;
created_at: string;
}
// 用户编辑/创建数据类型 // 用户编辑/创建数据类型
export interface UpdateUserData { export interface UpdateUserData {
real_name?: string; real_name?: string;
...@@ -400,4 +413,14 @@ export const adminApi = { ...@@ -400,4 +413,14 @@ export const adminApi = {
// === 数据导出 === // === 数据导出 ===
exportData: (type: string) => exportData: (type: string) =>
post<{ url: string; filename: string }>(`/admin/export/${type}`, {}), post<{ url: string; filename: string }>(`/admin/export/${type}`, {}),
// === 合规报告 ===
listReports: () =>
get<ComplianceReport[]>('/admin/reports'),
generateReport: (params: { type: string; name: string; period: string }) =>
post<ComplianceReport>('/admin/reports/generate', params),
submitReport: (id: string) =>
post<null>(`/admin/reports/${id}/submit`, {}),
}; };
...@@ -247,6 +247,14 @@ export const consultApi = { ...@@ -247,6 +247,14 @@ export const consultApi = {
cancelConsult: (idOrSerial: string, reason?: string) => cancelConsult: (idOrSerial: string, reason?: string) =>
post<null>(`/consult/${idOrSerial}/cancel`, { reason }), post<null>(`/consult/${idOrSerial}/cancel`, { reason }),
// 标记消息已读
markMessagesRead: (idOrSerial: string) =>
post<{ marked_count: number }>(`/consult/${idOrSerial}/mark-read`, {}),
// 评价问诊
rateConsult: (idOrSerial: string, data: { rating: number; comment?: string }) =>
post<null>(`/consult/${idOrSerial}/rate`, data),
// v13: 上传多媒体文件(支持 consult_id 或 serial_number) // v13: 上传多媒体文件(支持 consult_id 或 serial_number)
uploadMedia: (idOrSerial: string, file: File) => { uploadMedia: (idOrSerial: string, file: File) => {
const formData = new FormData(); const formData = new FormData();
......
import axios from 'axios'; import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { useUserStore } from '@/store/userStore';
// 统一响应格式 // 统一响应格式
export interface ApiResponse<T = unknown> { export interface ApiResponse<T = unknown> {
...@@ -30,10 +31,27 @@ const request: AxiosInstance = axios.create({ ...@@ -30,10 +31,27 @@ const request: AxiosInstance = axios.create({
}, },
}); });
// Token 刷新状态管理
let isRefreshing = false;
let pendingRequests: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach(({ resolve }) => resolve(newToken));
pendingRequests = [];
}
function onRefreshFailed(error: unknown) {
pendingRequests.forEach(({ reject }) => reject(error));
pendingRequests = [];
}
// 请求拦截器 // 请求拦截器
request.interceptors.request.use( request.interceptors.request.use(
(config) => { (config) => {
const token = localStorage.getItem('access_token'); const token = useUserStore.getState().accessToken || localStorage.getItem('access_token');
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
...@@ -54,14 +72,74 @@ request.interceptors.response.use( ...@@ -54,14 +72,74 @@ request.interceptors.response.use(
} }
return response; return response;
}, },
(error) => { async (error) => {
if (error.response?.status === 401) { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Token 过期,清除登录状态
localStorage.removeItem('access_token'); // 非 401 或已重试过,直接拒绝
localStorage.removeItem('refresh_token'); if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
// 刷新 token 的请求本身 401,直接登出
if (originalRequest.url?.includes('/user/refresh-token')) {
useUserStore.getState().logout();
window.location.href = '/login'; window.location.href = '/login';
return Promise.reject(error);
}
const refreshToken = useUserStore.getState().refreshToken || localStorage.getItem('refresh_token');
if (!refreshToken) {
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(error);
}
// 如果已经在刷新中,排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({
resolve: (token: string) => {
originalRequest._retry = true;
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(request(originalRequest));
},
reject,
});
});
}
// 开始刷新
isRefreshing = true;
originalRequest._retry = true;
try {
const res = await axios.post(
`${getBaseURL()}/user/refresh-token`,
{ refresh_token: refreshToken },
);
const newAccessToken = res.data?.data?.access_token;
if (!newAccessToken) {
throw new Error('refresh failed');
}
// 更新 store 和 localStorage
localStorage.setItem('access_token', newAccessToken);
useUserStore.setState({ accessToken: newAccessToken });
// 重放排队的请求
onTokenRefreshed(newAccessToken);
// 重放原始请求
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return request(originalRequest);
} catch (refreshError) {
onRefreshFailed(refreshError);
useUserStore.getState().logout();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
return Promise.reject(error);
} }
); );
......
...@@ -57,6 +57,20 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) { ...@@ -57,6 +57,20 @@ function DoctorLayoutInner({ children }: { children: React.ReactNode }) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [user]); }, [user]);
// AI浮窗导航事件
useEffect(() => {
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
let path = detail.page;
if (!path.startsWith('/')) path = '/' + path;
router.push(path);
}
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router]);
const handleBellClick = () => { const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {}); notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
}; };
......
...@@ -78,6 +78,20 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) { ...@@ -78,6 +78,20 @@ function PatientLayoutInner({ children }: { children: React.ReactNode }) {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [user]); }, [user]);
// AI浮窗导航事件
useEffect(() => {
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
let path = detail.page;
if (!path.startsWith('/')) path = '/' + path;
router.push(path);
}
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router]);
const handleBellClick = () => { const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {}); notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
}; };
......
...@@ -457,11 +457,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => { ...@@ -457,11 +457,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
/> />
)} )}
</div> </div>
{role === 'doctor' && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4, textAlign: 'center' }}>
AI建议仅供参考,请结合临床实际情况
</div>
)}
</div> </div>
{/* Blinking cursor CSS */} {/* Blinking cursor CSS */}
......
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { import {
Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic, Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic,
Button, Input, DatePicker, Select, Modal, message, Spin, Button, Input, DatePicker, Select, Modal, message, App,
} from 'antd'; } from 'antd';
import { import {
AuditOutlined, AuditOutlined,
FileSearchOutlined, FileSearchOutlined,
DownloadOutlined, DownloadOutlined,
CloudUploadOutlined, CloudUploadOutlined,
SafetyCertificateOutlined,
UserOutlined, UserOutlined,
SettingOutlined, SettingOutlined,
PlusOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { adminApi, type SystemLog } from '../../../api/admin'; import { adminApi, type SystemLog, type ComplianceReport } from '../../../api/admin';
import type { PaginatedResponse } from '../../../types';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const { Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
interface ReportItem { const exportTypeMap: Record<string, string> = {
id: string; consultations: '问诊数据',
name: string; users: '用户数据',
type: string; prescriptions: '处方数据',
period: string; logs: '系统日志',
status: 'generated' | 'pending' | 'submitted'; };
generated_at?: string;
submitted_at?: string; const reportTypeLabel: Record<string, string> = {
} monthly: '月度报告',
special: '专项报告',
security: '安全报告',
};
const AdminCompliancePage: React.FC = () => { const AdminCompliancePage: React.FC = () => {
const [exportModalVisible, setExportModalVisible] = useState(false); const { message: msg } = App.useApp();
// 审计日志
const [logs, setLogs] = useState<SystemLog[]>([]); const [logs, setLogs] = useState<SystemLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false); const [logsLoading, setLogsLoading] = useState(false);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsPage, setLogsPage] = useState(1); const [logsPage, setLogsPage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState(''); const [searchKeyword, setSearchKeyword] = useState('');
const [searchDateRange, setSearchDateRange] = useState<any>(null); const [searchDateRange, setSearchDateRange] = useState<any>(null);
// 数据导出
const [exportModalVisible, setExportModalVisible] = useState(false);
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
const [exportType, setExportType] = useState('consultations');
const reports: ReportItem[] = [ // 合规报告
{ id: '1', name: `${dayjs().format('YYYY')}年${dayjs().format('M')}月运营月报`, type: '月度报告', period: dayjs().format('YYYY-MM'), status: 'pending' }, const [reports, setReports] = useState<ComplianceReport[]>([]);
{ id: '2', name: `${dayjs().subtract(1, 'month').format('YYYY')}年${dayjs().subtract(1, 'month').format('M')}月运营月报`, type: '月度报告', period: dayjs().subtract(1, 'month').format('YYYY-MM'), status: 'submitted', generated_at: dayjs().subtract(20, 'day').format('YYYY-MM-DD'), submitted_at: dayjs().subtract(15, 'day').format('YYYY-MM-DD') }, const [reportsLoading, setReportsLoading] = useState(false);
{ id: '3', name: '处方用药合规专项报告', type: '专项报告', period: dayjs().format('YYYY-MM'), status: 'generated', generated_at: dayjs().subtract(5, 'day').format('YYYY-MM-DD') }, const [generateModalVisible, setGenerateModalVisible] = useState(false);
{ id: '4', name: '等保三级自查报告', type: '安全报告', period: dayjs().format('YYYY-MM'), status: 'pending' }, const [generateLoading, setGenerateLoading] = useState(false);
]; const [genType, setGenType] = useState('monthly');
const [genPeriod, setGenPeriod] = useState<any>(dayjs());
const fetchLogs = async (page = 1) => { // ========== 审计日志 ==========
const fetchLogs = useCallback(async (page = 1) => {
setLogsLoading(true); setLogsLoading(true);
try { try {
const res = await adminApi.getSystemLogs({ const res = await adminApi.getSystemLogs({
...@@ -58,37 +68,100 @@ const AdminCompliancePage: React.FC = () => { ...@@ -58,37 +68,100 @@ const AdminCompliancePage: React.FC = () => {
start_date: searchDateRange?.[0]?.format('YYYY-MM-DD') || undefined, start_date: searchDateRange?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: searchDateRange?.[1]?.format('YYYY-MM-DD') || undefined, end_date: searchDateRange?.[1]?.format('YYYY-MM-DD') || undefined,
}); });
setLogs(res.data.list || []); setLogs(res.data?.list || []);
setLogsTotal(res.data.total || 0); setLogsTotal(res.data?.total || 0);
} catch { } catch {
message.error('获取审计日志失败'); msg.error('获取审计日志失败');
} finally { } finally {
setLogsLoading(false); setLogsLoading(false);
} }
}; }, [searchKeyword, searchDateRange, msg]);
// ========== 合规报告 ==========
const fetchReports = useCallback(async () => {
setReportsLoading(true);
try {
const res = await adminApi.listReports();
setReports(res.data || []);
} catch {
// 首次可能无数据
setReports([]);
} finally {
setReportsLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
fetchLogs(); fetchLogs();
fetchReports();
}, []); }, []);
const handleExport = async (exportType?: string) => { // ========== 数据导出 ==========
const handleExport = async (type: string) => {
try { try {
setExportLoading(true); setExportLoading(true);
const res = await adminApi.exportData(exportType || 'consultations'); const res = await adminApi.exportData(type);
if (res.data?.url) { if (res.data?.url) {
window.open(res.data.url, '_blank'); window.open(res.data.url, '_blank');
message.success('导出成功'); msg.success(`${exportTypeMap[type] || type} 导出成功`);
} else {
message.success('数据导出任务已提交,请稍后在下载中心查看');
} }
} catch { } catch {
message.error('导出失败'); msg.error('导出失败');
} finally { } finally {
setExportLoading(false); setExportLoading(false);
setExportModalVisible(false); setExportModalVisible(false);
} }
}; };
// ========== 生成报告 ==========
const handleGenerateReport = async () => {
const period = genPeriod?.format('YYYY-MM');
if (!period) {
msg.warning('请选择报告周期');
return;
}
const nameMap: Record<string, string> = {
monthly: `${genPeriod.format('YYYY')}年${genPeriod.format('M')}月运营月报`,
special: `${period} 处方用药合规专项报告`,
security: `${period} 信息安全自查报告`,
};
setGenerateLoading(true);
try {
await adminApi.generateReport({
type: genType,
name: nameMap[genType] || `${period} 报告`,
period,
});
msg.success('报告生成成功');
setGenerateModalVisible(false);
fetchReports();
} catch {
msg.error('报告生成失败');
} finally {
setGenerateLoading(false);
}
};
// ========== 上报 ==========
const handleSubmit = async (id: string) => {
try {
await adminApi.submitReport(id);
msg.success('已上报至监管平台');
fetchReports();
} catch {
msg.error('上报失败');
}
};
// ========== 下载 ==========
const handleDownload = (fileUrl: string) => {
if (fileUrl) {
window.open(fileUrl, '_blank');
} else {
msg.warning('报告文件不存在');
}
};
const logColumns = [ const logColumns = [
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') }, { title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') },
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 100, ellipsis: true }, { title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 100, ellipsis: true },
...@@ -108,28 +181,33 @@ const AdminCompliancePage: React.FC = () => { ...@@ -108,28 +181,33 @@ const AdminCompliancePage: React.FC = () => {
const reportColumns = [ const reportColumns = [
{ title: '报告名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> }, { title: '报告名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="purple">{v}</Tag> }, { title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="purple">{reportTypeLabel[v] || v}</Tag> },
{ title: '周期', dataIndex: 'period', key: 'period', width: 100 }, { title: '周期', dataIndex: 'period', key: 'period', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) }, { title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStatusTag(v) },
{ title: '生成时间', dataIndex: 'generated_at', key: 'generated_at', width: 120, render: (v: string) => v || '-' }, { title: '生成时间', dataIndex: 'generated_at', key: 'generated_at', width: 170, render: (v: string | null) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
{ title: '上报时间', dataIndex: 'submitted_at', key: 'submitted_at', width: 120, render: (v: string) => v || '-' }, { title: '上报时间', dataIndex: 'submitted_at', key: 'submitted_at', width: 170, render: (v: string | null) => v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-' },
{ {
title: '操作', key: 'action', width: 200, title: '操作', key: 'action', width: 200,
render: (_: any, record: ReportItem) => ( render: (_: unknown, record: ComplianceReport) => (
<Space> <Space>
{record.status === 'pending' && <Button size="small" type="primary" onClick={() => message.info('报告生成中,请稍后查看')}>生成报告</Button>}
{record.status === 'generated' && ( {record.status === 'generated' && (
<> <>
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('consultations')}>下载</Button> <Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(record.file_url)}>下载</Button>
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => message.success('已上报至监管平台')}>上报</Button> <Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => handleSubmit(record.id)}>上报</Button>
</> </>
)} )}
{record.status === 'submitted' && <Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('consultations')}>下载</Button>} {record.status === 'submitted' && (
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleDownload(record.file_url)}>下载</Button>
)}
</Space> </Space>
), ),
}, },
]; ];
const pendingCount = reports.filter((r) => r.status === 'pending').length;
const submittedCount = reports.filter((r) => r.status === 'submitted').length;
const generatedCount = reports.filter((r) => r.status === 'generated').length;
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div> <div>
...@@ -145,17 +223,17 @@ const AdminCompliancePage: React.FC = () => { ...@@ -145,17 +223,17 @@ const AdminCompliancePage: React.FC = () => {
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #fa8c16' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #fa8c16' }}>
<Statistic title="待处理" value={reports.filter((r) => r.status === 'pending').length} valueStyle={{ color: '#fa8c16' }} suffix="项" /> <Statistic title="待处理" value={pendingCount} valueStyle={{ color: '#fa8c16' }} suffix="项" />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #52c41a' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #52c41a' }}>
<Statistic title="已上报" value={reports.filter((r) => r.status === 'submitted').length} suffix="项" /> <Statistic title="已上报" value={submittedCount} suffix="项" />
</Card> </Card>
</Col> </Col>
<Col span={6}> <Col span={6}>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #722ed1' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc', borderLeft: '4px solid #722ed1' }}>
<Statistic title="已生成" value={reports.filter((r) => r.status === 'generated').length} suffix="项" /> <Statistic title="已生成" value={generatedCount} suffix="项" />
</Card> </Card>
</Col> </Col>
</Row> </Row>
...@@ -185,14 +263,15 @@ const AdminCompliancePage: React.FC = () => { ...@@ -185,14 +263,15 @@ const AdminCompliancePage: React.FC = () => {
children: ( children: (
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {([
{ title: '问诊数据', desc: '问诊记录、诊断、处方', icon: <FileSearchOutlined style={{ fontSize: 24, color: '#1890ff' }} /> }, { type: 'consultations', title: '问诊数据', desc: '问诊记录、诊断、处方', icon: <FileSearchOutlined style={{ fontSize: 24, color: '#1890ff' }} /> },
{ title: '用户数据', desc: '患者和医生基本信息', icon: <UserOutlined style={{ fontSize: 24, color: '#52c41a' }} /> }, { type: 'users', title: '用户数据', desc: '患者和医生基本信息', icon: <UserOutlined style={{ fontSize: 24, color: '#52c41a' }} /> },
{ title: '处方数据', desc: '处方记录、用药统计', icon: <AuditOutlined style={{ fontSize: 24, color: '#722ed1' }} /> }, { type: 'prescriptions', title: '处方数据', desc: '处方记录、用药统计', icon: <AuditOutlined style={{ fontSize: 24, color: '#722ed1' }} /> },
{ title: '系统日志', desc: '操作日志和审计记录', icon: <SettingOutlined style={{ fontSize: 24, color: '#fa8c16' }} /> }, { type: 'logs', title: '系统日志', desc: '操作日志和审计记录', icon: <SettingOutlined style={{ fontSize: 24, color: '#fa8c16' }} /> },
].map((item, i) => ( ] as const).map((item) => (
<Col span={6} key={i}> <Col span={6} key={item.type}>
<Card style={{ borderRadius: 12, textAlign: 'center', cursor: 'pointer' }} hoverable onClick={() => setExportModalVisible(true)}> <Card style={{ borderRadius: 12, textAlign: 'center', cursor: 'pointer' }} hoverable
onClick={() => { setExportType(item.type); setExportModalVisible(true); }}>
<div style={{ marginBottom: 10 }}>{item.icon}</div> <div style={{ marginBottom: 10 }}>{item.icon}</div>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{item.title}</div> <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>{item.title}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{item.desc}</div> <div style={{ fontSize: 12, color: '#8c8c8c' }}>{item.desc}</div>
...@@ -210,32 +289,44 @@ const AdminCompliancePage: React.FC = () => { ...@@ -210,32 +289,44 @@ const AdminCompliancePage: React.FC = () => {
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>管理合规报告,支持下载和上报</span> <span style={{ fontSize: 13, color: '#8c8c8c' }}>管理合规报告,支持下载和上报</span>
<Button type="primary" onClick={() => message.info('报告生成中,请稍后查看')}>生成新报告</Button> <Button type="primary" icon={<PlusOutlined />} onClick={() => setGenerateModalVisible(true)}>生成新报告</Button>
</div> </div>
<Table columns={reportColumns} dataSource={reports} rowKey="id" size="small" pagination={false} /> <Table columns={reportColumns} dataSource={reports} rowKey="id" size="small"
loading={reportsLoading} pagination={false} />
</Card> </Card>
), ),
}, },
]} /> ]} />
<Modal title="数据导出" open={exportModalVisible} onOk={() => handleExport()} {/* 数据导出确认弹窗 */}
onCancel={() => setExportModalVisible(false)} okText="确认导出" confirmLoading={exportLoading} width={400}> <Modal title={`导出${exportTypeMap[exportType] || '数据'}`} open={exportModalVisible}
<div className="space-y-3"> onOk={() => handleExport(exportType)}
<div> onCancel={() => setExportModalVisible(false)}
<Text strong className="text-xs!">导出格式</Text> okText="确认导出" confirmLoading={exportLoading} width={360}>
<Select defaultValue="xlsx" size="small" style={{ width: '100%', marginTop: 4 }} options={[ <div style={{ padding: '8px 0', fontSize: 13, color: '#595959' }}>
{ label: 'Excel (.xlsx)', value: 'xlsx' }, { label: 'CSV', value: 'csv' }, { label: 'JSON', value: 'json' }, 将导出最近 1000 条 <Text strong>{exportTypeMap[exportType]}</Text> 记录为 CSV 格式文件。
]} /> </div>
</div> </Modal>
{/* 生成报告弹窗 */}
<Modal title="生成合规报告" open={generateModalVisible}
onOk={handleGenerateReport}
onCancel={() => setGenerateModalVisible(false)}
okText="确认生成" confirmLoading={generateLoading} width={400}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: '8px 0' }}>
<div> <div>
<Text strong className="text-xs!">时间范围</Text> <Text strong style={{ fontSize: 13 }}>报告类型</Text>
<RangePicker size="small" style={{ width: '100%', marginTop: 4 }} /> <Select value={genType} onChange={setGenType} style={{ width: '100%', marginTop: 4 }}
options={[
{ label: '月度运营报告', value: 'monthly' },
{ label: '处方合规专项报告', value: 'special' },
{ label: '信息安全自查报告', value: 'security' },
]} />
</div> </div>
<div> <div>
<Text strong className="text-xs!">数据脱敏</Text> <Text strong style={{ fontSize: 13 }}>报告周期</Text>
<Select defaultValue="partial" size="small" style={{ width: '100%', marginTop: 4 }} options={[ <DatePicker picker="month" value={genPeriod} onChange={setGenPeriod}
{ label: '部分脱敏(推荐)', value: 'partial' }, { label: '完全脱敏', value: 'full' }, { label: '不脱敏', value: 'none' }, style={{ width: '100%', marginTop: 4 }} />
]} />
</div> </div>
</div> </div>
</Modal> </Modal>
......
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useRef, useState } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, DatePicker, message, Modal, Descriptions } from 'antd'; import { App, Tag, Space, Button, Avatar, Modal, Descriptions } from 'antd';
import { ProTable } from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { import {
SearchOutlined, UserOutlined, MessageOutlined, VideoCameraOutlined, EyeOutlined UserOutlined, MessageOutlined, VideoCameraOutlined, EyeOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { adminApi } from '../../../api/admin'; import { adminApi } from '../../../api/admin';
import type { AdminConsultItem } from '../../../api/admin'; import type { AdminConsultItem } from '../../../api/admin';
const { Title } = Typography;
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待支付', color: 'orange' },
waiting: { text: '等待接诊', color: 'processing' },
in_progress: { text: '进行中', color: 'blue' },
completed: { text: '已完成', color: 'green' },
cancelled: { text: '已取消', color: 'red' },
};
const AdminConsultationsPage: React.FC = () => { const AdminConsultationsPage: React.FC = () => {
const [loading, setLoading] = useState(false); const { message } = App.useApp();
const [data, setData] = useState<AdminConsultItem[]>([]); const actionRef = useRef<ActionType>();
const [total, setTotal] = useState(0); const [detailItem, setDetailItem] = useState<(AdminConsultItem & Record<string, any>) | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<string | undefined>();
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
const [detailItem, setDetailItem] = useState<any>(null);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.getConsultList({
keyword: keyword || undefined,
type: typeFilter,
status: statusFilter,
start_date: dateRange?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: dateRange?.[1]?.format('YYYY-MM-DD') || undefined,
page,
page_size: pageSize,
});
const result = res.data as any;
setData(result?.list || []);
setTotal(result?.total || 0);
} catch {
message.error('获取问诊列表失败');
} finally {
setLoading(false);
}
}, [keyword, typeFilter, statusFilter, dateRange, page, pageSize]);
useEffect(() => { const columns: ProColumns<AdminConsultItem>[] = [
fetchData();
}, [fetchData]);
const handleSearch = () => {
setPage(1);
fetchData();
};
const columns: ColumnsType<AdminConsultItem> = [
{ {
title: '问诊ID', title: '问诊ID',
dataIndex: 'id', dataIndex: 'id',
key: 'id', search: false,
width: 100, width: 100,
render: (v: string) => v ? v.substring(0, 8) + '...' : '-', render: (_, record) => record.id ? record.id.substring(0, 8) + '...' : '-',
},
{
title: '关键词',
dataIndex: 'keyword',
hideInTable: true,
fieldProps: { placeholder: '搜索患者/医生' },
}, },
{ {
title: '患者', title: '患者',
dataIndex: 'patient_name', dataIndex: 'patient_name',
key: 'patient_name', search: false,
render: (name: string) => ( render: (_, record) => (
<Space> <Space>
<Avatar size="small" icon={<UserOutlined />} /> <Avatar size="small" icon={<UserOutlined />} />
{name || '未知'} {record.patient_name || '未知'}
</Space> </Space>
), ),
}, },
{ {
title: '医生', title: '医生',
dataIndex: 'doctor_name', dataIndex: 'doctor_name',
key: 'doctor_name', search: false,
render: (name: string) => name || '未分配', render: (v) => (v as string) || '未分配',
}, },
{ {
title: '类型', title: '类型',
dataIndex: 'type', dataIndex: 'type',
key: 'type',
width: 100, width: 100,
render: (type: string) => ( valueEnum: {
text: { text: '图文' },
video: { text: '视频' },
},
render: (_, record) => (
<Tag <Tag
icon={type === 'video' ? <VideoCameraOutlined /> : <MessageOutlined />} icon={record.type === 'video' ? <VideoCameraOutlined /> : <MessageOutlined />}
color={type === 'video' ? 'blue' : 'green'} color={record.type === 'video' ? 'blue' : 'green'}
> >
{type === 'video' ? '视频' : '图文'} {record.type === 'video' ? '视频' : '图文'}
</Tag> </Tag>
), ),
}, },
{ {
title: '主诉', title: '主诉',
dataIndex: 'chief_complaint', dataIndex: 'chief_complaint',
key: 'chief_complaint', search: false,
ellipsis: true, ellipsis: true,
}, },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status',
width: 100, width: 100,
render: (status: string) => { valueEnum: {
const s = statusMap[status] || { text: status, color: 'default' }; pending: { text: '待支付', status: 'Warning' },
return <Tag color={s.color}>{s.text}</Tag>; waiting: { text: '等待接诊', status: 'Processing' },
in_progress: { text: '进行中', status: 'Processing' },
completed: { text: '已完成', status: 'Success' },
cancelled: { text: '已取消', status: 'Error' },
}, },
}, },
{ {
title: '创建时间', title: '创建时间',
dataIndex: 'created_at', dataIndex: 'created_at',
key: 'created_at', valueType: 'dateRange',
search: {
transform: (value) => ({ start_date: value[0], end_date: value[1] }),
},
render: (_, record) =>
record.created_at ? dayjs(record.created_at).format('YYYY-MM-DD HH:mm') : '-',
width: 170, width: 170,
render: (t: string) => t ? dayjs(t).format('YYYY-MM-DD HH:mm') : '-',
}, },
{ {
title: '操作', title: '操作',
key: 'action', valueType: 'option',
width: 80, width: 80,
render: (_: any, record: any) => ( render: (_, record) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(record)}>详情</Button> <Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => setDetailItem(record)}
>
详情
</Button>
), ),
}, },
]; ];
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <ProTable<AdminConsultItem>
<div> headerTitle="问诊管理"
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>问诊管理</h2> tooltip="查看和管理平台所有问诊记录"
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>查看和管理平台所有问诊记录</div> actionRef={actionRef}
</div> rowKey="id"
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {total} 条记录</span> cardBordered
</div> columns={columns}
search={{ labelWidth: 'auto' }}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> options={{ density: true, reload: true, setting: true }}
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}> request={async (params) => {
<Input try {
placeholder="搜索患者/医生" const res = await adminApi.getConsultList({
prefix={<SearchOutlined />} keyword: params.keyword,
style={{ width: 180, borderRadius: 8 }} type: params.type,
value={keyword} status: params.status,
onChange={(e) => setKeyword(e.target.value)} start_date: params.start_date,
onPressEnter={handleSearch} end_date: params.end_date,
/> page: params.current,
<Select page_size: params.pageSize,
placeholder="类型" });
style={{ width: 100 }} const result = res.data as any;
allowClear return {
value={typeFilter} data: result?.list || [],
onChange={setTypeFilter} total: result?.total || 0,
options={[ success: true,
{ label: '图文', value: 'text' }, };
{ label: '视频', value: 'video' }, } catch {
]} message.error('获取问诊列表失败');
/> return { data: [], total: 0, success: false };
<Select }
placeholder="状态" }}
style={{ width: 110 }} pagination={{ defaultPageSize: 10, showSizeChanger: true }}
allowClear />
value={statusFilter}
onChange={setStatusFilter}
options={Object.entries(statusMap).map(([value, { text }]) => ({ label: text, value }))}
/>
<RangePicker value={dateRange} onChange={(dates) => setDateRange(dates)} />
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
</div>
</Card>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
size: 'small',
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
</Card>
<Modal title="问诊详情" open={!!detailItem} onCancel={() => setDetailItem(null)} footer={null} width={600}> <Modal
title="问诊详情"
open={!!detailItem}
onCancel={() => setDetailItem(null)}
footer={null}
width={600}
>
{detailItem && ( {detailItem && (
<Descriptions column={2} bordered size="small"> <Descriptions column={2} bordered size="small">
<Descriptions.Item label="问诊ID">{detailItem.id}</Descriptions.Item> <Descriptions.Item label="问诊ID">{detailItem.id}</Descriptions.Item>
<Descriptions.Item label="流水号">{detailItem.serial_number}</Descriptions.Item> <Descriptions.Item label="流水号">{detailItem.serial_number}</Descriptions.Item>
<Descriptions.Item label="患者">{detailItem.patient_name}</Descriptions.Item> <Descriptions.Item label="患者">{detailItem.patient_name}</Descriptions.Item>
<Descriptions.Item label="医生">{detailItem.doctor_name}</Descriptions.Item> <Descriptions.Item label="医生">{detailItem.doctor_name}</Descriptions.Item>
<Descriptions.Item label="类型">{detailItem.type === 'text' ? '图文' : '视频'}</Descriptions.Item> <Descriptions.Item label="类型">
{detailItem.type === 'text' ? '图文' : '视频'}
</Descriptions.Item>
<Descriptions.Item label="状态">{detailItem.status}</Descriptions.Item> <Descriptions.Item label="状态">{detailItem.status}</Descriptions.Item>
<Descriptions.Item label="主诉" span={2}>{detailItem.chief_complaint}</Descriptions.Item> <Descriptions.Item label="主诉" span={2}>
{detailItem.chief_complaint}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{detailItem.created_at}</Descriptions.Item> <Descriptions.Item label="创建时间">{detailItem.created_at}</Descriptions.Item>
<Descriptions.Item label="结束时间">{detailItem.ended_at || '-'}</Descriptions.Item> <Descriptions.Item label="结束时间">
{detailItem.ended_at || '-'}
</Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Modal> </Modal>
......
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Card, Row, Col, Typography, List, Avatar, Tag, Progress } from 'antd'; import { Row, Col, Typography, List, Avatar, Tag, Progress } from 'antd';
import { StatisticCard, ProCard } from '@ant-design/pro-components';
import { import {
UserOutlined, UserOutlined,
TeamOutlined, TeamOutlined,
...@@ -133,20 +134,28 @@ const AdminDashboardPage: React.FC = () => { ...@@ -133,20 +134,28 @@ const AdminDashboardPage: React.FC = () => {
{/* Row1: 核心指标 */} {/* Row1: 核心指标 */}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{[ {[
{ title: '注册用户', value: stats.total_users, icon: <TeamOutlined />, color: '#1890ff', bg: '#e6f7ff' }, { title: '注册用户', value: stats.total_users, prefix: '', icon: <TeamOutlined style={{ fontSize: 18, color: '#1890ff' }} />, color: '#1890ff', bg: '#e6f7ff', extra: '' },
{ title: '注册医生', value: stats.total_doctors, icon: <MedicineBoxOutlined />, color: '#52c41a', bg: '#f6ffed', extra: `在线 ${stats.online_doctors}` }, { title: '注册医生', value: stats.total_doctors, prefix: '', icon: <MedicineBoxOutlined style={{ fontSize: 18, color: '#52c41a' }} />, color: '#52c41a', bg: '#f6ffed', extra: `在线 ${stats.online_doctors}` },
{ title: '今日问诊', value: stats.today_consultations, icon: <MessageOutlined />, color: '#722ed1', bg: '#f9f0ff' }, { title: '今日问诊', value: stats.today_consultations, prefix: '', icon: <MessageOutlined style={{ fontSize: 18, color: '#722ed1' }} />, color: '#722ed1', bg: '#f9f0ff', extra: '' },
{ title: '今日收入', value: `¥${(stats.revenue_today / 100).toLocaleString()}`, icon: <DollarOutlined />, color: '#fa8c16', bg: '#fff7e6' }, { title: '今日收入', value: (stats.revenue_today / 100), prefix: '¥', icon: <DollarOutlined style={{ fontSize: 18, color: '#fa8c16' }} />, color: '#fa8c16', bg: '#fff7e6', extra: '' },
].map((item, i) => ( ].map((item, i) => (
<Col xs={12} sm={6} key={i}> <Col xs={12} sm={6} key={i}>
<div style={{ background: '#fff', borderRadius: 12, padding: '16px 20px', border: '1px solid #edf2fc' }}> <StatisticCard
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}> loading={loading}
<div style={{ width: 36, height: 36, borderRadius: 10, background: item.bg, color: item.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18 }}>{item.icon}</div> statistic={{
<span style={{ fontSize: 13, color: '#8c8c8c' }}>{item.title}</span> title: item.title,
</div> value: item.value,
<div style={{ fontSize: 24, fontWeight: 700, color: item.color }}>{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}</div> prefix: item.prefix || undefined,
{item.extra && <div style={{ fontSize: 12, color: '#bfbfbf', marginTop: 4 }}>{item.extra}</div>} description: item.extra ? <Text style={{ fontSize: 12, color: '#bfbfbf' }}>{item.extra}</Text> : undefined,
</div> icon: (
<div style={{ width: 36, height: 36, borderRadius: 10, background: item.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{item.icon}
</div>
),
valueStyle: { fontSize: 24, fontWeight: 700, color: item.color },
}}
style={{ borderRadius: 12, border: '1px solid #edf2fc' }}
/>
</Col> </Col>
))} ))}
</Row> </Row>
...@@ -163,7 +172,12 @@ const AdminDashboardPage: React.FC = () => { ...@@ -163,7 +172,12 @@ const AdminDashboardPage: React.FC = () => {
<EChartsCard title="AI 场景调用 TOP5" option={aiSceneOption} loading={loading} height={220} /> <EChartsCard title="AI 场景调用 TOP5" option={aiSceneOption} loading={loading} height={220} />
</Col> </Col>
<Col xs={24} md={8}> <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' } }}> <ProCard
title={<span style={{ fontSize: 14, fontWeight: 600 }}>平台概况</span>}
headerBordered
style={{ borderRadius: 12, border: '1px solid #edf2fc' }}
bodyStyle={{ padding: '12px 16px' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div> <div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
...@@ -204,15 +218,16 @@ const AdminDashboardPage: React.FC = () => { ...@@ -204,15 +218,16 @@ const AdminDashboardPage: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
</Card> </ProCard>
</Col> </Col>
</Row> </Row>
{/* Row4: 待审核 + AI24h分布 */} {/* Row4: 待审核 + AI24h分布 */}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Card <ProCard
title={<span style={{ fontSize: 14, fontWeight: 600 }}>待审核医生 <Tag color="orange" style={{ marginLeft: 6 }}>{stats.pending_doctor_reviews}</Tag></span>} title={<span style={{ fontSize: 14, fontWeight: 600 }}>待审核医生 <Tag color="orange" style={{ marginLeft: 6 }}>{stats.pending_doctor_reviews}</Tag></span>}
headerBordered
style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }} style={{ borderRadius: 12, border: '1px solid #edf2fc', height: '100%' }}
> >
<List <List
...@@ -229,7 +244,7 @@ const AdminDashboardPage: React.FC = () => { ...@@ -229,7 +244,7 @@ const AdminDashboardPage: React.FC = () => {
</List.Item> </List.Item>
)} )}
/> />
</Card> </ProCard>
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<EChartsCard title="AI 调用分布(24h)" option={ai24hOption} loading={loading} height={240} /> <EChartsCard title="AI 调用分布(24h)" option={ai24hOption} loading={loading} height={240} />
......
'use client'; 'use client';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { import { Typography, Space, Button, Modal, App } from 'antd';
Card, Table, Typography, Space, Button, Modal, Form, Input, InputNumber, message
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import { ProTable, ModalForm, ProFormText, ProFormDigit } from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { adminApi } from '../../../api/admin'; import { adminApi } from '../../../api/admin';
import type { Department } from '../../../api/doctor'; import type { Department } from '../../../api/doctor';
const { Text } = Typography;
const AdminDepartmentsPage: React.FC = () => { const AdminDepartmentsPage: React.FC = () => {
const { message } = App.useApp();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [isModalOpen, setIsModalOpen] = useState(false); const actionRef = useRef<ActionType>();
const [modalVisible, setModalVisible] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null); const [editingDept, setEditingDept] = useState<Department | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<Department[]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const fetchDepartments = async () => {
setLoading(true);
try {
const res = await adminApi.getDepartmentList();
setDepartments(res.data || []);
} catch {
message.error('获取科室列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
fetchDepartments(); if (searchParams.get('action') === 'add') setModalVisible(true);
}, []); }, [searchParams]);
// ?action=add 自动打开添加弹窗
useEffect(() => {
if (searchParams.get('action') === 'add') {
handleAdd();
}
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps
const handleAdd = () => {
setEditingDept(null);
form.resetFields();
setIsModalOpen(true);
};
const handleEdit = (record: Department) => {
setEditingDept(record);
form.setFieldsValue(record);
setIsModalOpen(true);
};
const handleDelete = (record: Department) => { const handleDelete = (record: Department) => {
Modal.confirm({ Modal.confirm({
...@@ -64,7 +28,7 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -64,7 +28,7 @@ const AdminDepartmentsPage: React.FC = () => {
try { try {
await adminApi.deleteDepartment(record.id); await adminApi.deleteDepartment(record.id);
message.success('已删除'); message.success('已删除');
fetchDepartments(); actionRef.current?.reload();
} catch { } catch {
message.error('删除失败'); message.error('删除失败');
} }
...@@ -72,50 +36,28 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -72,50 +36,28 @@ const AdminDepartmentsPage: React.FC = () => {
}); });
}; };
const handleSave = async (values: any) => { const columns: ProColumns<Department>[] = [
setSaveLoading(true);
try {
if (editingDept) {
await adminApi.updateDepartment(editingDept.id, values);
message.success('科室更新成功');
} else {
await adminApi.createDepartment(values);
message.success('科室创建成功');
}
setIsModalOpen(false);
fetchDepartments();
} catch {
message.error('操作失败');
} finally {
setSaveLoading(false);
}
};
const columns: ColumnsType<Department> = [
{ {
title: '图标', title: '图标',
dataIndex: 'icon', dataIndex: 'icon',
key: 'icon',
width: 60, width: 60,
render: (icon: string) => <span style={{ fontSize: 24 }}>{icon || '🏥'}</span>, render: (_, record) => <span style={{ fontSize: 24 }}>{record.icon || '🏥'}</span>,
}, },
{ {
title: '科室名称', title: '科室名称',
dataIndex: 'name', dataIndex: 'name',
key: 'name', render: (_, record) => <Typography.Text strong>{record.name}</Typography.Text>,
render: (name: string) => <Text strong>{name}</Text>,
}, },
{ {
title: '排序', title: '排序',
dataIndex: 'sort_order', dataIndex: 'sort_order',
key: 'sort_order',
}, },
{ {
title: '操作', title: '操作',
key: 'action', valueType: 'option',
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditingDept(record); setModalVisible(true); }}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>删除</Button> <Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>删除</Button>
</Space> </Space>
), ),
...@@ -123,34 +65,60 @@ const AdminDepartmentsPage: React.FC = () => { ...@@ -123,34 +65,60 @@ const AdminDepartmentsPage: React.FC = () => {
]; ];
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <ProTable<Department>
<div> headerTitle="科室管理"
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>科室管理</h2> tooltip="管理医院科室分类与排序"
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理医院科室分类与排序</div> rowKey="id"
</div> actionRef={actionRef}
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加科室</Button> cardBordered
</div> search={false}
options={{ density: true, reload: true, setting: true }}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> request={async () => {
<Table columns={columns} dataSource={departments} rowKey="id" loading={loading} size="small" pagination={false} /> const res = await adminApi.getDepartmentList();
</Card> return { data: res.data || [], success: true };
}}
pagination={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingDept(null); setModalVisible(true); }}>
添加科室
</Button>,
]}
columns={columns}
/>
<Modal title={editingDept ? '编辑科室' : '添加科室'} open={isModalOpen} onCancel={() => setIsModalOpen(false)} <ModalForm
onOk={() => form.submit()} confirmLoading={saveLoading} width={400}> title={editingDept ? '编辑科室' : '添加科室'}
<Form form={form} onFinish={handleSave} layout="vertical" size="small"> open={modalVisible}
<Form.Item name="name" label="科室名称" rules={[{ required: true, message: '请输入科室名称' }]}> onOpenChange={(open) => {
<Input placeholder="请输入科室名称" /> setModalVisible(open);
</Form.Item> if (!open) setEditingDept(null);
<Form.Item name="icon" label="图标"> }}
<Input placeholder="请输入Emoji图标" /> initialValues={editingDept || { sort_order: 1 }}
</Form.Item> width={400}
<Form.Item name="sort_order" label="排序号" initialValue={1}> modalProps={{ destroyOnClose: true }}
<InputNumber min={1} style={{ width: '100%' }} /> onFinish={async (values) => {
</Form.Item> try {
</Form> if (editingDept) {
</Modal> await adminApi.updateDepartment(editingDept.id, values);
</div> message.success('科室更新成功');
} else {
await adminApi.createDepartment(values);
message.success('科室创建成功');
}
actionRef.current?.reload();
return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
<ProFormText name="name" label="科室名称" rules={[{ required: true, message: '请输入科室名称' }]} placeholder="请输入科室名称" />
<ProFormText name="icon" label="图标" placeholder="请输入Emoji图标" />
<ProFormDigit name="sort_order" label="排序号" min={1} fieldProps={{ style: { width: '100%' } }} />
</ModalForm>
</>
); );
}; };
......
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useRef, useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { import {
Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message, Tag, Typography, Space, Button, Avatar, Modal, App,
Input, Select, Form, Descriptions, Row, Col, Input, Descriptions,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, SearchOutlined, CheckCircleOutlined, CloseCircleOutlined, UserOutlined, CheckCircleOutlined,
EyeOutlined, EditOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, PlusOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import {
ProTable, ModalForm, ProFormText, ProFormSelect, ProFormTextArea,
} from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { adminApi } from '../../../api/admin'; import { adminApi } from '../../../api/admin';
import type { DoctorItem } from '../../../api/admin'; import type { DoctorItem } from '../../../api/admin';
const { Text } = Typography; const { Text } = Typography;
const statusMap: Record<string, { color: string; text: string }> = { const titleOptions = [
not_submitted: { color: 'default', text: '未提交认证' }, { label: '主任医师', value: '主任医师' },
pending: { color: 'orange', text: '待审核' }, { label: '副主任医师', value: '副主任医师' },
approved: { color: 'green', text: '已认证' }, { label: '主治医师', value: '主治医师' },
rejected: { color: 'red', text: '已拒绝' }, { label: '住院医师', value: '住院医师' },
}; ];
const departmentOptions = [
{ label: '内科', value: '内科' },
{ label: '外科', value: '外科' },
{ label: '儿科', value: '儿科' },
{ label: '妇产科', value: '妇产科' },
{ label: '皮肤科', value: '皮肤科' },
{ label: '眼科', value: '眼科' },
{ label: '耳鼻喉科', value: '耳鼻喉科' },
{ label: '口腔科', value: '口腔科' },
{ label: '中医科', value: '中医科' },
{ label: '精神心理科', value: '精神心理科' },
];
const AdminDoctorsPage: React.FC = () => { const AdminDoctorsPage: React.FC = () => {
const { message } = App.useApp();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [loading, setLoading] = useState(false); const actionRef = useRef<ActionType>(null);
const [doctors, setDoctors] = useState<DoctorItem[]>([]);
const [total, setTotal] = useState(0); // Add modal state (controlled, for ?action=add support)
const [page, setPage] = useState(1); const [addModalVisible, setAddModalVisible] = useState(false);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(''); // Edit modal state
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [detailVisible, setDetailVisible] = useState(false);
const [currentDoctor, setCurrentDoctor] = useState<DoctorItem | null>(null);
const [reviewModalVisible, setReviewModalVisible] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [editModalVisible, setEditModalVisible] = useState(false); const [editModalVisible, setEditModalVisible] = useState(false);
const [editingDoctor, setEditingDoctor] = useState<DoctorItem | null>(null); const [editingDoctor, setEditingDoctor] = useState<DoctorItem | null>(null);
const [editForm] = Form.useForm();
const [editLoading, setEditLoading] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [addForm] = Form.useForm();
const [addLoading, setAddLoading] = useState(false);
const fetchDoctors = useCallback(async () => { // Detail modal state
setLoading(true); const [detailVisible, setDetailVisible] = useState(false);
try { const [currentDoctor, setCurrentDoctor] = useState<DoctorItem | null>(null);
const res = await adminApi.getDoctorList({
keyword: keyword || undefined,
status: statusFilter,
page,
page_size: pageSize,
});
const data = res.data as any;
setDoctors(data?.list || []);
setTotal(data?.total || 0);
} catch {
message.error('获取医生列表失败');
} finally {
setLoading(false);
}
}, [keyword, statusFilter, page, pageSize]);
useEffect(() => { // Review modal state
fetchDoctors(); const [reviewModalVisible, setReviewModalVisible] = useState(false);
}, [fetchDoctors]); const [rejectReason, setRejectReason] = useState('');
// ?action=add 自动打开添加弹窗 // ?action=add auto-open
useEffect(() => { useEffect(() => {
if (searchParams.get('action') === 'add') { if (searchParams.get('action') === 'add') {
setAddModalVisible(true); setAddModalVisible(true);
} }
}, [searchParams]); }, [searchParams]);
const handleSearch = () => { // ---------- Handlers ----------
setPage(1);
fetchDoctors();
};
const handleViewDetail = (record: DoctorItem) => { const handleViewDetail = (record: DoctorItem) => {
setCurrentDoctor(record); setCurrentDoctor(record);
...@@ -95,7 +84,7 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -95,7 +84,7 @@ const AdminDoctorsPage: React.FC = () => {
await adminApi.approveDoctorReview(currentDoctor.review_id); await adminApi.approveDoctorReview(currentDoctor.review_id);
message.success('审核通过'); message.success('审核通过');
setReviewModalVisible(false); setReviewModalVisible(false);
fetchDoctors(); actionRef.current?.reload();
} catch (error: any) { } catch (error: any) {
message.error(error.response?.data?.message || '审核失败'); message.error(error.response?.data?.message || '审核失败');
} }
...@@ -111,7 +100,7 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -111,7 +100,7 @@ const AdminDoctorsPage: React.FC = () => {
message.success('已拒绝'); message.success('已拒绝');
setReviewModalVisible(false); setReviewModalVisible(false);
setRejectReason(''); setRejectReason('');
fetchDoctors(); actionRef.current?.reload();
} catch (error: any) { } catch (error: any) {
message.error(error.response?.data?.message || '操作失败'); message.error(error.response?.data?.message || '操作失败');
} }
...@@ -126,7 +115,7 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -126,7 +115,7 @@ const AdminDoctorsPage: React.FC = () => {
try { try {
await adminApi.updateUserStatus(record.user_id, newStatus); await adminApi.updateUserStatus(record.user_id, newStatus);
message.success('操作成功'); message.success('操作成功');
fetchDoctors(); actionRef.current?.reload();
} catch { } catch {
message.error('操作失败'); message.error('操作失败');
} }
...@@ -151,60 +140,27 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -151,60 +140,27 @@ const AdminDoctorsPage: React.FC = () => {
const handleEditDoctor = (record: DoctorItem) => { const handleEditDoctor = (record: DoctorItem) => {
setEditingDoctor(record); setEditingDoctor(record);
editForm.setFieldsValue({
real_name: record.name,
phone: record.phone,
title: record.title,
department_id: record.department_id,
hospital: record.hospital,
});
setEditModalVisible(true); setEditModalVisible(true);
}; };
const handleSaveEdit = async () => {
try {
const values = await editForm.validateFields();
setEditLoading(true);
await adminApi.updateDoctor(editingDoctor!.id, values);
message.success('医生信息已更新');
setEditModalVisible(false);
fetchDoctors();
} catch (error: any) {
if (!error?.errorFields) {
message.error('更新失败');
}
} finally {
setEditLoading(false);
}
};
const handleAddDoctor = async () => {
try {
const values = await addForm.validateFields();
setAddLoading(true);
await adminApi.createDoctor(values);
message.success('医生添加成功');
setAddModalVisible(false);
addForm.resetFields();
fetchDoctors();
} catch (error: any) {
if (!error?.errorFields) {
message.error(error?.response?.data?.message || '添加失败');
}
} finally {
setAddLoading(false);
}
};
const getStatusTag = (status: string) => { const getStatusTag = (status: string) => {
const config = statusMap[status] || { color: 'default', text: status }; const map: Record<string, { color: string; text: string }> = {
not_submitted: { color: 'default', text: '未提交认证' },
pending: { color: 'orange', text: '待审核' },
approved: { color: 'green', text: '已认证' },
rejected: { color: 'red', text: '已拒绝' },
};
const config = map[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>; return <Tag color={config.color}>{config.text}</Tag>;
}; };
const columns: ColumnsType<DoctorItem> = [ // ---------- Columns ----------
const columns: ProColumns<DoctorItem>[] = [
{ {
title: '医生', title: '医生',
key: 'doctor', dataIndex: 'name',
search: false,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#87d068' }} /> <Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#87d068' }} />
...@@ -216,31 +172,91 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -216,31 +172,91 @@ const AdminDoctorsPage: React.FC = () => {
</Space> </Space>
), ),
}, },
{ title: '职称', dataIndex: 'title', key: 'title', width: 100, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : '-' },
{ title: '科室', dataIndex: 'department_name', key: 'department_name', width: 100 },
{ title: '医院', dataIndex: 'hospital', key: 'hospital', width: 150, ellipsis: true },
{ title: '认证状态', dataIndex: 'review_status', key: 'review_status', width: 100, render: (v: string) => getStatusTag(v) },
{ {
title: '账号状态', dataIndex: 'user_status', key: 'user_status', width: 90, title: '关键词',
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '正常' : '已禁用'}</Tag>, dataIndex: 'keyword',
hideInTable: true,
fieldProps: {
placeholder: '搜索姓名/手机号',
},
},
{
title: '职称',
dataIndex: 'title',
search: false,
width: 100,
render: (v) => v ? <Tag color="blue">{v as string}</Tag> : '-',
},
{
title: '科室',
dataIndex: 'department_name',
search: false,
width: 100,
},
{
title: '医院',
dataIndex: 'hospital',
search: false,
width: 150,
ellipsis: true,
},
{
title: '认证状态',
dataIndex: 'review_status',
width: 100,
valueEnum: {
not_submitted: { text: '未提交认证', status: 'Default' },
pending: { text: '待审核', status: 'Warning' },
approved: { text: '已认证', status: 'Success' },
rejected: { text: '已拒绝', status: 'Error' },
},
},
{
title: '账号状态',
dataIndex: 'user_status',
search: false,
width: 90,
render: (v) => (
<Tag color={v === 'active' ? 'green' : 'red'}>
{v === 'active' ? '正常' : '已禁用'}
</Tag>
),
}, },
{ {
title: '操作', key: 'action', width: 220, title: '操作',
valueType: 'option',
width: 260,
render: (_, record) => ( render: (_, record) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>详情</Button> <Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
详情
</Button>
{record.review_status === 'pending' && ( {record.review_status === 'pending' && (
<Button type="link" size="small" icon={<CheckCircleOutlined />} onClick={() => handleReview(record)}>审核</Button> <Button type="link" size="small" icon={<CheckCircleOutlined />} onClick={() => handleReview(record)}>
审核
</Button>
)} )}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditDoctor(record)}>编辑</Button> <Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditDoctor(record)}>
<Button type="link" size="small" danger={record.user_status === 'active'} onClick={() => handleToggleStatus(record)}> 编辑
</Button>
<Button
type="link"
size="small"
danger={record.user_status === 'active'}
onClick={() => handleToggleStatus(record)}
>
{record.user_status === 'active' ? '禁用' : '启用'} {record.user_status === 'active' ? '禁用' : '启用'}
</Button> </Button>
<Button type="link" size="small" onClick={() => handleResetPassword(record)}>
重置密码
</Button>
</Space> </Space>
), ),
}, },
]; ];
// ---------- Render ----------
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div> <div>
...@@ -248,28 +264,203 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -248,28 +264,203 @@ const AdminDoctorsPage: React.FC = () => {
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台注册医生,审核资质认证</div> <div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台注册医生,审核资质认证</div>
</div> </div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> <ProTable<DoctorItem>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}> headerTitle="医生列表"
<Input placeholder="搜索姓名/手机号" prefix={<SearchOutlined />} style={{ width: 180, borderRadius: 8 }} tooltip="管理平台注册医生,审核资质认证"
value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={handleSearch} /> rowKey="id"
<Select placeholder="认证状态" style={{ width: 130 }} allowClear value={statusFilter} onChange={setStatusFilter} actionRef={actionRef}
options={Object.entries(statusMap).map(([k, v]) => ({ label: v.text, value: k }))} /> cardBordered
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button> columns={columns}
<Button icon={<ReloadOutlined />} onClick={() => { setKeyword(''); setStatusFilter(undefined); setPage(1); }}>重置</Button> search={{ labelWidth: 'auto' }}
<Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setAddModalVisible(true)}>添加医生</Button> options={{ density: true, reload: true, setting: true }}
</div> request={async (params) => {
</Card> const res = await adminApi.getDoctorList({
keyword: params.keyword,
status: params.review_status,
page: params.current,
page_size: params.pageSize,
});
const data = res.data as any;
return {
data: data?.list || [],
total: data?.total || 0,
success: true,
};
}}
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
toolBarRender={() => [
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => setAddModalVisible(true)}
>
添加医生
</Button>,
]}
/>
{/* ========== Add Doctor ModalForm ========== */}
<ModalForm
title="添加医生"
open={addModalVisible}
onOpenChange={setAddModalVisible}
width={600}
grid
rowProps={{ gutter: 16 }}
modalProps={{ destroyOnClose: true }}
initialValues={{ password: '123456' }}
onFinish={async (values) => {
try {
await adminApi.createDoctor(values);
message.success('医生添加成功');
actionRef.current?.reload();
return true;
} catch (error: any) {
message.error(error?.response?.data?.message || '添加失败');
return false;
}
}}
>
<ProFormText
name="real_name"
label="姓名"
placeholder="请输入医生姓名"
rules={[{ required: true, message: '请输入姓名' }]}
colProps={{ span: 12 }}
/>
<ProFormText
name="phone"
label="手机号"
placeholder="请输入手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号' },
]}
colProps={{ span: 12 }}
/>
<ProFormSelect
name="title"
label="职称"
placeholder="选择职称"
options={titleOptions}
rules={[{ required: true, message: '请选择职称' }]}
colProps={{ span: 12 }}
/>
<ProFormSelect
name="department_name"
label="科室"
placeholder="选择科室"
options={departmentOptions}
rules={[{ required: true, message: '请选择科室' }]}
colProps={{ span: 12 }}
/>
<ProFormText
name="hospital"
label="医院"
placeholder="请输入医院名称"
rules={[{ required: true, message: '请输入医院名称' }]}
colProps={{ span: 12 }}
/>
<ProFormText
name="license_no"
label="执业证号"
placeholder="请输入执业证号(选填)"
colProps={{ span: 12 }}
/>
<ProFormText.Password
name="password"
label="初始密码"
placeholder="默认密码:123456"
colProps={{ span: 24 }}
/>
<ProFormTextArea
name="introduction"
label="简介"
placeholder="请输入医生简介(选填)"
fieldProps={{ rows: 3 }}
colProps={{ span: 24 }}
/>
</ModalForm>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> {/* ========== Edit Doctor ModalForm ========== */}
<Table columns={columns} dataSource={doctors} rowKey="id" loading={loading} size="small" <ModalForm
pagination={{ title="编辑医生信息"
current: page, pageSize, total, showSizeChanger: true, size: 'small', open={editModalVisible}
showTotal: (t) => `共 ${t} 条`, onOpenChange={(visible) => {
onChange: (p, ps) => { setPage(p); setPageSize(ps); }, setEditModalVisible(visible);
}} /> if (!visible) setEditingDoctor(null);
</Card> }}
width={500}
grid
rowProps={{ gutter: 16 }}
modalProps={{ destroyOnClose: true }}
initialValues={
editingDoctor
? {
real_name: editingDoctor.name,
phone: editingDoctor.phone,
title: editingDoctor.title,
department_id: editingDoctor.department_id,
hospital: editingDoctor.hospital,
}
: undefined
}
onFinish={async (values) => {
if (!editingDoctor) return false;
try {
await adminApi.updateDoctor(editingDoctor.id, values);
message.success('医生信息已更新');
actionRef.current?.reload();
return true;
} catch {
message.error('更新失败');
return false;
}
}}
>
<ProFormText
name="real_name"
label="姓名"
placeholder="请输入姓名"
rules={[{ required: true, message: '请输入姓名' }]}
colProps={{ span: 12 }}
/>
<ProFormText
name="phone"
label="手机号"
placeholder="请输入手机号"
colProps={{ span: 12 }}
/>
<ProFormSelect
name="title"
label="职称"
placeholder="选择职称"
options={titleOptions}
colProps={{ span: 12 }}
/>
<ProFormText
name="department_id"
label="科室"
placeholder="科室ID"
colProps={{ span: 12 }}
/>
<ProFormText
name="hospital"
label="医院"
placeholder="请输入医院名称"
colProps={{ span: 24 }}
/>
</ModalForm>
<Modal title="医生详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={600}> {/* ========== Detail Modal (regular Modal) ========== */}
<Modal
title="医生详情"
open={detailVisible}
onCancel={() => setDetailVisible(false)}
footer={null}
width={600}
>
{currentDoctor && ( {currentDoctor && (
<Descriptions column={2} bordered size="small"> <Descriptions column={2} bordered size="small">
<Descriptions.Item label="姓名">{currentDoctor.name}</Descriptions.Item> <Descriptions.Item label="姓名">{currentDoctor.name}</Descriptions.Item>
...@@ -289,11 +480,17 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -289,11 +480,17 @@ const AdminDoctorsPage: React.FC = () => {
)} )}
</Modal> </Modal>
<Modal title="审核医生认证" open={reviewModalVisible} onCancel={() => { setReviewModalVisible(false); setRejectReason(''); }} {/* ========== Review Modal (regular Modal with custom footer) ========== */}
<Modal
title="审核医生认证"
open={reviewModalVisible}
onCancel={() => { setReviewModalVisible(false); setRejectReason(''); }}
footer={[ footer={[
<Button key="reject" danger onClick={handleReject} disabled={!rejectReason}>拒绝</Button>, <Button key="reject" danger onClick={handleReject} disabled={!rejectReason}>拒绝</Button>,
<Button key="approve" type="primary" onClick={handleApprove}>通过</Button>, <Button key="approve" type="primary" onClick={handleApprove}>通过</Button>,
]} width={500}> ]}
width={500}
>
{currentDoctor && ( {currentDoctor && (
<div className="space-y-4"> <div className="space-y-4">
<Descriptions column={1} size="small"> <Descriptions column={1} size="small">
...@@ -304,124 +501,16 @@ const AdminDoctorsPage: React.FC = () => { ...@@ -304,124 +501,16 @@ const AdminDoctorsPage: React.FC = () => {
</Descriptions> </Descriptions>
<div> <div>
<Text type="secondary" className="text-xs">拒绝原因(拒绝时必填):</Text> <Text type="secondary" className="text-xs">拒绝原因(拒绝时必填):</Text>
<Input.TextArea rows={3} placeholder="请输入拒绝原因..." value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} /> <Input.TextArea
rows={3}
placeholder="请输入拒绝原因..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div> </div>
</div> </div>
)} )}
</Modal> </Modal>
<Modal title="编辑医生信息" open={editModalVisible} onCancel={() => setEditModalVisible(false)}
onOk={handleSaveEdit} okText="保存" confirmLoading={editLoading} width={500}>
<Form form={editForm} layout="vertical" size="small">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="real_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone">
<Input placeholder="请输入手机号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="职称" name="title">
<Select placeholder="选择职称" options={[
{ label: '主任医师', value: '主任医师' },
{ label: '副主任医师', value: '副主任医师' },
{ label: '主治医师', value: '主治医师' },
{ label: '住院医师', value: '住院医师' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="科室" name="department_id">
<Input placeholder="科室ID" />
</Form.Item>
</Col>
</Row>
<Form.Item label="医院" name="hospital">
<Input placeholder="请输入医院名称" />
</Form.Item>
</Form>
</Modal>
{/* 添加医生Modal */}
<Modal
title="添加医生"
open={addModalVisible}
onCancel={() => { setAddModalVisible(false); addForm.resetFields(); }}
onOk={handleAddDoctor}
okText="添加"
confirmLoading={addLoading}
width={600}
>
<Form form={addForm} layout="vertical" size="small">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="real_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入医生姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone" rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1\d{10}$/, message: '请输入正确的手机号' }
]}>
<Input placeholder="请输入手机号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="职称" name="title" rules={[{ required: true, message: '请选择职称' }]}>
<Select placeholder="选择职称" options={[
{ label: '主任医师', value: '主任医师' },
{ label: '副主任医师', value: '副主任医师' },
{ label: '主治医师', value: '主治医师' },
{ label: '住院医师', value: '住院医师' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="科室" name="department_name" rules={[{ required: true, message: '请选择科室' }]}>
<Select placeholder="选择科室" options={[
{ label: '内科', value: '内科' },
{ label: '外科', value: '外科' },
{ label: '儿科', value: '儿科' },
{ label: '妇产科', value: '妇产科' },
{ label: '皮肤科', value: '皮肤科' },
{ label: '眼科', value: '眼科' },
{ label: '耳鼻喉科', value: '耳鼻喉科' },
{ label: '口腔科', value: '口腔科' },
{ label: '中医科', value: '中医科' },
{ label: '精神心理科', value: '精神心理科' },
]} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="医院" name="hospital" rules={[{ required: true, message: '请输入医院名称' }]}>
<Input placeholder="请输入医院名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="执业证号" name="license_no">
<Input placeholder="请输入执业证号(选填)" />
</Form.Item>
</Col>
</Row>
<Form.Item label="初始密码" name="password" initialValue="123456">
<Input.Password placeholder="默认密码:123456" />
</Form.Item>
<Form.Item label="简介" name="introduction">
<Input.TextArea rows={3} placeholder="请输入医生简介(选填)" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
}; };
......
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { Button, Space, Tag, Avatar, Modal, Typography, App } from 'antd';
import { import {
Card, Table, Input, Select, Button, Space, Tag, Avatar, Modal, message, UserOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
Typography, Form, InputNumber, Row, Col,
} from 'antd';
import {
SearchOutlined, UserOutlined, ReloadOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import {
ProTable, ModalForm, ProFormText, ProFormSelect, ProFormDigit,
} from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { adminApi } from '../../../api/admin'; import { adminApi } from '../../../api/admin';
import type { UserInfo } from '../../../api/user'; import type { UserInfo } from '../../../api/user';
const { Text } = Typography; const { Text } = Typography;
const AdminPatientsPage: React.FC = () => { const AdminPatientsPage: React.FC = () => {
const { message } = App.useApp();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [loading, setLoading] = useState(false); const actionRef = useRef<ActionType>();
const [patients, setPatients] = useState<UserInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [modalVisible, setModalVisible] = useState(false);
const [modalType, setModalType] = useState<'add' | 'edit'>('add');
const [editingRecord, setEditingRecord] = useState<UserInfo | null>(null);
const [modalForm] = Form.useForm();
const [modalLoading, setModalLoading] = useState(false);
const fetchPatients = useCallback(async () => { // Add modal state
setLoading(true); const [addModalVisible, setAddModalVisible] = useState(false);
try {
const res = await adminApi.getPatientList({ keyword, status: statusFilter, page, page_size: pageSize });
setPatients(res.data.list || []);
setTotal(res.data.total || 0);
} catch {
message.error('获取患者列表失败');
} finally {
setLoading(false);
}
}, [keyword, statusFilter, page, pageSize]);
useEffect(() => { // Edit modal state
fetchPatients(); const [editModalVisible, setEditModalVisible] = useState(false);
}, [fetchPatients]); const [editingRecord, setEditingRecord] = useState<UserInfo | null>(null);
// ?action=add 自动打开添加弹窗 // ?action=add auto-open add modal
useEffect(() => { useEffect(() => {
if (searchParams.get('action') === 'add') { if (searchParams.get('action') === 'add') {
handleAdd(); setAddModalVisible(true);
} }
}, [searchParams]); // eslint-disable-line react-hooks/exhaustive-deps }, [searchParams]);
const handleSearch = () => {
setPage(1);
fetchPatients();
};
const handleToggleStatus = (record: UserInfo) => { const handleToggleStatus = (record: UserInfo) => {
const newStatus = record.status === 'active' ? 'disabled' : 'active'; const newStatus = record.status === 'active' ? 'disabled' : 'active';
...@@ -68,7 +43,7 @@ const AdminPatientsPage: React.FC = () => { ...@@ -68,7 +43,7 @@ const AdminPatientsPage: React.FC = () => {
try { try {
await adminApi.updatePatientStatus(record.id, newStatus); await adminApi.updatePatientStatus(record.id, newStatus);
message.success('操作成功'); message.success('操作成功');
fetchPatients(); actionRef.current?.reload();
} catch { } catch {
message.error('操作失败'); message.error('操作失败');
} }
...@@ -91,23 +66,9 @@ const AdminPatientsPage: React.FC = () => { ...@@ -91,23 +66,9 @@ const AdminPatientsPage: React.FC = () => {
}); });
}; };
const handleAdd = () => {
setModalType('add');
setEditingRecord(null);
modalForm.resetFields();
setModalVisible(true);
};
const handleEdit = (record: UserInfo) => { const handleEdit = (record: UserInfo) => {
setModalType('edit');
setEditingRecord(record); setEditingRecord(record);
modalForm.setFieldsValue({ setEditModalVisible(true);
real_name: record.real_name,
phone: record.phone,
gender: record.gender,
age: record.age,
});
setModalVisible(true);
}; };
const handleDelete = (record: UserInfo) => { const handleDelete = (record: UserInfo) => {
...@@ -119,7 +80,7 @@ const AdminPatientsPage: React.FC = () => { ...@@ -119,7 +80,7 @@ const AdminPatientsPage: React.FC = () => {
try { try {
await adminApi.deletePatient(record.id); await adminApi.deletePatient(record.id);
message.success('删除成功'); message.success('删除成功');
fetchPatients(); actionRef.current?.reload();
} catch { } catch {
message.error('删除失败'); message.error('删除失败');
} }
...@@ -127,44 +88,10 @@ const AdminPatientsPage: React.FC = () => { ...@@ -127,44 +88,10 @@ const AdminPatientsPage: React.FC = () => {
}); });
}; };
const handleModalSubmit = async () => { const columns: ProColumns<UserInfo>[] = [
try {
const values = await modalForm.validateFields();
setModalLoading(true);
if (modalType === 'add') {
await adminApi.createPatient({
phone: values.phone,
password: values.password || '123456',
real_name: values.real_name,
gender: values.gender,
age: values.age,
});
message.success('患者添加成功');
} else if (editingRecord) {
await adminApi.updatePatient(editingRecord.id, {
real_name: values.real_name,
phone: values.phone,
gender: values.gender,
age: values.age,
});
message.success('患者信息更新成功');
}
setModalVisible(false);
modalForm.resetFields();
fetchPatients();
} catch (error: any) {
if (!error?.errorFields) {
message.error('操作失败');
}
} finally {
setModalLoading(false);
}
};
const columns: ColumnsType<UserInfo> = [
{ {
title: '患者', title: '患者',
key: 'patient', dataIndex: 'real_name',
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#1890ff' }} /> <Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#1890ff' }} />
...@@ -176,96 +103,221 @@ const AdminPatientsPage: React.FC = () => { ...@@ -176,96 +103,221 @@ const AdminPatientsPage: React.FC = () => {
</Space> </Space>
), ),
}, },
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 80, render: (v: string) => v || '-' },
{ title: '年龄', dataIndex: 'age', key: 'age', width: 80, render: (v: number) => v || '-' },
{ {
title: '实名认证', dataIndex: 'is_verified', key: 'is_verified', title: '关键词',
render: (verified: boolean) => <Tag color={verified ? 'green' : 'default'}>{verified ? '已认证' : '未认证'}</Tag>, dataIndex: 'keyword',
hideInTable: true,
},
{
title: '性别',
dataIndex: 'gender',
search: false,
width: 80,
render: (v) => (v as string) || '-',
}, },
{ {
title: '状态', dataIndex: 'status', key: 'status', title: '年龄',
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'red'}>{status === 'active' ? '正常' : '已禁用'}</Tag>, dataIndex: 'age',
search: false,
width: 80,
render: (v) => (v as number) || '-',
}, },
{ {
title: '注册时间', dataIndex: 'created_at', key: 'created_at', title: '实名认证',
render: (time: string) => time ? new Date(time).toLocaleString('zh-CN') : '-', dataIndex: 'is_verified',
search: false,
render: (v) => <Tag color={v ? 'green' : 'default'}>{v ? '已认证' : '未认证'}</Tag>,
}, },
{ {
title: '操作', key: 'action', width: 260, title: '状态',
dataIndex: 'status',
valueEnum: {
active: { text: '正常', status: 'Success' },
disabled: { text: '已禁用', status: 'Error' },
},
},
{
title: '注册时间',
dataIndex: 'created_at',
search: false,
render: (_, record) => record.created_at ? new Date(record.created_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
valueType: 'option',
width: 260,
render: (_, record) => ( render: (_, record) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button> <a onClick={() => handleEdit(record)}><EditOutlined /> 编辑</a>
<Button type="link" size="small" danger={record.status === 'active'} onClick={() => handleToggleStatus(record)}> <a
style={record.status === 'active' ? { color: '#ff4d4f' } : undefined}
onClick={() => handleToggleStatus(record)}
>
{record.status === 'active' ? '禁用' : '启用'} {record.status === 'active' ? '禁用' : '启用'}
</Button> </a>
<Button type="link" size="small" onClick={() => handleResetPassword(record)}>重置密码</Button> <a onClick={() => handleResetPassword(record)}>重置密码</a>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>删除</Button> <a style={{ color: '#ff4d4f' }} onClick={() => handleDelete(record)}><DeleteOutlined /> 删除</a>
</Space> </Space>
), ),
}, },
]; ];
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <ProTable<UserInfo>
<div> headerTitle="患者管理"
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>患者管理</h2> tooltip="管理平台注册患者信息与账号状态"
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台注册患者信息与账号状态</div> actionRef={actionRef}
</div> rowKey="id"
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {total} 条记录</span> columns={columns}
</div> cardBordered
request={async (params) => {
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> const res = await adminApi.getPatientList({
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}> keyword: params.keyword,
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}> status: params.status,
<Input placeholder="搜索姓名/手机号" prefix={<SearchOutlined />} value={keyword} page: params.current,
onChange={(e) => setKeyword(e.target.value)} onPressEnter={handleSearch} style={{ width: 200, borderRadius: 8 }} /> page_size: params.pageSize,
<Select placeholder="状态" allowClear value={statusFilter || undefined} });
onChange={(v) => setStatusFilter(v || '')} style={{ width: 110 }} return {
options={[{ label: '正常', value: 'active' }, { label: '已禁用', value: 'disabled' }]} /> data: res.data.list || [],
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button> total: res.data.total || 0,
<Button icon={<ReloadOutlined />} onClick={() => { setKeyword(''); setStatusFilter(''); setPage(1); }}>重置</Button> success: true,
</div> };
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加患者</Button> }}
</div> pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (t) => `共 ${t} 条` }}
</Card> search={{ labelWidth: 'auto' }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
添加患者
</Button>,
]}
/>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> {/* Add Patient ModalForm */}
<Table columns={columns} dataSource={patients} rowKey="id" loading={loading} size="small" <ModalForm
pagination={{ title="添加患者"
current: page, pageSize, total, showTotal: (t) => `共 ${t} 条`, size: 'small', open={addModalVisible}
onChange: (p, ps) => { setPage(p); setPageSize(ps); }, onOpenChange={setAddModalVisible}
}} /> width={440}
</Card> modalProps={{ destroyOnClose: true }}
onFinish={async (values) => {
try {
await adminApi.createPatient({
phone: values.phone,
password: values.password || '123456',
real_name: values.real_name,
gender: values.gender,
age: values.age,
});
message.success('患者添加成功');
actionRef.current?.reload();
return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
<ProFormText
name="real_name"
label="姓名"
placeholder="患者姓名"
rules={[{ required: true, message: '请输入姓名' }]}
/>
<ProFormText
name="phone"
label="手机号"
placeholder="手机号"
rules={[{ required: true, message: '请输入手机号' }]}
/>
<ProFormText.Password
name="password"
label="初始密码"
placeholder="默认 123456"
/>
<ProFormSelect
name="gender"
label="性别"
placeholder="选择性别"
allowClear
options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]}
colProps={{ span: 12 }}
/>
<ProFormDigit
name="age"
label="年龄"
placeholder="年龄"
min={1}
max={150}
fieldProps={{ style: { width: '100%' } }}
colProps={{ span: 12 }}
/>
</ModalForm>
<Modal title={modalType === 'add' ? '添加患者' : '编辑患者'} open={modalVisible} {/* Edit Patient ModalForm */}
onOk={handleModalSubmit} onCancel={() => setModalVisible(false)} confirmLoading={modalLoading} destroyOnHidden width={440}> <ModalForm
<Form form={modalForm} layout="vertical" size="small"> title="编辑患者"
<Form.Item name="real_name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}> open={editModalVisible}
<Input placeholder="患者姓名" /> onOpenChange={(open) => {
</Form.Item> setEditModalVisible(open);
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}> if (!open) setEditingRecord(null);
<Input placeholder="手机号" /> }}
</Form.Item> width={440}
{modalType === 'add' && ( modalProps={{ destroyOnClose: true }}
<Form.Item name="password" label="初始密码"> initialValues={editingRecord ? {
<Input.Password placeholder="默认 123456" /> real_name: editingRecord.real_name,
</Form.Item> phone: editingRecord.phone,
)} gender: editingRecord.gender,
<Row gutter={12}> age: editingRecord.age,
<Col span={12}> } : undefined}
<Form.Item name="gender" label="性别"> onFinish={async (values) => {
<Select placeholder="选择性别" allowClear options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]} /> if (!editingRecord) return false;
</Form.Item> try {
</Col> await adminApi.updatePatient(editingRecord.id, {
<Col span={12}> real_name: values.real_name,
<Form.Item name="age" label="年龄"> phone: values.phone,
<InputNumber style={{ width: '100%' }} min={1} max={150} placeholder="年龄" /> gender: values.gender,
</Form.Item> age: values.age,
</Col> });
</Row> message.success('患者信息更新成功');
</Form> actionRef.current?.reload();
</Modal> return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
<ProFormText
name="real_name"
label="姓名"
placeholder="患者姓名"
rules={[{ required: true, message: '请输入姓名' }]}
/>
<ProFormText
name="phone"
label="手机号"
placeholder="手机号"
rules={[{ required: true, message: '请输入手机号' }]}
/>
<ProFormSelect
name="gender"
label="性别"
placeholder="选择性别"
allowClear
options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]}
colProps={{ span: 12 }}
/>
<ProFormDigit
name="age"
label="年龄"
placeholder="年龄"
min={1}
max={150}
fieldProps={{ style: { width: '100%' } }}
colProps={{ span: 12 }}
/>
</ModalForm>
</div> </div>
); );
}; };
......
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { import {
Avatar, Tag, Button, Typography, Space, Empty, Input, Tooltip, Popover, Tabs, Spin, Avatar, Tag, Button, Typography, Space, Empty, Input, Tooltip, Popover,
Image, Upload, message as antdMessage, Upload, message as antdMessage,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined, UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined,
FileTextOutlined, ThunderboltOutlined, SmileOutlined, RobotOutlined, FileTextOutlined, ThunderboltOutlined, SmileOutlined,
SettingOutlined, PictureOutlined, PaperClipOutlined, PictureOutlined, PaperClipOutlined,
DownloadOutlined, PlayCircleOutlined, SwapOutlined, ClockCircleOutlined, SwapOutlined, ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ConsultMessage } from '../../../api/consult'; import type { ConsultMessage } from '../../../api/consult';
import { consultApi } from '../../../api/consult'; import { consultApi } from '../../../api/consult';
...@@ -16,6 +16,8 @@ import type { QuickReplyTemplate } from '../../../api/doctorPortal'; ...@@ -16,6 +16,8 @@ import type { QuickReplyTemplate } from '../../../api/doctorPortal';
import PrescriptionModal from '../Prescription'; import PrescriptionModal from '../Prescription';
import TransferModal from './TransferModal'; import TransferModal from './TransferModal';
import EndConsultModal from './EndConsultModal'; import EndConsultModal from './EndConsultModal';
import { QuickReplyContent } from './QuickReplyContent';
import { MessageBubble } from './MessageBubble';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
...@@ -35,15 +37,7 @@ const DEFAULT_REPLIES = [ ...@@ -35,15 +37,7 @@ const DEFAULT_REPLIES = [
{ category: 'prescription', content: '我这边为您开具处方,请到附近药房取药并按时服用。' }, { category: 'prescription', content: '我这边为您开具处方,请到附近药房取药并按时服用。' },
]; ];
const CATEGORY_LABELS: Record<string, string> = { export interface ActiveConsult {
greeting: '问候',
inquiry: '询问',
diagnosis: '诊断',
prescription: '处方',
closing: '结束',
};
interface ActiveConsult {
consult_id: string; consult_id: string;
serial_number: string; serial_number: string;
patient_id?: string; patient_id?: string;
...@@ -94,6 +88,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -94,6 +88,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
const [timerSeconds, setTimerSeconds] = useState(0); const [timerSeconds, setTimerSeconds] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const isCompleted = activeConsult?.status === 'completed';
// 问诊计时器 // 问诊计时器
useEffect(() => { useEffect(() => {
if (!activeConsult?.started_at || isCompleted) { if (!activeConsult?.started_at || isCompleted) {
...@@ -107,7 +103,20 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -107,7 +103,20 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
updateTimer(); updateTimer();
const timer = setInterval(updateTimer, 1000); const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [activeConsult?.started_at, activeConsult?.status]); }, [activeConsult?.started_at, activeConsult?.status, isCompleted]);
// 自动标记患者消息为已读
const lastMarkReadRef = useRef<number>(0);
useEffect(() => {
if (!activeConsult?.consult_id || !messages.length) return;
const unreadFromPatient = messages.some(
(m) => m.sender_type === 'patient' && !m.read_at
);
if (unreadFromPatient && Date.now() - lastMarkReadRef.current > 5000) {
lastMarkReadRef.current = Date.now();
consultApi.markMessagesRead(activeConsult.consult_id).catch(() => {});
}
}, [activeConsult?.consult_id, messages]);
const formatTimer = (seconds: number) => { const formatTimer = (seconds: number) => {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
...@@ -193,8 +202,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -193,8 +202,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
} }
}; };
const isCompleted = activeConsult?.status === 'completed';
const handleUpload = async (file: File) => { const handleUpload = async (file: File) => {
if (!activeConsult?.serial_number) return; if (!activeConsult?.serial_number) return;
try { try {
...@@ -207,62 +214,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -207,62 +214,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
} }
}; };
const renderMessageContent = (msg: ConsultMessage) => {
const ct = msg.content_type || 'text';
const mediaUrl = msg.media_url || msg.content;
switch (ct) {
case 'image':
return (
<Image
src={mediaUrl}
alt="图片"
style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8 }}
placeholder
/>
);
case 'file':
return (
<div style={{
padding: '8px 12px', background: '#f5f5f5', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>文件</div>
<div style={{ fontSize: 11, color: '#999' }}>点击下载</div>
</div>
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<DownloadOutlined style={{ fontSize: 16, color: '#1890ff' }} />
</a>
</div>
);
case 'audio':
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlayCircleOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<audio controls src={mediaUrl} style={{ height: 32 }} />
</div>
);
case 'prescription':
return (
<div style={{
padding: '8px 12px', background: '#fffbe6', borderRadius: 8,
border: '1px solid #ffe58f',
}}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#d48806' }}>
<FileTextOutlined style={{ marginRight: 4 }} />处方
</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>
{msg.content}
</div>
</div>
);
default:
return msg.content;
}
};
// 合并 API 回复 + 默认回复 // 合并 API 回复 + 默认回复
const allReplies = quickReplies.length > 0 const allReplies = quickReplies.length > 0
? quickReplies ? quickReplies
...@@ -274,89 +225,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -274,89 +225,6 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
const categories = ['all', ...Array.from(new Set(allReplies.map(r => r.category)))]; const categories = ['all', ...Array.from(new Set(allReplies.map(r => r.category)))];
const quickReplyContent = (
<div style={{ width: 320, maxHeight: 420, overflow: 'auto' }}>
{/* AI 推荐区 */}
<div style={{ padding: '6px 0 8px', borderBottom: '1px solid #f0f0f0', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 12, color: '#fa8c16', fontWeight: 500 }}>
<RobotOutlined style={{ marginRight: 4 }} />AI 智能推荐
</span>
<Button type="link" size="small" onClick={handleAISuggest} loading={aiSugLoading} style={{ fontSize: 11, padding: 0 }}>
{aiSuggestions.length > 0 ? '刷新' : '生成推荐'}
</Button>
</div>
{aiSugLoading ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : aiSuggestions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{aiSuggestions.map((text, i) => (
<div
key={i}
onClick={() => handleQuickReply(text)}
onDoubleClick={() => handleQuickReplyEdit(text)}
style={{
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#fff7e6', border: '1px solid #ffe58f',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff1b8'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff7e6'; }}
>
<ThunderboltOutlined style={{ color: '#fa8c16', marginRight: 4, fontSize: 10 }} />
{text}
</div>
))}
</div>
) : (
<div style={{ fontSize: 11, color: '#bbb', textAlign: 'center', padding: 4 }}>
点击"生成推荐"获取智能回复
</div>
)}
</div>
{/* 分类标签 */}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{categories.map(cat => (
<Tag
key={cat}
color={qrCategory === cat ? 'blue' : undefined}
style={{ cursor: 'pointer', fontSize: 11, margin: 0 }}
onClick={() => setQrCategory(cat)}
>
{cat === 'all' ? '全部' : (CATEGORY_LABELS[cat] || cat)}
</Tag>
))}
</div>
{/* 模板列表 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{filteredReplies.map(tpl => (
<div
key={tpl.id}
onClick={() => handleQuickReply(tpl.content, tpl.id > 0 ? tpl.id : undefined)}
onDoubleClick={() => handleQuickReplyEdit(tpl.content)}
title="单击发送,双击填入编辑框"
style={{
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#f9fafb', border: '1px solid #f0f0f0',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#e6f7ff'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#f9fafb'; }}
>
{tpl.content}
</div>
))}
</div>
{/* 提示 */}
<div style={{ fontSize: 10, color: '#bbb', marginTop: 6, textAlign: 'center' }}>
单击直接发送 · 双击填入编辑框
</div>
</div>
);
return ( return (
<> <>
<div style={{ <div style={{
...@@ -482,64 +350,12 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -482,64 +350,12 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</div> </div>
)} )}
{messages.map(msg => ( {messages.map(msg => (
<div <MessageBubble
key={msg.id} key={msg.id}
style={{ msg={msg}
display: 'flex', activeConsult={activeConsult}
justifyContent: msg.sender_type === 'doctor' formatTime={formatTime}
? 'flex-end' />
: msg.sender_type === 'system'
? 'center'
: 'flex-start',
marginBottom: 16,
}}
>
{msg.sender_type === 'system' ? (
<div style={{
background: 'rgba(0,0,0,0.04)',
borderRadius: 12,
padding: '3px 14px',
fontSize: 11,
color: '#8c8c8c',
}}>
{msg.content}
</div>
) : (
<div style={{
maxWidth: '72%',
display: 'flex',
flexDirection: 'column',
alignItems: msg.sender_type === 'doctor' ? 'flex-end' : 'flex-start',
}}>
<div style={{ fontSize: 11, color: '#aaa', marginBottom: 5 }}>
{msg.sender_type === 'doctor' ? '我' : activeConsult.patient_name}
{' · '}
{formatTime(msg.created_at)}
</div>
<div style={{
padding: '10px 14px',
borderRadius: msg.sender_type === 'doctor'
? '12px 4px 12px 12px'
: '4px 12px 12px 12px',
background: msg.sender_type === 'doctor' ? '#52c41a' : '#fff',
color: msg.sender_type === 'doctor' ? '#fff' : '#1d2129',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 14,
lineHeight: 1.6,
}}>
{renderMessageContent(msg)}
</div>
{/* 已读标记 */}
{msg.sender_type === 'doctor' && msg.read_at && (
<div style={{ fontSize: 10, color: '#52c41a', marginTop: 2, textAlign: 'right' }}>
✓✓ 已读
</div>
)}
</div>
)}
</div>
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</> </>
...@@ -575,7 +391,19 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -575,7 +391,19 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
borderBottom: '1px solid #f5f5f5', borderBottom: '1px solid #f5f5f5',
}}> }}>
<Popover <Popover
content={quickReplyContent} content={
<QuickReplyContent
aiSuggestions={aiSuggestions}
aiSugLoading={aiSugLoading}
onAISuggest={handleAISuggest}
filteredReplies={filteredReplies}
categories={categories}
qrCategory={qrCategory}
onCategoryChange={setQrCategory}
onQuickReply={handleQuickReply}
onQuickReplyEdit={handleQuickReplyEdit}
/>
}
trigger="click" trigger="click"
open={quickReplyOpen} open={quickReplyOpen}
onOpenChange={setQuickReplyOpen} onOpenChange={setQuickReplyOpen}
......
import React from 'react';
import { Image } from 'antd';
import {
FileTextOutlined, DownloadOutlined, PlayCircleOutlined,
} from '@ant-design/icons';
import type { ConsultMessage } from '../../../api/consult';
interface ActiveConsult {
patient_name: string;
}
interface MessageBubbleProps {
msg: ConsultMessage;
activeConsult: ActiveConsult;
formatTime: (t: string) => string;
}
const renderMessageContent = (msg: ConsultMessage) => {
const ct = msg.content_type || 'text';
const mediaUrl = msg.media_url || msg.content;
switch (ct) {
case 'image':
return (
<Image
src={mediaUrl}
alt="图片"
style={{ maxWidth: 200, maxHeight: 200, borderRadius: 8 }}
placeholder
/>
);
case 'file':
return (
<div style={{
padding: '8px 12px', background: '#f5f5f5', borderRadius: 8,
display: 'flex', alignItems: 'center', gap: 8,
}}>
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12, fontWeight: 500 }}>文件</div>
<div style={{ fontSize: 11, color: '#999' }}>点击下载</div>
</div>
<a href={mediaUrl} target="_blank" rel="noopener noreferrer">
<DownloadOutlined style={{ fontSize: 16, color: '#1890ff' }} />
</a>
</div>
);
case 'audio':
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<PlayCircleOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<audio controls src={mediaUrl} style={{ height: 32 }} />
</div>
);
case 'prescription':
return (
<div style={{
padding: '8px 12px', background: '#fffbe6', borderRadius: 8,
border: '1px solid #ffe58f',
}}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#d48806' }}>
<FileTextOutlined style={{ marginRight: 4 }} />处方
</div>
<div style={{ fontSize: 12, color: '#666', marginTop: 4 }}>
{msg.content}
</div>
</div>
);
default:
return msg.content;
}
};
const MessageBubble: React.FC<Partial<MessageBubbleProps>> = ({ msg, activeConsult, formatTime }) => {
if (!msg || !activeConsult || !formatTime) return null;
return (
<div
style={{
display: 'flex',
justifyContent: msg.sender_type === 'doctor'
? 'flex-end'
: msg.sender_type === 'system'
? 'center'
: 'flex-start',
marginBottom: 16,
}}
>
{msg.sender_type === 'system' ? (
<div style={{
background: 'rgba(0,0,0,0.04)',
borderRadius: 12,
padding: '3px 14px',
fontSize: 11,
color: '#8c8c8c',
}}>
{msg.content}
</div>
) : (
<div style={{
maxWidth: '72%',
display: 'flex',
flexDirection: 'column',
alignItems: msg.sender_type === 'doctor' ? 'flex-end' : 'flex-start',
}}>
<div style={{ fontSize: 11, color: '#aaa', marginBottom: 5 }}>
{msg.sender_type === 'doctor' ? '我' : activeConsult.patient_name}
{' · '}
{formatTime(msg.created_at)}
</div>
<div style={{
padding: '10px 14px',
borderRadius: msg.sender_type === 'doctor'
? '12px 4px 12px 12px'
: '4px 12px 12px 12px',
background: msg.sender_type === 'doctor' ? '#52c41a' : '#fff',
color: msg.sender_type === 'doctor' ? '#fff' : '#1d2129',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 14,
lineHeight: 1.6,
}}>
{renderMessageContent(msg)}
</div>
{/* 已读标记 */}
{msg.sender_type === 'doctor' && msg.read_at && (
<div style={{ fontSize: 10, color: '#52c41a', marginTop: 2, textAlign: 'right' }}>
{'\u2713\u2713'} 已读
</div>
)}
</div>
)}
</div>
);
};
export { MessageBubble };
export default MessageBubble;
import React from 'react';
import { Tag, Button, Spin } from 'antd';
import { RobotOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { QuickReplyTemplate } from '../../../api/doctorPortal';
const CATEGORY_LABELS: Record<string, string> = {
greeting: '问候',
inquiry: '询问',
diagnosis: '诊断',
prescription: '处方',
closing: '结束',
};
interface QuickReplyContentProps {
aiSuggestions: string[];
aiSugLoading: boolean;
onAISuggest: () => void;
filteredReplies: QuickReplyTemplate[];
categories: string[];
qrCategory: string;
onCategoryChange: (cat: string) => void;
onQuickReply: (text: string, templateId?: number) => void;
onQuickReplyEdit: (text: string) => void;
}
const QuickReplyContent: React.FC<Partial<QuickReplyContentProps>> = ({
aiSuggestions = [],
aiSugLoading,
onAISuggest,
filteredReplies,
categories,
qrCategory,
onCategoryChange,
onQuickReply,
onQuickReplyEdit,
} = {}) => {
if (!filteredReplies || !categories) return null;
return (
<div style={{ width: 320, maxHeight: 420, overflow: 'auto' }}>
{/* AI 推荐区 */}
<div style={{ padding: '6px 0 8px', borderBottom: '1px solid #f0f0f0', marginBottom: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<span style={{ fontSize: 12, color: '#fa8c16', fontWeight: 500 }}>
<RobotOutlined style={{ marginRight: 4 }} />AI 智能推荐
</span>
<Button type="link" size="small" onClick={onAISuggest} loading={aiSugLoading} style={{ fontSize: 11, padding: 0 }}>
{aiSuggestions.length > 0 ? '刷新' : '生成推荐'}
</Button>
</div>
{aiSugLoading ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : aiSuggestions.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{aiSuggestions.map((text, i) => (
<div
key={i}
onClick={() => onQuickReply(text)}
onDoubleClick={() => onQuickReplyEdit(text)}
style={{
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#fff7e6', border: '1px solid #ffe58f',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff1b8'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#fff7e6'; }}
>
<ThunderboltOutlined style={{ color: '#fa8c16', marginRight: 4, fontSize: 10 }} />
{text}
</div>
))}
</div>
) : (
<div style={{ fontSize: 11, color: '#bbb', textAlign: 'center', padding: 4 }}>
点击"生成推荐"获取智能回复
</div>
)}
</div>
{/* 分类标签 */}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{categories.map(cat => (
<Tag
key={cat}
color={qrCategory === cat ? 'blue' : undefined}
style={{ cursor: 'pointer', fontSize: 11, margin: 0 }}
onClick={() => onCategoryChange(cat)}
>
{cat === 'all' ? '全部' : (CATEGORY_LABELS[cat] || cat)}
</Tag>
))}
</div>
{/* 模板列表 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{filteredReplies.map(tpl => (
<div
key={tpl.id}
onClick={() => onQuickReply(tpl.content, tpl.id > 0 ? tpl.id : undefined)}
onDoubleClick={() => onQuickReplyEdit(tpl.content)}
title="单击发送,双击填入编辑框"
style={{
padding: '6px 8px', borderRadius: 6, fontSize: 12, cursor: 'pointer',
color: '#374151', background: '#f9fafb', border: '1px solid #f0f0f0',
lineHeight: 1.4, transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#e6f7ff'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#f9fafb'; }}
>
{tpl.content}
</div>
))}
</div>
{/* 提示 */}
<div style={{ fontSize: 10, color: '#bbb', marginTop: 6, textAlign: 'center' }}>
单击直接发送 · 双击填入编辑框
</div>
</div>
);
};
export { QuickReplyContent };
export default QuickReplyContent;
'use client';
import React from 'react';
import { Avatar, Tag } from 'antd';
import {
RobotOutlined,
UserOutlined,
LoadingOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import MarkdownRenderer from '../../../components/MarkdownRenderer';
import type { DisplayMessage } from './index';
interface ChatMessageItemProps {
msg: DisplayMessage;
}
const ChatMessageItem: React.FC<Partial<ChatMessageItemProps>> = ({ msg } = {}) => {
if (!msg) return null;
const isUser = msg.role === 'user';
const isSystem = msg.role === 'system';
if (isSystem) {
return (
<div style={{ textAlign: 'center', margin: '16px 0' }}>
<Tag icon={<InfoCircleOutlined />} color="processing">{msg.content}</Tag>
</div>
);
}
return (
<div
style={{
display: 'flex',
flexDirection: isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
marginBottom: 20,
gap: 12,
}}
>
<Avatar
size={40}
icon={isUser ? <UserOutlined /> : <RobotOutlined />}
style={{
backgroundColor: isUser ? '#1890ff' : '#722ed1',
flexShrink: 0,
}}
/>
<div
style={{
maxWidth: '75%',
padding: '12px 16px',
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
backgroundColor: isUser ? '#1890ff' : '#f5f5f5',
color: isUser ? '#fff' : '#333',
fontSize: 15,
lineHeight: 1.7,
boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
position: 'relative',
}}
>
{msg.content && (
isUser ? (
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{msg.content}
</span>
) : (
<MarkdownRenderer
content={msg.content}
fontSize={15}
lineHeight={1.7}
textColor="#333"
/>
)
)}
{msg.loading && (
<span style={{ display: 'inline-block', marginLeft: 4 }}>
<LoadingOutlined style={{ color: isUser ? '#fff' : '#722ed1' }} />
</span>
)}
</div>
</div>
);
};
export { ChatMessageItem };
export default ChatMessageItem;
'use client';
import React from 'react';
import {
Typography, Space, Avatar, Tag, List, Drawer, Spin, Empty,
} from 'antd';
import {
HistoryOutlined,
FileTextOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import type { PreConsultResponse } from '../../../api/preConsult';
const { Text } = Typography;
interface HistoryDrawerProps {
open: boolean;
onClose: () => void;
loading: boolean;
historyList: PreConsultResponse[];
currentSessionId: string | null;
onRestore: (session: PreConsultResponse) => void;
}
const HistoryDrawer: React.FC<Partial<HistoryDrawerProps>> = ({
open = false,
onClose,
loading = false,
historyList = [],
currentSessionId,
onRestore,
} = {}) => {
if (!onClose || !onRestore) return null;
return (
<Drawer
title={
<Space>
<HistoryOutlined />
<span>历史对话记录</span>
</Space>
}
placement="right"
width={400}
open={open}
onClose={onClose}
>
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin tip="加载中.." />
</div>
) : historyList.length === 0 ? (
<Empty description="暂无历史对话" />
) : (
<List
dataSource={historyList}
renderItem={(item) => (
<List.Item
style={{
cursor: 'pointer',
borderRadius: 8,
marginBottom: 8,
padding: 12,
background: item.id === currentSessionId ? '#f0f5ff' : '#fafafa',
}}
onClick={() => onRestore(item)}
>
<List.Item.Meta
avatar={
<Avatar
icon={item.status === 'completed' ? <FileTextOutlined /> : <ClockCircleOutlined />}
style={{ backgroundColor: item.status === 'completed' ? '#52c41a' : '#faad14' }}
/>
}
title={
<Space>
<Text strong ellipsis style={{ maxWidth: 200 }}>
{item.chief_complaint || '预问诊对话'}
</Text>
{item.id === currentSessionId && <Tag color="blue">当前</Tag>}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{item.status === 'completed' ? (
<>
<Tag color="green" style={{ marginRight: 4 }}>已完成</Tag>
{item.ai_department && <Tag>{item.ai_department}</Tag>}
</>
) : (
<Tag color="orange">进行中</Tag>
)}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
{item.chat_messages ? `${Math.floor(item.chat_messages.length / 2)}轮对话` : '无对话'} · {new Date(item.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
);
};
export { HistoryDrawer };
export default HistoryDrawer;
'use client';
import React from 'react';
import {
Typography, Space, Avatar, Tag, Card, List, Button, Divider,
} from 'antd';
import {
UserOutlined,
ArrowRightOutlined,
MedicineBoxOutlined,
FileTextOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import type { DoctorBrief, ChatDoneInfo } from '../../../api/preConsult';
import MarkdownRenderer from '../../../components/MarkdownRenderer';
const { Text } = Typography;
// ====== Report Card ======
interface ReportCardProps {
reportContent: string;
generating: boolean;
reportDone: boolean;
}
const ReportCard: React.FC<ReportCardProps> = ({
reportContent,
generating,
reportDone,
}) => {
if (!reportContent && !generating) return null;
return (
<div style={{ margin: '24px 0' }}>
<Card
title={
<Space>
<FileTextOutlined style={{ color: '#722ed1' }} />
<span>AI 预问诊分析报告</span>
{generating && <LoadingOutlined style={{ color: '#722ed1' }} />}
{reportDone && <Tag color="success">生成完成</Tag>}
</Space>
}
style={{
borderRadius: 12,
borderTop: '3px solid #722ed1',
}}
>
<div style={{ padding: '8px 0' }}>
<MarkdownRenderer content={reportContent} fontSize={15} lineHeight={1.8} />
{generating && <LoadingOutlined style={{ marginLeft: 4, color: '#722ed1' }} />}
</div>
</Card>
</div>
);
};
// ====== Doctor Recommendations ======
interface DoctorRecommendationsProps {
doneInfo: ChatDoneInfo | null;
onSelectDoctor: (doctor: DoctorBrief) => void;
}
const DoctorRecommendations: React.FC<DoctorRecommendationsProps> = ({
doneInfo,
onSelectDoctor,
}) => {
const doctors = doneInfo?.recommended_doctors;
if (!doctors || doctors.length === 0) return null;
return (
<Card
style={{ borderRadius: 12, marginTop: 16 }}
title={
<Space>
<MedicineBoxOutlined style={{ color: '#52c41a' }} />
<span>AI 推荐医生</span>
{doneInfo?.ai_department && <Tag color="green">{doneInfo.ai_department}</Tag>}
</Space>
}
>
<List
dataSource={doctors}
renderItem={(doctor) => (
<List.Item
actions={[
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => onSelectDoctor(doctor)}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
发起问诊
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar size={48} src={doctor.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
}
title={
<Space>
<Text strong>{doctor.name}</Text>
<Tag color="blue">{doctor.title}</Tag>
<Tag>{doctor.department_name}</Tag>
</Space>
}
description={
<Space split={<Divider type="vertical" />}>
<span>评分 {doctor.rating}</span>
<span>问诊 {doctor.consult_count} 次</span>
<span style={{ color: '#fa8c16' }}>¥{(doctor.price / 100).toFixed(0)}/次</span>
</Space>
}
/>
</List.Item>
)}
/>
</Card>
);
};
// ====== Combined ReportSection ======
interface ReportSectionProps {
reportContent: string;
generating: boolean;
reportDone: boolean;
doneInfo: ChatDoneInfo | null;
onSelectDoctor: (doctor: DoctorBrief) => void;
}
const ReportSection: React.FC<Partial<ReportSectionProps>> = ({
reportContent = '',
generating = false,
reportDone = false,
doneInfo,
onSelectDoctor,
} = {}) => {
if (!onSelectDoctor) return null;
return (
<>
<ReportCard
reportContent={reportContent}
generating={generating}
reportDone={reportDone}
/>
{reportDone && (
<DoctorRecommendations
doneInfo={doneInfo}
onSelectDoctor={onSelectDoctor}
/>
)}
</>
);
};
export { ReportCard, DoctorRecommendations, ReportSection };
export default ReportSection;
...@@ -2,33 +2,28 @@ ...@@ -2,33 +2,28 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { import {
Input, Button, Typography, Space, Avatar, Tag, Card, List, Divider, Input, Button, Space, Alert, message, Spin,
Alert, message, Spin, Drawer, Empty,
} from 'antd'; } from 'antd';
import { import {
RobotOutlined, RobotOutlined,
UserOutlined,
SendOutlined, SendOutlined,
MedicineBoxOutlined,
ArrowRightOutlined,
FileTextOutlined, FileTextOutlined,
LoadingOutlined, LoadingOutlined,
InfoCircleOutlined,
HistoryOutlined, HistoryOutlined,
ClockCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { preConsultApi } from '../../../api/preConsult'; import { preConsultApi } from '../../../api/preConsult';
import type { DoctorBrief, ChatDoneInfo, ChatMessage, PreConsultResponse } from '../../../api/preConsult'; import type { DoctorBrief, ChatDoneInfo, ChatMessage, PreConsultResponse } from '../../../api/preConsult';
import { useUserStore } from '../../../store/userStore'; import { useUserStore } from '../../../store/userStore';
import ConsultCreateModal from '../../../components/ConsultCreateModal'; import ConsultCreateModal from '../../../components/ConsultCreateModal';
import MarkdownRenderer from '../../../components/MarkdownRenderer'; import { ChatMessageItem } from './ChatMessageItem';
import { ReportSection } from './ReportSection';
import { HistoryDrawer } from './HistoryDrawer';
const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
// 显示用的消息类型(扩展了loading等状态) // 显示用的消息类型(扩展了loading等状态)
interface DisplayMessage { export interface DisplayMessage {
id: string; id: string;
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
...@@ -167,7 +162,7 @@ const PatientPreConsultPage: React.FC = () => { ...@@ -167,7 +162,7 @@ const PatientPreConsultPage: React.FC = () => {
const welcomeMsg: DisplayMessage = { const welcomeMsg: DisplayMessage = {
id: 'welcome', id: 'welcome',
role: 'assistant', role: 'assistant',
content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`, content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:"我头痛三天了,还有点恶心"`,
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages([welcomeMsg]); setMessages([welcomeMsg]);
...@@ -308,161 +303,6 @@ const PatientPreConsultPage: React.FC = () => { ...@@ -308,161 +303,6 @@ const PatientPreConsultPage: React.FC = () => {
// =============== 渲染 =============== // =============== 渲染 ===============
// 渲染单条消息
const renderMessage = (msg: DisplayMessage) => {
const isUser = msg.role === 'user';
const isSystem = msg.role === 'system';
if (isSystem) {
return (
<div key={msg.id} style={{ textAlign: 'center', margin: '16px 0' }}>
<Tag icon={<InfoCircleOutlined />} color="processing">{msg.content}</Tag>
</div>
);
}
return (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
marginBottom: 20,
gap: 12,
}}
>
<Avatar
size={40}
icon={isUser ? <UserOutlined /> : <RobotOutlined />}
style={{
backgroundColor: isUser ? '#1890ff' : '#722ed1',
flexShrink: 0,
}}
/>
<div
style={{
maxWidth: '75%',
padding: '12px 16px',
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
backgroundColor: isUser ? '#1890ff' : '#f5f5f5',
color: isUser ? '#fff' : '#333',
fontSize: 15,
lineHeight: 1.7,
boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
position: 'relative',
}}
>
{msg.content && (
isUser ? (
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{msg.content}
</span>
) : (
<MarkdownRenderer
content={msg.content}
fontSize={15}
lineHeight={1.7}
textColor="#333"
/>
)
)}
{msg.loading && (
<span style={{ display: 'inline-block', marginLeft: 4 }}>
<LoadingOutlined style={{ color: isUser ? '#fff' : '#722ed1' }} />
</span>
)}
</div>
</div>
);
};
// 分析报告区域
const renderReport = () => {
if (!reportContent && !generating) return null;
return (
<div style={{ margin: '24px 0' }}>
<Card
title={
<Space>
<FileTextOutlined style={{ color: '#722ed1' }} />
<span>AI 预问诊分析报告</span>
{generating && <LoadingOutlined style={{ color: '#722ed1' }} />}
{reportDone && <Tag color="success">生成完成</Tag>}
</Space>
}
style={{
borderRadius: 12,
borderTop: '3px solid #722ed1',
}}
>
<div style={{ padding: '8px 0' }}>
<MarkdownRenderer content={reportContent} fontSize={15} lineHeight={1.8} />
{generating && <LoadingOutlined style={{ marginLeft: 4, color: '#722ed1' }} />}
</div>
</Card>
</div>
);
};
// 推荐医生列表
const renderDoctors = () => {
const doctors = doneInfo?.recommended_doctors;
if (!doctors || doctors.length === 0) return null;
return (
<Card
style={{ borderRadius: 12, marginTop: 16 }}
title={
<Space>
<MedicineBoxOutlined style={{ color: '#52c41a' }} />
<span>AI 推荐医生</span>
{doneInfo?.ai_department && <Tag color="green">{doneInfo.ai_department}</Tag>}
</Space>
}
>
<List
dataSource={doctors}
renderItem={(doctor) => (
<List.Item
actions={[
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => handleSelectDoctor(doctor)}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
发起问诊
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar size={48} src={doctor.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
}
title={
<Space>
<Text strong>{doctor.name}</Text>
<Tag color="blue">{doctor.title}</Tag>
<Tag>{doctor.department_name}</Tag>
</Space>
}
description={
<Space split={<Divider type="vertical" />}>
<span>评分 {doctor.rating}</span>
<span>问诊 {doctor.consult_count} 次</span>
<span style={{ color: '#fa8c16' }}>¥{(doctor.price / 100).toFixed(0)}/次</span>
</Space>
}
/>
</List.Item>
)}
/>
</Card>
);
};
return ( return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', maxWidth: 800, margin: '0 auto' }}> <div style={{ height: '100%', display: 'flex', flexDirection: 'column', maxWidth: 800, margin: '0 auto' }}>
{/* 头部 */} {/* 头部 */}
...@@ -545,13 +385,18 @@ const PatientPreConsultPage: React.FC = () => { ...@@ -545,13 +385,18 @@ const PatientPreConsultPage: React.FC = () => {
</div> </div>
)} )}
{messages.map(renderMessage)} {messages.map((msg) => (
<ChatMessageItem key={msg.id} msg={msg} />
{/* 分析报告 */} ))}
{renderReport()}
{/* 分析报告 + 推荐医生 */}
{/* 推荐医生 */} <ReportSection
{reportDone && renderDoctors()} reportContent={reportContent}
generating={generating}
reportDone={reportDone}
doneInfo={doneInfo}
onSelectDoctor={handleSelectDoctor}
/>
{/* 底部操作区(对话完成后) */} {/* 底部操作区(对话完成后) */}
{reportDone && ( {reportDone && (
...@@ -570,7 +415,7 @@ const PatientPreConsultPage: React.FC = () => { ...@@ -570,7 +415,7 @@ const PatientPreConsultPage: React.FC = () => {
const welcomeMsg: DisplayMessage = { const welcomeMsg: DisplayMessage = {
id: 'welcome', id: 'welcome',
role: 'assistant', role: 'assistant',
content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`, content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:"我头痛三天了,还有点恶心"`,
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages([welcomeMsg]); setMessages([welcomeMsg]);
...@@ -653,73 +498,16 @@ const PatientPreConsultPage: React.FC = () => { ...@@ -653,73 +498,16 @@ const PatientPreConsultPage: React.FC = () => {
)} )}
{/* 历史对话抽屉 */} {/* 历史对话抽屉 */}
<Drawer <HistoryDrawer
title={
<Space>
<HistoryOutlined />
<span>历史对话记录</span>
</Space>
}
placement="right"
width={400}
open={historyDrawerOpen} open={historyDrawerOpen}
onClose={() => setHistoryDrawerOpen(false)} onClose={() => setHistoryDrawerOpen(false)}
> loading={historyLoading}
{historyLoading ? ( historyList={historyList}
<div style={{ textAlign: 'center', padding: 60 }}> currentSessionId={sessionId}
<Spin tip="加载中.." /> onRestore={restoreSession}
</div> />
) : historyList.length === 0 ? (
<Empty description="暂无历史对话" />
) : (
<List
dataSource={historyList}
renderItem={(item) => (
<List.Item
style={{ cursor: 'pointer', borderRadius: 8, marginBottom: 8, padding: 12, background: item.id === sessionId ? '#f0f5ff' : '#fafafa' }}
onClick={() => restoreSession(item)}
>
<List.Item.Meta
avatar={
<Avatar
icon={item.status === 'completed' ? <FileTextOutlined /> : <ClockCircleOutlined />}
style={{ backgroundColor: item.status === 'completed' ? '#52c41a' : '#faad14' }}
/>
}
title={
<Space>
<Text strong ellipsis style={{ maxWidth: 200 }}>
{item.chief_complaint || '预问诊对话'}
</Text>
{item.id === sessionId && <Tag color="blue">当前</Tag>}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{item.status === 'completed' ? (
<>
<Tag color="green" style={{ marginRight: 4 }}>已完成</Tag>
{item.ai_department && <Tag>{item.ai_department}</Tag>}
</>
) : (
<Tag color="orange">进行中</Tag>
)}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
{item.chat_messages ? `${Math.floor(item.chat_messages.length / 2)}轮对话` : '无对话'} · {new Date(item.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
</div> </div>
); );
}; };
export default PatientPreConsultPage; export default PatientPreConsultPage;
'use client'; 'use client';
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter, useParams } from 'next/navigation'; import { useRouter, useParams } from 'next/navigation';
import { Card, Input, Button, List, Avatar, Typography, Space, Row, Col, Tag, Divider, message } from 'antd'; import { Card, Input, Button, List, Avatar, Typography, Space, Row, Col, Tag, Divider, message } from 'antd';
import { import {
SendOutlined, RobotOutlined, UserOutlined, PhoneOutlined, SendOutlined, RobotOutlined, UserOutlined, PhoneOutlined,
MedicineBoxOutlined, ClockCircleOutlined, ArrowLeftOutlined, MedicineBoxOutlined, ClockCircleOutlined, ArrowLeftOutlined,
CheckOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { consultApi, type ConsultMessage } from '../../../api/consult'; import { consultApi, type ConsultMessage } from '../../../api/consult';
...@@ -57,6 +58,19 @@ const PatientTextConsultPage: React.FC = () => { ...@@ -57,6 +58,19 @@ const PatientTextConsultPage: React.FC = () => {
}, },
}); });
// 自动标记对方消息为已读
const lastMarkReadRef = useRef<number>(0);
useEffect(() => {
if (!id || !messagesData?.data) return;
const unreadFromDoctor = messagesData.data.some(
(m) => m.sender_type !== 'patient' && !m.read_at
);
if (unreadFromDoctor && Date.now() - lastMarkReadRef.current > 5000) {
lastMarkReadRef.current = Date.now();
consultApi.markMessagesRead(id).catch(() => {});
}
}, [id, messagesData]);
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messagesData]); }, [messagesData]);
...@@ -116,10 +130,15 @@ const PatientTextConsultPage: React.FC = () => { ...@@ -116,10 +130,15 @@ const PatientTextConsultPage: React.FC = () => {
> >
{msg.content} {msg.content}
</div> </div>
<div style={{ textAlign: isMe ? 'right' : 'left', marginTop: 4 }}> <div style={{ textAlign: isMe ? 'right' : 'left', marginTop: 4, display: 'flex', alignItems: 'center', justifyContent: isMe ? 'flex-end' : 'flex-start', gap: 6 }}>
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(msg.created_at).format('HH:mm')} {dayjs(msg.created_at).format('HH:mm')}
</Text> </Text>
{isMe && msg.read_at && (
<span style={{ fontSize: 10, color: '#52c41a' }}>
<CheckOutlined /><CheckOutlined style={{ marginLeft: -4 }} /> 已读
</span>
)}
</div> </div>
</div> </div>
{isMe && ( {isMe && (
......
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { UserInfo } from '../api/user'; import type { UserInfo } from '../api/user';
import type { Menu } from '../api/rbac';
interface UserState { interface UserState {
user: UserInfo | null; user: UserInfo | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null; refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
setUser: (user: UserInfo) => void; menus: Menu[];
setUser: (user: UserInfo, menus?: Menu[]) => void;
setTokens: (accessToken: string, refreshToken: string) => void; setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void; logout: () => void;
} }
...@@ -19,8 +21,9 @@ export const useUserStore = create<UserState>()( ...@@ -19,8 +21,9 @@ export const useUserStore = create<UserState>()(
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
menus: [],
setUser: (user) => set({ user, isAuthenticated: true }), setUser: (user, menus) => set({ user, isAuthenticated: true, menus: menus || [] }),
setTokens: (accessToken, refreshToken) => { setTokens: (accessToken, refreshToken) => {
localStorage.setItem('access_token', accessToken); localStorage.setItem('access_token', accessToken);
...@@ -36,6 +39,7 @@ export const useUserStore = create<UserState>()( ...@@ -36,6 +39,7 @@ export const useUserStore = create<UserState>()(
accessToken: null, accessToken: null,
refreshToken: null, refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
menus: [],
}); });
}, },
}), }),
...@@ -46,6 +50,7 @@ export const useUserStore = create<UserState>()( ...@@ -46,6 +50,7 @@ export const useUserStore = create<UserState>()(
accessToken: state.accessToken, accessToken: state.accessToken,
refreshToken: state.refreshToken, refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated, isAuthenticated: state.isAuthenticated,
menus: state.menus,
}), }),
} }
) )
......
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