Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
I
Internet-hospital
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Labels
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Jobs
Commits
Open sidebar
yuguo
Internet-hospital
Commits
6e652bf1
Commit
6e652bf1
authored
Mar 04, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
beffd7fe
Changes
56
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
56 changed files
with
2027 additions
and
366 deletions
+2027
-366
.claude/settings.local.json
.claude/settings.local.json
+2
-1
server/cmd/api/main.go
server/cmd/api/main.go
+44
-0
server/internal/model/notification.go
server/internal/model/notification.go
+14
-0
server/internal/service/admin/handler.go
server/internal/service/admin/handler.go
+15
-0
server/internal/service/admin/service.go
server/internal/service/admin/service.go
+73
-3
server/internal/service/chronic/handler.go
server/internal/service/chronic/handler.go
+51
-0
server/internal/service/chronic/service.go
server/internal/service/chronic/service.go
+42
-0
server/internal/service/doctorportal/handler.go
server/internal/service/doctorportal/handler.go
+68
-15
server/internal/service/doctorportal/service.go
server/internal/service/doctorportal/service.go
+418
-43
server/internal/service/knowledgesvc/handler.go
server/internal/service/knowledgesvc/handler.go
+26
-0
server/internal/service/notification/handler.go
server/internal/service/notification/handler.go
+84
-0
server/internal/service/notification/service.go
server/internal/service/notification/service.go
+50
-0
server/internal/service/payment/handler.go
server/internal/service/payment/handler.go
+10
-0
server/internal/service/payment/service.go
server/internal/service/payment/service.go
+8
-0
server/internal/service/user/handler.go
server/internal/service/user/handler.go
+26
-0
server/internal/service/user/service.go
server/internal/service/user/service.go
+14
-0
server/internal/service/workflowsvc/handler.go
server/internal/service/workflowsvc/handler.go
+27
-0
server/pkg/websocket/handler.go
server/pkg/websocket/handler.go
+12
-0
web/src/api/admin.ts
web/src/api/admin.ts
+4
-0
web/src/api/agent.ts
web/src/api/agent.ts
+6
-0
web/src/api/chronic.ts
web/src/api/chronic.ts
+5
-0
web/src/api/notification.ts
web/src/api/notification.ts
+26
-0
web/src/api/payment.ts
web/src/api/payment.ts
+4
-0
web/src/api/user.ts
web/src/api/user.ts
+5
-1
web/src/app/(auth)/login/page.tsx
web/src/app/(auth)/login/page.tsx
+17
-4
web/src/app/(auth)/register/page.tsx
web/src/app/(auth)/register/page.tsx
+16
-3
web/src/app/(main)/admin/ai-center/page.tsx
web/src/app/(main)/admin/ai-center/page.tsx
+43
-7
web/src/app/(main)/admin/knowledge/page.tsx
web/src/app/(main)/admin/knowledge/page.tsx
+33
-3
web/src/app/(main)/admin/layout.tsx
web/src/app/(main)/admin/layout.tsx
+50
-4
web/src/app/(main)/admin/workflows/page.tsx
web/src/app/(main)/admin/workflows/page.tsx
+17
-3
web/src/app/(main)/doctor/layout.tsx
web/src/app/(main)/doctor/layout.tsx
+63
-6
web/src/app/(main)/patient/layout.tsx
web/src/app/(main)/patient/layout.tsx
+51
-6
web/src/components/GlobalAIFloat/ChatPanel.tsx
web/src/components/GlobalAIFloat/ChatPanel.tsx
+5
-10
web/src/components/GlobalAIFloat/FloatContainer.tsx
web/src/components/GlobalAIFloat/FloatContainer.tsx
+103
-8
web/src/components/GlobalAIFloat/embeds/EmbedWrapper.tsx
web/src/components/GlobalAIFloat/embeds/EmbedWrapper.tsx
+0
-76
web/src/components/GlobalAIFloat/embeds/registry.ts
web/src/components/GlobalAIFloat/embeds/registry.ts
+0
-26
web/src/hooks/useVideoCall.ts
web/src/hooks/useVideoCall.ts
+206
-27
web/src/pages/admin/Compliance/index.tsx
web/src/pages/admin/Compliance/index.tsx
+31
-13
web/src/pages/admin/Consultations/index.tsx
web/src/pages/admin/Consultations/index.tsx
+20
-3
web/src/pages/admin/Prescription/index.tsx
web/src/pages/admin/Prescription/index.tsx
+7
-6
web/src/pages/doctor/Certification/index.tsx
web/src/pages/doctor/Certification/index.tsx
+19
-2
web/src/pages/doctor/ChronicReview/index.tsx
web/src/pages/doctor/ChronicReview/index.tsx
+31
-49
web/src/pages/doctor/Consult/AIPanel.tsx
web/src/pages/doctor/Consult/AIPanel.tsx
+16
-2
web/src/pages/doctor/Consult/ChatPanel.tsx
web/src/pages/doctor/Consult/ChatPanel.tsx
+6
-1
web/src/pages/doctor/Income/index.tsx
web/src/pages/doctor/Income/index.tsx
+14
-2
web/src/pages/doctor/PatientRecord/index.tsx
web/src/pages/doctor/PatientRecord/index.tsx
+10
-5
web/src/pages/doctor/Profile/index.tsx
web/src/pages/doctor/Profile/index.tsx
+29
-3
web/src/pages/patient/Chronic/index.tsx
web/src/pages/patient/Chronic/index.tsx
+31
-0
web/src/pages/patient/HealthRecords/index.tsx
web/src/pages/patient/HealthRecords/index.tsx
+17
-1
web/src/pages/patient/Home/index.tsx
web/src/pages/patient/Home/index.tsx
+23
-5
web/src/pages/patient/Payment/index.tsx
web/src/pages/patient/Payment/index.tsx
+28
-5
web/src/pages/patient/PharmacyOrder/index.tsx
web/src/pages/patient/PharmacyOrder/index.tsx
+22
-12
web/src/pages/patient/Prescription/index.tsx
web/src/pages/patient/Prescription/index.tsx
+15
-1
web/src/pages/patient/Profile/index.tsx
web/src/pages/patient/Profile/index.tsx
+58
-4
web/src/pages/patient/TextConsult/index.tsx
web/src/pages/patient/TextConsult/index.tsx
+1
-1
web/src/pages/patient/VideoConsult/index.tsx
web/src/pages/patient/VideoConsult/index.tsx
+11
-5
No files found.
.claude/settings.local.json
View file @
6e652bf1
...
...
@@ -36,7 +36,8 @@
"Bash(pkill:*)"
,
"Bash(bash:*)"
,
"Bash(make run:*)"
,
"Bash(rm:*)"
"Bash(rm:*)"
,
"Bash(gh pr:*)"
]
}
}
server/cmd/api/main.go
View file @
6e652bf1
...
...
@@ -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"
)
...
...
server/internal/model/notification.go
0 → 100644
View file @
6e652bf1
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"`
}
server/internal/service/admin/handler.go
View file @
6e652bf1
...
...
@@ -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
)
}
server/internal/service/admin/service.go
View file @
6e652bf1
...
...
@@ -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
}
server/internal/service/chronic/handler.go
View file @
6e652bf1
...
...
@@ -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
)
}
server/internal/service/chronic/service.go
View file @
6e652bf1
...
...
@@ -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
}
server/internal/service/doctorportal/handler.go
View file @
6e652bf1
...
...
@@ -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
}
...
...
server/internal/service/doctorportal/service.go
View file @
6e652bf1
This diff is collapsed.
Click to expand it.
server/internal/service/knowledgesvc/handler.go
View file @
6e652bf1
...
...
@@ -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
...
...
server/internal/service/notification/handler.go
0 → 100644
View file @
6e652bf1
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
})
}
server/internal/service/notification/service.go
0 → 100644
View file @
6e652bf1
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
}
server/internal/service/payment/handler.go
View file @
6e652bf1
...
...
@@ -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
)
{
...
...
server/internal/service/payment/service.go
View file @
6e652bf1
...
...
@@ -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
{
...
...
server/internal/service/user/handler.go
View file @
6e652bf1
...
...
@@ -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"
,
})
}
server/internal/service/user/service.go
View file @
6e652bf1
...
...
@@ -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
{
...
...
server/internal/service/workflowsvc/handler.go
View file @
6e652bf1
...
...
@@ -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"
})
}
server/pkg/websocket/handler.go
View file @
6e652bf1
...
...
@@ -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"
:
// 这些类型由服务端主动推送,客户端发送时忽略
...
...
web/src/api/admin.ts
View file @
6e652bf1
...
...
@@ -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
}
`
,
{}),
};
web/src/api/agent.ts
View file @
6e652bf1
...
...
@@ -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
}),
};
web/src/api/chronic.ts
View file @
6e652bf1
...
...
@@ -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
),
...
...
web/src/api/notification.ts
0 → 100644
View file @
6e652bf1
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
'
),
};
web/src/api/payment.ts
View file @
6e652bf1
...
...
@@ -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
...
...
web/src/api/user.ts
View file @
6e652bf1
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
),
...
...
web/src/app/(auth)/login/page.tsx
View file @
6e652bf1
...
...
@@ -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
>
);
};
...
...
web/src/app/(auth)/register/page.tsx
View file @
6e652bf1
...
...
@@ -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=
"真实姓名"
/>
...
...
web/src/app/(main)/admin/ai-center/page.tsx
View file @
6e652bf1
'
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 追踪"
...
...
web/src/app/(main)/admin/knowledge/page.tsx
View file @
6e652bf1
'
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
(
...
...
web/src/app/(main)/admin/layout.tsx
View file @
6e652bf1
...
...
@@ -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
'
}
}
>
...
...
web/src/app/(main)/admin/workflows/page.tsx
View file @
6e652bf1
'
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:
1
60,
title: '操作', key: 'action', width:
2
60,
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>
),
},
...
...
web/src/app/(main)/doctor/layout.tsx
View file @
6e652bf1
'
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=
{
setIsOnlin
e
}
onChange=
{
handleOnlineToggl
e
}
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
'
}
}
>
...
...
web/src/app/(main)/patient/layout.tsx
View file @
6e652bf1
'
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
'
}
}
>
...
...
web/src/components/GlobalAIFloat/ChatPanel.tsx
View file @
6e652bf1
...
...
@@ -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
;
...
...
web/src/components/GlobalAIFloat/FloatContainer.tsx
View file @
6e652bf1
...
...
@@ -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
>
);
...
...
web/src/components/GlobalAIFloat/embeds/EmbedWrapper.tsx
deleted
100644 → 0
View file @
beffd7fe
'
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
;
web/src/components/GlobalAIFloat/embeds/registry.ts
deleted
100644 → 0
View file @
beffd7fe
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
;
}
web/src/hooks/useVideoCall.ts
View file @
6e652bf1
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
[
r
oomInfo
,
setRoomInfo
]
=
useState
<
VideoRoomInfo
|
null
>
(
null
);
const
[
r
emoteStream
,
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
;
}
//
关闭 PeerC
onnection
//
Close peer c
onnection
if
(
peerConnectionRef
.
current
)
{
peerConnectionRef
.
current
.
close
();
peerConnectionRef
.
current
=
null
;
}
// Close WebSocket
if
(
wsRef
.
current
)
{
wsRef
.
current
.
close
();
wsRef
.
current
=
null
;
}
setIsConnected
(
false
);
setR
oomInfo
(
null
);
setR
emoteStream
(
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
,
...
...
web/src/pages/admin/Compliance/index.tsx
View file @
6e652bf1
...
...
@@ -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
>
...
...
web/src/pages/admin/Consultations/index.tsx
View file @
6e652bf1
'
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
>
);
};
...
...
web/src/pages/admin/Prescription/index.tsx
View file @
6e652bf1
...
...
@@ -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"
...
...
web/src/pages/doctor/Certification/index.tsx
View file @
6e652bf1
...
...
@@ -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
}
>
...
...
web/src/pages/doctor/ChronicReview/index.tsx
View file @
6e652bf1
'
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
>
);
};
...
...
web/src/pages/doctor/Consult/AIPanel.tsx
View file @
6e652bf1
...
...
@@ -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
>
),
...
...
web/src/pages/doctor/Consult/ChatPanel.tsx
View file @
6e652bf1
...
...
@@ -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
>
)
}
...
...
web/src/pages/doctor/Income/index.tsx
View file @
6e652bf1
...
...
@@ -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
...
...
web/src/pages/doctor/PatientRecord/index.tsx
View file @
6e652bf1
...
...
@@ -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=
"请选择患者查看健康信息"
/>
),
}
,
]
}
/>
...
...
web/src/pages/doctor/Profile/index.tsx
View file @
6e652bf1
'
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
>
);
};
...
...
web/src/pages/patient/Chronic/index.tsx
View file @
6e652bf1
...
...
@@ -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
}))
}
/>
...
...
web/src/pages/patient/HealthRecords/index.tsx
View file @
6e652bf1
...
...
@@ -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"
...
...
web/src/pages/patient/Home/index.tsx
View file @
6e652bf1
...
...
@@ -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
[]
>
([]);
...
...
web/src/pages/patient/Payment/index.tsx
View file @
6e652bf1
'
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
>
);
};
...
...
web/src/pages/patient/PharmacyOrder/index.tsx
View file @
6e652bf1
...
...
@@ -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
>
),
...
...
web/src/pages/patient/Prescription/index.tsx
View file @
6e652bf1
...
...
@@ -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
&&
(
...
...
web/src/pages/patient/Profile/index.tsx
View file @
6e652bf1
'
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
>
);
};
...
...
web/src/pages/patient/TextConsult/index.tsx
View file @
6e652bf1
...
...
@@ -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
}
>
...
...
web/src/pages/patient/VideoConsult/index.tsx
View file @
6e652bf1
...
...
@@ -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
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment