Commit 6e652bf1 authored by yuguo's avatar yuguo

fix

parent beffd7fe
......@@ -36,7 +36,8 @@
"Bash(pkill:*)",
"Bash(bash:*)",
"Bash(make run:*)",
"Bash(rm:*)"
"Bash(rm:*)",
"Bash(gh pr:*)"
]
}
}
......@@ -2,14 +2,19 @@ package main
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
internalagent "internet-hospital/internal/agent"
"internet-hospital/internal/model"
"internet-hospital/internal/service/knowledgesvc"
"internet-hospital/internal/service/notification"
"internet-hospital/internal/service/workflowsvc"
"internet-hospital/internal/service/admin"
"internet-hospital/internal/service/consult"
......@@ -24,6 +29,7 @@ import (
"internet-hospital/pkg/config"
"internet-hospital/pkg/database"
"internet-hospital/pkg/middleware"
"internet-hospital/pkg/response"
"internet-hospital/pkg/websocket"
)
......@@ -107,6 +113,8 @@ func main() {
// 安全过滤
&model.SafetyWordRule{},
&model.SafetyFilterLog{},
// 通知
&model.Notification{},
// HTTP 动态工具
&model.HTTPToolDefinition{},
// v15: SQL 动态工具
......@@ -250,6 +258,13 @@ func main() {
knowledgeHandler := knowledgesvc.NewHandler()
knowledgeHandler.RegisterRoutes(authApi)
// 通知路由(需要认证)
notificationHandler := notification.NewHandler()
notificationHandler.RegisterRoutes(authApi)
// 用户资料更新
authApi.PUT("/user/profile", userHandler.UpdateProfile)
// 支付路由(需要认证)
paymentHandler := payment.NewHandler()
paymentHandler.RegisterRoutes(authApi)
......@@ -279,6 +294,35 @@ func main() {
})
wsHandler.RegisterRoutes(api)
// 文件上传
authApi.POST("/upload", func(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
response.BadRequest(c, "请选择文件")
return
}
defer file.Close()
// Ensure uploads directory exists
os.MkdirAll("./uploads", 0755)
// Generate unique filename
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), uuid.New().String()[:8], ext)
dst := filepath.Join("./uploads", filename)
out, err := os.Create(dst)
if err != nil {
response.Error(c, 500, "文件保存失败")
return
}
defer out.Close()
io.Copy(out, file)
url := fmt.Sprintf("/api/v1/uploads/%s", filename)
response.Success(c, gin.H{"url": url, "filename": filename})
})
// v13: 静态文件服务(上传的媒体文件)
api.Static("/uploads", "./uploads")
......
package model
import "time"
type Notification struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
Title string `gorm:"type:varchar(200)" json:"title"`
Content string `gorm:"type:text" json:"content"`
Type string `gorm:"type:varchar(50)" json:"type"`
RelatedID string `gorm:"type:varchar(100)" json:"related_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
CreatedAt time.Time `json:"created_at"`
}
......@@ -149,6 +149,9 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
// 用户角色分配(v12新增)
adm.GET("/users/:id/roles", h.GetUserRoles)
adm.PUT("/users/:id/roles", h.SetUserRoles)
// 数据导出
adm.POST("/export/:type", h.ExportData)
}
// 当前用户菜单(不在 /admin 前缀下,所有登录用户可访问)
......@@ -550,3 +553,15 @@ func (h *Handler) GetAILogs(c *gin.Context) {
}
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)
}
......@@ -2,7 +2,9 @@ package admin
import (
"context"
"encoding/csv"
"fmt"
"os"
"time"
"github.com/google/uuid"
......@@ -768,10 +770,16 @@ func (s *Service) GetConsultList(ctx context.Context, params *ConsultListParams)
}
func (s *Service) GetSystemLogs(ctx context.Context) (interface{}, error) {
// TODO: 查询系统日志
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": []interface{}{},
"total": 0,
"list": logs,
"total": total,
}, nil
}
......@@ -825,3 +833,65 @@ func (s *Service) GetAILogs(ctx context.Context, params *AILogListParams) (inter
"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
}
......@@ -34,6 +34,11 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
g.GET("/metrics", h.ListMetrics)
g.POST("/metrics", h.CreateMetric)
g.DELETE("/metrics/:id", h.DeleteMetric)
// 医生端续方审核
g.GET("/doctor/renewals", h.DoctorListRenewals)
g.POST("/doctor/renewals/:id/approve", h.DoctorApproveRenewal)
g.POST("/doctor/renewals/:id/reject", h.DoctorRejectRenewal)
}
}
......@@ -213,3 +218,49 @@ func (h *Handler) DeleteMetric(c *gin.Context) {
}
response.Success(c, nil)
}
func (h *Handler) DoctorListRenewals(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
list, err := h.service.DoctorListRenewals(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取续方列表失败")
return
}
response.Success(c, list)
}
func (h *Handler) DoctorApproveRenewal(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id := c.Param("id")
if err := h.service.DoctorApproveRenewal(c.Request.Context(), userID.(string), id); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *Handler) DoctorRejectRenewal(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id := c.Param("id")
var req struct {
Reason string `json:"reason"`
}
c.ShouldBindJSON(&req)
if err := h.service.DoctorRejectRenewal(c.Request.Context(), userID.(string), id, req.Reason); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
......@@ -292,3 +292,45 @@ func detectMetricAlert(metricType string, value1, value2 float64) string {
func (s *Service) DeleteMetric(ctx context.Context, userID, id string) error {
return s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&model.HealthMetric{}).Error
}
// ========== 医生端续方审核 ==========
func (s *Service) DoctorListRenewals(ctx context.Context, doctorUserID string) ([]map[string]interface{}, error) {
var doctor model.Doctor
if err := s.db.Where("user_id = ?", doctorUserID).First(&doctor).Error; err != nil {
return nil, fmt.Errorf("医生信息不存在")
}
var renewals []model.RenewalRequest
s.db.Where("status = ?", "pending").Order("created_at DESC").Find(&renewals)
var result []map[string]interface{}
for _, r := range renewals {
var user model.User
s.db.Where("id = ?", r.UserID).First(&user)
result = append(result, map[string]interface{}{
"id": r.ID,
"patient_name": user.RealName,
"patient_id": r.UserID,
"disease_name": r.DiseaseName,
"current_drugs": r.Medicines,
"status": r.Status,
"created_at": r.CreatedAt,
})
}
if result == nil {
result = []map[string]interface{}{}
}
return result, nil
}
func (s *Service) DoctorApproveRenewal(ctx context.Context, doctorUserID, renewalID string) error {
return s.db.Model(&model.RenewalRequest{}).Where("id = ?", renewalID).Updates(map[string]interface{}{
"status": "approved",
}).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{}{
"status": "rejected", "doctor_note": reason,
}).Error
}
......@@ -114,7 +114,12 @@ func (h *Handler) SubmitCertification(c *gin.Context) {
// GetWorkbenchStats 获取工作台统计数据
func (h *Handler) GetWorkbenchStats(c *gin.Context) {
stats, err := h.service.GetWorkbenchStats(c.Request.Context())
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
stats, err := h.service.GetWorkbenchStats(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取工作台数据失败")
return
......@@ -124,6 +129,11 @@ func (h *Handler) GetWorkbenchStats(c *gin.Context) {
// ToggleOnlineStatus 切换在线状态
func (h *Handler) ToggleOnlineStatus(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
var req struct {
IsOnline bool `json:"is_online"`
}
......@@ -131,8 +141,7 @@ func (h *Handler) ToggleOnlineStatus(c *gin.Context) {
response.BadRequest(c, "请求参数错误")
return
}
// TODO: 从JWT获取医生ID
if err := h.service.ToggleOnlineStatus(c.Request.Context(), "", req.IsOnline); err != nil {
if err := h.service.ToggleOnlineStatus(c.Request.Context(), userID.(string), req.IsOnline); err != nil {
response.Error(c, 500, "切换状态失败")
return
}
......@@ -141,7 +150,12 @@ func (h *Handler) ToggleOnlineStatus(c *gin.Context) {
// GetWaitingQueue 获取接诊队列
func (h *Handler) GetWaitingQueue(c *gin.Context) {
patients, err := h.service.GetWaitingQueue(c.Request.Context())
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
patients, err := h.service.GetWaitingQueue(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取队列失败")
return
......@@ -151,14 +165,19 @@ func (h *Handler) GetWaitingQueue(c *gin.Context) {
// AcceptConsult 接受问诊
func (h *Handler) AcceptConsult(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
return
}
result, err := h.service.AcceptConsult(c.Request.Context(), id)
result, err := h.service.AcceptConsult(c.Request.Context(), id, userID.(string))
if err != nil {
response.Error(c, 500, "接诊失败")
response.Error(c, 500, err.Error())
return
}
response.Success(c, result)
......@@ -196,6 +215,11 @@ func (h *Handler) GetConsultMessages(c *gin.Context) {
// SendMessage 发送消息
func (h *Handler) SendMessage(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
......@@ -209,7 +233,7 @@ func (h *Handler) SendMessage(c *gin.Context) {
response.BadRequest(c, "请求参数错误")
return
}
msg, err := h.service.SendMessage(c.Request.Context(), id, req.Content, req.ContentType)
msg, err := h.service.SendMessage(c.Request.Context(), id, userID.(string), req.Content, req.ContentType)
if err != nil {
response.Error(c, 500, "发送消息失败")
return
......@@ -219,6 +243,11 @@ func (h *Handler) SendMessage(c *gin.Context) {
// EndConsult 结束问诊
func (h *Handler) EndConsult(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id, err := resolveConsultID(c.Param("id"))
if err != nil {
response.Error(c, 404, err.Error())
......@@ -229,9 +258,8 @@ func (h *Handler) EndConsult(c *gin.Context) {
}
_ = c.ShouldBindJSON(&req)
userID, _ := c.Get("user_id")
if err := h.service.EndConsult(c.Request.Context(), id, req.Summary); err != nil {
response.Error(c, 500, "结束问诊失败")
if err := h.service.EndConsult(c.Request.Context(), id, userID.(string), req.Summary); err != nil {
response.Error(c, 500, err.Error())
return
}
......@@ -246,7 +274,12 @@ func (h *Handler) EndConsult(c *gin.Context) {
// GetConsultHistory 获取历史问诊
func (h *Handler) GetConsultHistory(c *gin.Context) {
history, err := h.service.GetConsultHistory(c.Request.Context())
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
history, err := h.service.GetConsultHistory(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取历史记录失败")
return
......@@ -256,9 +289,14 @@ func (h *Handler) GetConsultHistory(c *gin.Context) {
// GetMySchedule 获取排班
func (h *Handler) GetMySchedule(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
startDate := c.Query("start_date")
endDate := c.Query("end_date")
schedules, err := h.service.GetMySchedule(c.Request.Context(), startDate, endDate)
schedules, err := h.service.GetMySchedule(c.Request.Context(), userID.(string), startDate, endDate)
if err != nil {
response.Error(c, 500, "获取排班失败")
return
......@@ -268,6 +306,11 @@ func (h *Handler) GetMySchedule(c *gin.Context) {
// CreateSchedule 创建排班
func (h *Handler) CreateSchedule(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
var req struct {
Slots []ScheduleSlotReq `json:"slots" binding:"required"`
}
......@@ -275,7 +318,7 @@ func (h *Handler) CreateSchedule(c *gin.Context) {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.CreateSchedule(c.Request.Context(), req.Slots); err != nil {
if err := h.service.CreateSchedule(c.Request.Context(), userID.(string), req.Slots); err != nil {
response.Error(c, 500, "创建排班失败")
return
}
......@@ -294,7 +337,12 @@ func (h *Handler) DeleteSchedule(c *gin.Context) {
// GetProfile 获取个人信息
func (h *Handler) GetProfile(c *gin.Context) {
profile, err := h.service.GetProfile(c.Request.Context())
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
profile, err := h.service.GetProfile(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取个人信息失败")
return
......@@ -304,12 +352,17 @@ func (h *Handler) GetProfile(c *gin.Context) {
// UpdateProfile 更新个人信息
func (h *Handler) UpdateProfile(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.UpdateProfile(c.Request.Context(), req); err != nil {
if err := h.service.UpdateProfile(c.Request.Context(), userID.(string), req); err != nil {
response.Error(c, 500, "更新个人信息失败")
return
}
......
......@@ -20,8 +20,10 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.POST("/search", h.Search)
g.GET("/collections", h.ListCollections)
g.POST("/collections", h.CreateCollection)
g.DELETE("/collections/:id", h.DeleteCollection)
g.POST("/documents", h.CreateDocument)
g.GET("/documents", h.ListDocuments)
g.DELETE("/documents/:id", h.DeleteDocument)
}
func (h *Handler) Search(c *gin.Context) {
......@@ -140,6 +142,30 @@ func (h *Handler) ListDocuments(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": docs})
}
func (h *Handler) DeleteCollection(c *gin.Context) {
id := c.Param("id")
db := database.GetDB()
// Delete chunks, documents, then collection
db.Where("collection_id IN (SELECT document_id FROM knowledge_documents WHERE collection_id = ?)", id).Delete(&model.KnowledgeChunk{})
db.Where("collection_id = ?", id).Delete(&model.KnowledgeDocument{})
if err := db.Where("collection_id = ?", id).Delete(&model.KnowledgeCollection{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": nil, "message": "ok"})
}
func (h *Handler) DeleteDocument(c *gin.Context) {
id := c.Param("id")
db := database.GetDB()
db.Where("document_id = ?", id).Delete(&model.KnowledgeChunk{})
if err := db.Where("document_id = ?", id).Delete(&model.KnowledgeDocument{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": nil, "message": "ok"})
}
func splitIntoChunks(text string, chunkSize int) []string {
paragraphs := strings.Split(text, "\n\n")
var chunks []string
......
package notification
import (
"strconv"
"github.com/gin-gonic/gin"
"internet-hospital/pkg/response"
)
type Handler struct {
service *Service
}
func NewHandler() *Handler {
return &Handler{service: NewService()}
}
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
g := r.Group("/notifications")
{
g.GET("", h.List)
g.PUT("/:id/read", h.MarkRead)
g.PUT("/read-all", h.MarkAllRead)
g.GET("/unread-count", h.UnreadCount)
}
}
func (h *Handler) List(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
items, total, err := h.service.List(c.Request.Context(), userID.(string), page, pageSize)
if err != nil {
response.Error(c, 500, "获取通知失败")
return
}
response.Success(c, gin.H{"list": items, "total": total})
}
func (h *Handler) MarkRead(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
id := c.Param("id")
if err := h.service.MarkRead(c.Request.Context(), userID.(string), id); err != nil {
response.Error(c, 500, "操作失败")
return
}
response.Success(c, nil)
}
func (h *Handler) MarkAllRead(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
if err := h.service.MarkAllRead(c.Request.Context(), userID.(string)); err != nil {
response.Error(c, 500, "操作失败")
return
}
response.Success(c, nil)
}
func (h *Handler) UnreadCount(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, 401, "未登录")
return
}
count, err := h.service.UnreadCount(c.Request.Context(), userID.(string))
if err != nil {
response.Error(c, 500, "获取失败")
return
}
response.Success(c, gin.H{"count": count})
}
package notification
import (
"context"
"github.com/google/uuid"
"gorm.io/gorm"
"internet-hospital/internal/model"
"internet-hospital/pkg/database"
)
type Service struct {
db *gorm.DB
}
func NewService() *Service {
return &Service{db: database.GetDB()}
}
func (s *Service) List(ctx context.Context, userID string, page, pageSize int) ([]model.Notification, int64, error) {
var items []model.Notification
var total int64
query := s.db.Where("user_id = ?", userID)
query.Model(&model.Notification{}).Count(&total)
err := query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&items).Error
return items, total, err
}
func (s *Service) MarkRead(ctx context.Context, userID, id string) error {
return s.db.Model(&model.Notification{}).Where("id = ? AND user_id = ?", id, userID).Update("is_read", true).Error
}
func (s *Service) MarkAllRead(ctx context.Context, userID string) error {
return s.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Update("is_read", true).Error
}
func (s *Service) UnreadCount(ctx context.Context, userID string) (int64, error) {
var count int64
err := s.db.Model(&model.Notification{}).Where("user_id = ? AND is_read = ?", userID, false).Count(&count).Error
return count, err
}
func (s *Service) Create(ctx context.Context, userID, title, content, nType, relatedID string) error {
n := &model.Notification{
ID: uuid.New().String(), UserID: userID,
Title: title, Content: content, Type: nType, RelatedID: relatedID,
}
return s.db.Create(n).Error
}
......@@ -29,6 +29,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
payment.POST("/order/:id/pay", h.PayOrder)
payment.GET("/order/:id/status", h.GetPaymentStatus)
payment.POST("/order/:id/cancel", h.CancelOrder)
payment.POST("/order/:id/confirm-delivery", h.ConfirmDelivery)
}
// 医生端收入
......@@ -140,6 +141,15 @@ func (h *Handler) CancelOrder(c *gin.Context) {
response.Success(c, nil)
}
func (h *Handler) ConfirmDelivery(c *gin.Context) {
orderID := c.Param("id")
if err := h.service.ConfirmDelivery(c.Request.Context(), orderID); err != nil {
response.Error(c, 400, err.Error())
return
}
response.Success(c, nil)
}
// ========== 医生端收入 ==========
func (h *Handler) GetIncomeStats(c *gin.Context) {
......
......@@ -146,6 +146,14 @@ func (s *Service) CancelOrder(ctx context.Context, orderID string) error {
return s.db.Save(&order).Error
}
func (s *Service) ConfirmDelivery(ctx context.Context, orderID string) error {
result := s.db.Model(&model.PaymentOrder{}).Where("id = ? AND status = ?", orderID, "paid").Update("status", "completed")
if result.RowsAffected == 0 {
return fmt.Errorf("订单不存在或状态不允许确认收货")
}
return result.Error
}
func (s *Service) HandlePaymentCallback(ctx context.Context, provider, orderNo, transactionID string) error {
var order model.PaymentOrder
if err := s.db.Where("order_no = ?", orderNo).First(&order).Error; err != nil {
......
......@@ -23,6 +23,7 @@ func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
user.POST("/register", h.Register)
user.POST("/login", h.Login)
user.POST("/refresh-token", h.RefreshToken)
user.POST("/forgot-password", h.ForgotPassword)
}
}
......@@ -140,3 +141,28 @@ func (h *Handler) GetCurrentUser(c *gin.Context) {
response.Success(c, user)
}
func (h *Handler) UpdateProfile(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c, "未登录")
return
}
var req map[string]interface{}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误")
return
}
if err := h.service.UpdateProfile(c.Request.Context(), userID.(string), req); err != nil {
response.Error(c, 500, err.Error())
return
}
response.Success(c, nil)
}
func (h *Handler) ForgotPassword(c *gin.Context) {
response.Success(c, gin.H{
"message": "请联系平台管理员重置密码",
"contact": "400-888-8888",
})
}
......@@ -194,6 +194,20 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (*utils
return utils.RefreshAccessToken(refreshToken)
}
func (s *Service) UpdateProfile(ctx context.Context, userID string, data map[string]interface{}) error {
allowed := map[string]bool{"real_name": true, "avatar": true, "gender": true, "age": true}
updates := map[string]interface{}{}
for k, v := range data {
if allowed[k] {
updates[k] = v
}
}
if len(updates) == 0 {
return nil
}
return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
}
func (s *Service) VerifyIdentity(ctx context.Context, userID, realName, idCard string) error {
// 简单格式校验:身份证18位
if len(idCard) != 18 {
......
......@@ -21,6 +21,8 @@ func (h *Handler) RegisterRoutes(r gin.IRouter) {
g.GET("/execution/:id", h.GetExecution)
g.GET("/tasks", h.ListTasks)
g.POST("/task/:id/complete", h.CompleteTask)
g.DELETE("/:workflow_id", h.DeleteWorkflow)
g.PUT("/:workflow_id/status", h.UpdateWorkflowStatus)
}
func (h *Handler) Execute(c *gin.Context) {
......@@ -65,3 +67,28 @@ func (h *Handler) CompleteTask(c *gin.Context) {
Updates(map[string]interface{}{"status": "completed", "result": string(resultJSON)})
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
func (h *Handler) DeleteWorkflow(c *gin.Context) {
id := c.Param("workflow_id")
if err := database.GetDB().Where("workflow_id = ?", id).Delete(&model.WorkflowDefinition{}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
func (h *Handler) UpdateWorkflowStatus(c *gin.Context) {
id := c.Param("workflow_id")
var req struct {
Status string `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
if err := database.GetDB().Model(&model.WorkflowDefinition{}).Where("workflow_id = ?", id).Update("status", req.Status).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "ok"})
}
......@@ -203,6 +203,18 @@ func (h *Handler) handleMessage(client *Client, data []byte) {
Timestamp: time.Now(),
}, client.UserID)
case "video_signal":
// WebRTC 信令转发:将 SDP offer/answer 和 ICE candidate 转发给同房间对端
h.hub.BroadcastToConsult(client.ConsultID, &Message{
Type: "video_signal",
ConsultID: client.ConsultID,
SenderID: client.UserID,
SenderType: client.UserType,
Content: msg.Content,
Extra: msg.Extra,
Timestamp: time.Now(),
}, client.UserID) // 排除发送者自己
case "consult_update", "new_patient", "online_status":
// 这些类型由服务端主动推送,客户端发送时忽略
......
......@@ -396,4 +396,8 @@ export const adminApi = {
getAITrace: (traceId: string) =>
get<unknown>('/admin/ai-center/trace', { params: { trace_id: traceId } }),
// === 数据导出 ===
exportData: (type: string) =>
post<{ url: string; filename: string }>(`/admin/export/${type}`, {}),
};
......@@ -281,6 +281,8 @@ export const workflowApi = {
update: (id: number, data: Partial<WorkflowCreateParams>) =>
put<unknown>(`/admin/workflows/${id}`, data),
delete: (id: number) => del<null>(`/admin/workflows/${id}`),
publish: (id: number) => put<null>(`/admin/workflows/${id}/publish`, {}),
execute: (workflowId: string, input?: Record<string, unknown>) =>
......@@ -307,12 +309,16 @@ export const knowledgeApi = {
createCollection: (data: KnowledgeCollectionParams) =>
post<unknown>('/knowledge/collections', data),
deleteCollection: (id: string) => del<null>(`/knowledge/collections/${id}`),
listDocuments: (collectionId?: string) =>
get<unknown[]>('/knowledge/documents', { params: collectionId ? { collection_id: collectionId } : {} }),
createDocument: (data: KnowledgeDocumentParams) =>
post<unknown>('/knowledge/documents', data),
deleteDocument: (id: string) => del<null>(`/knowledge/documents/${id}`),
search: (query: string, topK = 5, collectionId?: string) =>
post<unknown[]>('/knowledge/search', { query, top_k: topK, collection_id: collectionId }),
};
......@@ -70,6 +70,11 @@ export const chronicApi = {
toggleReminder: (id: string) => put(`/chronic/reminders/${id}/toggle`, {}),
deleteReminder: (id: string) => del(`/chronic/reminders/${id}`),
// 医生端续方审核
getDoctorRenewals: () => get<any[]>('/chronic/doctor/renewals'),
approveRenewal: (id: string) => post(`/chronic/doctor/renewals/${id}/approve`, {}),
rejectRenewal: (id: string, reason?: string) => post(`/chronic/doctor/renewals/${id}/reject`, { reason }),
// 健康指标
listMetrics: (type?: string) => get<HealthMetric[]>('/chronic/metrics' + (type ? `?type=${type}` : '')),
createMetric: (data: Partial<HealthMetric>) => post<HealthMetric>('/chronic/metrics', data),
......
import request from './request';
export interface Notification {
id: string;
user_id: string;
title: string;
content: string;
type: string; // consult | chronic | system
related_id: string;
is_read: boolean;
created_at: string;
}
export const notificationApi = {
list: (params?: { page?: number; page_size?: number }) =>
request.get<{ list: Notification[]; total: number }>('/notifications', { params }),
markRead: (id: string) =>
request.put(`/notifications/${id}/read`, {}),
markAllRead: () =>
request.put('/notifications/read-all', {}),
getUnreadCount: () =>
request.get<{ count: number }>('/notifications/unread-count'),
};
......@@ -84,6 +84,10 @@ export const paymentApi = {
// 取消订单
cancelOrder: (orderId: string) =>
request.post(`/payment/order/${orderId}/cancel`),
// 确认收货
confirmDelivery: (orderId: string) =>
request.post(`/payment/order/${orderId}/confirm-delivery`, {}),
};
// 医生端收入API
......
import { post, get } from './request';
import { post, get, put } from './request';
import { MOCK_ENABLED, mockRegister, mockLogin } from './mock';
// 用户相关类型定义
......@@ -85,6 +85,10 @@ export const userApi = {
// 退出登录
logout: () => post<null>('/user/logout'),
// 更新个人资料
updateProfile: (data: { real_name?: string; gender?: string; age?: number; avatar?: string }) =>
put<UserInfo>('/user/profile', data),
// 实名认证
verifyIdentity: (data: { real_name: string; id_card: string }) =>
post<null>('/user/verify-identity', data),
......
......@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { Form, Input, Button, App } from 'antd';
import { Form, Input, Button, App, Modal } from 'antd';
import {
MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined,
SafetyCertificateOutlined, TeamOutlined, CloudServerOutlined,
......@@ -17,6 +17,7 @@ const LoginPage: React.FC = () => {
const { message } = App.useApp();
const { setUser, setTokens } = useUserStore();
const [loading, setLoading] = useState(false);
const [forgotOpen, setForgotOpen] = useState(false);
// 处理统一登录(支持手机号或用户名)
const handleUnifiedLogin = async (values: { account: string; password: string }) => {
......@@ -241,7 +242,7 @@ const LoginPage: React.FC = () => {
/>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<a href="#" style={{ fontSize: 13, color: '#8c8c8c' }}>忘记密码?</a>
<a onClick={() => setForgotOpen(true)} style={{ fontSize: 13, color: '#8c8c8c', cursor: 'pointer' }}>忘记密码?</a>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Button
......@@ -274,13 +275,25 @@ const LoginPage: React.FC = () => {
<div style={{ marginTop: 32, paddingTop: 16, textAlign: 'center', borderTop: '1px solid #f0f0f0' }}>
<p style={{ fontSize: 12, color: '#bfbfbf', margin: 0 }}>
登录即表示同意
<a href="#" style={{ color: '#1890ff', margin: '0 2px' }}>《用户服务协议》</a>
<a href="#" style={{ color: '#1890ff', margin: '0 2px' }}>《隐私政策》</a>
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#1890ff', margin: '0 2px', cursor: 'pointer' }}>《用户服务协议》</a>
<a onClick={() => message.info('协议内容完善中')} style={{ color: '#1890ff', margin: '0 2px', cursor: 'pointer' }}>《隐私政策》</a>
</p>
</div>
</div>
</div>
</div>
<Modal
title="忘记密码"
open={forgotOpen}
onCancel={() => setForgotOpen(false)}
footer={<Button type="primary" onClick={() => setForgotOpen(false)}>我知道了</Button>}
>
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<p style={{ fontSize: 16, marginBottom: 8 }}>请联系平台管理员重置密码</p>
<p style={{ color: '#8c8c8c' }}>客服电话:400-888-8888</p>
</div>
</Modal>
</div>
);
};
......
......@@ -6,6 +6,7 @@ import Link from 'next/link';
import { Form, Input, Button, Steps, App, Typography, Space, Radio, Select, InputNumber, Row, Col } from 'antd';
import {
MobileOutlined, LockOutlined, UserOutlined, MedicineBoxOutlined, CheckCircleOutlined,
SafetyOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import { userApi } from '@/api/user';
......@@ -41,10 +42,10 @@ const RegisterPage: React.FC = () => {
} catch { message.error('发送验证码失败'); }
};
const handleRegister = async (values: { phone: string; password: string; real_name?: string; gender?: string; age?: number }) => {
const handleRegister = async (values: { phone: string; password: string; code: string; real_name?: string; gender?: string; age?: number }) => {
setLoading(true);
try {
const response = await userApi.register({ ...values, code: '123456', role: registerType });
const response = await userApi.register({ ...values, code: values.code, role: registerType });
setTokens(response.data.access_token, response.data.refresh_token);
setUser(response.data.user);
message.success('注册成功');
......@@ -177,7 +178,19 @@ const RegisterPage: React.FC = () => {
{currentStep === 1 && (
<Form form={form} onFinish={handleRegister} size="middle">
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
<Input prefix={<MobileOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="手机号" />
<Space.Compact style={{ width: '100%' }}>
<Input prefix={<MobileOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="手机号" style={{ flex: 1 }} />
<Button
disabled={countdown > 0}
onClick={handleSendCode}
style={{ width: 120 }}
>
{countdown > 0 ? `${countdown}s后重发` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="code" rules={[{ required: true, message: '请输入验证码' }]}>
<Input prefix={<SafetyOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="请输入6位验证码" maxLength={6} />
</Form.Item>
<Form.Item name="real_name" rules={[{ required: true, message: '请输入真实姓名' }]}>
<Input prefix={<UserOutlined style={{ color: 'rgba(24,144,255,0.6)' }} />} placeholder="真实姓名" />
......
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Typography, Row, Col,
Statistic, Tabs, Input, Select, Timeline, Spin, Empty,
......@@ -13,6 +13,7 @@ import { useQuery } from '@tanstack/react-query';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { adminApi } from '@/api/admin';
import { agentApi } from '@/api/agent';
const { Title, Text } = Typography;
......@@ -40,6 +41,19 @@ interface TraceDetail {
tool_calls: unknown[];
}
interface AICenterStats {
total_calls: number;
success_calls: number;
total_tokens: number;
agent_execs: number;
tool_calls: number;
mock_calls: number;
recent_logs: AIUsageLog[];
logs_total: number;
agent_counts: Array<{ agent_id: string; count: number }>;
scene_counts: Array<{ scene: string; count: number }>;
}
export default function AICenterPage() {
const [activeTab, setActiveTab] = useState('overview');
const [page, setPage] = useState(1);
......@@ -47,6 +61,21 @@ export default function AICenterPage() {
const [traceSearch, setTraceSearch] = useState('');
const [traceModalOpen, setTraceModalOpen] = useState(false);
const [selectedTraceID, setSelectedTraceID] = useState('');
const [agents, setAgents] = useState<{value:string,label:string}[]>([]);
useEffect(() => {
agentApi.listDefinitions().then(res => {
const list = (res.data || []).map((a: any) => ({ value: a.agent_id, label: a.name }));
setAgents([{ value: '', label: '全部Agent' }, ...list]);
}).catch(() => {
setAgents([
{ value: '', label: '全部Agent' },
{ value: 'patient_universal_agent', label: '患者Agent' },
{ value: 'doctor_universal_agent', label: '医生Agent' },
{ value: 'admin_universal_agent', label: '管理Agent' },
]);
});
}, []);
const { data: stats, isLoading } = useQuery({
queryKey: ['ai-center-stats', page, agentFilter],
......@@ -54,7 +83,18 @@ export default function AICenterPage() {
const res = await adminApi.getAICenterStats({
page, page_size: 20, agent_id: agentFilter || undefined,
});
return (res.data ?? {}) as Record<string, unknown>;
return (res.data ?? {
total_calls: 0,
success_calls: 0,
total_tokens: 0,
agent_execs: 0,
tool_calls: 0,
mock_calls: 0,
recent_logs: [],
logs_total: 0,
agent_counts: [],
scene_counts: [],
}) as unknown as AICenterStats;
},
refetchInterval: 60000,
});
......@@ -196,11 +236,7 @@ export default function AICenterPage() {
style={{ width: 200 }}
value={agentFilter || undefined}
onChange={(v) => { setAgentFilter(v ?? ''); setPage(1); }}
options={[
{ value: 'patient_universal_agent', label: '患者智能助手' },
{ value: 'doctor_universal_agent', label: '医生智能助手' },
{ value: 'admin_universal_agent', label: '管理员智能助手' },
]}
options={agents.filter(a => a.value !== '')}
/>
<Input.Search
placeholder="按 TraceID 追踪"
......
'use client';
import { useEffect, useState } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, Tabs, Typography } from 'antd';
import { useEffect, useState, useMemo } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, Tabs, Typography, Popconfirm } from 'antd';
import { BookOutlined, PlusOutlined, SearchOutlined, FileTextOutlined, ReloadOutlined } from '@ant-design/icons';
import { knowledgeApi } from '@/api/agent';
......@@ -28,6 +28,12 @@ export default function KnowledgePage() {
const [docForm] = Form.useForm();
const [searchForm] = Form.useForm();
const collectionMap = useMemo(() => {
const map: Record<string, string> = {};
collections.forEach((c: any) => { map[c.id] = c.name; });
return map;
}, [collections]);
const fetchCollections = async () => {
setColLoading(true);
try {
......@@ -88,6 +94,18 @@ export default function KnowledgePage() {
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => <Tag color="green">{v || '正常'}</Tag>,
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, record: any) => (
<Popconfirm title="确认删除此集合?" onConfirm={async () => {
await knowledgeApi.deleteCollection(record.id);
message.success('删除成功');
fetchCollections();
}}>
<Button type="link" danger size="small">删除</Button>
</Popconfirm>
),
},
];
const docColumns = [
......@@ -95,12 +113,24 @@ export default function KnowledgePage() {
title: '文档标题', dataIndex: 'title', key: 'title',
render: (v: string) => <Space><FileTextOutlined style={{ color: '#722ed1' }} /><Text>{v}</Text></Space>,
},
{ title: '所属集合', dataIndex: 'collection_id', key: 'collection_id', width: 200, render: (v: string) => <Text type="secondary">{v}</Text> },
{ title: '所属集合', dataIndex: 'collection_id', key: 'collection_id', width: 200, render: (id: string) => <Text type="secondary">{collectionMap[id] || id}</Text> },
{ title: '分块数', dataIndex: 'chunk_count', key: 'chunk_count', width: 80, render: (v: number) => v ?? 0 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (v: string) => <Tag color="blue">{v || '已处理'}</Tag>,
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, record: any) => (
<Popconfirm title="确认删除此文档?" onConfirm={async () => {
await knowledgeApi.deleteDocument(record.id);
message.success('删除成功');
fetchDocuments();
}}>
<Button type="link" danger size="small">删除</Button>
</Popconfirm>
),
},
];
return (
......
......@@ -2,7 +2,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag, Spin } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag, Spin, Popover, List } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
......@@ -15,6 +15,7 @@ import {
import { useUserStore } from '@/store/userStore';
import { myMenuApi } from '@/api/rbac';
import type { Menu as MenuType } from '@/api/rbac';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
......@@ -96,8 +97,28 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const [collapsed, setCollapsed] = useState(false);
const [dynamicMenus, setDynamicMenus] = useState<MenuType[]>([]);
const [menuLoading, setMenuLoading] = useState(true);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
notificationApi.getUnreadCount().then(res => setUnreadCount(res.data?.count || 0)).catch(() => {});
};
fetchUnread();
const timer = setInterval(fetchUnread, 60000);
return () => clearInterval(timer);
}, [user]);
const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
};
const handleMarkAllRead = () => {
notificationApi.markAllRead().then(() => { setUnreadCount(0); setNotifications(n => n.map(i => ({ ...i, is_read: true }))); }).catch(() => {});
};
// 从 API 获取动态菜单
useEffect(() => {
let cancelled = false;
......@@ -143,6 +164,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile' || key === 'settings') { router.push('/admin/dashboard'); }
};
const getSelectedKeys = useCallback(() => {
......@@ -224,9 +246,33 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={2} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
<Popover
trigger="click"
placement="bottomRight"
onOpenChange={(open) => { if (open) handleBellClick(); }}
content={
<div style={{ width: 300 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>通知</span>
{unreadCount > 0 && <a onClick={handleMarkAllRead} style={{ fontSize: 12 }}>全部已读</a>}
</div>
<List
size="small"
dataSource={notifications}
locale={{ emptyText: '暂无通知' }}
renderItem={(item) => (
<List.Item style={{ opacity: item.is_read ? 0.6 : 1 }}>
<List.Item.Meta title={<span style={{ fontSize: 13 }}>{item.title}</span>} description={<span style={{ fontSize: 12 }}>{item.content}</span>} />
</List.Item>
)}
/>
</div>
}
>
<Badge count={unreadCount} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
</Popover>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
......
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, Badge } from 'antd';
import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, Badge, Popconfirm } from 'antd';
import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { workflowApi } from '@/api/agent';
import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor';
......@@ -129,11 +129,25 @@ export default function WorkflowsPage() {
render: (v: string) => <Badge status={statusColor[v] || 'default'} text={statusLabel[v] || v} />,
},
{
title: '操作', key: 'action', width: 160,
title: '操作', key: 'action', width: 260,
render: (_: unknown, r: Workflow) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditingWorkflow(r); setEditorModal(true); }}>编辑</Button>
<Button type="link" size="small" icon={<PlayCircleOutlined />} disabled={r.status !== 'active'} onClick={() => handleExecute(r.workflow_id)}>执行</Button>
{r.status === 'draft' && (
<Button type="link" size="small" onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
fetchWorkflows();
}}>激活</Button>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.id);
message.success('删除成功');
fetchWorkflows();
}}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
......
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Switch, Typography, Tag, Popover, List } from 'antd';
import {
DashboardOutlined, UserOutlined, MessageOutlined, CalendarOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
......@@ -10,6 +10,8 @@ import {
CheckCircleOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import { doctorPortalApi } from '@/api/doctorPortal';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
......@@ -31,8 +33,28 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
const { user, logout } = useUserStore();
const [isOnline, setIsOnline] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
notificationApi.getUnreadCount().then(res => setUnreadCount(res.data?.count || 0)).catch(() => {});
};
fetchUnread();
const timer = setInterval(fetchUnread, 60000);
return () => clearInterval(timer);
}, [user]);
const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
};
const handleMarkAllRead = () => {
notificationApi.markAllRead().then(() => { setUnreadCount(0); setNotifications(n => n.map(i => ({ ...i, is_read: true }))); }).catch(() => {});
};
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
......@@ -46,9 +68,20 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
}
};
const handleOnlineToggle = async (checked: boolean) => {
const prev = isOnline;
setIsOnline(checked);
try {
await doctorPortalApi.toggleOnlineStatus(checked);
} catch {
setIsOnline(prev);
}
};
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile') router.push('/doctor/profile');
else if (key === 'settings') { router.push('/doctor/profile'); }
};
const getSelectedKeys = () => {
......@@ -125,14 +158,38 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Switch
checked={isOnline}
onChange={setIsOnline}
onChange={handleOnlineToggle}
checkedChildren="接诊中"
unCheckedChildren="离线"
style={{ backgroundColor: isOnline ? '#52c41a' : undefined }}
/>
<Badge count={5} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
<Popover
trigger="click"
placement="bottomRight"
onOpenChange={(open) => { if (open) handleBellClick(); }}
content={
<div style={{ width: 300 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>通知</span>
{unreadCount > 0 && <a onClick={handleMarkAllRead} style={{ fontSize: 12 }}>全部已读</a>}
</div>
<List
size="small"
dataSource={notifications}
locale={{ emptyText: '暂无通知' }}
renderItem={(item) => (
<List.Item style={{ opacity: item.is_read ? 0.6 : 1 }}>
<List.Item.Meta title={<span style={{ fontSize: 13 }}>{item.title}</span>} description={<span style={{ fontSize: 12 }}>{item.content}</span>} />
</List.Item>
)}
/>
</div>
}
>
<Badge count={unreadCount} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
</Popover>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
......
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag } from 'antd';
import { Layout, Menu, Avatar, Dropdown, Button, Badge, Space, Typography, Tag, Popover, List } from 'antd';
import {
HomeOutlined, UserOutlined, MedicineBoxOutlined, FileTextOutlined,
HeartOutlined, SettingOutlined, LogoutOutlined, BellOutlined,
VideoCameraOutlined, PayCircleOutlined,
ShoppingCartOutlined, FolderOpenOutlined, SearchOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, CheckOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
......@@ -53,8 +54,28 @@ export default function PatientLayout({ children }: { children: React.ReactNode
const pathname = usePathname();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
notificationApi.getUnreadCount().then(res => setUnreadCount(res.data?.count || 0)).catch(() => {});
};
fetchUnread();
const timer = setInterval(fetchUnread, 60000);
return () => clearInterval(timer);
}, [user]);
const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
};
const handleMarkAllRead = () => {
notificationApi.markAllRead().then(() => { setUnreadCount(0); setNotifications(n => n.map(i => ({ ...i, is_read: true }))); }).catch(() => {});
};
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人中心' },
{ key: 'settings', icon: <SettingOutlined />, label: '设置' },
......@@ -159,9 +180,33 @@ export default function PatientLayout({ children }: { children: React.ReactNode
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Badge count={3} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
<Popover
trigger="click"
placement="bottomRight"
onOpenChange={(open) => { if (open) handleBellClick(); }}
content={
<div style={{ width: 300 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>通知</span>
{unreadCount > 0 && <a onClick={handleMarkAllRead} style={{ fontSize: 12 }}>全部已读</a>}
</div>
<List
size="small"
dataSource={notifications}
locale={{ emptyText: '暂无通知' }}
renderItem={(item) => (
<List.Item style={{ opacity: item.is_read ? 0.6 : 1 }}>
<List.Item.Meta title={<span style={{ fontSize: 13 }}>{item.title}</span>} description={<span style={{ fontSize: 12 }}>{item.content}</span>} />
</List.Item>
)}
/>
</div>
}
>
<Badge count={unreadCount} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
</Popover>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
......
......@@ -32,7 +32,6 @@ interface ChatPanelProps {
patientName?: string;
symptoms?: string;
};
onEmbed?: (url: string) => void;
}
const ROLE_ICONS: Record<WidgetRole, React.ReactNode> = {
......@@ -47,7 +46,7 @@ const QUICK_ICON: Record<WidgetRole, React.ReactNode> = {
admin: <CompassOutlined style={{ marginRight: 2 }} />,
};
const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext, onEmbed }) => {
const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext }) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
......@@ -287,14 +286,10 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ role, patientContext, onEmbed })
}, [agentId, sessionId, patientContext]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNavigateFromAction = useCallback((path: string) => {
if (onEmbed) {
onEmbed(path);
} else {
window.dispatchEvent(new CustomEvent('ai-action', {
detail: { action: 'navigate', page: path },
}));
}
}, [onEmbed]);
window.dispatchEvent(new CustomEvent('ai-action', {
detail: { action: 'navigate', page: path },
}));
}, []);
const renderToolCalls = (toolCalls?: ToolCall[], isStreaming?: boolean) => {
if (!toolCalls || toolCalls.length === 0) return null;
......
......@@ -10,6 +10,7 @@ import {
ExpandOutlined,
CompressOutlined,
SettingOutlined,
FullscreenExitOutlined,
} from '@ant-design/icons';
import { Rnd } from 'react-rnd';
import { useUserStore } from '../../store/userStore';
......@@ -28,13 +29,38 @@ const FloatContainer: React.FC = () => {
openWidget, closeWidget, minimizeWidget, toggleFullscreen, setBounds,
} = useAIAssistStore();
const [mounted, setMounted] = useState(false);
// 嵌入的业务页面URL(分屏模式)
const [embeddedUrl, setEmbeddedUrl] = useState<string | null>(null);
useEffect(() => { setMounted(true); }, []);
// 键盘快捷键: Ctrl+Shift+A 打开/关闭 AI 助手
// 监听 ai-action 事件,当导航时自动进入分屏模式
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;
// 设置嵌入URL并进入全屏分屏模式
setEmbeddedUrl(path);
if (!isFullscreen) {
toggleFullscreen();
}
}
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [isFullscreen, toggleFullscreen]);
// 关闭嵌入页面
const closeEmbedded = useCallback(() => {
setEmbeddedUrl(null);
}, []);
// 键盘快捷键: Ctrl+K 打开/关闭 AI 助手
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && (e.key === 'a' || e.key === 'A')) {
if (e.ctrlKey && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
if (isOpen) {
closeWidget();
......@@ -135,8 +161,11 @@ const FloatContainer: React.FC = () => {
);
}
// ==================== Fullscreen mode ====================
// ==================== Fullscreen mode (支持分屏) ====================
if (isFullscreen) {
// 有嵌入URL时显示分屏模式:左边对话框 + 右边业务系统
const isSplitMode = !!embeddedUrl;
return (
<div style={{
position: 'fixed',
......@@ -165,19 +194,85 @@ const FloatContainer: React.FC = () => {
{roleIconSmall}
</div>
<div>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 15 }}>{theme.label}</div>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 15 }}>
{theme.label}
{isSplitMode && <span style={{ marginLeft: 8, fontSize: 12, opacity: 0.8 }}>· 分屏模式</span>}
</div>
<div style={{ color: 'rgba(255,255,255,0.8)', fontSize: 11 }}>{theme.subtitle}</div>
</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<HeaderBtn icon={<CompressOutlined />} onClick={toggleFullscreen} title="退出全屏" />
{isSplitMode && (
<HeaderBtn icon={<FullscreenExitOutlined />} onClick={closeEmbedded} title="关闭业务页面" />
)}
<HeaderBtn icon={<CompressOutlined />} onClick={() => { closeEmbedded(); toggleFullscreen(); }} title="退出全屏" />
<HeaderBtn icon={<MinusOutlined />} onClick={minimizeWidget} title="最小化" />
<HeaderBtn icon={<CloseOutlined />} onClick={closeWidget} title="关闭" />
</div>
</div>
{/* Chat */}
<div style={{ flex: 1, overflow: 'hidden' }}>
<ChatPanel role={role!} patientContext={role === 'doctor' ? (patientContext || undefined) : undefined} />
{/* 内容区:分屏或纯对话 */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex' }}>
{/* 左侧:对话框 */}
<div style={{
width: isSplitMode ? '35%' : '100%',
minWidth: isSplitMode ? 360 : undefined,
maxWidth: isSplitMode ? 480 : undefined,
borderRight: isSplitMode ? '1px solid #e5e7eb' : 'none',
display: 'flex',
flexDirection: 'column',
transition: 'width 0.3s ease',
}}>
<ChatPanel role={role!} patientContext={role === 'doctor' ? (patientContext || undefined) : undefined} />
</div>
{/* 右侧:嵌入的业务系统页面 */}
{isSplitMode && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', background: '#f5f6fa' }}>
{/* 页面标题栏 */}
<div style={{
padding: '8px 16px',
background: '#fff',
borderBottom: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontSize: 13, color: '#374151', fontWeight: 500 }}>
{embeddedUrl}
</span>
<div
onClick={closeEmbedded}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: 4,
fontSize: 12,
color: '#6b7280',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
onMouseEnter={e => { e.currentTarget.style.background = '#f3f4f6'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
>
<CloseOutlined style={{ fontSize: 11 }} />
关闭
</div>
</div>
{/* iframe 嵌入业务系统 */}
<iframe
src={embeddedUrl}
style={{
flex: 1,
width: '100%',
border: 'none',
background: '#fff',
}}
title="业务页面"
/>
</div>
)}
</div>
</div>
);
......
'use client';
import React, { useState, useEffect, type ComponentType } from 'react';
import { Button, Spin, Typography } from 'antd';
import { CloseOutlined, ExpandOutlined } from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { getEmbedLoader, type EmbedComponentProps } from './registry';
const { Text } = Typography;
interface EmbedWrapperProps {
pageCode: string;
pageName: string;
operation: 'view_list' | 'open_add' | 'open_edit';
route: string;
params?: Record<string, unknown>;
onClose: () => void;
onNavigate?: (pageCode: string, operation?: string, params?: Record<string, unknown>) => void;
}
const EmbedWrapper: React.FC<EmbedWrapperProps> = ({
pageCode, pageName, operation, route, params, onClose, onNavigate,
}) => {
const router = useRouter();
const [Component, setComponent] = useState<ComponentType<EmbedComponentProps> | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
const loader = getEmbedLoader(pageCode);
if (!loader) { setError(true); return; }
loader().then(mod => setComponent(() => mod.default)).catch(() => setError(true));
}, [pageCode]);
return (
<div style={{
border: '1px solid #e5e7eb', borderRadius: 12, overflow: 'hidden',
background: '#fff', display: 'flex', flexDirection: 'column',
maxHeight: 420,
}}>
<div style={{
padding: '8px 12px', background: '#f9fafb', borderBottom: '1px solid #f3f4f6',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<Text strong style={{ fontSize: 13 }}>{pageName}</Text>
<div style={{ display: 'flex', gap: 4 }}>
<Button type="text" size="small" icon={<ExpandOutlined />}
onClick={() => router.push(route)} title="在新页面打开" />
<Button type="text" size="small" icon={<CloseOutlined />}
onClick={onClose} title="关闭" />
</div>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
{error ? (
<div style={{ textAlign: 'center', padding: 24, color: '#9ca3af' }}>
<div>暂不支持嵌入显示</div>
<Button type="link" size="small" onClick={() => router.push(route)}>
前往完整页面
</Button>
</div>
) : Component ? (
<Component
pageCode={pageCode}
operation={operation}
params={params}
onNavigate={onNavigate}
onClose={onClose}
/>
) : (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
)}
</div>
</div>
);
};
export default EmbedWrapper;
import type { ComponentType } from 'react';
export interface EmbedComponentProps {
pageCode: string;
operation: 'view_list' | 'open_add' | 'open_edit';
params?: Record<string, unknown>;
onNavigate?: (pageCode: string, operation?: string, params?: Record<string, unknown>) => void;
onClose?: () => void;
}
const registry: Record<string, () => Promise<{ default: ComponentType<EmbedComponentProps> }>> = {
patient_management: () => import('./EmbedPatientList'),
doctor_management: () => import('./EmbedDoctorList'),
department_management: () => import('./EmbedDepartmentList'),
find_doctor: () => import('./EmbedFindDoctor'),
my_consultations: () => import('./EmbedConsultList'),
pre_consult: () => import('./EmbedPreConsult'),
};
export function isEmbeddable(pageCode: string): boolean {
return pageCode in registry;
}
export function getEmbedLoader(pageCode: string) {
return registry[pageCode] ?? null;
}
import { useState, useCallback, useRef } from 'react';
import { consultApi } from '../api/consult';
import type { VideoRoomInfo } from '../api/consult';
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseVideoCallOptions {
consultId: string;
userType: 'patient' | 'doctor';
onRemoteStreamReady?: (stream: MediaStream) => void;
onCallEnded?: () => void;
}
export const useVideoCall = ({ consultId, onCallEnded }: UseVideoCallOptions) => {
const ICE_SERVERS: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
function getWsBaseUrl(): string {
const envUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
const base = envUrl.replace(/\/api\/v1$/, '');
return base.replace(/^http/, 'ws') + '/api/v1';
}
export const useVideoCall = ({ consultId, userType = 'patient', onRemoteStreamReady, onCallEnded }: UseVideoCallOptions) => {
const [isConnecting, setIsConnecting] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isVideoOff, setIsVideoOff] = useState(false);
const [roomInfo, setRoomInfo] = useState<VideoRoomInfo | null>(null);
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const makingOfferRef = useRef(false);
const isPoliteRef = useRef(userType === 'patient'); // patient is polite peer
const initLocalStream = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
localStreamRef.current = stream;
return stream;
}, []);
const createPeerConnection = useCallback(() => {
const pc = new RTCPeerConnection(ICE_SERVERS);
// Add local tracks to peer connection
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((track) => {
pc.addTrack(track, localStreamRef.current!);
});
localStreamRef.current = stream;
return stream;
} catch (error) {
console.error('获取本地媒体流失败:', error);
throw error;
}
// Handle incoming remote tracks
pc.ontrack = (event) => {
const [stream] = event.streams;
if (stream) {
setRemoteStream(stream);
onRemoteStreamReady?.(stream);
setIsConnected(true);
}
};
// Send ICE candidates via WebSocket
pc.onicecandidate = (event) => {
if (event.candidate && wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'video_signal',
content: JSON.stringify({
signal_type: 'ice-candidate',
candidate: event.candidate,
}),
}));
}
};
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
setIsConnected(false);
} else if (pc.iceConnectionState === 'connected') {
setIsConnected(true);
}
};
// Perfect negotiation: handle negotiationneeded
pc.onnegotiationneeded = async () => {
try {
makingOfferRef.current = true;
await pc.setLocalDescription();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'video_signal',
content: JSON.stringify({
signal_type: 'offer',
sdp: pc.localDescription,
}),
}));
}
} catch (err) {
console.error('negotiationneeded error:', err);
} finally {
makingOfferRef.current = false;
}
};
peerConnectionRef.current = pc;
return pc;
}, [onRemoteStreamReady]);
const handleSignalingMessage = useCallback(async (data: { signal_type: string; sdp?: RTCSessionDescriptionInit; candidate?: RTCIceCandidateInit }) => {
const pc = peerConnectionRef.current;
if (!pc) return;
try {
if (data.signal_type === 'offer' || data.signal_type === 'answer') {
const description = new RTCSessionDescription(data.sdp!);
const offerCollision = data.signal_type === 'offer' &&
(makingOfferRef.current || pc.signalingState !== 'stable');
const ignoreOffer = !isPoliteRef.current && offerCollision;
if (ignoreOffer) return;
await pc.setRemoteDescription(description);
if (data.signal_type === 'offer') {
await pc.setLocalDescription();
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'video_signal',
content: JSON.stringify({
signal_type: 'answer',
sdp: pc.localDescription,
}),
}));
}
}
} else if (data.signal_type === 'ice-candidate' && data.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
} else if (data.signal_type === 'hangup') {
leaveRoom();
}
} catch (err) {
console.error('signaling error:', err);
}
}, []);
const connectWebSocket = useCallback((userId: string, token: string) => {
const wsBase = getWsBaseUrl();
const wsUrl = `${wsBase}/ws/consult/${consultId}?user_id=${userId}&user_type=${userType}&token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Video signaling WebSocket connected');
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'video_signal' && msg.content) {
const signalData = JSON.parse(msg.content);
handleSignalingMessage(signalData);
}
} catch (err) {
console.error('WS message parse error:', err);
}
};
ws.onclose = () => {
console.log('Video signaling WebSocket closed');
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
wsRef.current = ws;
return ws;
}, [consultId, userType, handleSignalingMessage]);
const joinRoom = useCallback(async () => {
setIsConnecting(true);
try {
// 获取房间信息
const response = await consultApi.getVideoRoomInfo(consultId);
setRoomInfo(response.data);
// Get user info from localStorage
const stored = localStorage.getItem('user-store');
const userStore = stored ? JSON.parse(stored) : {};
const userId = userStore?.state?.user?.id || '';
const token = userStore?.state?.token || '';
// 初始化本地流
if (!userId) throw new Error('用户未登录');
// Initialize local stream
await initLocalStream();
// TODO: 集成 TRTC SDK 进行实际的视频通话
// 这里是基础的 WebRTC 实现框架
setIsConnected(true);
// Connect WebSocket for signaling
connectWebSocket(userId, token);
// Create peer connection (negotiationneeded will fire an offer if we added tracks)
createPeerConnection();
} catch (error) {
console.error('加入房间失败:', error);
throw error;
} finally {
setIsConnecting(false);
}
}, [consultId, initLocalStream]);
}, [initLocalStream, connectWebSocket, createPeerConnection]);
const leaveRoom = useCallback(() => {
// 停止本地流
// Send hangup signal
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'video_signal',
content: JSON.stringify({ signal_type: 'hangup' }),
}));
}
// Stop local stream
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((track) => track.stop());
localStreamRef.current = null;
}
// 关闭 PeerConnection
// Close peer connection
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
// Close WebSocket
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setRoomInfo(null);
setRemoteStream(null);
onCallEnded?.();
}, [onCallEnded]);
......@@ -92,13 +256,28 @@ export const useVideoCall = ({ consultId, onCallEnded }: UseVideoCallOptions) =>
}
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((track) => track.stop());
}
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);
return {
isConnecting,
isConnected,
isMuted,
isVideoOff,
roomInfo,
localStream: localStreamRef.current,
remoteStream,
joinRoom,
leaveRoom,
toggleMute,
......
......@@ -37,6 +37,9 @@ const AdminCompliancePage: React.FC = () => {
const [logsLoading, setLogsLoading] = useState(false);
const [logsTotal, setLogsTotal] = useState(0);
const [logsPage, setLogsPage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchDateRange, setSearchDateRange] = useState<any>(null);
const [exportLoading, setExportLoading] = useState(false);
const reports: ReportItem[] = [
{ id: '1', name: `${dayjs().format('YYYY')}${dayjs().format('M')}月运营月报`, type: '月度报告', period: dayjs().format('YYYY-MM'), status: 'pending' },
......@@ -62,9 +65,22 @@ const AdminCompliancePage: React.FC = () => {
fetchLogs();
}, []);
const handleExport = () => {
message.success('数据导出任务已提交,请稍后在下载中心查看');
setExportModalVisible(false);
const handleExport = async (exportType?: string) => {
try {
setExportLoading(true);
const res = await adminApi.exportData(exportType || 'consultations');
if (res.data?.url) {
window.open(res.data.url, '_blank');
message.success('导出成功');
} else {
message.success('数据导出任务已提交,请稍后在下载中心查看');
}
} catch {
message.error('导出失败');
} finally {
setExportLoading(false);
setExportModalVisible(false);
}
};
const logColumns = [
......@@ -95,14 +111,14 @@ const AdminCompliancePage: React.FC = () => {
title: '操作', key: 'action', width: 200,
render: (_: any, record: ReportItem) => (
<Space>
{record.status === 'pending' && <Button size="small" type="primary">生成报告</Button>}
{record.status === 'pending' && <Button size="small" type="primary" onClick={() => message.info('报告生成中,请稍后查看')}>生成报告</Button>}
{record.status === 'generated' && (
<>
<Button size="small" icon={<DownloadOutlined />}>下载</Button>
<Button size="small" type="primary" icon={<CloudUploadOutlined />}>上报</Button>
<Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('consultations')}>下载</Button>
<Button size="small" type="primary" icon={<CloudUploadOutlined />} onClick={() => message.success('已上报至监管平台')}>上报</Button>
</>
)}
{record.status === 'submitted' && <Button size="small" icon={<DownloadOutlined />}>下载</Button>}
{record.status === 'submitted' && <Button size="small" icon={<DownloadOutlined />} onClick={() => handleExport('consultations')}>下载</Button>}
</Space>
),
},
......@@ -145,9 +161,11 @@ const AdminCompliancePage: React.FC = () => {
children: (
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input placeholder="搜索操作内容" style={{ width: 180, borderRadius: 8 }} allowClear />
<RangePicker />
<Button type="primary" ghost onClick={() => fetchLogs(1)}>查询</Button>
<Input placeholder="搜索操作内容" style={{ width: 180, borderRadius: 8 }} allowClear
value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)}
onPressEnter={() => { setLogsPage(1); fetchLogs(1); }} />
<RangePicker value={searchDateRange} onChange={setSearchDateRange} />
<Button type="primary" ghost onClick={() => { setLogsPage(1); fetchLogs(1); }}>查询</Button>
</div>
<Table columns={logColumns} dataSource={logs} rowKey="id" size="small" loading={logsLoading}
pagination={{ current: logsPage, pageSize: 10, total: logsTotal,
......@@ -186,7 +204,7 @@ const AdminCompliancePage: React.FC = () => {
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontSize: 13, color: '#8c8c8c' }}>管理合规报告,支持下载和上报</span>
<Button type="primary">生成新报告</Button>
<Button type="primary" onClick={() => message.info('报告生成中,请稍后查看')}>生成新报告</Button>
</div>
<Table columns={reportColumns} dataSource={reports} rowKey="id" size="small" pagination={false} />
</Card>
......@@ -194,8 +212,8 @@ const AdminCompliancePage: React.FC = () => {
},
]} />
<Modal title="数据导出" open={exportModalVisible} onOk={handleExport}
onCancel={() => setExportModalVisible(false)} okText="确认导出" width={400}>
<Modal title="数据导出" open={exportModalVisible} onOk={() => handleExport()}
onCancel={() => setExportModalVisible(false)} okText="确认导出" confirmLoading={exportLoading} width={400}>
<div className="space-y-3">
<div>
<Text strong className="text-xs!">导出格式</Text>
......
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, DatePicker, message } from 'antd';
import { Card, Table, Tag, Typography, Space, Input, Select, Button, Avatar, DatePicker, message, Modal, Descriptions } from 'antd';
import {
SearchOutlined, UserOutlined, MessageOutlined, VideoCameraOutlined, EyeOutlined
} from '@ant-design/icons';
......@@ -31,6 +31,7 @@ const AdminConsultationsPage: React.FC = () => {
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);
......@@ -129,8 +130,8 @@ const AdminConsultationsPage: React.FC = () => {
title: '操作',
key: 'action',
width: 80,
render: () => (
<Button type="link" size="small" icon={<EyeOutlined />}>详情</Button>
render: (_: any, record: any) => (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setDetailItem(record)}>详情</Button>
),
},
];
......@@ -197,6 +198,22 @@ const AdminConsultationsPage: React.FC = () => {
}}
/>
</Card>
<Modal title="问诊详情" open={!!detailItem} onCancel={() => setDetailItem(null)} footer={null} width={600}>
{detailItem && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="问诊ID">{detailItem.id}</Descriptions.Item>
<Descriptions.Item label="流水号">{detailItem.serial_number}</Descriptions.Item>
<Descriptions.Item label="患者">{detailItem.patient_name}</Descriptions.Item>
<Descriptions.Item label="医生">{detailItem.doctor_name}</Descriptions.Item>
<Descriptions.Item label="类型">{detailItem.type === 'text' ? '图文' : '视频'}</Descriptions.Item>
<Descriptions.Item label="状态">{detailItem.status}</Descriptions.Item>
<Descriptions.Item label="主诉" span={2}>{detailItem.chief_complaint}</Descriptions.Item>
<Descriptions.Item label="创建时间">{detailItem.created_at}</Descriptions.Item>
<Descriptions.Item label="结束时间">{detailItem.ended_at || '-'}</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
);
};
......
......@@ -32,6 +32,7 @@ const AdminPrescriptionPage: React.FC = () => {
const [warningLevel, setWarningLevel] = useState('');
const [rejectModalVisible, setRejectModalVisible] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [dateRange, setDateRange] = useState<any>(null);
const fetchStats = useCallback(async () => {
try {
......@@ -43,16 +44,16 @@ const AdminPrescriptionPage: React.FC = () => {
const fetchList = useCallback(async (wl?: string) => {
setLoading(true);
try {
const res = await prescriptionAdminApi.getList({
keyword, warning_level: wl !== undefined ? wl : warningLevel,
page, page_size: 10,
});
const params: any = { keyword, warning_level: wl !== undefined ? wl : warningLevel, page, page_size: 10 };
if (dateRange?.[0]) params.start_date = dateRange[0].format('YYYY-MM-DD');
if (dateRange?.[1]) params.end_date = dateRange[1].format('YYYY-MM-DD');
const res = await prescriptionAdminApi.getList(params);
const data = res.data as any;
setPrescriptions(data?.list || []);
setTotal(data?.total || 0);
} catch { /* ignore */ }
setLoading(false);
}, [keyword, warningLevel, page]);
}, [keyword, warningLevel, page, dateRange]);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => { fetchList(); }, [fetchList]);
......@@ -161,7 +162,7 @@ const AdminPrescriptionPage: React.FC = () => {
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Input prefix={<SearchOutlined />} placeholder="搜索处方/患者/医生" style={{ width: 200, borderRadius: 8 }} allowClear
value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={() => { setPage(1); fetchList(); }} />
<RangePicker />
<RangePicker value={dateRange} onChange={setDateRange} />
<Button type="primary" ghost onClick={() => { setPage(1); fetchList(); }}>查询</Button>
</div>
<Table columns={columns} dataSource={prescriptions} rowKey="id" loading={loading} size="small"
......
......@@ -53,8 +53,8 @@ const DoctorCertificationPage: React.FC = () => {
title: values.title,
hospital: values.hospital,
department_name: values.department_name,
license_image: licenseFileList[0]?.url || '',
qualification_image: qualificationFileList[0]?.url || '',
license_image: licenseFileList[0]?.response?.url || licenseFileList[0]?.url || '',
qualification_image: qualificationFileList[0]?.response?.url || qualificationFileList[0]?.url || '',
});
if (res.data.code === 0) {
......@@ -74,6 +74,21 @@ const DoctorCertificationPage: React.FC = () => {
}
};
const handleUpload = async (options: any) => {
const { file, onSuccess, onError } = options;
const formData = new FormData();
formData.append('file', file);
try {
const res = await request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
file.url = res.data.url;
onSuccess(res.data);
} catch (err) {
onError(err);
}
};
const beforeUpload = (file: File) => {
const isImage = file.type.startsWith('image/');
if (!isImage) {
......@@ -149,6 +164,7 @@ const DoctorCertificationPage: React.FC = () => {
listType="picture-card"
fileList={licenseFileList}
beforeUpload={beforeUpload}
customRequest={handleUpload}
onChange={({ fileList }) => setLicenseFileList(fileList)}
maxCount={1}
>
......@@ -164,6 +180,7 @@ const DoctorCertificationPage: React.FC = () => {
listType="picture-card"
fileList={qualificationFileList}
beforeUpload={beforeUpload}
customRequest={handleUpload}
onChange={({ fileList }) => setQualificationFileList(fileList)}
maxCount={1}
>
......
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import {
Card, Row, Col, Table, Button, Tag, Typography, Space, Modal, Form,
Input, message, Tabs, Badge, Descriptions, Timeline,
Input, message, Tabs, Badge, Descriptions, Timeline, Spin,
} from 'antd';
import { chronicApi } from '../../../api/chronic';
import {
CheckCircleOutlined,
CloseCircleOutlined,
......@@ -33,70 +34,49 @@ interface ChronicPrescription {
submitted_at: string;
}
const mockData: ChronicPrescription[] = [
{
id: '1',
patient_name: '张三',
patient_age: 58,
patient_gender: '',
disease: '2型糖尿病',
last_prescription_date: '2026-01-25',
ai_draft: {
drugs: [
{ name: '二甲双胍缓释片', spec: '0.5g/片', dosage: '每次1片', frequency: '每日2次', days: 30 },
{ name: '格列美脲片', spec: '2mg/片', dosage: '每次1片', frequency: '每日1次', days: 30 },
],
note: '患者血糖控制良好,建议继续当前方案。近期HbA1c: 6.8%',
},
status: 'pending',
submitted_at: '2026-02-25 09:30:00',
},
{
id: '2',
patient_name: '李四',
patient_age: 65,
patient_gender: '',
disease: '高血压',
last_prescription_date: '2026-01-20',
ai_draft: {
drugs: [
{ name: '氨氯地平片', spec: '5mg/片', dosage: '每次1片', frequency: '每日1次', days: 30 },
{ name: '缬沙坦胶囊', spec: '80mg/粒', dosage: '每次1粒', frequency: '每日1次', days: 30 },
],
note: '患者血压控制稳定,建议继续联合用药方案。近期血压 130/85mmHg',
},
status: 'pending',
submitted_at: '2026-02-25 10:15:00',
},
];
const ChronicReviewPage: React.FC = () => {
const [data, setData] = useState<ChronicPrescription[]>(mockData);
const [data, setData] = useState<ChronicPrescription[]>([]);
const [loading, setLoading] = useState(false);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<ChronicPrescription | null>(null);
const [modifyForm] = Form.useForm();
useEffect(() => {
setLoading(true);
chronicApi.getDoctorRenewals()
.then(res => setData(res.data || []))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleView = (record: ChronicPrescription) => {
setCurrentRecord(record);
setDetailModalVisible(true);
};
const handleApprove = (id: string) => {
setData(data.map((d) => (d.id === id ? { ...d, status: 'approved' as const } : d)));
message.success('续方已审核通过并签名发送');
setDetailModalVisible(false);
const handleApprove = async (id: string) => {
try {
await chronicApi.approveRenewal(id);
message.success('审核通过');
setData(prev => prev.map(item => item.id === id ? { ...item, status: 'approved' as const } : item));
setDetailModalVisible(false);
} catch { message.error('操作失败'); }
};
const handleReject = (id: string) => {
let rejectReason = '';
Modal.confirm({
title: '拒绝续方申请',
content: (
<TextArea placeholder="请输入拒绝理由..." rows={3} />
<TextArea placeholder="请输入拒绝理由..." rows={3} onChange={(e) => { rejectReason = e.target.value; }} />
),
onOk: () => {
setData(data.map((d) => (d.id === id ? { ...d, status: 'rejected' as const } : d)));
message.info('续方申请已拒绝');
setDetailModalVisible(false);
onOk: async () => {
try {
await chronicApi.rejectRenewal(id, rejectReason);
message.info('续方申请已拒绝');
setData(prev => prev.map(item => item.id === id ? { ...item, status: 'rejected' as const } : item));
setDetailModalVisible(false);
} catch { message.error('操作失败'); }
},
});
};
......@@ -158,6 +138,7 @@ const ChronicReviewPage: React.FC = () => {
const pendingCount = data.filter((d) => d.status === 'pending').length;
return (
<Spin spinning={loading}>
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>慢病续方审核</h2>
......@@ -239,6 +220,7 @@ const ChronicReviewPage: React.FC = () => {
)}
</Modal>
</div>
</Spin>
);
};
......
......@@ -361,7 +361,13 @@ const AIPanel: React.FC<AIPanelProps> = ({
</span>
),
children: renderAIAssistContent('consult_medication',
<Button size="small" type="primary" ghost icon={<MedicineBoxOutlined />}>
<Button size="small" type="primary" ghost icon={<MedicineBoxOutlined />}
onClick={() => {
if (onMedicationChange && getScene('consult_medication').content) {
onMedicationChange(getScene('consult_medication').content);
message.success('已同步至处方');
}
}}>
已同步至处方
</Button>
),
......@@ -380,7 +386,15 @@ const AIPanel: React.FC<AIPanelProps> = ({
key: 'followup',
label: <span><CalendarOutlined /> 随访</span>,
children: renderAIAssistContent('consult_follow_up',
<Button size="small" type="primary" ghost icon={<SendOutlined />}>
<Button size="small" type="primary" ghost icon={<SendOutlined />}
onClick={async () => {
if (activeConsultId && getScene('consult_follow_up').content) {
try {
await consultApi.sendMessage(activeConsultId, getScene('consult_follow_up').content, 'text');
message.success('已发送给患者');
} catch { message.error('发送失败'); }
}
}}>
发送给患者
</Button>
),
......
......@@ -432,7 +432,12 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</Button>
)}
{activeConsult.type === 'video' && !isCompleted && (
<Button size="small" type="primary" ghost icon={<VideoCameraOutlined />}>
<Button size="small" type="primary" ghost icon={<VideoCameraOutlined />}
onClick={() => {
if (activeConsult?.consult_id) {
window.open(`/doctor/consult/video/${activeConsult.consult_id}`, '_blank');
}
}}>
发起视频
</Button>
)}
......
......@@ -27,11 +27,21 @@ const IncomePage: React.FC = () => {
const [monthlyBills, setMonthlyBills] = useState<MonthlyBill[]>([]);
const [withdrawals, setWithdrawals] = useState<WithdrawalRecord[]>([]);
const [withdrawForm] = Form.useForm();
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchRecords = async (startDate?: string, endDate?: string) => {
try {
const res = await incomeApi.getRecords({ page: 1, page_size: 20, start_date: startDate, end_date: endDate });
setRecords(res.data.list || []);
} catch {
// 静默处理
}
};
const fetchData = async () => {
setLoading(true);
try {
......@@ -162,8 +172,10 @@ const IncomePage: React.FC = () => {
<Card style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16 }}>
<Space>
<RangePicker />
<Button type="primary" ghost>查询</Button>
<RangePicker value={dateRange} onChange={(dates) => setDateRange(dates as any)} />
<Button type="primary" ghost onClick={() => {
fetchRecords(dateRange?.[0]?.format('YYYY-MM-DD'), dateRange?.[1]?.format('YYYY-MM-DD'));
}}>查询</Button>
</Space>
</div>
<Table
......
......@@ -236,12 +236,17 @@ const PatientRecordPage: React.FC = () => {
{
key: 'health',
label: <span><LineChartOutlined /> 健康趋势</span>,
children: (
<div style={{ textAlign: 'center', padding: '48px 0' }}>
<HeartOutlined style={{ fontSize: 40, color: '#ffadd2', marginBottom: 12 }} />
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 6, color: '#1d2129' }}>健康趋势图表</div>
<Text type="secondary">功能开发中...</Text>
children: selectedPatient ? (
<div>
<Descriptions column={2} size="small" bordered>
<Descriptions.Item label="问诊次数">{selectedPatient.consultation_count || 0}</Descriptions.Item>
<Descriptions.Item label="过敏史">{selectedPatient.allergy_history || ''}</Descriptions.Item>
<Descriptions.Item label="病史">{selectedPatient.medical_history || ''}</Descriptions.Item>
<Descriptions.Item label="医保类型">{selectedPatient.insurance_type || '未知'}</Descriptions.Item>
</Descriptions>
</div>
) : (
<Empty description="请选择患者查看健康信息" />
),
},
]} />
......
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space, Spin, message } from 'antd';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Divider, Switch, Space, Spin, message, Modal, Form, Input, InputNumber } from 'antd';
import {
UserOutlined,
EditOutlined,
......@@ -16,6 +16,8 @@ const DoctorProfilePage: React.FC = () => {
const { user } = useUserStore();
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<DoctorProfile | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editForm] = Form.useForm();
useEffect(() => {
const fetchProfile = async () => {
......@@ -86,7 +88,8 @@ const DoctorProfilePage: React.FC = () => {
<Tag icon={<SafetyCertificateOutlined />} color="success" style={{ borderRadius: 20 }}>已认证</Tag>
</Space>
</div>
<Button icon={<EditOutlined />} style={{ borderRadius: 8, background: 'rgba(255,255,255,0.15)', border: 'none', color: '#fff' }}>
<Button icon={<EditOutlined />} style={{ borderRadius: 8, background: 'rgba(255,255,255,0.15)', border: 'none', color: '#fff' }}
onClick={() => { editForm.setFieldsValue(profile); setEditOpen(true); }}>
编辑资料
</Button>
</div>
......@@ -123,7 +126,13 @@ const DoctorProfilePage: React.FC = () => {
<div style={{ fontSize: 13, fontWeight: 600, color: '#1d2129' }}>自动接诊</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>开启后图文问诊将自动接诊</div>
</div>
<Switch />
<Switch checked={(profile as any)?.auto_accept || false} onChange={async (checked) => {
try {
await doctorPortalApi.updateProfile({ auto_accept: checked } as any);
setProfile(prev => prev ? { ...prev, auto_accept: checked } as any : prev);
message.success(checked ? '自动接诊已开启' : '自动接诊已关闭');
} catch { message.error('操作失败'); }
}} />
</div>
</div>
</Card>
......@@ -141,6 +150,23 @@ const DoctorProfilePage: React.FC = () => {
<Card title={<span style={{ fontSize: 14, fontWeight: 600 }}>个人简介</span>} style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Text style={{ fontSize: 14, color: '#595959', lineHeight: 1.7 }}>{profile.introduction}</Text>
</Card>
<Modal title="编辑资料" open={editOpen} onCancel={() => setEditOpen(false)}
onOk={async () => {
const values = await editForm.validateFields();
try {
await doctorPortalApi.updateProfile(values);
setProfile(prev => prev ? { ...prev, ...values } : prev);
message.success('更新成功');
setEditOpen(false);
} catch { message.error('更新失败'); }
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="introduction" label="个人简介"><Input.TextArea rows={3} /></Form.Item>
<Form.Item name="specialties" label="擅长领域"><Input /></Form.Item>
<Form.Item name="price" label="问诊价格(元)"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
</div>
);
};
......
......@@ -417,6 +417,21 @@ const MetricsTab: React.FC = () => {
onSuccess: () => { message.success('删除成功'); qc.invalidateQueries({ queryKey: ['metrics', activeType] }); },
});
// Fetch latest values for all metric types for summary cards
const { data: bpData } = useQuery({ queryKey: ['metrics', 'blood_pressure'], queryFn: () => chronicApi.listMetrics('blood_pressure'), enabled: true });
const { data: bsData } = useQuery({ queryKey: ['metrics', 'blood_sugar'], queryFn: () => chronicApi.listMetrics('blood_sugar'), enabled: true });
const { data: wtData } = useQuery({ queryKey: ['metrics', 'weight'], queryFn: () => chronicApi.listMetrics('weight'), enabled: true });
const { data: hrData } = useQuery({ queryKey: ['metrics', 'heart_rate'], queryFn: () => chronicApi.listMetrics('heart_rate'), enabled: true });
const { data: tmpData } = useQuery({ queryKey: ['metrics', 'temperature'], queryFn: () => chronicApi.listMetrics('temperature'), enabled: true });
const allLatest: { type: string; label: string; value: string; unit: string; color: string }[] = [
{ type: 'blood_pressure', label: '血压', value: bpData?.data?.[0] ? `${bpData.data[0].value1}/${bpData.data[0].value2}` : '-', unit: 'mmHg', color: '#1890ff' },
{ type: 'blood_sugar', label: '血糖', value: bsData?.data?.[0] ? `${bsData.data[0].value1}` : '-', unit: 'mmol/L', color: '#52c41a' },
{ type: 'weight', label: '体重', value: wtData?.data?.[0] ? `${wtData.data[0].value1}` : '-', unit: 'kg', color: '#fa8c16' },
{ type: 'heart_rate', label: '心率', value: hrData?.data?.[0] ? `${hrData.data[0].value1}` : '-', unit: 'bpm', color: '#eb2f96' },
{ type: 'temperature', label: '体温', value: tmpData?.data?.[0] ? `${tmpData.data[0].value1}` : '-', unit: '\u2103', color: '#722ed1' },
];
const list = data?.data || [];
const meta = metricTypeMap[activeType];
......@@ -448,6 +463,22 @@ const MetricsTab: React.FC = () => {
return (
<>
{/* 各指标最新值汇总 */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{allLatest.map((item) => (
<Col key={item.type} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
<Card size="small" style={{ borderRadius: 8, cursor: 'pointer', borderLeft: `3px solid ${item.color}` }}
onClick={() => setActiveType(item.type)}
styles={{ body: { padding: '10px 12px' } }}>
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 4 }}>{item.label}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: item.color, lineHeight: 1.2 }}>
{item.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#bfbfbf' }}>{item.value !== '-' ? item.unit : ''}</span>
</div>
</Card>
</Col>
))}
</Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Select value={activeType} onChange={setActiveType} style={{ width: 140 }}
options={Object.entries(metricTypeMap).map(([v, m]) => ({ value: v, label: m.label }))} />
......
......@@ -3,7 +3,7 @@
import React, { useState } from 'react';
import {
Card, Tabs, Form, Input, Select, DatePicker, Button, Table, Tag,
message, Spin, Modal, Popconfirm, Empty, Row, Col, Statistic, Typography
message, Spin, Modal, Popconfirm, Empty, Row, Col, Statistic, Typography, Progress
} from 'antd';
import {
PlusOutlined, DeleteOutlined, RobotOutlined, FileTextOutlined, TeamOutlined,
......@@ -238,6 +238,22 @@ const HealthTrendTab: React.FC = () => {
<Col span={8}><Card><Statistic title="近6个月检验报告" value={total.report} prefix={<FileTextOutlined />} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={8}><Card><Statistic title="健康记录月份" value={data.filter(d => d.consult_count + d.report_count > 0).length} suffix="/ 6" valueStyle={{ color: '#722ed1' }} /></Card></Col>
</Row>
<Card size="small" style={{ marginBottom: 16, borderRadius: 8, background: '#fafcff' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 12 }}>月度活跃趋势</div>
{data.map((d) => {
const maxTotal = Math.max(...data.map(item => item.consult_count + item.report_count), 1);
const monthTotal = d.consult_count + d.report_count;
const percent = Math.round((monthTotal / maxTotal) * 100);
return (
<div key={d.month} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Text style={{ width: 70, fontSize: 12, color: '#8c8c8c' }}>{d.month}</Text>
<Progress percent={percent} size="small" style={{ flex: 1, margin: 0 }}
strokeColor={monthTotal > 0 ? '#1677ff' : '#d9d9d9'}
format={() => `${monthTotal} 条记录`} />
</div>
);
})}
</Card>
<Table
dataSource={data}
rowKey="month"
......
......@@ -23,6 +23,7 @@ import { consultApi, type Consultation } from '../../../api/consult';
import { chronicApi, type ChronicRecord, type MedicationReminder } from '../../../api/chronic';
import { healthApi, type HealthTrend } from '../../../api/health';
import { EChartsCard, CHART_COLORS } from '../../../components/Charts';
import request from '../../../api/request';
const { Text } = Typography;
......@@ -46,11 +47,7 @@ const departments = [
'耳鼻喉科', '口腔科', '骨科', '神经内科', '心血管内科', '消化内科',
];
const bannerStats = [
{ icon: <TeamOutlined />, value: '1,280', label: '注册医生' },
{ icon: <SafetyCertificateOutlined />, value: '56,800', label: '服务患者' },
{ icon: <ClockCircleOutlined />, value: '3 分钟', label: '平均响应' },
];
const defaultPlatformStats = { doctors: '1,200+', patients: '50,000+', response: '< 5 分钟' };
const statusLabels: Record<string, { text: string; color: string }> = {
pending: { text: '待接诊', color: 'orange' },
......@@ -65,6 +62,27 @@ const PatientHomePage: React.FC = () => {
const { user } = useUserStore();
const isLoggedIn = !!user;
const [platformStats, setPlatformStats] = useState(defaultPlatformStats);
useEffect(() => {
// Try to get real platform stats from a public endpoint
request.get('/admin/dashboard/stats').then((res: any) => {
if (res.data) {
setPlatformStats({
doctors: `${res.data.total_doctors || 0}`,
patients: `${res.data.total_users || 0}`,
response: '< 5 分钟',
});
}
}).catch(() => {}); // Silently fail, use defaults
}, []);
const bannerStats = [
{ icon: <TeamOutlined />, value: platformStats.doctors, label: '注册医生' },
{ icon: <SafetyCertificateOutlined />, value: platformStats.patients, label: '服务患者' },
{ icon: <ClockCircleOutlined />, value: platformStats.response, label: '平均响应' },
];
const [consults, setConsults] = useState<Consultation[]>([]);
const [chronicRecords, setChronicRecords] = useState<ChronicRecord[]>([]);
const [reminders, setReminders] = useState<MedicationReminder[]>([]);
......
'use client';
import React, { useState, useEffect } from 'react';
import { Card, Typography, Button, Row, Col, Table, Tag, Empty, Tabs, Statistic, message, Modal, Radio, Spin } from 'antd';
import { Card, Typography, Button, Row, Col, Table, Tag, Empty, Tabs, Statistic, message, Modal, Radio, Spin, Descriptions } from 'antd';
import {
PayCircleOutlined,
WalletOutlined,
......@@ -25,6 +25,8 @@ const PatientPaymentPage: React.FC = () => {
const [selectedOrder, setSelectedOrder] = useState<PaymentOrder | null>(null);
const [paymentMethod, setPaymentMethod] = useState('wechat');
const [paying, setPaying] = useState(false);
const [detailVisible, setDetailVisible] = useState(false);
const [detailOrder, setDetailOrder] = useState<any>(null);
useEffect(() => {
fetchOrders();
......@@ -107,10 +109,16 @@ const PatientPaymentPage: React.FC = () => {
},
{
title: '操作', key: 'action', width: 80,
render: (_: unknown, r: PaymentOrder) => (
r.status === 'pending'
? <Button type="primary" size="small" onClick={() => handlePay(r)}>去支付</Button>
: <Button type="link" size="small">详情</Button>
render: (_: any, record: PaymentOrder) => (
record.status === 'pending'
? <Button type="primary" size="small" onClick={() => handlePay(record)}>去支付</Button>
: <Button type="link" size="small" onClick={async () => {
try {
const res = await paymentApi.getOrderDetail(record.id);
setDetailOrder(res.data);
setDetailVisible(true);
} catch { message.error('获取详情失败'); }
}}>详情</Button>
),
},
];
......@@ -262,6 +270,21 @@ const PatientPaymentPage: React.FC = () => {
</div>
)}
</Modal>
{/* 订单详情Modal */}
<Modal title="订单详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null}>
{detailOrder && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="订单号">{detailOrder.order_no}</Descriptions.Item>
<Descriptions.Item label="类型">{detailOrder.order_type === 'consult' ? '问诊' : detailOrder.order_type === 'pharmacy' ? '购药' : detailOrder.order_type}</Descriptions.Item>
<Descriptions.Item label="金额">{'\u00A5'}{((detailOrder.amount || 0) / 100).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{detailOrder.status}</Descriptions.Item>
<Descriptions.Item label="支付方式">{detailOrder.payment_method || '-'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{detailOrder.created_at}</Descriptions.Item>
{detailOrder.paid_at && <Descriptions.Item label="支付时间">{detailOrder.paid_at}</Descriptions.Item>}
</Descriptions>
)}
</Modal>
</div>
);
};
......
......@@ -14,6 +14,7 @@ import {
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { prescriptionPatientApi, type Prescription } from '../../../api/prescription';
import { paymentApi } from '../../../api/payment';
import dayjs from 'dayjs';
const { Text } = Typography;
......@@ -53,18 +54,19 @@ const PatientPharmacyOrderPage: React.FC = () => {
const [prescriptions, setPrescriptions] = useState<Prescription[]>([]);
const [detailModal, setDetailModal] = useState<OrderItem | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const res = await prescriptionPatientApi.getList({ page: 1, page_size: 50 });
setPrescriptions(res.data?.list || []);
} catch {
// 静默处理
} finally {
setLoading(false);
}
};
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = await prescriptionPatientApi.getList({ page: 1, page_size: 50 });
setPrescriptions(res.data?.list || []);
} catch {
// 静默处理
} finally {
setLoading(false);
}
};
fetchData();
}, []);
......@@ -126,7 +128,15 @@ const PatientPharmacyOrderPage: React.FC = () => {
<Space size={4}>
<Button type="link" size="small" onClick={() => setDetailModal(r)}>详情</Button>
{r.status === 'delivered' && (
<Button type="primary" size="small" onClick={() => message.success('确认收货成功')}>确认收货</Button>
<Button type="primary" size="small" onClick={async () => {
try {
await paymentApi.confirmDelivery(r.id);
message.success('确认收货成功');
fetchData();
} catch {
message.error('确认收货失败');
}
}}>确认收货</Button>
)}
</Space>
),
......
......@@ -13,6 +13,7 @@ import {
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { prescriptionPatientApi } from '../../../api/prescription';
import { paymentApi } from '../../../api/payment';
import type { Prescription, PrescriptionItem } from '../../../api/prescription';
const { Title, Text } = Typography;
......@@ -123,7 +124,20 @@ const PatientPrescriptionPage: React.FC = () => {
footer={
currentRx && ['approved', 'signed'].includes(currentRx.status) ? [
<Button key="buy" type="primary" icon={<ShoppingCartOutlined />}
onClick={() => { message.info('购药功能即将上线'); }}>一键购药</Button>,
onClick={async () => {
try {
await paymentApi.createOrder({
order_type: 'pharmacy',
related_id: currentRx!.id,
amount: currentRx!.total_amount || 0,
});
message.success('购药订单已创建,请前往支付');
setDetailVisible(false);
router.push('/patient/pharmacy/order');
} catch {
message.error('创建订单失败');
}
}}>一键购药</Button>,
] : null
}>
{currentRx && (
......
'use client';
import React from 'react';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col } from 'antd';
import React, { useState } from 'react';
import { Card, Avatar, Button, Descriptions, Tag, Typography, Row, Col, Modal, Form, Input, Select, InputNumber, App } from 'antd';
import {
UserOutlined,
PhoneOutlined,
......@@ -18,12 +18,18 @@ import {
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { useUserStore } from '../../../store/userStore';
import { userApi } from '../../../api/user';
const { Text } = Typography;
const PatientProfilePage: React.FC = () => {
const { user } = useUserStore();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [verifyOpen, setVerifyOpen] = useState(false);
const [editForm] = Form.useForm();
const [verifyForm] = Form.useForm();
const { message } = App.useApp();
const serviceLinks = [
{ icon: <VideoCameraOutlined />, title: '就诊记录', path: '/patient/consult', color: '#1890ff' },
......@@ -63,7 +69,8 @@ const PatientProfilePage: React.FC = () => {
<Tag color="blue" style={{ borderRadius: 20 }}>患者</Tag>
</div>
</div>
<Button icon={<EditOutlined />} style={{ borderRadius: 8, background: 'rgba(255,255,255,0.15)', border: 'none', color: '#fff' }}>
<Button icon={<EditOutlined />} style={{ borderRadius: 8, background: 'rgba(255,255,255,0.15)', border: 'none', color: '#fff' }}
onClick={() => { editForm.setFieldsValue({ real_name: user?.real_name, gender: user?.gender, age: user?.age }); setEditOpen(true); }}>
编辑资料
</Button>
</div>
......@@ -113,7 +120,7 @@ const PatientProfilePage: React.FC = () => {
<div style={{ fontSize: 13, color: '#8c8c8c', marginBottom: 12 }}>
完成实名认证后可享受完整的线上问诊服务
</div>
<Button type="primary" style={{ borderRadius: 8 }}>去认证</Button>
<Button type="primary" style={{ borderRadius: 8 }} onClick={() => setVerifyOpen(true)}>去认证</Button>
</div>
)}
</Card>
......@@ -152,6 +159,53 @@ const PatientProfilePage: React.FC = () => {
))}
</Row>
</Card>
{/* 编辑资料 Modal */}
<Modal title="编辑资料" open={editOpen} onCancel={() => setEditOpen(false)}
onOk={async () => {
const values = await editForm.validateFields();
try {
await userApi.updateProfile(values);
message.success('资料更新成功');
const res = await userApi.getCurrentUser();
useUserStore.getState().setUser(res.data);
setEditOpen(false);
} catch { message.error('更新失败'); }
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="real_name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select options={[{ value: 'male', label: '' }, { value: 'female', label: '' }]} />
</Form.Item>
<Form.Item name="age" label="年龄">
<InputNumber min={1} max={150} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* 实名认证 Modal */}
<Modal title="实名认证" open={verifyOpen} onCancel={() => setVerifyOpen(false)}
onOk={async () => {
const values = await verifyForm.validateFields();
try {
await userApi.verifyIdentity(values);
message.success('认证成功');
const res = await userApi.getCurrentUser();
useUserStore.getState().setUser(res.data);
setVerifyOpen(false);
} catch { message.error('认证失败'); }
}}>
<Form form={verifyForm} layout="vertical">
<Form.Item name="real_name" label="真实姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="id_card" label="身份证号" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
};
......
......@@ -136,7 +136,7 @@ const PatientTextConsultPage: React.FC = () => {
return (
<div style={{ height: 'calc(100vh - 120px)' }}>
<Button type="link" size="small" icon={<ArrowLeftOutlined />}
onClick={() => router.push('/patient/consult-list')} className="p-0! mb-1">返回列表</Button>
onClick={() => router.push('/patient/consult')} className="p-0! mb-1">返回列表</Button>
<Row gutter={8} style={{ height: 'calc(100% - 32px)' }}>
<Col span={6}>
......
......@@ -2,7 +2,7 @@
import React, { useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Card, Button, Space, message, Spin, Typography } from 'antd';
import { Card, Button, Space, message, Spin } from 'antd';
import {
AudioMutedOutlined,
AudioOutlined,
......@@ -12,8 +12,6 @@ import {
} from '@ant-design/icons';
import { useVideoCall } from '../../../hooks/useVideoCall';
const { Title } = Typography;
const PatientVideoConsultPage: React.FC = () => {
const params = useParams<{ id: string }>();
const id = params?.id;
......@@ -27,12 +25,14 @@ const PatientVideoConsultPage: React.FC = () => {
isMuted,
isVideoOff,
localStream,
remoteStream,
joinRoom,
leaveRoom,
toggleMute,
toggleVideo,
} = useVideoCall({
consultId: id || '',
userType: 'patient',
onCallEnded: () => {
message.info('通话已结束');
router.push('/patient/consult');
......@@ -45,6 +45,12 @@ const PatientVideoConsultPage: React.FC = () => {
}
}, [localStream]);
useEffect(() => {
if (remoteStream && remoteVideoRef.current) {
remoteVideoRef.current.srcObject = remoteStream;
}
}, [remoteStream]);
useEffect(() => {
joinRoom().catch((error) => {
message.error('加入房间失败: ' + error.message);
......@@ -53,7 +59,8 @@ const PatientVideoConsultPage: React.FC = () => {
return () => {
leaveRoom();
};
}, [joinRoom, leaveRoom]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleEndCall = () => {
leaveRoom();
......@@ -99,4 +106,3 @@ const PatientVideoConsultPage: React.FC = () => {
};
export default PatientVideoConsultPage;
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