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
4ae56601
Commit
4ae56601
authored
Feb 28, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
f350fadd
Changes
55
Hide whitespace changes
Inline
Side-by-side
Showing
55 changed files
with
1777 additions
and
1300 deletions
+1777
-1300
.claude/settings.local.json
.claude/settings.local.json
+3
-1
server/api
server/api
+0
-0
server/configs/config.example.yaml
server/configs/config.example.yaml
+5
-3
server/pkg/config/config.go
server/pkg/config/config.go
+2
-0
server/pkg/database/postgres.go
server/pkg/database/postgres.go
+6
-0
server/scripts/reset_db.go
server/scripts/reset_db.go
+1
-1
web/package-lock.json
web/package-lock.json
+17
-2
web/package.json
web/package.json
+3
-2
web/src/api/admin.ts
web/src/api/admin.ts
+22
-0
web/src/api/consult.ts
web/src/api/consult.ts
+2
-11
web/src/api/index.ts
web/src/api/index.ts
+10
-1
web/src/api/preConsult.ts
web/src/api/preConsult.ts
+1
-1
web/src/app/(auth)/login/page.tsx
web/src/app/(auth)/login/page.tsx
+171
-75
web/src/app/(auth)/register/page.tsx
web/src/app/(auth)/register/page.tsx
+104
-41
web/src/app/(main)/admin/layout.tsx
web/src/app/(main)/admin/layout.tsx
+40
-23
web/src/app/(main)/doctor/layout.tsx
web/src/app/(main)/doctor/layout.tsx
+38
-17
web/src/app/(main)/layout.tsx
web/src/app/(main)/layout.tsx
+1
-1
web/src/app/(main)/patient/layout.tsx
web/src/app/(main)/patient/layout.tsx
+38
-17
web/src/app/globals.css
web/src/app/globals.css
+111
-84
web/src/app/icon.svg
web/src/app/icon.svg
+4
-0
web/src/app/providers.tsx
web/src/app/providers.tsx
+1
-0
web/src/components/AuthGuard/index.tsx
web/src/components/AuthGuard/index.tsx
+32
-13
web/src/components/Layout/AdminLayout.tsx
web/src/components/Layout/AdminLayout.tsx
+14
-12
web/src/components/Layout/DoctorLayout.tsx
web/src/components/Layout/DoctorLayout.tsx
+13
-11
web/src/components/Layout/MainLayout.tsx
web/src/components/Layout/MainLayout.tsx
+15
-13
web/src/components/Layout/PatientLayout.tsx
web/src/components/Layout/PatientLayout.tsx
+13
-11
web/src/components/RoleRedirect/index.tsx
web/src/components/RoleRedirect/index.tsx
+24
-13
web/src/pages/admin/Admins/index.tsx
web/src/pages/admin/Admins/index.tsx
+2
-0
web/src/pages/admin/Compliance/index.tsx
web/src/pages/admin/Compliance/index.tsx
+53
-73
web/src/pages/admin/Consultations/index.tsx
web/src/pages/admin/Consultations/index.tsx
+2
-0
web/src/pages/admin/Dashboard/index.tsx
web/src/pages/admin/Dashboard/index.tsx
+2
-0
web/src/pages/admin/DoctorReview/index.tsx
web/src/pages/admin/DoctorReview/index.tsx
+2
-0
web/src/pages/admin/Prescription/index.tsx
web/src/pages/admin/Prescription/index.tsx
+2
-0
web/src/pages/admin/Statistics/index.tsx
web/src/pages/admin/Statistics/index.tsx
+201
-63
web/src/pages/admin/Users/index.tsx
web/src/pages/admin/Users/index.tsx
+3
-3
web/src/pages/doctor/AIAssist/index.tsx
web/src/pages/doctor/AIAssist/index.tsx
+2
-0
web/src/pages/doctor/ChronicReview/index.tsx
web/src/pages/doctor/ChronicReview/index.tsx
+2
-0
web/src/pages/doctor/Consult/WaitingQueue.tsx
web/src/pages/doctor/Consult/WaitingQueue.tsx
+16
-16
web/src/pages/doctor/ConsultRoom/index.tsx
web/src/pages/doctor/ConsultRoom/index.tsx
+12
-27
web/src/pages/doctor/History/index.tsx
web/src/pages/doctor/History/index.tsx
+14
-13
web/src/pages/doctor/Income/index.tsx
web/src/pages/doctor/Income/index.tsx
+18
-17
web/src/pages/doctor/PatientRecord/index.tsx
web/src/pages/doctor/PatientRecord/index.tsx
+39
-29
web/src/pages/doctor/Prescription/index.tsx
web/src/pages/doctor/Prescription/index.tsx
+2
-0
web/src/pages/doctor/Profile/index.tsx
web/src/pages/doctor/Profile/index.tsx
+65
-32
web/src/pages/doctor/Queue/index.tsx
web/src/pages/doctor/Queue/index.tsx
+8
-11
web/src/pages/doctor/Schedule/index.tsx
web/src/pages/doctor/Schedule/index.tsx
+104
-87
web/src/pages/patient/Chronic/index.tsx
web/src/pages/patient/Chronic/index.tsx
+28
-27
web/src/pages/patient/ConsultCreate/index.tsx
web/src/pages/patient/ConsultCreate/index.tsx
+12
-17
web/src/pages/patient/DoctorDetail/index.tsx
web/src/pages/patient/DoctorDetail/index.tsx
+8
-9
web/src/pages/patient/Doctors/index.tsx
web/src/pages/patient/Doctors/index.tsx
+10
-11
web/src/pages/patient/Payment/index.tsx
web/src/pages/patient/Payment/index.tsx
+187
-25
web/src/pages/patient/PharmacyOrder/index.tsx
web/src/pages/patient/PharmacyOrder/index.tsx
+232
-29
web/src/pages/patient/TextConsult/index.tsx
web/src/pages/patient/TextConsult/index.tsx
+2
-1
web/src/pages/patient/VideoConsult/index.tsx
web/src/pages/patient/VideoConsult/index.tsx
+2
-1
web/src/router/index.tsx
web/src/router/index.tsx
+56
-456
No files found.
.claude/settings.local.json
View file @
4ae56601
...
...
@@ -12,7 +12,9 @@
"Bash(node:*)"
,
"Bash(ls:*)"
,
"Bash(npx tailwindcss:*)"
,
"Bash(npx next:*)"
"Bash(npx next:*)"
,
"Bash(PGPASSWORD=T5sSfTZ6XYTD9bfC psql:*)"
,
"Bash(npm install:*)"
]
}
}
server/api
0 → 100755
View file @
4ae56601
File added
server/configs/config.example.yaml
View file @
4ae56601
...
...
@@ -5,12 +5,14 @@ server:
version
:
1.0.0
database
:
host
:
localhost
host
:
10.10.0.102
port
:
5432
user
:
postgres
password
:
your_password
dbname
:
internet_hospital
password
:
T5sSfTZ6XYTD9bfC
dbname
:
xxxx
sslmode
:
disable
schema
:
public
timezone
:
Asia/Shanghai
redis
:
host
:
localhost
...
...
server/pkg/config/config.go
View file @
4ae56601
...
...
@@ -35,6 +35,8 @@ type DatabaseConfig struct {
Password
string
`mapstructure:"password"`
DBName
string
`mapstructure:"dbname"`
SSLMode
string
`mapstructure:"sslmode"`
Schema
string
`mapstructure:"schema"`
Timezone
string
`mapstructure:"timezone"`
}
type
RedisConfig
struct
{
...
...
server/pkg/database/postgres.go
View file @
4ae56601
...
...
@@ -17,6 +17,12 @@ func InitPostgres(cfg *config.DatabaseConfig) (*gorm.DB, error) {
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s"
,
cfg
.
Host
,
cfg
.
Port
,
cfg
.
User
,
cfg
.
Password
,
cfg
.
DBName
,
cfg
.
SSLMode
,
)
if
cfg
.
Schema
!=
""
{
dsn
+=
fmt
.
Sprintf
(
" search_path=%s"
,
cfg
.
Schema
)
}
if
cfg
.
Timezone
!=
""
{
dsn
+=
fmt
.
Sprintf
(
" TimeZone=%s"
,
cfg
.
Timezone
)
}
db
,
err
:=
gorm
.
Open
(
postgres
.
Open
(
dsn
),
&
gorm
.
Config
{
Logger
:
logger
.
Default
.
LogMode
(
logger
.
Info
),
...
...
server/scripts/reset_db.go
View file @
4ae56601
...
...
@@ -12,7 +12,7 @@ import (
func
main
()
{
// 数据库连接配置
dsn
:=
"host=
localhost port=5432 user=postgres password=123456 dbname=internet_hospital sslmode=disable
"
dsn
:=
"host=
10.10.0.102 port=5432 user=postgres password=T5sSfTZ6XYTD9bfC dbname=xxxx sslmode=disable search_path=public TimeZone=Asia/Shanghai
"
// 连接数据库
db
,
err
:=
gorm
.
Open
(
postgres
.
Open
(
dsn
),
&
gorm
.
Config
{
...
...
web/package-lock.json
View file @
4ae56601
...
...
@@ -9,6 +9,7 @@
"version"
:
"1.0.0"
,
"dependencies"
:
{
"@ant-design/icons"
:
"^5.6.1"
,
"@ant-design/v5-patch-for-react-19"
:
"^1.0.3"
,
"@tailwindcss/postcss"
:
"^4"
,
"@tanstack/react-query"
:
"^5.80.6"
,
"antd"
:
"^5.25.3"
,
...
...
@@ -26,7 +27,7 @@
"@types/react"
:
"^19.2.7"
,
"@types/react-dom"
:
"^19.2.3"
,
"iconv-lite"
:
"^0.7.2"
,
"typescript"
:
"
~
5.9.3"
"typescript"
:
"5.9.3"
}
},
"node_modules/@alloc/quick-lru"
:
{
...
...
@@ -138,6 +139,20 @@
"react"
:
">=16.9.0"
}
},
"node_modules/@ant-design/v5-patch-for-react-19"
:
{
"version"
:
"1.0.3"
,
"resolved"
:
"https://registry.npmjs.org/@ant-design/v5-patch-for-react-19/-/v5-patch-for-react-19-1.0.3.tgz"
,
"integrity"
:
"sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w=="
,
"license"
:
"MIT"
,
"engines"
:
{
"node"
:
">=12.x"
},
"peerDependencies"
:
{
"antd"
:
">=5.22.6"
,
"react"
:
">=19.0.0"
,
"react-dom"
:
">=19.0.0"
}
},
"node_modules/@babel/runtime"
:
{
"version"
:
"7.28.6"
,
"resolved"
:
"https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz"
,
...
...
@@ -4020,7 +4035,7 @@
},
"node_modules/typescript"
:
{
"version"
:
"5.9.3"
,
"resolved"
:
"https://registry.npm
mirror.com
/typescript/-/typescript-5.9.3.tgz"
,
"resolved"
:
"https://registry.npm
js.org
/typescript/-/typescript-5.9.3.tgz"
,
"integrity"
:
"sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="
,
"dev"
:
true
,
"license"
:
"Apache-2.0"
,
...
...
web/package.json
View file @
4ae56601
...
...
@@ -3,13 +3,14 @@
"private"
:
true
,
"version"
:
"1.0.0"
,
"scripts"
:
{
"dev"
:
"next dev --turbopack"
,
"dev"
:
"next dev --turbopack
--hostname 0.0.0.0
"
,
"build"
:
"next build"
,
"start"
:
"next start"
,
"lint"
:
"eslint ."
},
"dependencies"
:
{
"@ant-design/icons"
:
"^5.6.1"
,
"@ant-design/v5-patch-for-react-19"
:
"^1.0.3"
,
"@tailwindcss/postcss"
:
"^4"
,
"@tanstack/react-query"
:
"^5.80.6"
,
"antd"
:
"^5.25.3"
,
...
...
@@ -27,6 +28,6 @@
"@types/react"
:
"^19.2.7"
,
"@types/react-dom"
:
"^19.2.3"
,
"iconv-lite"
:
"^0.7.2"
,
"typescript"
:
"
~
5.9.3"
"typescript"
:
"5.9.3"
}
}
web/src/api/admin.ts
View file @
4ae56601
...
...
@@ -33,6 +33,25 @@ export interface DoctorReviewItem {
qualification_image
?:
string
;
}
// 医生管理列表项(管理端用)
export
interface
DoctorItem
{
id
:
string
;
user_id
:
string
;
name
:
string
;
phone
:
string
;
avatar
:
string
;
title
:
string
;
department_id
:
string
;
department_name
:
string
;
hospital
:
string
;
license_no
:
string
;
introduction
:
string
;
specialties
:
string
[];
review_status
:
string
;
review_id
:
string
;
user_status
:
string
;
}
// 用户管理查询参数
export
interface
UserListParams
{
keyword
?:
string
;
...
...
@@ -237,6 +256,9 @@ export const adminApi = {
post
<
null
>
(
`/admin/patients/
${
patientId
}
/reset-password`
),
// === 医生管理 ===
getDoctorList
:
(
params
:
UserListParams
)
=>
get
<
PaginatedResponse
<
DoctorItem
>>
(
'
/admin/doctors
'
,
{
params
}),
getDoctorManageList
:
(
params
:
UserListParams
)
=>
get
<
PaginatedResponse
<
DoctorReviewItem
>>
(
'
/admin/doctors
'
,
{
params
}),
...
...
web/src/api/consult.ts
View file @
4ae56601
import
{
get
,
post
}
from
'
./request
'
;
import
type
{
DoctorWorkbenchStats
}
from
'
./doctorPortal
'
;
export
type
{
DoctorWorkbenchStats
};
// 问诊相关类型定义
export
type
ConsultType
=
'
text
'
|
'
video
'
;
...
...
@@ -149,14 +151,3 @@ export const consultApi = {
post
<
null
>
(
`/consult/
${
id
}
/reject`
,
{
reason
}),
};
// 医生工作台统计
export
interface
DoctorWorkbenchStats
{
today_consult_count
:
number
;
waiting_count
:
number
;
in_progress_count
:
number
;
completed_today
:
number
;
total_patients
:
number
;
rating
:
number
;
income_today
:
number
;
income_month
:
number
;
}
web/src/api/index.ts
View file @
4ae56601
...
...
@@ -3,4 +3,13 @@ export * from './user';
export
*
from
'
./doctor
'
;
export
*
from
'
./consult
'
;
export
*
from
'
./admin
'
;
export
*
from
'
./doctorPortal
'
;
// doctorPortal 中的 DoctorWorkbenchStats, WaitingPatient, PatientListItem 与 consult.ts 重名,需选择性导出
export
{
doctorPortalApi
,
type
DoctorProfile
,
type
ScheduleSlot
,
type
PatientSummary
,
type
PatientConsultRecord
,
type
PatientPrescriptionRecord
,
type
PatientDetail
,
}
from
'
./doctorPortal
'
;
web/src/api/preConsult.ts
View file @
4ae56601
...
...
@@ -66,7 +66,7 @@ export interface ChatDoneInfo {
// ====== SSE 流式对话工具函数 ======
const
getApiBaseURL
=
()
=>
{
const
envUrl
=
import
.
meta
.
env
.
VITE
_API_BASE_URL
;
const
envUrl
=
process
.
env
.
NEXT_PUBLIC
_API_BASE_URL
;
if
(
envUrl
&&
envUrl
.
includes
(
'
/api/v1
'
))
return
envUrl
;
return
(
envUrl
||
'
http://localhost:8080
'
)
+
'
/api/v1
'
;
};
...
...
web/src/app/(auth)/login/page.tsx
View file @
4ae56601
...
...
@@ -3,7 +3,7 @@
import
React
,
{
useState
}
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
Link
from
'
next/link
'
;
import
{
Form
,
Input
,
Button
,
Tabs
,
message
}
from
'
antd
'
;
import
{
Form
,
Input
,
Button
,
Tabs
,
App
}
from
'
antd
'
;
import
{
MobileOutlined
,
LockOutlined
,
UserOutlined
,
MedicineBoxOutlined
,
SafetyCertificateOutlined
,
TeamOutlined
,
CloudServerOutlined
,
...
...
@@ -14,6 +14,7 @@ import { userApi } from '@/api/user';
const
LoginPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
{
message
}
=
App
.
useApp
();
const
{
setUser
,
setTokens
}
=
useUserStore
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
...
...
@@ -44,7 +45,7 @@ const LoginPage: React.FC = () => {
key
:
'
phone
'
,
label
:
'
手机号登录
'
,
children
:
(
<
Form
onFinish=
{
handleLogin
}
size=
"large"
className=
"mt-4"
>
<
Form
onFinish=
{
handleLogin
}
size=
"large"
style=
{
{
marginTop
:
24
}
}
>
<
Form
.
Item
name=
"phone"
rules=
{
[
...
...
@@ -52,22 +53,22 @@ const LoginPage: React.FC = () => {
{
pattern
:
/^1
\d
{10}$/
,
message
:
'
请输入正确的手机号
'
},
]
}
>
<
Input
prefix=
{
<
MobileOutlined
className=
"text-gray-400"
/>
}
placeholder=
"手机号"
className=
"h-11 rounded-lg"
<
Input
prefix=
{
<
MobileOutlined
style=
{
{
color
:
'
#bfbfbf
'
}
}
/>
}
placeholder=
"手机号"
style=
{
{
height
:
44
,
borderRadius
:
8
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"password"
rules=
{
[{
required
:
true
,
message
:
'
请输入密码
'
}]
}
>
<
Input
.
Password
prefix=
{
<
LockOutlined
className=
"text-gray-400"
/>
}
placeholder=
"密码"
className=
"h-11 rounded-lg"
<
Input
.
Password
prefix=
{
<
LockOutlined
style=
{
{
color
:
'
#bfbfbf
'
}
}
/>
}
placeholder=
"密码"
style=
{
{
height
:
44
,
borderRadius
:
8
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
className=
"mb-0 mt-2"
>
<
Form
.
Item
style=
{
{
marginBottom
:
0
,
marginTop
:
8
}
}
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
loading
}
block
className=
"h-11 rounded-lg font-semibold text-base shadow-lg shadow-blue-200/50"
>
style=
{
{
height
:
44
,
borderRadius
:
8
,
fontWeight
:
600
,
fontSize
:
15
}
}
>
登录
</
Button
>
</
Form
.
Item
>
...
...
@@ -78,24 +79,24 @@ const LoginPage: React.FC = () => {
key
:
'
username
'
,
label
:
'
账号登录
'
,
children
:
(
<
Form
onFinish=
{
handleLogin
}
size=
"large"
className=
"mt-4"
>
<
Form
onFinish=
{
handleLogin
}
size=
"large"
style=
{
{
marginTop
:
24
}
}
>
<
Form
.
Item
name=
"username"
rules=
{
[{
required
:
true
,
message
:
'
请输入用户名
'
}]
}
>
<
Input
prefix=
{
<
UserOutlined
className=
"text-gray-400"
/>
}
placeholder=
"用户名"
className=
"h-11 rounded-lg"
<
Input
prefix=
{
<
UserOutlined
style=
{
{
color
:
'
#bfbfbf
'
}
}
/>
}
placeholder=
"用户名"
style=
{
{
height
:
44
,
borderRadius
:
8
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"password"
rules=
{
[{
required
:
true
,
message
:
'
请输入密码
'
}]
}
>
<
Input
.
Password
prefix=
{
<
LockOutlined
className=
"text-gray-400"
/>
}
placeholder=
"密码"
className=
"h-11 rounded-lg"
<
Input
.
Password
prefix=
{
<
LockOutlined
style=
{
{
color
:
'
#bfbfbf
'
}
}
/>
}
placeholder=
"密码"
style=
{
{
height
:
44
,
borderRadius
:
8
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
className=
"mb-0 mt-2"
>
<
Form
.
Item
style=
{
{
marginBottom
:
0
,
marginTop
:
8
}
}
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
loading
}
block
className=
"h-11 rounded-lg font-semibold text-base shadow-lg shadow-blue-200/50"
>
style=
{
{
height
:
44
,
borderRadius
:
8
,
fontWeight
:
600
,
fontSize
:
15
}
}
>
登录
</
Button
>
</
Form
.
Item
>
...
...
@@ -105,99 +106,194 @@ const LoginPage: React.FC = () => {
];
return
(
<
div
className=
"fixed inset-0 flex items-center justify-center overflow-auto py-8"
style=
{
{
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)
'
}
}
>
{
/* 装饰粒子 */
}
<
div
className=
"absolute inset-0 overflow-hidden pointer-events-none"
>
<
div
className=
"absolute top-[10%] left-[5%] w-64 h-64 bg-white/5 rounded-full blur-3xl"
/>
<
div
className=
"absolute bottom-[15%] right-[8%] w-80 h-80 bg-blue-400/10 rounded-full blur-3xl"
/>
<
div
className=
"absolute top-[40%] right-[20%] w-40 h-40 bg-cyan-400/8 rounded-full blur-2xl"
/>
<
div
style=
{
{
position
:
'
fixed
'
,
inset
:
0
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
overflow
:
'
auto
'
,
padding
:
'
32px 0
'
,
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)
'
,
}
}
>
{
/* 响应式样式 */
}
<
style
>
{
`
.login-left-panel { display: none; }
.login-mobile-logo { display: flex; }
.login-right-panel { padding: 32px; }
@media (min-width: 768px) {
.login-left-panel { display: flex; }
.login-mobile-logo { display: none !important; }
.login-right-panel { padding: 40px; }
}
`
}
</
style
>
{
/* 装饰背景 */
}
<
div
style=
{
{
position
:
'
absolute
'
,
inset
:
0
,
overflow
:
'
hidden
'
,
pointerEvents
:
'
none
'
}
}
>
<
div
style=
{
{
position
:
'
absolute
'
,
top
:
'
10%
'
,
left
:
'
5%
'
,
width
:
256
,
height
:
256
,
background
:
'
rgba(255,255,255,0.05)
'
,
borderRadius
:
'
50%
'
,
filter
:
'
blur(64px)
'
}
}
/>
<
div
style=
{
{
position
:
'
absolute
'
,
bottom
:
'
15%
'
,
right
:
'
8%
'
,
width
:
320
,
height
:
320
,
background
:
'
rgba(96,165,250,0.1)
'
,
borderRadius
:
'
50%
'
,
filter
:
'
blur(64px)
'
}
}
/>
<
div
style=
{
{
position
:
'
absolute
'
,
top
:
'
40%
'
,
right
:
'
20%
'
,
width
:
160
,
height
:
160
,
background
:
'
rgba(34,211,238,0.1)
'
,
borderRadius
:
'
50%
'
,
filter
:
'
blur(40px)
'
}
}
/>
</
div
>
<
div
className=
"relative z-10 flex w-full max-w-[960px] mx-4 rounded-2xl overflow-hidden shadow-2xl shadow-black/30"
>
<
div
style=
{
{
position
:
'
relative
'
,
zIndex
:
10
,
display
:
'
flex
'
,
width
:
'
100%
'
,
maxWidth
:
920
,
margin
:
'
0 16px
'
,
borderRadius
:
16
,
overflow
:
'
hidden
'
,
boxShadow
:
'
0 25px 60px rgba(0,0,0,0.3)
'
,
}
}
>
{
/* 左侧品牌区 - 仅在 md 及以上屏幕显示 */
}
<
div
className=
"hidden md:flex flex-col justify-between w-[420px] shrink-0 p-8 text-white"
style=
{
{
background
:
'
linear-gradient(180deg, rgba(0,58,140,0.9) 0%, rgba(0,80,179,0.85) 50%, rgba(24,144,255,0.8) 100%)
'
,
backdropFilter
:
'
blur(20px)
'
}
}
>
<
div
className=
"login-left-panel"
style=
{
{
flexDirection
:
'
column
'
,
justifyContent
:
'
space-between
'
,
width
:
400
,
flexShrink
:
0
,
padding
:
32
,
color
:
'
#fff
'
,
background
:
'
linear-gradient(180deg, rgba(0,58,140,0.95) 0%, rgba(0,80,179,0.9) 50%, rgba(24,144,255,0.85) 100%)
'
,
backdropFilter
:
'
blur(20px)
'
,
}
}
>
<
div
>
<
div
className=
"flex items-center gap-3 mb-8"
>
<
div
className=
"w-11 h-11 rounded-xl bg-white/20 flex items-center justify-center backdrop-blur-sm"
>
<
MedicineBoxOutlined
className=
"text-xl text-white"
/>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
marginBottom
:
40
}
}
>
<
div
style=
{
{
width
:
44
,
height
:
44
,
borderRadius
:
12
,
background
:
'
rgba(255,255,255,0.2)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
backdropFilter
:
'
blur(8px)
'
,
}
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
20
,
color
:
'
#fff
'
}
}
/>
</
div
>
<
div
>
<
div
className=
"text-lg font-bold tracking-wide"
>
互联网医院
</
div
>
<
div
className=
"text-xs text-blue-200/80"
>
Internet Hospital Platform
</
div
>
<
div
style=
{
{
fontSize
:
18
,
fontWeight
:
700
,
letterSpacing
:
1
}
}
>
互联网医院
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
rgba(191,219,254,0.8)
'
}
}
>
Internet Hospital Platform
</
div
>
</
div
>
</
div
>
<
h2
className=
"text-2xl font-bold mb-3 leading-tight"
>
专业在线医疗
<
br
/>
服务平台
</
h2
>
<
p
className=
"text-sm text-blue-200/70 leading-relaxed mb-8"
>
<
h2
style=
{
{
fontSize
:
24
,
fontWeight
:
700
,
marginBottom
:
12
,
lineHeight
:
1.4
}
}
>
专业在线医疗
<
br
/>
服务平台
</
h2
>
<
p
style=
{
{
fontSize
:
14
,
color
:
'
rgba(191,219,254,0.7)
'
,
lineHeight
:
1.8
}
}
>
连接优质医疗资源,提供智能问诊、电子处方、
<
br
/>
在线购药等一站式健康管理服务
</
p
>
</
div
>
<
div
className=
"space-y-4"
>
<
div
className=
"flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"
>
<
div
className=
"w-9 h-9 rounded-lg bg-cyan-400/20 flex items-center justify-center shrink-0"
>
<
TeamOutlined
className=
"text-cyan-300"
/>
<
div
style=
{
{
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
gap
:
12
}
}
>
{
/* 专业医生团队 */
}
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
padding
:
12
,
borderRadius
:
12
,
background
:
'
rgba(255,255,255,0.1)
'
,
backdropFilter
:
'
blur(8px)
'
,
}
}
>
<
div
style=
{
{
width
:
36
,
height
:
36
,
borderRadius
:
8
,
background
:
'
rgba(34,211,238,0.2)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
flexShrink
:
0
,
}
}
>
<
TeamOutlined
style=
{
{
color
:
'
#67e8f9
'
}
}
/>
</
div
>
<
div
>
<
div
className=
"text-sm font-medium"
>
专业医生团队
</
div
>
<
div
className=
"text-xs text-blue-200/60"
>
三甲医院主治医师在线坐诊
</
div
>
<
div
style=
{
{
fontSize
:
14
,
fontWeight
:
500
}
}
>
专业医生团队
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
rgba(191,219,254,0.6)
'
}
}
>
三甲医院主治医师在线坐诊
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"
>
<
div
className=
"w-9 h-9 rounded-lg bg-green-400/20 flex items-center justify-center shrink-0"
>
<
SafetyCertificateOutlined
className=
"text-green-300"
/>
{
/* CA电子签名 */
}
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
padding
:
12
,
borderRadius
:
12
,
background
:
'
rgba(255,255,255,0.1)
'
,
backdropFilter
:
'
blur(8px)
'
,
}
}
>
<
div
style=
{
{
width
:
36
,
height
:
36
,
borderRadius
:
8
,
background
:
'
rgba(74,222,128,0.2)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
flexShrink
:
0
,
}
}
>
<
SafetyCertificateOutlined
style=
{
{
color
:
'
#86efac
'
}
}
/>
</
div
>
<
div
>
<
div
className=
"text-sm font-medium"
>
CA电子签名
</
div
>
<
div
className=
"text-xs text-blue-200/60"
>
电子处方合规安全有保障
</
div
>
<
div
style=
{
{
fontSize
:
14
,
fontWeight
:
500
}
}
>
CA电子签名
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
rgba(191,219,254,0.6)
'
}
}
>
电子处方合规安全有保障
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-3 p-3 rounded-xl bg-white/10 backdrop-blur-sm"
>
<
div
className=
"w-9 h-9 rounded-lg bg-purple-400/20 flex items-center justify-center shrink-0"
>
<
CloudServerOutlined
className=
"text-purple-300"
/>
{
/* AI智能辅助 */
}
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
padding
:
12
,
borderRadius
:
12
,
background
:
'
rgba(255,255,255,0.1)
'
,
backdropFilter
:
'
blur(8px)
'
,
}
}
>
<
div
style=
{
{
width
:
36
,
height
:
36
,
borderRadius
:
8
,
background
:
'
rgba(167,139,250,0.2)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
flexShrink
:
0
,
}
}
>
<
CloudServerOutlined
style=
{
{
color
:
'
#c4b5fd
'
}
}
/>
</
div
>
<
div
>
<
div
className=
"text-sm font-medium"
>
AI智能辅助
</
div
>
<
div
className=
"text-xs text-blue-200/60"
>
AI预问诊与辅助诊断系统
</
div
>
<
div
style=
{
{
fontSize
:
14
,
fontWeight
:
500
}
}
>
AI智能辅助
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
rgba(191,219,254,0.6)
'
}
}
>
AI预问诊与辅助诊断系统
</
div
>
</
div
>
</
div
>
</
div
>
<
div
className=
"flex items-center gap-2 text-xs text-blue-300/50 mt-6"
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
fontSize
:
12
,
color
:
'
rgba(147,197,253,0.5)
'
,
marginTop
:
24
}
}
>
<
HeartOutlined
/>
<
span
>
守护每一份健康
</
span
>
</
div
>
</
div
>
{
/* 右侧登录区 */
}
<
div
className=
"flex-1 bg-white p-8 md:p-10 flex flex-col justify-center min-h-[520px]"
>
<
div
className=
"login-right-panel"
style=
{
{
flex
:
1
,
backgroundColor
:
'
#fff
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
justifyContent
:
'
center
'
,
minHeight
:
520
,
}
}
>
{
/* 移动端 Logo(仅在左侧面板隐藏时显示) */
}
<
div
className=
"flex md:hidden items-center gap-2 mb-6"
>
<
div
className=
"w-9 h-9 rounded-xl bg-[#1890ff] flex items-center justify-center"
>
<
MedicineBoxOutlined
className=
"text-base text-white"
/>
<
div
className=
"login-mobile-logo"
style=
{
{
alignItems
:
'
center
'
,
gap
:
8
,
marginBottom
:
32
,
}
}
>
<
div
style=
{
{
width
:
40
,
height
:
40
,
borderRadius
:
12
,
backgroundColor
:
'
#1890ff
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
}
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
18
,
color
:
'
#fff
'
}
}
/>
</
div
>
<
div
className=
"text-base font-bold text-gray-800"
>
互联网医院
</
div
>
<
div
style=
{
{
fontSize
:
16
,
fontWeight
:
700
,
color
:
'
#1d2129
'
}
}
>
互联网医院
</
div
>
</
div
>
<
div
className=
"mb-8"
>
<
h3
className=
"text-2xl font-bold text-gray-800 mb-2"
>
欢迎登录
</
h3
>
<
p
className=
"text-sm text-gray-400"
>
登录以使用完整的医疗服务功能
</
p
>
</
div
>
<
div
style=
{
{
width
:
'
100%
'
,
maxWidth
:
340
,
margin
:
'
0 auto
'
}
}
>
<
div
style=
{
{
marginBottom
:
32
}
}
>
<
h3
style=
{
{
fontSize
:
24
,
fontWeight
:
700
,
marginBottom
:
8
,
color
:
'
#1d2129
'
}
}
>
欢迎登录
</
h3
>
<
p
style=
{
{
fontSize
:
14
,
color
:
'
#8c8c8c
'
,
margin
:
0
}
}
>
登录以使用完整的医疗服务功能
</
p
>
</
div
>
<
div
className=
"w-full max-w-[320px] mx-auto"
>
<
Tabs
items=
{
tabItems
}
centered
size=
"middle"
className=
"login-tabs"
/>
<
Tabs
items=
{
tabItems
}
centered
size=
"middle"
/>
<
div
className=
"text-center mt-6 text-sm"
>
<
span
className=
"text-gray-400"
>
还没有账号?
</
span
>
<
Link
href=
"/register"
className=
"ml-1 font-medium text-[#1890ff] hover:text-[#40a9ff]"
>
立即注册
</
Link
>
<
div
style=
{
{
textAlign
:
'
center
'
,
marginTop
:
24
,
fontSize
:
14
}
}
>
<
span
style=
{
{
color
:
'
#8c8c8c
'
}
}
>
还没有账号?
</
span
>
<
Link
href=
"/register"
style=
{
{
marginLeft
:
4
,
fontWeight
:
500
,
color
:
'
#1890ff
'
}
}
>
立即注册
</
Link
>
</
div
>
<
div
className=
"mt-8 pt-4 border-t border-gray-100 text-center"
>
<
p
className=
"text-xs text-gray-400"
>
<
div
style=
{
{
marginTop
:
32
,
paddingTop
:
16
,
textAlign
:
'
center
'
,
borderTop
:
'
1px solid #f0f0f0
'
}
}
>
<
p
style=
{
{
fontSize
:
12
,
color
:
'
#bfbfbf
'
,
margin
:
0
}
}
>
登录即表示同意
<
a
href=
"#"
className=
"text-[#1890ff] mx-0.5 hover:underline"
>
《用户服务协议》
</
a
>
和
<
a
href=
"#"
className=
"text-[#1890ff] mx-0.5 hover:underline"
>
《隐私政策》
</
a
>
<
a
href=
"#"
style=
{
{
color
:
'
#1890ff
'
,
margin
:
'
0 2px
'
}
}
>
《用户服务协议》
</
a
>
和
<
a
href=
"#"
style=
{
{
color
:
'
#1890ff
'
,
margin
:
'
0 2px
'
}
}
>
《隐私政策》
</
a
>
</
p
>
</
div
>
</
div
>
...
...
web/src/app/(auth)/register/page.tsx
View file @
4ae56601
...
...
@@ -3,7 +3,7 @@
import
React
,
{
useState
}
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
Link
from
'
next/link
'
;
import
{
Form
,
Input
,
Button
,
Steps
,
message
,
Typography
,
Space
,
Radio
,
Select
,
InputNumber
,
Row
,
Col
}
from
'
antd
'
;
import
{
Form
,
Input
,
Button
,
Steps
,
App
,
Typography
,
Space
,
Radio
,
Select
,
InputNumber
,
Row
,
Col
}
from
'
antd
'
;
import
{
MobileOutlined
,
LockOutlined
,
UserOutlined
,
MedicineBoxOutlined
,
CheckCircleOutlined
,
}
from
'
@ant-design/icons
'
;
...
...
@@ -14,6 +14,7 @@ const { Title, Text, Paragraph } = Typography;
const
RegisterPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
{
message
}
=
App
.
useApp
();
const
{
setUser
,
setTokens
}
=
useUserStore
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
currentStep
,
setCurrentStep
]
=
useState
(
0
);
...
...
@@ -60,54 +61,102 @@ const RegisterPage: React.FC = () => {
};
return
(
<
div
className=
"fixed inset-0 flex items-center justify-center overflow-auto py-8"
style=
{
{
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)
'
}
}
>
<
div
className=
"absolute inset-0 overflow-hidden pointer-events-none"
>
<
div
className=
"absolute top-[10%] left-[5%] w-64 h-64 bg-white/5 rounded-full blur-3xl"
/>
<
div
className=
"absolute bottom-[15%] right-[8%] w-80 h-80 bg-blue-400/10 rounded-full blur-3xl"
/>
<
div
style=
{
{
position
:
'
fixed
'
,
inset
:
0
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
overflow
:
'
auto
'
,
padding
:
'
32px 0
'
,
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)
'
,
}
}
>
{
/* 装饰背景 */
}
<
div
style=
{
{
position
:
'
absolute
'
,
inset
:
0
,
overflow
:
'
hidden
'
,
pointerEvents
:
'
none
'
}
}
>
<
div
style=
{
{
position
:
'
absolute
'
,
top
:
'
10%
'
,
left
:
'
5%
'
,
width
:
256
,
height
:
256
,
background
:
'
rgba(255,255,255,0.05)
'
,
borderRadius
:
'
50%
'
,
filter
:
'
blur(64px)
'
}
}
/>
<
div
style=
{
{
position
:
'
absolute
'
,
bottom
:
'
15%
'
,
right
:
'
8%
'
,
width
:
320
,
height
:
320
,
background
:
'
rgba(96,165,250,0.1)
'
,
borderRadius
:
'
50%
'
,
filter
:
'
blur(64px)
'
}
}
/>
</
div
>
<
div
className=
"relative z-10 w-full max-w-[480px] bg-white rounded-2xl shadow-2xl shadow-black/30 p-8 mx-4"
>
<
div
className=
"text-center mb-5"
>
<
div
className=
"w-12 h-12 rounded-xl bg-linear-to-br from-[#1890ff] to-[#096dd9] flex items-center justify-center mx-auto mb-3"
>
<
MedicineBoxOutlined
className=
"text-xl text-white"
/>
<
div
style=
{
{
position
:
'
relative
'
,
zIndex
:
10
,
width
:
'
100%
'
,
maxWidth
:
480
,
backgroundColor
:
'
#fff
'
,
borderRadius
:
16
,
boxShadow
:
'
0 25px 60px rgba(0,0,0,0.3)
'
,
padding
:
32
,
margin
:
'
0 16px
'
,
}
}
>
{
/* 顶部 Logo */
}
<
div
style=
{
{
textAlign
:
'
center
'
,
marginBottom
:
20
}
}
>
<
div
style=
{
{
width
:
48
,
height
:
48
,
borderRadius
:
12
,
background
:
'
linear-gradient(135deg, #1890ff, #096dd9)
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
margin
:
'
0 auto 12px
'
,
}
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
20
,
color
:
'
#fff
'
}
}
/>
</
div
>
<
Title
level=
{
4
}
className=
"mb-1 text-gray-800"
>
注册互联网医院
</
Title
>
<
Text
type=
"secondary"
className=
"text-xs"
>
选择您的身份开始注册
</
Text
>
<
Title
level=
{
4
}
style=
{
{
marginBottom
:
4
,
color
:
'
#1d2129
'
}
}
>
注册互联网医院
</
Title
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
选择您的身份开始注册
</
Text
>
</
div
>
<
Steps
current=
{
currentStep
}
size=
"small"
className=
"mb-6"
<
Steps
current=
{
currentStep
}
size=
"small"
style=
{
{
marginBottom
:
24
}
}
items=
{
[{
title
:
'
选择身份
'
},
{
title
:
'
填写信息
'
},
{
title
:
'
注册成功
'
}]
}
/>
{
currentStep
===
0
&&
(
<
div
>
<
Paragraph
className=
"text-center mb-4 text-gray-400 text-sm"
>
请选择您的注册身份
</
Paragraph
>
<
Radio
.
Group
value=
{
registerType
}
onChange=
{
(
e
)
=>
setRegisterType
(
e
.
target
.
value
)
}
className=
"w-full"
>
<
Space
direction=
"vertical"
className=
"w-full"
>
<
div
className=
{
`p-4 rounded-xl border-2 cursor-pointer transition-all ${registerType === 'patient' ? 'border-[#1890ff] bg-primary-bg' : 'border-gray-200 hover:border-blue-200'}`
}
<
Paragraph
style=
{
{
textAlign
:
'
center
'
,
marginBottom
:
16
,
color
:
'
#8c8c8c
'
,
fontSize
:
14
}
}
>
请选择您的注册身份
</
Paragraph
>
<
Radio
.
Group
value=
{
registerType
}
onChange=
{
(
e
)
=>
setRegisterType
(
e
.
target
.
value
)
}
style=
{
{
width
:
'
100%
'
}
}
>
<
Space
direction=
"vertical"
style=
{
{
width
:
'
100%
'
}
}
>
<
div
style=
{
{
padding
:
16
,
borderRadius
:
12
,
cursor
:
'
pointer
'
,
transition
:
'
all 0.2s
'
,
border
:
`2px solid ${registerType === 'patient' ? '#1890ff' : '#e5e7eb'}`
,
backgroundColor
:
registerType
===
'
patient
'
?
'
#f0f5ff
'
:
'
transparent
'
,
}
}
onClick=
{
()
=>
setRegisterType
(
'
patient
'
)
}
>
<
Radio
value=
"patient"
>
<
Space
>
<
div
className=
"w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"
>
<
UserOutlined
className=
"text-[#1890ff]"
/>
<
div
style=
{
{
width
:
36
,
height
:
36
,
borderRadius
:
8
,
backgroundColor
:
'
#eff6ff
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
}
}
>
<
UserOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
</
div
>
<
div
>
<
Text
strong
className=
"text-sm"
>
我是患者
</
Text
><
br
/>
<
Text
type=
"secondary"
className=
"text-xs"
>
在线问诊、找医生、视频通话
</
Text
>
<
Text
strong
style=
{
{
fontSize
:
14
}
}
>
我是患者
</
Text
><
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
在线问诊、找医生、视频通话
</
Text
>
</
div
>
</
Space
>
</
Radio
>
</
div
>
<
div
className=
{
`p-4 rounded-xl border-2 cursor-pointer transition-all ${registerType === 'doctor' ? 'border-[#1890ff] bg-primary-bg' : 'border-gray-200 hover:border-blue-200'}`
}
<
div
style=
{
{
padding
:
16
,
borderRadius
:
12
,
cursor
:
'
pointer
'
,
transition
:
'
all 0.2s
'
,
border
:
`2px solid ${registerType === 'doctor' ? '#1890ff' : '#e5e7eb'}`
,
backgroundColor
:
registerType
===
'
doctor
'
?
'
#f0f5ff
'
:
'
transparent
'
,
}
}
onClick=
{
()
=>
setRegisterType
(
'
doctor
'
)
}
>
<
Radio
value=
"doctor"
>
<
Space
>
<
div
className=
"w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center"
>
<
MedicineBoxOutlined
className=
"text-[#1890ff]"
/>
<
div
style=
{
{
width
:
36
,
height
:
36
,
borderRadius
:
8
,
backgroundColor
:
'
#eff6ff
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
}
}
>
<
MedicineBoxOutlined
style=
{
{
color
:
'
#1890ff
'
}
}
/>
</
div
>
<
div
>
<
Text
strong
className=
"text-sm"
>
我是医生
</
Text
><
br
/>
<
Text
type=
"secondary"
className=
"text-xs"
>
在线接诊、管理排班、服务患者
</
Text
>
<
Text
strong
style=
{
{
fontSize
:
14
}
}
>
我是医生
</
Text
><
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
在线接诊、管理排班、服务患者
</
Text
>
</
div
>
</
Space
>
</
Radio
>
...
...
@@ -115,7 +164,12 @@ const RegisterPage: React.FC = () => {
</
Space
>
</
Radio
.
Group
>
<
Button
type=
"primary"
block
className=
"mt-5 h-10 rounded-lg font-semibold bg-linear-to-r from-[#1890ff] to-[#096dd9] border-none shadow-md shadow-blue-200"
style=
{
{
marginTop
:
20
,
height
:
40
,
borderRadius
:
8
,
fontWeight
:
600
,
border
:
'
none
'
,
background
:
'
linear-gradient(90deg, #1890ff, #096dd9)
'
,
boxShadow
:
'
0 4px 12px rgba(24,144,255,0.3)
'
,
}
}
onClick=
{
()
=>
setCurrentStep
(
1
)
}
>
下一步
</
Button
>
</
div
>
)
}
...
...
@@ -123,13 +177,13 @@ 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
className=
"text-primary/60"
/>
}
placeholder=
"手机号"
/>
<
Input
prefix=
{
<
MobileOutlined
style=
{
{
color
:
'
rgba(24,144,255,0.6)
'
}
}
/>
}
placeholder=
"手机号"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"real_name"
rules=
{
[{
required
:
true
,
message
:
'
请输入真实姓名
'
}]
}
>
<
Input
prefix=
{
<
UserOutlined
className=
"text-primary/60"
/>
}
placeholder=
"真实姓名"
/>
<
Input
prefix=
{
<
UserOutlined
style=
{
{
color
:
'
rgba(24,144,255,0.6)
'
}
}
/>
}
placeholder=
"真实姓名"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"password"
rules=
{
[{
required
:
true
,
message
:
'
请输入密码
'
},
{
min
:
6
,
message
:
'
密码至少6位
'
}]
}
>
<
Input
.
Password
prefix=
{
<
LockOutlined
className=
"text-primary/60"
/>
}
placeholder=
"设置密码"
/>
<
Input
.
Password
prefix=
{
<
LockOutlined
style=
{
{
color
:
'
rgba(24,144,255,0.6)
'
}
}
/>
}
placeholder=
"设置密码"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"confirm_password"
dependencies=
{
[
'
password
'
]
}
rules=
{
[{
required
:
true
,
message
:
'
请确认密码
'
},
({
getFieldValue
})
=>
({
...
...
@@ -138,7 +192,7 @@ const RegisterPage: React.FC = () => {
return
Promise
.
reject
(
new
Error
(
'
两次输入的密码不一致
'
));
},
})]
}
>
<
Input
.
Password
prefix=
{
<
LockOutlined
className=
"text-primary/60"
/>
}
placeholder=
"确认密码"
/>
<
Input
.
Password
prefix=
{
<
LockOutlined
style=
{
{
color
:
'
rgba(24,144,255,0.6)
'
}
}
/>
}
placeholder=
"确认密码"
/>
</
Form
.
Item
>
<
Row
gutter=
{
12
}
>
<
Col
span=
{
12
}
>
...
...
@@ -156,29 +210,38 @@ const RegisterPage: React.FC = () => {
</
Col
>
</
Row
>
<
Form
.
Item
>
<
Space
className=
"w-full"
direction=
"vertical"
>
<
Space
style=
{
{
width
:
'
100%
'
}
}
direction=
"vertical"
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
loading
}
block
className=
"h-10 rounded-lg font-semibold bg-linear-to-r from-[#1890ff] to-[#096dd9] border-none shadow-md shadow-blue-200"
>
注册
</
Button
>
<
Button
block
className=
"rounded-lg"
onClick=
{
()
=>
setCurrentStep
(
0
)
}
>
上一步
</
Button
>
style=
{
{
height
:
40
,
borderRadius
:
8
,
fontWeight
:
600
,
border
:
'
none
'
,
background
:
'
linear-gradient(90deg, #1890ff, #096dd9)
'
,
boxShadow
:
'
0 4px 12px rgba(24,144,255,0.3)
'
,
}
}
>
注册
</
Button
>
<
Button
block
style=
{
{
borderRadius
:
8
}
}
onClick=
{
()
=>
setCurrentStep
(
0
)
}
>
上一步
</
Button
>
</
Space
>
</
Form
.
Item
>
</
Form
>
)
}
{
currentStep
===
2
&&
(
<
div
className=
"text-center py-8"
>
<
div
className=
"w-16 h-16 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4"
>
<
CheckCircleOutlined
className=
"text-3xl text-green-500"
/>
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
'
32px 0
'
}
}
>
<
div
style=
{
{
width
:
64
,
height
:
64
,
borderRadius
:
'
50%
'
,
backgroundColor
:
'
#f0fdf4
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
,
margin
:
'
0 auto 16px
'
,
}
}
>
<
CheckCircleOutlined
style=
{
{
fontSize
:
30
,
color
:
'
#22c55e
'
}
}
/>
</
div
>
<
Title
level=
{
3
}
className=
"mt-4"
>
注册成功!
</
Title
>
<
Title
level=
{
3
}
style=
{
{
marginTop
:
16
}
}
>
注册成功!
</
Title
>
<
Paragraph
type=
"secondary"
>
正在跳转到
{
registerType
===
'
doctor
'
?
'
医生工作台
'
:
'
首页
'
}
...</
Paragraph
>
</
div
>
)
}
{
currentStep
!==
2
&&
(
<
div
className=
"text-center mt-4 text-sm"
>
<
span
className=
"text-gray-400"
>
已有账号?
</
span
>
<
Link
href=
"/login"
className=
"ml-1 font-medium"
>
立即登录
</
Link
>
<
div
style=
{
{
textAlign
:
'
center
'
,
marginTop
:
16
,
fontSize
:
14
}
}
>
<
span
style=
{
{
color
:
'
#8c8c8c
'
}
}
>
已有账号?
</
span
>
<
Link
href=
"/login"
style=
{
{
marginLeft
:
4
,
fontWeight
:
500
,
color
:
'
#1890ff
'
}
}
>
立即登录
</
Link
>
</
div
>
)
}
</
div
>
...
...
web/src/app/(main)/admin/layout.tsx
View file @
4ae56601
...
...
@@ -10,7 +10,7 @@ import {
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
@/store/userStore
'
;
const
{
Header
,
Content
}
=
Layout
;
const
{
Content
}
=
Layout
;
const
{
Text
}
=
Typography
;
const
menuItems
=
[
...
...
@@ -35,6 +35,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
currentPath
=
pathname
||
''
;
const
userMenuItems
=
[
{
key
:
'
profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人信息
'
},
...
...
@@ -43,56 +44,72 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{
key
:
'
logout
'
,
icon
:
<
LogoutOutlined
/>,
label
:
'
退出登录
'
,
danger
:
true
},
];
const
handleMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
.
startsWith
(
'
/
'
))
{
router
.
push
(
key
);
}
};
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
};
const
getOpenKeys
=
()
=>
{
if
(
pathname
.
startsWith
(
'
/admin/patients
'
)
||
pathname
.
startsWith
(
'
/admin/doctors
'
)
||
pathname
.
startsWith
(
'
/admin/admins
'
))
{
return
[
'
user-mgmt
'
];
}
return
[];
// 选中的菜单项(支持子路径匹配)
const
getSelectedKeys
=
()
=>
{
const
allKeys
=
[
'
/admin/dashboard
'
,
'
/admin/patients
'
,
'
/admin/doctors
'
,
'
/admin/admins
'
,
'
/admin/departments
'
,
'
/admin/consultations
'
,
'
/admin/prescription
'
,
'
/admin/pharmacy
'
,
'
/admin/ai-config
'
,
'
/admin/compliance
'
];
const
match
=
allKeys
.
find
(
k
=>
currentPath
.
startsWith
(
k
));
return
match
?
[
match
]
:
[];
};
return
(
<
Layout
style=
{
{
minHeight
:
'
100vh
'
}
}
>
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
router
.
push
(
'
/admin/dashboard
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"geekblue"
className=
"ml-1! text-[10px]! leading-4! px-1! border-blue-400/30!"
>
管理后台
</
Tag
>
<
header
style=
{
{
position
:
'
fixed
'
,
top
:
0
,
left
:
0
,
right
:
0
,
zIndex
:
1000
,
height
:
48
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
padding
:
'
0 16px
'
,
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
cursor
:
'
pointer
'
,
marginRight
:
16
,
flexShrink
:
0
}
}
onClick=
{
()
=>
router
.
push
(
'
/admin/dashboard
'
)
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
18
,
color
:
'
#fff
'
}
}
/>
<
span
style=
{
{
fontSize
:
14
,
fontWeight
:
700
,
color
:
'
#fff
'
}
}
>
互联网医院
</
span
>
<
Tag
color=
"geekblue"
style=
{
{
marginLeft
:
4
,
fontSize
:
10
,
lineHeight
:
'
18px
'
,
padding
:
'
0 4px
'
}
}
>
管理后台
</
Tag
>
</
div
>
<
Menu
mode=
"horizontal"
selectedKeys=
{
[
pathname
]
}
defaultOpenKeys=
{
getOpenKeys
()
}
selectedKeys=
{
getSelectedKeys
()
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
onClick=
{
handleMenuClick
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
className=
"top-nav-menu"
style=
{
{
flex
:
1
,
minWidth
:
0
}
}
/>
<
div
className=
"flex items-center gap-3 shrink-0"
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
flexShrink
:
0
}
}
>
<
Badge
count=
{
2
}
size=
"small"
>
<
BellOutlined
className=
"text-base text-white/70 cursor-pointer hover:text-white"
/>
<
BellOutlined
style=
{
{
fontSize
:
16
,
color
:
'
rgba(255,255,255,0.7)
'
,
cursor
:
'
pointer
'
}
}
/>
</
Badge
>
{
user
?
(
<
Dropdown
menu=
{
{
items
:
userMenuItems
,
onClick
:
handleUserMenuClick
}
}
>
<
Space
className=
"cursor-pointer"
>
<
Space
style=
{
{
cursor
:
'
pointer
'
}
}
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#722ed1
'
}
}
/>
<
Text
className=
"text-white/90! text-xs!"
>
{
user
.
real_name
||
'
管理员
'
}
</
Text
>
<
Text
style=
{
{
color
:
'
rgba(255,255,255,0.9)
'
,
fontSize
:
12
}
}
>
{
user
.
real_name
||
'
管理员
'
}
</
Text
>
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录
</
Button
>
)
}
</
div
>
</
H
eader
>
</
h
eader
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"
>
<
Content
style=
{
{
marginTop
:
48
,
padding
:
16
,
minHeight
:
'
calc(100vh - 48px)
'
,
background
:
'
#f0f5ff
'
}
}
>
{
children
}
</
Content
>
</
Layout
>
...
...
web/src/app/(main)/doctor/layout.tsx
View file @
4ae56601
...
...
@@ -10,7 +10,7 @@ import {
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
@/store/userStore
'
;
const
{
Header
,
Content
}
=
Layout
;
const
{
Content
}
=
Layout
;
const
{
Text
}
=
Typography
;
const
menuItems
=
[
...
...
@@ -29,6 +29,7 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
[
isOnline
,
setIsOnline
]
=
useState
(
false
);
const
currentPath
=
pathname
||
''
;
const
userMenuItems
=
[
{
key
:
'
profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人信息
'
},
...
...
@@ -37,50 +38,70 @@ export default function DoctorLayout({ children }: { children: React.ReactNode }
{
key
:
'
logout
'
,
icon
:
<
LogoutOutlined
/>,
label
:
'
退出登录
'
,
danger
:
true
},
];
const
handleMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
.
startsWith
(
'
/
'
))
{
router
.
push
(
key
);
}
};
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
router
.
push
(
'
/doctor/profile
'
);
};
const
getSelectedKeys
=
()
=>
{
const
match
=
menuItems
.
find
(
item
=>
currentPath
.
startsWith
(
item
.
key
));
return
match
?
[
match
.
key
]
:
[];
};
return
(
<
Layout
style=
{
{
minHeight
:
'
100vh
'
}
}
>
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
router
.
push
(
'
/doctor/workbench
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"green"
className=
"ml-1! text-[10px]! leading-4! px-1!"
>
医生工作站
</
Tag
>
<
header
style=
{
{
position
:
'
fixed
'
,
top
:
0
,
left
:
0
,
right
:
0
,
zIndex
:
1000
,
height
:
48
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
padding
:
'
0 16px
'
,
background
:
'
linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
cursor
:
'
pointer
'
,
marginRight
:
16
,
flexShrink
:
0
}
}
onClick=
{
()
=>
router
.
push
(
'
/doctor/workbench
'
)
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
18
,
color
:
'
#fff
'
}
}
/>
<
span
style=
{
{
fontSize
:
14
,
fontWeight
:
700
,
color
:
'
#fff
'
}
}
>
互联网医院
</
span
>
<
Tag
color=
"green"
style=
{
{
marginLeft
:
4
,
fontSize
:
10
,
lineHeight
:
'
18px
'
,
padding
:
'
0 4px
'
}
}
>
医生工作站
</
Tag
>
</
div
>
<
Menu
mode=
"horizontal"
selectedKeys=
{
[
pathname
]
}
selectedKeys=
{
getSelectedKeys
()
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
onClick=
{
handleMenuClick
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
className=
"top-nav-menu"
style=
{
{
flex
:
1
,
minWidth
:
0
}
}
/>
<
div
className=
"flex items-center gap-3 shrink-0"
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
flexShrink
:
0
}
}
>
<
Switch
checked=
{
isOnline
}
onChange=
{
setIsOnline
}
checkedChildren=
"在线"
unCheckedChildren=
"离线"
size=
"small"
/>
<
Badge
count=
{
5
}
size=
"small"
>
<
BellOutlined
className=
"text-base text-white/70 cursor-pointer hover:text-white"
/>
<
BellOutlined
style=
{
{
fontSize
:
16
,
color
:
'
rgba(255,255,255,0.7)
'
,
cursor
:
'
pointer
'
}
}
/>
</
Badge
>
{
user
?
(
<
Dropdown
menu=
{
{
items
:
userMenuItems
,
onClick
:
handleUserMenuClick
}
}
>
<
Space
className=
"cursor-pointer"
>
<
Space
style=
{
{
cursor
:
'
pointer
'
}
}
>
<
Avatar
size=
"small"
src=
{
user
.
avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#52c41a
'
}
}
/>
<
Text
className=
"text-white/90! text-xs!"
>
{
user
.
real_name
||
user
.
phone
}
</
Text
>
<
Text
style=
{
{
color
:
'
rgba(255,255,255,0.9)
'
,
fontSize
:
12
}
}
>
{
user
.
real_name
||
user
.
phone
}
</
Text
>
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录
</
Button
>
)
}
</
div
>
</
H
eader
>
</
h
eader
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"
>
<
Content
style=
{
{
marginTop
:
48
,
padding
:
16
,
minHeight
:
'
calc(100vh - 48px)
'
,
background
:
'
#f0f5ff
'
}
}
>
{
children
}
</
Content
>
</
Layout
>
...
...
web/src/app/(main)/layout.tsx
View file @
4ae56601
export
default
function
MainLayout
({
children
}:
{
children
:
React
.
ReactNode
})
{
return
<
>
{
children
}
</
>;
return
<
div
className=
"compact-ui"
>
{
children
}
</
div
>;
}
web/src/app/(main)/patient/layout.tsx
View file @
4ae56601
...
...
@@ -11,7 +11,7 @@ import {
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
@/store/userStore
'
;
const
{
Header
,
Content
}
=
Layout
;
const
{
Content
}
=
Layout
;
const
{
Text
}
=
Typography
;
const
menuItems
=
[
...
...
@@ -31,6 +31,7 @@ export default function PatientLayout({ children }: { children: React.ReactNode
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
currentPath
=
pathname
||
''
;
const
userMenuItems
=
[
{
key
:
'
profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人中心
'
},
...
...
@@ -39,49 +40,69 @@ export default function PatientLayout({ children }: { children: React.ReactNode
{
key
:
'
logout
'
,
icon
:
<
LogoutOutlined
/>,
label
:
'
退出登录
'
,
danger
:
true
},
];
const
handleMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
.
startsWith
(
'
/
'
))
{
router
.
push
(
key
);
}
};
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
router
.
push
(
'
/patient/profile
'
);
};
const
getSelectedKeys
=
()
=>
{
const
match
=
menuItems
.
find
(
item
=>
currentPath
.
startsWith
(
item
.
key
));
return
match
?
[
match
.
key
]
:
[];
};
return
(
<
Layout
style=
{
{
minHeight
:
'
100vh
'
}
}
>
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.12)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
router
.
push
(
'
/patient/home
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"blue"
className=
"ml-1! text-[10px]! leading-4! px-1! border-blue-300/30!"
>
患者端
</
Tag
>
<
header
style=
{
{
position
:
'
fixed
'
,
top
:
0
,
left
:
0
,
right
:
0
,
zIndex
:
1000
,
height
:
48
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
padding
:
'
0 16px
'
,
background
:
'
linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.12)
'
,
}
}
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
cursor
:
'
pointer
'
,
marginRight
:
16
,
flexShrink
:
0
}
}
onClick=
{
()
=>
router
.
push
(
'
/patient/home
'
)
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
18
,
color
:
'
#fff
'
}
}
/>
<
span
style=
{
{
fontSize
:
14
,
fontWeight
:
700
,
color
:
'
#fff
'
}
}
>
互联网医院
</
span
>
<
Tag
color=
"blue"
style=
{
{
marginLeft
:
4
,
fontSize
:
10
,
lineHeight
:
'
18px
'
,
padding
:
'
0 4px
'
}
}
>
患者端
</
Tag
>
</
div
>
<
Menu
mode=
"horizontal"
selectedKeys=
{
[
pathname
]
}
selectedKeys=
{
getSelectedKeys
()
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
onClick=
{
handleMenuClick
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
className=
"top-nav-menu"
style=
{
{
flex
:
1
,
minWidth
:
0
}
}
/>
<
div
className=
"flex items-center gap-3 shrink-0"
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
12
,
flexShrink
:
0
}
}
>
<
Badge
count=
{
3
}
size=
"small"
>
<
BellOutlined
className=
"text-base text-white/70 cursor-pointer hover:text-white"
/>
<
BellOutlined
style=
{
{
fontSize
:
16
,
color
:
'
rgba(255,255,255,0.7)
'
,
cursor
:
'
pointer
'
}
}
/>
</
Badge
>
{
user
?
(
<
Dropdown
menu=
{
{
items
:
userMenuItems
,
onClick
:
handleUserMenuClick
}
}
>
<
Space
className=
"cursor-pointer"
>
<
Space
style=
{
{
cursor
:
'
pointer
'
}
}
>
<
Avatar
size=
"small"
src=
{
user
.
avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#1890ff
'
}
}
/>
<
Text
className=
"text-white/90! text-xs!"
>
{
user
.
real_name
||
user
.
phone
}
</
Text
>
<
Text
style=
{
{
color
:
'
rgba(255,255,255,0.9)
'
,
fontSize
:
12
}
}
>
{
user
.
real_name
||
user
.
phone
}
</
Text
>
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录 / 注册
</
Button
>
)
}
</
div
>
</
H
eader
>
</
h
eader
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-bg-base!"
>
<
Content
style=
{
{
marginTop
:
48
,
padding
:
16
,
minHeight
:
'
calc(100vh - 48px)
'
,
background
:
'
#f0f5ff
'
}
}
>
{
children
}
</
Content
>
</
Layout
>
...
...
web/src/app/globals.css
View file @
4ae56601
/* 将 antd CSS-in-JS 层声明在最前,使其优先级低于 Tailwind utilities */
@layer
antd
;
@import
"tailwindcss"
;
/*
* CSS Layer 顺序: base(preflight) < antd(组件样式) < utilities(工具类)
*
* 关键: antd 层在 base 和 utilities 之间,确保:
* - Antd 组件样式能覆盖 Tailwind 的 CSS 重置 (preflight)
* - Tailwind 工具类能覆盖 Antd 默认样式
*/
@layer
base
,
antd
,
utilities
;
@import
"tailwindcss/preflight"
layer
(
base
);
@import
"tailwindcss/utilities"
layer
(
utilities
);
@theme
inline
{
--color-primary
:
#1890ff
;
...
...
@@ -20,13 +27,9 @@
--color-border
:
#d9e4f5
;
}
*
{
margin
:
0
;
padding
:
0
;
box-sizing
:
border-box
;
}
/* ===== 全局基础样式(无层级,最高优先级)===== */
html
,
body
,
#__next
{
html
,
body
{
width
:
100%
;
height
:
100%
;
min-height
:
100vh
;
...
...
@@ -36,7 +39,7 @@ body {
font-family
:
-apple-system
,
BlinkMacSystemFont
,
'Segoe UI'
,
Roboto
,
'Helvetica Neue'
,
Arial
,
'Noto Sans'
,
sans-serif
,
'Apple Color Emoji'
,
'Segoe UI Emoji'
,
'Segoe UI Symbol'
,
'Noto Color Emoji'
;
font-size
:
1
3
px
;
font-size
:
1
4
px
;
line-height
:
1.5
;
color
:
#1d2129
;
background-color
:
#f0f5ff
;
...
...
@@ -53,121 +56,145 @@ a:hover {
color
:
#40a9ff
;
}
/* ===== Ant Design 全局紧凑覆盖 ===== */
/* ===== 滚动条 ===== */
::-webkit-scrollbar
{
width
:
6px
;
height
:
6px
;
}
::-webkit-scrollbar-track
{
background
:
#f0f5ff
;
}
::-webkit-scrollbar-thumb
{
background
:
#bbd4f0
;
border-radius
:
3px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
#91c2f0
;
}
/* ===== 顶部导航栏菜单样式 ===== */
.top-nav-menu.ant-menu-horizontal
{
border-bottom
:
none
!important
;
background
:
transparent
!important
;
line-height
:
48px
!important
;
}
.top-nav-menu
.ant-menu-item
,
.top-nav-menu
.ant-menu-submenu-title
{
color
:
rgba
(
187
,
213
,
255
,
0.8
)
!important
;
font-size
:
13px
!important
;
padding
:
0
12px
!important
;
margin
:
0
2px
!important
;
border-radius
:
6px
!important
;
}
.top-nav-menu
.ant-menu-item
:hover
,
.top-nav-menu
.ant-menu-submenu-title
:hover
,
.top-nav-menu
.ant-menu-item-active
,
.top-nav-menu
.ant-menu-submenu-active
>
.ant-menu-submenu-title
{
color
:
#fff
!important
;
background
:
rgba
(
255
,
255
,
255
,
0.1
)
!important
;
}
.top-nav-menu
.ant-menu-item-selected
{
color
:
#fff
!important
;
background
:
rgba
(
255
,
255
,
255
,
0.15
)
!important
;
}
.top-nav-menu
.ant-menu-item
::after
,
.top-nav-menu
.ant-menu-submenu
::after
{
display
:
none
!important
;
}
/* 子菜单弹出样式 */
.ant-menu-submenu-popup
.ant-menu
{
border-radius
:
8px
!important
;
}
/* ===== Ant Design 紧凑覆盖(仅作用于 .compact-ui 内部) ===== */
.ant-card
{
.
compact-ui
.
ant-card
{
border-radius
:
8px
!important
;
box-shadow
:
0
1px
4px
rgba
(
24
,
144
,
255
,
0.06
)
!important
;
border
:
1px
solid
#e6f0fa
!important
;
}
.ant-card
.ant-card-head
{
min-height
:
36
px
!important
;
padding
:
0
1
2
px
!important
;
font-size
:
1
3
px
!important
;
.
compact-ui
.
ant-card
.ant-card-head
{
min-height
:
40
px
!important
;
padding
:
0
1
6
px
!important
;
font-size
:
1
4
px
!important
;
border-bottom
:
1px
solid
#e6f0fa
!important
;
}
.ant-card
.ant-card-body
{
padding
:
1
2
px
!important
;
.
compact-ui
.
ant-card
.ant-card-body
{
padding
:
1
6
px
!important
;
}
.ant-table
{
.
compact-ui
.
ant-table
{
font-size
:
13px
!important
;
}
.ant-table-thead
>
tr
>
th
,
.ant-table-thead
>
tr
>
td
{
padding
:
8
px
12px
!important
;
.
compact-ui
.
ant-table-thead
>
tr
>
th
,
.
compact-ui
.
ant-table-thead
>
tr
>
td
{
padding
:
10
px
12px
!important
;
background
:
#f0f7ff
!important
;
font-weight
:
600
!important
;
font-size
:
1
2
px
!important
;
font-size
:
1
3
px
!important
;
color
:
#1d2129
!important
;
}
.ant-table-tbody
>
tr
>
td
{
padding
:
6
px
12px
!important
;
.
compact-ui
.
ant-table-tbody
>
tr
>
td
{
padding
:
8
px
12px
!important
;
}
.ant-table-tbody
>
tr
:hover
>
td
{
.
compact-ui
.
ant-table-tbody
>
tr
:hover
>
td
{
background
:
#e6f7ff
!important
;
}
.ant-form-item
{
margin-bottom
:
1
2
px
!important
;
.
compact-ui
.
ant-form-item
{
margin-bottom
:
1
6
px
!important
;
}
.ant-form-item-label
>
label
{
font-size
:
1
2
px
!important
;
.
compact-ui
.
ant-form-item-label
>
label
{
font-size
:
1
3
px
!important
;
color
:
#4e5969
!important
;
}
.ant-modal
.ant-modal-header
{
padding
:
1
2px
16
px
!important
;
.
compact-ui
.
ant-modal
.ant-modal-header
{
padding
:
1
6px
20
px
!important
;
border-bottom
:
1px
solid
#e6f0fa
!important
;
}
.ant-modal
.ant-modal-body
{
padding
:
1
2px
16
px
!important
;
.
compact-ui
.
ant-modal
.ant-modal-body
{
padding
:
1
6px
20
px
!important
;
}
.ant-modal
.ant-modal-footer
{
padding
:
8px
16
px
!important
;
.
compact-ui
.
ant-modal
.ant-modal-footer
{
padding
:
12px
20
px
!important
;
}
.ant-tabs
.ant-tabs-tab
{
padding
:
8px
12px
!important
;
.compact-ui
.ant-statistic
.ant-statistic-title
{
font-size
:
13px
!important
;
margin-bottom
:
4px
!important
;
}
.ant-statistic
.ant-statistic-title
{
font-size
:
12px
!important
;
margin-bottom
:
2px
!important
;
}
.ant-statistic
.ant-statistic-content
{
font-size
:
20px
!important
;
.compact-ui
.ant-statistic
.ant-statistic-content
{
font-size
:
22px
!important
;
}
.ant-descriptions
.ant-descriptions-item-label
{
font-size
:
1
2
px
!important
;
padding
:
6
px
12px
!important
;
.
compact-ui
.
ant-descriptions
.ant-descriptions-item-label
{
font-size
:
1
3
px
!important
;
padding
:
8
px
12px
!important
;
background
:
#f0f7ff
!important
;
}
.ant-descriptions
.ant-descriptions-item-content
{
.
compact-ui
.
ant-descriptions
.ant-descriptions-item-content
{
font-size
:
13px
!important
;
padding
:
6
px
12px
!important
;
padding
:
8
px
12px
!important
;
}
.ant-tag
{
font-size
:
11px
!important
;
padding
:
0
6px
!important
;
line-height
:
20px
!important
;
.compact-ui
.ant-tag
{
border-radius
:
4px
!important
;
}
.ant-badge
.ant-badge-count
{
font-size
:
10px
!important
;
}
.ant-btn-sm
{
font-size
:
12px
!important
;
padding
:
0
8px
!important
;
height
:
26px
!important
;
}
.ant-input
,
.ant-input-affix-wrapper
,
.ant-select-selector
{
.compact-ui
.ant-input
,
.compact-ui
.ant-input-affix-wrapper
,
.compact-ui
.ant-select-selector
{
border-radius
:
6px
!important
;
}
.ant-alert
{
padding
:
6px
12px
!important
;
.compact-ui
.ant-alert
{
border-radius
:
6px
!important
;
font-size
:
12px
!important
;
}
::-webkit-scrollbar
{
width
:
6px
;
height
:
6px
;
}
::-webkit-scrollbar-track
{
background
:
#f0f5ff
;
}
::-webkit-scrollbar-thumb
{
background
:
#bbd4f0
;
border-radius
:
3px
;
}
::-webkit-scrollbar-thumb:hover
{
background
:
#91c2f0
;
}
web/src/app/icon.svg
0 → 100644
View file @
4ae56601
<svg
xmlns=
"http://www.w3.org/2000/svg"
viewBox=
"0 0 32 32"
>
<rect
width=
"32"
height=
"32"
rx=
"8"
fill=
"#1677ff"
/>
<path
d=
"M16 6v20M6 16h20"
stroke=
"#fff"
stroke-width=
"4"
stroke-linecap=
"round"
/>
</svg>
web/src/app/providers.tsx
View file @
4ae56601
'
use client
'
;
import
'
@ant-design/v5-patch-for-react-19
'
;
import
React
from
'
react
'
;
import
{
ConfigProvider
,
App
as
AntdApp
}
from
'
antd
'
;
import
{
StyleProvider
}
from
'
@ant-design/cssinjs
'
;
...
...
web/src/components/AuthGuard/index.tsx
View file @
4ae56601
import
React
from
'
react
'
;
import
{
Navigate
,
useLocation
}
from
'
react-router-dom
'
;
'
use client
'
;
import
React
,
{
useEffect
}
from
'
react
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
useUserStore
}
from
'
../../store/userStore
'
;
import
type
{
UserRole
}
from
'
../../api/user
'
;
...
...
@@ -15,24 +17,41 @@ interface AuthGuardProps {
*/
const
AuthGuard
:
React
.
FC
<
AuthGuardProps
>
=
({
children
,
requiredRole
})
=>
{
const
{
isAuthenticated
,
user
}
=
useUserStore
();
const
location
=
useLocation
();
const
router
=
useRouter
();
const
pathname
=
usePathname
();
useEffect
(()
=>
{
if
(
!
isAuthenticated
||
!
user
)
{
router
.
replace
(
'
/login
'
);
return
;
}
if
(
requiredRole
)
{
const
roles
=
Array
.
isArray
(
requiredRole
)
?
requiredRole
:
[
requiredRole
];
if
(
!
roles
.
includes
(
user
.
role
as
UserRole
))
{
switch
(
user
.
role
)
{
case
'
doctor
'
:
router
.
replace
(
'
/doctor/workbench
'
);
break
;
case
'
admin
'
:
router
.
replace
(
'
/admin/dashboard
'
);
break
;
default
:
router
.
replace
(
'
/patient/home
'
);
break
;
}
}
}
},
[
isAuthenticated
,
user
,
requiredRole
,
router
,
pathname
]);
if
(
!
isAuthenticated
||
!
user
)
{
return
<
Navigate
to=
"/login"
state=
{
{
from
:
location
}
}
replace
/>
;
return
null
;
}
if
(
requiredRole
)
{
const
roles
=
Array
.
isArray
(
requiredRole
)
?
requiredRole
:
[
requiredRole
];
if
(
!
roles
.
includes
(
user
.
role
as
UserRole
))
{
// 角色不匹配,重定向到其对应端首页
switch
(
user
.
role
)
{
case
'
doctor
'
:
return
<
Navigate
to=
"/doctor/workbench"
replace
/>;
case
'
admin
'
:
return
<
Navigate
to=
"/admin/dashboard"
replace
/>;
default
:
return
<
Navigate
to=
"/patient/home"
replace
/>;
}
return
null
;
}
}
...
...
web/src/components/Layout/AdminLayout.tsx
View file @
4ae56601
'
use client
'
;
import
React
from
'
react
'
;
import
{
Outlet
,
useNavigate
,
useLocation
}
from
'
react-router-dom
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Button
,
Badge
,
Space
,
Typography
,
Tag
}
from
'
antd
'
;
import
{
DashboardOutlined
,
UserOutlined
,
TeamOutlined
,
ApartmentOutlined
,
...
...
@@ -29,9 +31,9 @@ const menuItems = [
{
key
:
'
/admin/compliance
'
,
icon
:
<
SafetyCertificateOutlined
/>,
label
:
'
合规报表
'
},
];
const
AdminLayout
:
React
.
FC
=
(
)
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
AdminLayout
:
React
.
FC
<
{
children
?:
React
.
ReactNode
}
>
=
({
children
}
)
=>
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
userMenuItems
=
[
...
...
@@ -42,13 +44,13 @@ const AdminLayout: React.FC = () => {
];
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
navigate
(
'
/login
'
);
}
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
};
const
getSelectedKeys
=
()
=>
[
location
.
pathname
];
const
currentPath
=
pathname
||
''
;
const
getSelectedKeys
=
()
=>
[
currentPath
];
const
getOpenKeys
=
()
=>
{
const
p
=
location
.
pathname
;
if
(
p
.
startsWith
(
'
/admin/patients
'
)
||
p
.
startsWith
(
'
/admin/doctors
'
)
||
p
.
startsWith
(
'
/admin/admins
'
))
{
if
(
currentPath
.
startsWith
(
'
/admin/patients
'
)
||
currentPath
.
startsWith
(
'
/admin/doctors
'
)
||
currentPath
.
startsWith
(
'
/admin/admins
'
))
{
return
[
'
user-mgmt
'
];
}
return
[];
...
...
@@ -59,7 +61,7 @@ const AdminLayout: React.FC = () => {
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 50%, #0050b3 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
navigate
(
'
/admin/dashboard
'
)
}
>
onClick=
{
()
=>
router
.
push
(
'
/admin/dashboard
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"geekblue"
className=
"ml-1! text-[10px]! leading-4! px-1! border-blue-400/30!"
>
管理后台
</
Tag
>
...
...
@@ -70,7 +72,7 @@ const AdminLayout: React.FC = () => {
selectedKeys=
{
getSelectedKeys
()
}
defaultOpenKeys=
{
getOpenKeys
()
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
navigate
(
key
)
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/>
...
...
@@ -87,13 +89,13 @@ const AdminLayout: React.FC = () => {
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
navigate
(
'
/login
'
)
}
>
登录
</
Button
>
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录
</
Button
>
)
}
</
div
>
</
Header
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"
>
<
Outlet
/>
{
children
}
</
Content
>
</
Layout
>
);
...
...
web/src/components/Layout/DoctorLayout.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Outlet
,
useNavigate
,
useLocation
}
from
'
react-router-dom
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Button
,
Badge
,
Space
,
Switch
,
Typography
,
Tag
}
from
'
antd
'
;
import
{
DashboardOutlined
,
UserOutlined
,
MessageOutlined
,
CalendarOutlined
,
...
...
@@ -22,9 +24,9 @@ const menuItems = [
{
key
:
'
/doctor/profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人信息
'
},
];
const
DoctorLayout
:
React
.
FC
=
(
)
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
DoctorLayout
:
React
.
FC
<
{
children
?:
React
.
ReactNode
}
>
=
({
children
}
)
=>
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
[
isOnline
,
setIsOnline
]
=
useState
(
false
);
...
...
@@ -36,8 +38,8 @@ const DoctorLayout: React.FC = () => {
];
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
navigate
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
navigate
(
'
/doctor/profile
'
);
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
router
.
push
(
'
/doctor/profile
'
);
};
return
(
...
...
@@ -45,7 +47,7 @@ const DoctorLayout: React.FC = () => {
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #002140 0%, #003a8c 50%, #0958d9 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.15)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
navigate
(
'
/doctor/workbench
'
)
}
>
onClick=
{
()
=>
router
.
push
(
'
/doctor/workbench
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"green"
className=
"ml-1! text-[10px]! leading-4! px-1!"
>
医生工作站
</
Tag
>
...
...
@@ -53,9 +55,9 @@ const DoctorLayout: React.FC = () => {
<
Menu
mode=
"horizontal"
selectedKeys=
{
[
location
.
pathname
]
}
selectedKeys=
{
[
pathname
||
''
]
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
navigate
(
key
)
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/>
...
...
@@ -73,13 +75,13 @@ const DoctorLayout: React.FC = () => {
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
navigate
(
'
/login
'
)
}
>
登录
</
Button
>
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录
</
Button
>
)
}
</
div
>
</
Header
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"
>
<
Outlet
/>
{
children
}
</
Content
>
</
Layout
>
);
...
...
web/src/components/Layout/MainLayout.tsx
View file @
4ae56601
'
use client
'
;
import
React
from
'
react
'
;
import
{
Outlet
,
useNavigate
,
useLocation
}
from
'
react-router-dom
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Button
,
Space
,
Badge
}
from
'
antd
'
;
import
{
HomeOutlined
,
...
...
@@ -16,9 +18,9 @@ import { useUserStore } from '../../store/userStore';
const
{
Header
,
Sider
,
Content
}
=
Layout
;
const
MainLayout
:
React
.
FC
=
(
)
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
MainLayout
:
React
.
FC
<
{
children
?:
React
.
ReactNode
}
>
=
({
children
}
)
=>
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
menuItems
=
[
...
...
@@ -40,11 +42,11 @@ const MainLayout: React.FC = () => {
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
navigate
(
'
/login
'
);
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
{
navigate
(
'
/profile
'
);
router
.
push
(
'
/profile
'
);
}
else
if
(
key
===
'
settings
'
)
{
navigate
(
'
/settings
'
);
router
.
push
(
'
/settings
'
);
}
};
...
...
@@ -68,7 +70,7 @@ const MainLayout: React.FC = () => {
>
<
div
style=
{
{
display
:
'
flex
'
,
alignItems
:
'
center
'
,
gap
:
8
,
cursor
:
'
pointer
'
}
}
onClick=
{
()
=>
navigate
(
'
/home
'
)
}
onClick=
{
()
=>
router
.
push
(
'
/home
'
)
}
>
<
MedicineBoxOutlined
style=
{
{
fontSize
:
26
,
color
:
'
#1677ff
'
}
}
/>
<
span
style=
{
{
fontSize
:
17
,
fontWeight
:
700
,
color
:
'
#1677ff
'
,
letterSpacing
:
1
}
}
>
...
...
@@ -89,8 +91,8 @@ const MainLayout: React.FC = () => {
</
Dropdown
>
)
:
(
<
Space
>
<
Button
onClick=
{
()
=>
navigate
(
'
/login
'
)
}
>
登录
</
Button
>
<
Button
type=
"primary"
onClick=
{
()
=>
navigate
(
'
/register
'
)
}
>
注册
</
Button
>
<
Button
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录
</
Button
>
<
Button
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/register
'
)
}
>
注册
</
Button
>
</
Space
>
)
}
</
Space
>
...
...
@@ -110,10 +112,10 @@ const MainLayout: React.FC = () => {
>
<
Menu
mode=
"inline"
selectedKeys=
{
[
location
.
pathname
]
}
selectedKeys=
{
[
pathname
||
''
]
}
style=
{
{
height
:
'
100%
'
,
borderRight
:
0
,
paddingTop
:
8
}
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
navigate
(
key
)
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
/>
</
Sider
>
...
...
@@ -126,7 +128,7 @@ const MainLayout: React.FC = () => {
overflow
:
'
auto
'
,
}
}
>
<
Outlet
/>
{
children
}
</
Content
>
</
Layout
>
</
Layout
>
...
...
web/src/components/Layout/PatientLayout.tsx
View file @
4ae56601
'
use client
'
;
import
React
from
'
react
'
;
import
{
Outlet
,
useNavigate
,
useLocation
}
from
'
react-router-dom
'
;
import
{
useRouter
,
usePathname
}
from
'
next/navigation
'
;
import
{
Layout
,
Menu
,
Avatar
,
Dropdown
,
Button
,
Badge
,
Space
,
Typography
,
Tag
}
from
'
antd
'
;
import
{
HomeOutlined
,
UserOutlined
,
MedicineBoxOutlined
,
FileTextOutlined
,
...
...
@@ -25,9 +27,9 @@ const menuItems = [
{
key
:
'
/patient/profile
'
,
icon
:
<
UserOutlined
/>,
label
:
'
个人中心
'
},
];
const
PatientLayout
:
React
.
FC
=
(
)
=>
{
const
navigate
=
useNavigate
();
const
location
=
useLocation
();
const
PatientLayout
:
React
.
FC
<
{
children
?:
React
.
ReactNode
}
>
=
({
children
}
)
=>
{
const
router
=
useRouter
();
const
pathname
=
usePathname
();
const
{
user
,
logout
}
=
useUserStore
();
const
userMenuItems
=
[
...
...
@@ -38,8 +40,8 @@ const PatientLayout: React.FC = () => {
];
const
handleUserMenuClick
=
({
key
}:
{
key
:
string
})
=>
{
if
(
key
===
'
logout
'
)
{
logout
();
navigate
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
navigate
(
'
/patient/profile
'
);
if
(
key
===
'
logout
'
)
{
logout
();
router
.
push
(
'
/login
'
);
}
else
if
(
key
===
'
profile
'
)
router
.
push
(
'
/patient/profile
'
);
};
return
(
...
...
@@ -47,7 +49,7 @@ const PatientLayout: React.FC = () => {
<
Header
className=
"fixed! top-0! left-0! right-0! z-100! flex! items-center! h-12! px-4! leading-12!"
style=
{
{
background
:
'
linear-gradient(135deg, #003a8c 0%, #0050b3 50%, #1890ff 100%)
'
,
borderBottom
:
'
none
'
,
boxShadow
:
'
0 2px 8px rgba(0,21,41,0.12)
'
,
padding
:
'
0 16px
'
}
}
>
<
div
className=
"flex items-center gap-2 cursor-pointer mr-4 shrink-0"
onClick=
{
()
=>
navigate
(
'
/patient/home
'
)
}
>
onClick=
{
()
=>
router
.
push
(
'
/patient/home
'
)
}
>
<
MedicineBoxOutlined
className=
"text-lg text-white"
/>
<
span
className=
"text-sm font-bold text-white"
>
互联网医院
</
span
>
<
Tag
color=
"blue"
className=
"ml-1! text-[10px]! leading-4! px-1! border-blue-300/30!"
>
患者端
</
Tag
>
...
...
@@ -55,9 +57,9 @@ const PatientLayout: React.FC = () => {
<
Menu
mode=
"horizontal"
selectedKeys=
{
[
location
.
pathname
]
}
selectedKeys=
{
[
pathname
||
''
]
}
items=
{
menuItems
}
onClick=
{
({
key
})
=>
navigate
(
key
)
}
onClick=
{
({
key
})
=>
router
.
push
(
key
)
}
theme=
"dark"
className=
"flex-1! border-none! min-w-0! bg-transparent! text-xs! [&_.ant-menu-item]:text-blue-100/80! [&_.ant-menu-item-selected]:text-white! [&_.ant-menu-item-selected]:bg-white/10! [&_.ant-menu-item:hover]:text-white!"
/>
...
...
@@ -74,13 +76,13 @@ const PatientLayout: React.FC = () => {
</
Space
>
</
Dropdown
>
)
:
(
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
navigate
(
'
/login
'
)
}
>
登录 / 注册
</
Button
>
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/login
'
)
}
>
登录 / 注册
</
Button
>
)
}
</
div
>
</
Header
>
<
Content
className=
"mt-12! p-3! min-h-[calc(100vh-48px)]! bg-[#f0f5ff]!"
>
<
Outlet
/>
{
children
}
</
Content
>
</
Layout
>
);
...
...
web/src/components/RoleRedirect/index.tsx
View file @
4ae56601
import
React
from
'
react
'
;
import
{
Navigate
}
from
'
react-router-dom
'
;
'
use client
'
;
import
React
,
{
useEffect
}
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
useUserStore
}
from
'
../../store/userStore
'
;
interface
RoleRedirectProps
{
...
...
@@ -12,19 +14,28 @@ interface RoleRedirectProps {
*/
const
RoleRedirect
:
React
.
FC
<
RoleRedirectProps
>
=
()
=>
{
const
{
isAuthenticated
,
user
}
=
useUserStore
();
const
router
=
useRouter
();
useEffect
(()
=>
{
if
(
!
isAuthenticated
||
!
user
)
{
router
.
replace
(
'
/login
'
);
return
;
}
if
(
!
isAuthenticated
||
!
user
)
{
return
<
Navigate
to=
"/login"
replace
/>;
}
switch
(
user
.
role
)
{
case
'
doctor
'
:
router
.
replace
(
'
/doctor/workbench
'
);
break
;
case
'
admin
'
:
router
.
replace
(
'
/admin/dashboard
'
);
break
;
default
:
router
.
replace
(
'
/patient/home
'
);
break
;
}
},
[
isAuthenticated
,
user
,
router
]);
switch
(
user
.
role
)
{
case
'
doctor
'
:
return
<
Navigate
to=
"/doctor/workbench"
replace
/>;
case
'
admin
'
:
return
<
Navigate
to=
"/admin/dashboard"
replace
/>;
default
:
return
<
Navigate
to=
"/patient/home"
replace
/>;
}
return
null
;
};
export
default
RoleRedirect
;
web/src/pages/admin/Admins/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Input
,
Button
,
Space
,
Tag
,
Avatar
,
Modal
,
message
,
Typography
,
Form
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
ReloadOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons
'
;
...
...
web/src/pages/admin/Compliance/index.tsx
View file @
4ae56601
import
React
,
{
useState
}
from
'
react
'
;
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Tag
,
Typography
,
Space
,
Tabs
,
Statistic
,
Button
,
Input
,
DatePicker
,
Select
,
Modal
,
message
,
Descriptions
,
Timeline
,
Button
,
Input
,
DatePicker
,
Select
,
Modal
,
message
,
Spin
,
}
from
'
antd
'
;
import
{
AuditOutlined
,
...
...
@@ -9,26 +11,16 @@ import {
DownloadOutlined
,
CloudUploadOutlined
,
SafetyCertificateOutlined
,
ClockCircleOutlined
,
UserOutlined
,
SettingOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
adminApi
,
type
SystemLog
}
from
'
../../../api/admin
'
;
import
type
{
PaginatedResponse
}
from
'
../../../types
'
;
import
dayjs
from
'
dayjs
'
;
const
{
T
itle
,
T
ext
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
interface
AuditLog
{
id
:
string
;
timestamp
:
string
;
user
:
string
;
role
:
string
;
action
:
string
;
module
:
string
;
ip
:
string
;
detail
:
string
;
level
:
'
info
'
|
'
warning
'
|
'
danger
'
;
}
interface
ReportItem
{
id
:
string
;
name
:
string
;
...
...
@@ -39,39 +31,49 @@ interface ReportItem {
submitted_at
?:
string
;
}
const
mockLogs
:
AuditLog
[]
=
[
{
id
:
'
1
'
,
timestamp
:
'
2026-02-25 18:30:15
'
,
user
:
'
管理员
'
,
role
:
'
admin
'
,
action
:
'
审核通过
'
,
module
:
'
医生管理
'
,
ip
:
'
192.168.1.100
'
,
detail
:
'
审核通过医生周医生的资质认证申请
'
,
level
:
'
info
'
},
{
id
:
'
2
'
,
timestamp
:
'
2026-02-25 17:45:22
'
,
user
:
'
陈医生
'
,
role
:
'
doctor
'
,
action
:
'
开具处方
'
,
module
:
'
处方管理
'
,
ip
:
'
192.168.1.105
'
,
detail
:
'
为患者张三开具处方 RX20260225001
'
,
level
:
'
info
'
},
{
id
:
'
3
'
,
timestamp
:
'
2026-02-25 16:20:08
'
,
user
:
'
管理员
'
,
role
:
'
admin
'
,
action
:
'
修改配置
'
,
module
:
'
AI配置
'
,
ip
:
'
192.168.1.100
'
,
detail
:
'
修改 AI 模型 temperature 参数从 0.5 到 0.3
'
,
level
:
'
warning
'
},
{
id
:
'
4
'
,
timestamp
:
'
2026-02-25 15:10:33
'
,
user
:
'
未知用户
'
,
role
:
'
unknown
'
,
action
:
'
登录失败
'
,
module
:
'
系统安全
'
,
ip
:
'
10.0.0.55
'
,
detail
:
'
连续3次密码错误,账户已临时锁定
'
,
level
:
'
danger
'
},
{
id
:
'
5
'
,
timestamp
:
'
2026-02-25 14:05:11
'
,
user
:
'
管理员
'
,
role
:
'
admin
'
,
action
:
'
数据导出
'
,
module
:
'
数据管理
'
,
ip
:
'
192.168.1.100
'
,
detail
:
'
导出本月问诊数据报表
'
,
level
:
'
info
'
},
{
id
:
'
6
'
,
timestamp
:
'
2026-02-25 11:30:45
'
,
user
:
'
刘医生
'
,
role
:
'
doctor
'
,
action
:
'
异常处方
'
,
module
:
'
处方监管
'
,
ip
:
'
192.168.1.108
'
,
detail
:
'
处方 RX20260225004 被系统标记为异常:抗生素使用不符合指南
'
,
level
:
'
danger
'
},
];
const
mockReports
:
ReportItem
[]
=
[
{
id
:
'
1
'
,
name
:
'
2026年2月运营月报
'
,
type
:
'
月度报告
'
,
period
:
'
2026-02
'
,
status
:
'
pending
'
},
{
id
:
'
2
'
,
name
:
'
2026年1月运营月报
'
,
type
:
'
月度报告
'
,
period
:
'
2026-01
'
,
status
:
'
submitted
'
,
generated_at
:
'
2026-02-05
'
,
submitted_at
:
'
2026-02-10
'
},
{
id
:
'
3
'
,
name
:
'
2025Q4季度合规报告
'
,
type
:
'
季度报告
'
,
period
:
'
2025-Q4
'
,
status
:
'
submitted
'
,
generated_at
:
'
2026-01-10
'
,
submitted_at
:
'
2026-01-15
'
},
{
id
:
'
4
'
,
name
:
'
处方用药合规专项报告
'
,
type
:
'
专项报告
'
,
period
:
'
2026-02
'
,
status
:
'
generated
'
,
generated_at
:
'
2026-02-20
'
},
{
id
:
'
5
'
,
name
:
'
等保三级自查报告
'
,
type
:
'
安全报告
'
,
period
:
'
2026-02
'
,
status
:
'
pending
'
},
];
const
AdminCompliancePage
:
React
.
FC
=
()
=>
{
const
[
exportModalVisible
,
setExportModalVisible
]
=
useState
(
false
);
const
[
logs
,
setLogs
]
=
useState
<
SystemLog
[]
>
([]);
const
[
logsLoading
,
setLogsLoading
]
=
useState
(
false
);
const
[
logsTotal
,
setLogsTotal
]
=
useState
(
0
);
const
[
logsPage
,
setLogsPage
]
=
useState
(
1
);
const
reports
:
ReportItem
[]
=
[
{
id
:
'
1
'
,
name
:
`
${
dayjs
().
format
(
'
YYYY
'
)}
年
${
dayjs
().
format
(
'
M
'
)}
月运营月报`
,
type
:
'
月度报告
'
,
period
:
dayjs
().
format
(
'
YYYY-MM
'
),
status
:
'
pending
'
},
{
id
:
'
2
'
,
name
:
`
${
dayjs
().
subtract
(
1
,
'
month
'
).
format
(
'
YYYY
'
)}
年
${
dayjs
().
subtract
(
1
,
'
month
'
).
format
(
'
M
'
)}
月运营月报`
,
type
:
'
月度报告
'
,
period
:
dayjs
().
subtract
(
1
,
'
month
'
).
format
(
'
YYYY-MM
'
),
status
:
'
submitted
'
,
generated_at
:
dayjs
().
subtract
(
20
,
'
day
'
).
format
(
'
YYYY-MM-DD
'
),
submitted_at
:
dayjs
().
subtract
(
15
,
'
day
'
).
format
(
'
YYYY-MM-DD
'
)
},
{
id
:
'
3
'
,
name
:
'
处方用药合规专项报告
'
,
type
:
'
专项报告
'
,
period
:
dayjs
().
format
(
'
YYYY-MM
'
),
status
:
'
generated
'
,
generated_at
:
dayjs
().
subtract
(
5
,
'
day
'
).
format
(
'
YYYY-MM-DD
'
)
},
{
id
:
'
4
'
,
name
:
'
等保三级自查报告
'
,
type
:
'
安全报告
'
,
period
:
dayjs
().
format
(
'
YYYY-MM
'
),
status
:
'
pending
'
},
];
const
fetchLogs
=
async
(
page
=
1
)
=>
{
setLogsLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getSystemLogs
({
page
,
page_size
:
10
});
setLogs
(
res
.
data
.
list
||
[]);
setLogsTotal
(
res
.
data
.
total
||
0
);
}
catch
{
message
.
error
(
'
获取审计日志失败
'
);
}
finally
{
setLogsLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchLogs
();
},
[]);
const
handleExport
=
()
=>
{
message
.
success
(
'
数据导出任务已提交,请稍后在下载中心查看
'
);
setExportModalVisible
(
false
);
};
const
getLevelTag
=
(
level
:
string
)
=>
{
switch
(
level
)
{
case
'
info
'
:
return
<
Tag
color=
"blue"
>
信息
</
Tag
>;
case
'
warning
'
:
return
<
Tag
color=
"orange"
>
警告
</
Tag
>;
case
'
danger
'
:
return
<
Tag
color=
"red"
>
危险
</
Tag
>;
default
:
return
<
Tag
>
{
level
}
</
Tag
>;
}
};
const
logColumns
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
170
,
render
:
(
v
:
string
)
=>
dayjs
(
v
).
format
(
'
YYYY-MM-DD HH:mm:ss
'
)
},
{
title
:
'
用户ID
'
,
dataIndex
:
'
user_id
'
,
key
:
'
user_id
'
,
width
:
100
,
ellipsis
:
true
},
{
title
:
'
操作
'
,
dataIndex
:
'
action
'
,
key
:
'
action
'
,
width
:
120
},
{
title
:
'
资源
'
,
dataIndex
:
'
resource
'
,
key
:
'
resource
'
,
width
:
120
},
{
title
:
'
IP
'
,
dataIndex
:
'
ip
'
,
key
:
'
ip
'
,
width
:
130
},
];
const
getStatusTag
=
(
status
:
string
)
=>
{
switch
(
status
)
{
...
...
@@ -82,23 +84,6 @@ const AdminCompliancePage: React.FC = () => {
}
};
const
logColumns
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
timestamp
'
,
key
:
'
timestamp
'
,
width
:
170
},
{
title
:
'
用户
'
,
dataIndex
:
'
user
'
,
key
:
'
user
'
,
width
:
80
},
{
title
:
'
角色
'
,
dataIndex
:
'
role
'
,
key
:
'
role
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
{
const
map
:
Record
<
string
,
string
>
=
{
admin
:
'
管理员
'
,
doctor
:
'
医生
'
,
patient
:
'
患者
'
,
unknown
:
'
未知
'
};
return
<
Tag
>
{
map
[
v
]
||
v
}
</
Tag
>;
},
},
{
title
:
'
操作
'
,
dataIndex
:
'
action
'
,
key
:
'
action
'
,
width
:
100
},
{
title
:
'
模块
'
,
dataIndex
:
'
module
'
,
key
:
'
module
'
,
width
:
100
},
{
title
:
'
IP
'
,
dataIndex
:
'
ip
'
,
key
:
'
ip
'
,
width
:
130
},
{
title
:
'
详情
'
,
dataIndex
:
'
detail
'
,
key
:
'
detail
'
,
ellipsis
:
true
},
{
title
:
'
级别
'
,
dataIndex
:
'
level
'
,
key
:
'
level
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
getLevelTag
(
v
)
},
];
const
reportColumns
=
[
{
title
:
'
报告名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
v
:
string
)
=>
<
Text
strong
>
{
v
}
</
Text
>
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
key
:
'
type
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
<
Tag
color=
"purple"
>
{
v
}
</
Tag
>
},
...
...
@@ -132,22 +117,22 @@ const AdminCompliancePage: React.FC = () => {
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #1890ff
'
}
}
>
<
Statistic
title=
"审计日志"
value=
{
mockLogs
.
length
}
prefix=
{
<
FileSearchOutlined
/>
}
suffix=
"条"
/>
<
Statistic
title=
"审计日志"
value=
{
logsTotal
}
prefix=
{
<
FileSearchOutlined
/>
}
suffix=
"条"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #fa8c16
'
}
}
>
<
Statistic
title=
"
安全告警"
value=
{
mockLogs
.
filter
((
l
)
=>
l
.
level
===
'
danger
'
).
length
}
valueStyle=
{
{
color
:
'
#f5222d
'
}
}
suffix=
"条
"
/>
<
Statistic
title=
"
待处理"
value=
{
reports
.
filter
((
r
)
=>
r
.
status
===
'
pending
'
).
length
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
suffix=
"项
"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #52c41a
'
}
}
>
<
Statistic
title=
"已上报"
value=
{
mockReports
.
filter
((
r
)
=>
r
.
status
===
'
submitted
'
).
length
}
suffix=
"条
"
/>
<
Statistic
title=
"已上报"
value=
{
reports
.
filter
((
r
)
=>
r
.
status
===
'
submitted
'
).
length
}
suffix=
"项
"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #722ed1
'
}
}
>
<
Statistic
title=
"
待生成"
value=
{
mockReports
.
filter
((
r
)
=>
r
.
status
===
'
pending
'
).
length
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
suffix=
"条
"
/>
<
Statistic
title=
"
已生成"
value=
{
reports
.
filter
((
r
)
=>
r
.
status
===
'
generated
'
).
length
}
suffix=
"项
"
/>
</
Card
>
</
Col
>
</
Row
>
...
...
@@ -159,18 +144,13 @@ const AdminCompliancePage: React.FC = () => {
children
:
(
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2 mb-2"
>
<
Input
placeholder=
"搜索用户/操作"
style=
{
{
width
:
160
}
}
size=
"small"
allowClear
/>
<
Select
placeholder=
"级别"
style=
{
{
width
:
80
}
}
size=
"small"
allowClear
options=
{
[
{
label
:
'
信息
'
,
value
:
'
info
'
},
{
label
:
'
警告
'
,
value
:
'
warning
'
},
{
label
:
'
危险
'
,
value
:
'
danger
'
},
]
}
/>
<
Select
placeholder=
"模块"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
options=
{
[
{
label
:
'
医生管理
'
,
value
:
'
医生管理
'
},
{
label
:
'
处方管理
'
,
value
:
'
处方管理
'
},
{
label
:
'
系统安全
'
,
value
:
'
系统安全
'
},
{
label
:
'
AI配置
'
,
value
:
'
AI配置
'
},
]
}
/>
<
Input
placeholder=
"搜索操作内容"
style=
{
{
width
:
160
}
}
size=
"small"
allowClear
/>
<
RangePicker
size=
"small"
/>
<
Button
type=
"primary"
size=
"small"
ghost
>
查询
</
Button
>
<
Button
type=
"primary"
size=
"small"
ghost
onClick=
{
()
=>
fetchLogs
(
1
)
}
>
查询
</
Button
>
</
div
>
<
Table
columns=
{
logColumns
}
dataSource=
{
mockLogs
}
rowKey=
"id"
size=
"small"
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
}
}
/>
<
Table
columns=
{
logColumns
}
dataSource=
{
logs
}
rowKey=
"id"
size=
"small"
loading=
{
logsLoading
}
pagination=
{
{
current
:
logsPage
,
pageSize
:
10
,
total
:
logsTotal
,
size
:
'
small
'
,
onChange
:
(
p
)
=>
{
setLogsPage
(
p
);
fetchLogs
(
p
);
}
}
}
/>
</
Card
>
),
},
...
...
@@ -207,7 +187,7 @@ const AdminCompliancePage: React.FC = () => {
<
span
className=
"text-xs text-gray-400"
>
管理合规报告,支持下载和上报
</
span
>
<
Button
type=
"primary"
size=
"small"
>
生成新报告
</
Button
>
</
div
>
<
Table
columns=
{
reportColumns
}
dataSource=
{
mockR
eports
}
rowKey=
"id"
size=
"small"
pagination=
{
false
}
/>
<
Table
columns=
{
reportColumns
}
dataSource=
{
r
eports
}
rowKey=
"id"
size=
"small"
pagination=
{
false
}
/>
</
Card
>
),
},
...
...
web/src/pages/admin/Consultations/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Input
,
Select
,
Button
,
Avatar
,
DatePicker
,
message
}
from
'
antd
'
;
import
{
...
...
web/src/pages/admin/Dashboard/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useEffect
,
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Statistic
,
Typography
,
List
,
Avatar
,
Tag
,
Space
,
Progress
}
from
'
antd
'
;
import
{
...
...
web/src/pages/admin/DoctorReview/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Button
,
Avatar
,
Modal
,
message
,
Descriptions
}
from
'
antd
'
;
import
{
UserOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
EyeOutlined
}
from
'
@ant-design/icons
'
;
...
...
web/src/pages/admin/Prescription/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Tag
,
Typography
,
Space
,
Tabs
,
Statistic
,
...
...
web/src/pages/admin/Statistics/index.tsx
View file @
4ae56601
import
React
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Typography
,
Space
,
Statistic
,
Select
,
DatePicker
}
from
'
antd
'
;
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Typography
,
Space
,
Statistic
,
Select
,
DatePicker
,
Table
,
Tag
,
Progress
,
Spin
,
message
}
from
'
antd
'
;
import
{
RiseOutlined
,
FallOutlined
,
UserOutlined
,
MessageOutlined
,
DollarOutlined
,
TeamOutlined
,
RiseOutlined
,
FallOutlined
,
UserOutlined
,
MessageOutlined
,
DollarOutlined
,
TeamOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
adminApi
,
type
DashboardStats
}
from
'
../../../api/admin
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Title
,
Text
}
=
Typography
;
const
{
Text
}
=
Typography
;
interface
DepartmentStat
{
name
:
string
;
count
:
number
;
percentage
:
number
;
color
:
string
;
}
interface
DoctorRank
{
rank
:
number
;
name
:
string
;
department
:
string
;
consult_count
:
number
;
rating
:
number
;
income
:
number
;
}
const
AdminStatisticsPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
stats
,
setStats
]
=
useState
<
DashboardStats
|
null
>
(
null
);
const
[
period
,
setPeriod
]
=
useState
(
'
month
'
);
useEffect
(()
=>
{
const
fetchStats
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getDashboardStats
();
setStats
(
res
.
data
);
}
catch
{
message
.
error
(
'
获取统计数据失败
'
);
}
finally
{
setLoading
(
false
);
}
};
fetchStats
();
},
[
period
]);
const
departmentStats
:
DepartmentStat
[]
=
[
{
name
:
'
内科
'
,
count
:
320
,
percentage
:
28
,
color
:
'
#1890ff
'
},
{
name
:
'
外科
'
,
count
:
245
,
percentage
:
21
,
color
:
'
#52c41a
'
},
{
name
:
'
妇产科
'
,
count
:
198
,
percentage
:
17
,
color
:
'
#722ed1
'
},
{
name
:
'
儿科
'
,
count
:
176
,
percentage
:
15
,
color
:
'
#fa8c16
'
},
{
name
:
'
皮肤科
'
,
count
:
112
,
percentage
:
10
,
color
:
'
#eb2f96
'
},
{
name
:
'
其他
'
,
count
:
109
,
percentage
:
9
,
color
:
'
#8c8c8c
'
},
];
const
doctorRankData
:
DoctorRank
[]
=
[
{
rank
:
1
,
name
:
'
陈医生
'
,
department
:
'
内科
'
,
consult_count
:
156
,
rating
:
4.9
,
income
:
78000
},
{
rank
:
2
,
name
:
'
张医生
'
,
department
:
'
外科
'
,
consult_count
:
132
,
rating
:
4.8
,
income
:
66000
},
{
rank
:
3
,
name
:
'
刘医生
'
,
department
:
'
妇产科
'
,
consult_count
:
118
,
rating
:
4.9
,
income
:
59000
},
{
rank
:
4
,
name
:
'
王医生
'
,
department
:
'
儿科
'
,
consult_count
:
105
,
rating
:
4.7
,
income
:
52500
},
{
rank
:
5
,
name
:
'
李医生
'
,
department
:
'
内科
'
,
consult_count
:
98
,
rating
:
4.8
,
income
:
49000
},
];
const
doctorColumns
=
[
{
title
:
'
排名
'
,
dataIndex
:
'
rank
'
,
key
:
'
rank
'
,
width
:
60
,
render
:
(
v
:
number
)
=>
{
const
colors
=
[
'
#f5222d
'
,
'
#fa8c16
'
,
'
#faad14
'
];
return
v
<=
3
?
<
span
style=
{
{
color
:
colors
[
v
-
1
],
fontWeight
:
'
bold
'
,
fontSize
:
16
}
}
>
{
v
}
</
span
>
:
<
span
style=
{
{
color
:
'
#8c8c8c
'
}
}
>
{
v
}
</
span
>;
},
},
{
title
:
'
医生
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
v
:
string
)
=>
<
Text
strong
>
{
v
}
</
Text
>
},
{
title
:
'
科室
'
,
dataIndex
:
'
department
'
,
key
:
'
department
'
,
render
:
(
v
:
string
)
=>
<
Tag
>
{
v
}
</
Tag
>
},
{
title
:
'
问诊量
'
,
dataIndex
:
'
consult_count
'
,
key
:
'
consult_count
'
,
render
:
(
v
:
number
)
=>
<
Text
strong
style=
{
{
color
:
'
#1890ff
'
}
}
>
{
v
}
</
Text
>
},
{
title
:
'
评分
'
,
dataIndex
:
'
rating
'
,
key
:
'
rating
'
,
render
:
(
v
:
number
)
=>
<
Tag
color=
{
v
>=
4.8
?
'
green
'
:
v
>=
4.5
?
'
blue
'
:
'
default
'
}
>
{
v
.
toFixed
(
1
)
}
</
Tag
>,
},
{
title
:
'
收入
'
,
dataIndex
:
'
income
'
,
key
:
'
income
'
,
render
:
(
v
:
number
)
=>
<
Text
style=
{
{
color
:
'
#52c41a
'
}
}
>
¥
{
(
v
/
100
).
toFixed
(
0
)
}
</
Text
>,
},
];
return
(
<
div
className=
"space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
数据统计
</
h4
>
<
div
className=
"flex items-center gap-2"
>
<
Select
defaultValue=
"month"
size=
"small"
style=
{
{
width
:
100
}
}
options=
{
[{
label
:
'
本周
'
,
value
:
'
week
'
},
{
label
:
'
本月
'
,
value
:
'
month
'
},
{
label
:
'
本季
'
,
value
:
'
quarter
'
},
{
label
:
'
本年
'
,
value
:
'
year
'
}]
}
/>
<
DatePicker
.
RangePicker
size=
"small"
/>
<
Spin
spinning=
{
loading
}
>
<
div
className=
"space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
数据统计
</
h4
>
<
div
className=
"flex items-center gap-2"
>
<
Select
defaultValue=
"month"
size=
"small"
style=
{
{
width
:
100
}
}
onChange=
{
setPeriod
}
options=
{
[{
label
:
'
本周
'
,
value
:
'
week
'
},
{
label
:
'
本月
'
,
value
:
'
month
'
},
{
label
:
'
本季
'
,
value
:
'
quarter
'
},
{
label
:
'
本年
'
,
value
:
'
year
'
}]
}
/>
<
DatePicker
.
RangePicker
size=
"small"
/>
</
div
>
</
div
>
</
div
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
><
Statistic
title=
"新增用户"
value=
{
328
}
prefix=
{
<
UserOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
12.5%
</
span
>
}
/></
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
><
Statistic
title=
"问诊量"
value=
{
1280
}
prefix=
{
<
MessageOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
8.3%
</
span
>
}
/></
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
><
Statistic
title=
"总收入"
value=
{
56800
}
prefix=
{
<
DollarOutlined
/>
}
precision=
{
0
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
15.2%
</
span
>
}
/></
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
><
Statistic
title=
"活跃医生"
value=
{
86
}
prefix=
{
<
TeamOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-red-500"
><
FallOutlined
/>
2.1%
</
span
>
}
/></
Card
>
</
Col
>
</
Row
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
>
<
Statistic
title=
"总用户数"
value=
{
stats
?.
total_users
||
0
}
prefix=
{
<
UserOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
12.5%
</
span
>
}
/>
</
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
>
<
Statistic
title=
"总问诊量"
value=
{
stats
?.
total_consultations
||
0
}
prefix=
{
<
MessageOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
8.3%
</
span
>
}
/>
</
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
>
<
Statistic
title=
"本月收入"
value=
{
stats
?.
revenue_month
?
stats
.
revenue_month
/
100
:
0
}
prefix=
{
<
DollarOutlined
/>
}
precision=
{
0
}
suffix=
{
<
span
className=
"text-xs text-green-500"
><
RiseOutlined
/>
15.2%
</
span
>
}
/>
</
Card
>
</
Col
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
>
<
Statistic
title=
"在线医生"
value=
{
stats
?.
online_doctors
||
0
}
prefix=
{
<
TeamOutlined
/>
}
suffix=
{
<
span
className=
"text-xs text-red-500"
><
FallOutlined
/>
2.1%
</
span
>
}
/>
</
Card
>
</
Col
>
</
Row
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
问诊趋势
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"flex justify-center items-center h-52 rounded-lg"
style=
{
{
background
:
'
linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)
'
}
}
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
问诊趋势图表 (待集成 ECharts)
</
Text
>
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
收入趋势
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"flex justify-center items-center h-52 rounded-lg"
style=
{
{
background
:
'
linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)
'
}
}
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
收入趋势图表 (待集成 ECharts)
</
Text
>
</
div
>
</
Card
>
</
Col
>
</
Row
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
问诊趋势(近7日)
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"space-y-2 px-2"
>
{
[
'
周一
'
,
'
周二
'
,
'
周三
'
,
'
周四
'
,
'
周五
'
,
'
周六
'
,
'
周日
'
].
map
((
day
,
i
)
=>
{
const
values
=
[
45
,
62
,
58
,
71
,
85
,
38
,
42
];
const
max
=
Math
.
max
(...
values
);
return
(
<
div
key=
{
day
}
className=
"flex items-center gap-2"
>
<
span
className=
"text-xs text-gray-500 w-8"
>
{
day
}
</
span
>
<
Progress
percent=
{
Math
.
round
(
values
[
i
]
/
max
*
100
)
}
size=
"small"
strokeColor=
"#1890ff"
format=
{
()
=>
<
span
className=
"text-xs"
>
{
values
[
i
]
}
</
span
>
}
style=
{
{
flex
:
1
}
}
/>
</
div
>
);
})
}
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
收入趋势(近7日)
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"space-y-2 px-2"
>
{
[
'
周一
'
,
'
周二
'
,
'
周三
'
,
'
周四
'
,
'
周五
'
,
'
周六
'
,
'
周日
'
].
map
((
day
,
i
)
=>
{
const
values
=
[
2800
,
3500
,
3200
,
4100
,
5200
,
1800
,
2200
];
const
max
=
Math
.
max
(...
values
);
return
(
<
div
key=
{
day
}
className=
"flex items-center gap-2"
>
<
span
className=
"text-xs text-gray-500 w-8"
>
{
day
}
</
span
>
<
Progress
percent=
{
Math
.
round
(
values
[
i
]
/
max
*
100
)
}
size=
"small"
strokeColor=
"#52c41a"
format=
{
()
=>
<
span
className=
"text-xs"
>
¥
{
values
[
i
]
}
</
span
>
}
style=
{
{
flex
:
1
}
}
/>
</
div
>
);
})
}
</
div
>
</
Card
>
</
Col
>
</
Row
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
科室问诊分布
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"flex justify-center items-center h-52 rounded-lg"
style=
{
{
background
:
'
linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)
'
}
}
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
科室分布饼图 (待集成 ECharts)
</
Text
>
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
医生排名
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"flex justify-center items-center h-52 rounded-lg"
style=
{
{
background
:
'
linear-gradient(135deg, #f5f7fa 0%, #e8edf5 100%)
'
}
}
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
医生排名列表 (待集成 ECharts)
</
Text
>
</
div
>
</
Card
>
</
Col
>
</
Row
>
</
div
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
><
MedicineBoxOutlined
className=
"mr-1"
/>
科室问诊分布
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
div
className=
"space-y-3 px-2"
>
{
departmentStats
.
map
((
dept
)
=>
(
<
div
key=
{
dept
.
name
}
>
<
div
className=
"flex items-center justify-between mb-1"
>
<
Space
size=
{
4
}
>
<
span
className=
"inline-block w-2.5 h-2.5 rounded-full"
style=
{
{
backgroundColor
:
dept
.
color
}
}
/>
<
Text
className=
"text-xs!"
>
{
dept
.
name
}
</
Text
>
</
Space
>
<
Space
size=
{
8
}
>
<
Text
strong
className=
"text-xs!"
>
{
dept
.
count
}
</
Text
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
{
dept
.
percentage
}
%
</
Text
>
</
Space
>
</
div
>
<
Progress
percent=
{
dept
.
percentage
}
size=
"small"
strokeColor=
{
dept
.
color
}
showInfo=
{
false
}
/>
</
div
>
))
}
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
><
TeamOutlined
className=
"mr-1"
/>
医生排名 TOP5
</
span
>
}
size=
"small"
style=
{
{
minHeight
:
280
}
}
>
<
Table
dataSource=
{
doctorRankData
}
columns=
{
doctorColumns
}
rowKey=
"rank"
size=
"small"
pagination=
{
false
}
/>
</
Card
>
</
Col
>
</
Row
>
</
div
>
</
Spin
>
);
};
...
...
web/src/pages/admin/Users/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Input
,
Select
,
Button
,
Avatar
,
Modal
,
message
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
LockOutlined
,
StopOutlined
,
EditOutlined
}
from
'
@ant-design/icons
'
;
...
...
@@ -8,9 +10,7 @@ import type { UserInfo } from '../../../api/user';
const
{
Title
,
Text
}
=
Typography
;
interface
UserRecord
extends
UserInfo
{
status
?:
'
active
'
|
'
disabled
'
;
}
type
UserRecord
=
UserInfo
;
const
roleMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
patient
:
{
text
:
'
患者
'
,
color
:
'
blue
'
},
...
...
web/src/pages/doctor/AIAssist/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Button
,
List
,
Tag
,
Typography
,
Tabs
,
Empty
,
Spin
,
Divider
,
Alert
}
from
'
antd
'
;
import
{
...
...
web/src/pages/doctor/ChronicReview/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Button
,
Tag
,
Typography
,
Space
,
Modal
,
Form
,
...
...
web/src/pages/doctor/Consult/WaitingQueue.tsx
View file @
4ae56601
...
...
@@ -21,7 +21,7 @@ const formatWaitingTime = (seconds: number) => {
if
(
seconds
<
60
)
return
'
刚刚
'
;
const
minutes
=
Math
.
floor
(
seconds
/
60
);
if
(
minutes
<
60
)
return
`
${
minutes
}
分钟`
;
return
`
${
Math
.
floor
(
minutes
/
60
)}
小时
${
minutes
%
60
}
分;
return
`
${
Math
.
floor
(
minutes
/
60
)}
小时
${
minutes
%
60
}
分
钟`
;
};
const
WaitingQueue
:
React
.
FC
<
WaitingQueueProps
>
=
({
...
...
@@ -47,9 +47,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<Text strong>{patient.patient_name || '患者}</Text>
<
Text
strong
>
{
patient
.
patient_name
||
'
患者
'
}
</
Text
>
<
Tag
color=
{
patient
.
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
style=
{
{
fontSize
:
11
}
}
>
{patient.type === 'video' ? '视频
' : '图文'}
{
patient
.
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
</
Space
>
</
div
>
...
...
@@ -58,7 +58,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
?
patient
.
chief_complaint
.
length
>
30
?
patient
.
chief_complaint
.
substring
(
0
,
30
)
+
'
...
'
:
patient
.
chief_complaint
: '
鏈~鍐欎富璇
}
:
'
未填写主诉
'
}
</
Text
>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
alignItems
:
'
center
'
}
}
>
<
Tag
icon=
{
<
ClockCircleOutlined
/>
}
color=
"orange"
style=
{
{
fontSize
:
11
}
}
>
...
...
@@ -92,9 +92,9 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#1890ff
'
}
}
/>
<Text strong>{consult.patient_name || '患者}</Text>
<
Text
strong
>
{
consult
.
patient_name
||
'
患者
'
}
</
Text
>
<
Tag
color=
{
consult
.
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
style=
{
{
fontSize
:
11
}
}
>
{consult.type === 'video' ? '视频
' : '图文'}
{
consult
.
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
</
Space
>
</
div
>
...
...
@@ -103,7 +103,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
?
consult
.
chief_complaint
.
length
>
30
?
consult
.
chief_complaint
.
substring
(
0
,
30
)
+
'
...
'
:
consult
.
chief_complaint
: '
鏈~鍐欎富璇
}
:
'
未填写主诉
'
}
</
Text
>
</
Card
>
);
...
...
@@ -113,12 +113,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
key
:
'
waiting
'
,
label
:
(
<
Space
>
<span>待接诊/span>
<
span
>
待接诊
<
/
span
>
<
Badge
count=
{
waitingList
.
length
}
style=
{
{
backgroundColor
:
'
#fa8c16
'
}
}
/>
</
Space
>
),
children
:
waitingList
.
length
===
0
?
(
<Empty description="暂无等待患者 style={{ marginTop: 40 }} />
<
Empty
description=
"暂无等待患者
"
style=
{
{
marginTop
:
40
}
}
/>
)
:
(
<
div
>
{
waitingList
.
map
(
renderWaitingItem
)
}
</
div
>
),
...
...
@@ -128,12 +128,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
label
:
(
<
Space
>
<
MessageOutlined
/>
<span>
杩涜涓
</span>
<
span
>
进行中
</
span
>
<
Badge
count=
{
inProgressList
.
length
}
style=
{
{
backgroundColor
:
'
#52c41a
'
}
}
/>
</
Space
>
),
children
:
inProgressList
.
length
===
0
?
(
<Empty description="暂无
杩涜涓棶璇
style={{ marginTop: 40 }} />
<
Empty
description=
"暂无
进行中问诊"
style=
{
{
marginTop
:
40
}
}
/>
)
:
(
<
div
>
{
inProgressList
.
map
(
renderInProgressItem
)
}
</
div
>
),
...
...
@@ -143,12 +143,12 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
label
:
(
<
Space
>
<
CheckCircleOutlined
/>
<span>已完成/span>
<
span
>
已完成
<
/
span
>
<
Badge
count=
{
completedList
.
length
}
style=
{
{
backgroundColor
:
'
#999
'
}
}
/>
</
Space
>
),
children
:
completedList
.
length
===
0
?
(
<Empty description="暂无已完成
愰棶璇
style={{ marginTop: 40 }} />
<
Empty
description=
"暂无已完成
问诊"
style=
{
{
marginTop
:
40
}
}
/>
)
:
(
<
div
>
{
completedList
.
map
((
consult
)
=>
(
<
Card
...
...
@@ -167,8 +167,8 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#999
'
}
}
/>
<Text strong>{consult.patient_name || '患者}</Text>
<Tag color="default" style={{ fontSize: 11 }}>已完成/Tag>
<
Text
strong
>
{
consult
.
patient_name
||
'
患者
'
}
</
Text
>
<
Tag
color=
"default"
style=
{
{
fontSize
:
11
}
}
>
已完成
<
/
Tag
>
</
Space
>
</
div
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
display
:
'
block
'
}
}
>
...
...
@@ -176,7 +176,7 @@ const WaitingQueue: React.FC<WaitingQueueProps> = ({
?
consult
.
chief_complaint
.
length
>
30
?
consult
.
chief_complaint
.
substring
(
0
,
30
)
+
'
...
'
:
consult
.
chief_complaint
: '
鏈~鍐欎富璇
}
:
'
未填写主诉
'
}
</
Text
>
</
Card
>
))
}
</
div
>
...
...
web/src/pages/doctor/ConsultRoom/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useRef
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Button
,
Avatar
,
Typography
,
Space
,
Descriptions
,
Tag
,
Divider
}
from
'
antd
'
;
import
{
...
...
@@ -8,52 +10,36 @@ import {
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Text
,
Title
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
DoctorConsultRoomPage
:
React
.
FC
=
()
=>
{
const
[
inputValue
,
setInputValue
]
=
useState
(
''
);
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
// TODO: 从API获取真实数据
const
currentConsult
=
{
id
:
'
c1
'
,
patient_name
:
'
张三
'
,
patient_avatar
:
''
,
type
:
'
text
'
,
status
:
'
in_progress
'
,
chief_complaint
:
'
头痛、乏力,持续3天
数
,
chief_complaint
:
'
头痛、乏力,持续3天
'
,
medical_history
:
'
高血压病史5年,长期服用降压药物
'
,
};
const
messages
=
[
{
id:
'
1
'
,
sender_type:
'
system
'
,
content:
'
问诊已开始
,
created_at
:
'
2026-02-25T13:00:00Z
'
,
},
{
id
:
'
2
'
,
sender_type
:
'
patient
'
,
content
:
'
医生您好,我最近三天一直头痛,感觉很乏力,精神也不好评,
created_at:
'
2026
-
02
-
25
T13
:
01
:
00
Z
'
,
},
{
id:
'
3
'
,
sender_type:
'
patient
'
,
content:
'
我有高血压,一直在吃药控制定
,
created_at
:
'
2026-02-25T13:01:30Z
'
,
},
{
id
:
'
1
'
,
sender_type
:
'
system
'
,
content
:
'
问诊已开始
'
,
created_at
:
'
2026-02-25T13:00:00Z
'
},
{
id
:
'
2
'
,
sender_type
:
'
patient
'
,
content
:
'
医生您好,我最近三天一直头痛,感觉很乏力,精神也不好。
'
,
created_at
:
'
2026-02-25T13:01:00Z
'
},
{
id
:
'
3
'
,
sender_type
:
'
patient
'
,
content
:
'
我有高血压,一直在吃药控制。
'
,
created_at
:
'
2026-02-25T13:01:30Z
'
},
];
const
patientInfo
=
{
name
:
'
张三
'
,
age
:
45
,
gender
:
'
?
,
gender
:
'
男
'
,
phone
:
'
138****8888
'
,
medical_history:
'
高血压病史
5
年
,
allergy_history
:
'
青霉素过敏,
medical_history
:
'
高血压病史5年
'
,
allergy_history
:
'
青霉素过敏
'
,
consultation_count
:
3
,
};
...
...
@@ -63,7 +49,6 @@ const DoctorConsultRoomPage: React.FC = () => {
const
handleSend
=
()
=>
{
if
(
!
inputValue
.
trim
())
return
;
// TODO: 发送消息到API
setInputValue
(
''
);
};
...
...
@@ -135,7 +120,7 @@ const DoctorConsultRoomPage: React.FC = () => {
<
Avatar
size=
{
48
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<
div
className=
"mt-1"
>
<
div
className=
"text-sm font-bold"
>
{
patientInfo
.
name
}
</
div
>
<Text type="secondary" className="text-xs!">{patientInfo.age}
?
· {patientInfo.gender}</Text>
<
Text
type=
"secondary"
className=
"text-xs!"
>
{
patientInfo
.
age
}
岁
·
{
patientInfo
.
gender
}
</
Text
>
</
div
>
</
div
>
<
Divider
className=
"my-1!"
/>
...
...
@@ -143,7 +128,7 @@ const DoctorConsultRoomPage: React.FC = () => {
<
Descriptions
.
Item
label=
"电话"
>
{
patientInfo
.
phone
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"病史"
>
{
patientInfo
.
medical_history
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"过敏"
><
Tag
color=
"red"
>
{
patientInfo
.
allergy_history
}
</
Tag
></
Descriptions
.
Item
>
<Descriptions.Item label="就诊">{patientInfo.consultation_count}
?
/Descriptions.Item>
<
Descriptions
.
Item
label=
"就诊"
>
{
patientInfo
.
consultation_count
}
次
<
/
Descriptions
.
Item
>
</
Descriptions
>
<
Divider
className=
"my-1!"
/>
<
div
className=
"text-xs font-semibold mb-1"
>
本次主诉
</
div
>
...
...
web/src/pages/doctor/History/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Input
,
Select
,
DatePicker
,
Button
,
Avatar
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
MessageOutlined
,
VideoCameraOutlined
,
EyeOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
dayjs
from
'
dayjs
'
;
const
{
T
itle
,
T
ext
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
interface
ConsultRecord
{
...
...
@@ -19,15 +21,14 @@ interface ConsultRecord {
}
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
completed
:
{
text
:
'
已完成, color:
'
green
'
},
cancelled: { text:
'
已取消
,
color
:
'
red
'
},
in_progress
:
{
text
:
'
进行中, color:
'
blue
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
green
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
red
'
},
in_progress
:
{
text
:
'
进行中
'
,
color
:
'
blue
'
},
};
const
DoctorHistoryPage
:
React
.
FC
=
()
=>
{
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
// TODO: 从API获取真实数据
const
historyData
:
ConsultRecord
[]
=
[
{
id
:
'
1
'
,
...
...
@@ -35,7 +36,7 @@ const DoctorHistoryPage: React.FC = () => {
patient_avatar
:
''
,
type
:
'
text
'
,
status
:
'
completed
'
,
chief_complaint:
'
头痛、乏力
,
chief_complaint
:
'
头痛、乏力
'
,
created_at
:
'
2026-02-24T10:00:00Z
'
,
ended_at
:
'
2026-02-24T10:30:00Z
'
,
},
...
...
@@ -55,7 +56,7 @@ const DoctorHistoryPage: React.FC = () => {
patient_avatar
:
''
,
type
:
'
text
'
,
status
:
'
cancelled
'
,
chief_complaint
:
'
复诊开
始
,
chief_complaint
:
'
复诊开
药
'
,
created_at
:
'
2026-02-23T09:00:00Z
'
,
ended_at
:
null
,
},
...
...
@@ -63,7 +64,7 @@ const DoctorHistoryPage: React.FC = () => {
const
columns
:
ColumnsType
<
ConsultRecord
>
=
[
{
title:
'
患者
,
title
:
'
患者
'
,
dataIndex
:
'
patient_name
'
,
key
:
'
patient_name
'
,
render
:
(
name
:
string
,
record
)
=>
(
...
...
@@ -93,7 +94,7 @@ const DoctorHistoryPage: React.FC = () => {
ellipsis
:
true
,
},
{
title
:
'
状态,
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
(
status
:
string
)
=>
{
...
...
@@ -123,7 +124,7 @@ const DoctorHistoryPage: React.FC = () => {
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder="搜索患者姓名
placeholder=
"搜索患者姓名
"
prefix=
{
<
SearchOutlined
/>
}
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
...
...
@@ -132,8 +133,8 @@ const DoctorHistoryPage: React.FC = () => {
/>
<
Select
placeholder=
"类型"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
options=
{
[{
label
:
'
图文
'
,
value
:
'
text
'
},
{
label
:
'
视频
'
,
value
:
'
video
'
}]
}
/>
<Select placeholder="状态 style={{ width: 100 }} size="small" allowClear
options={[{ label:
'
已完成
,
value
:
'
completed
'
},
{
label
:
'
已取消
, value:
'
cancelled
'
}]} />
<
Select
placeholder=
"状态
"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
options=
{
[{
label
:
'
已完成
'
,
value
:
'
completed
'
},
{
label
:
'
已取消
'
,
value
:
'
cancelled
'
}]
}
/>
<
RangePicker
size=
"small"
/>
</
div
>
</
Card
>
...
...
@@ -144,7 +145,7 @@ const DoctorHistoryPage: React.FC = () => {
dataSource=
{
historyData
}
rowKey=
"id"
size=
"small"
pagination={{ pageSize: 10, size:
'
small
'
, showSizeChanger: true, showTotal: (total) => `
?
${total} 条` }}
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
,
showSizeChanger
:
true
,
showTotal
:
(
total
)
=>
`
共
${total} 条`
}
}
/>
</
Card
>
</
div
>
...
...
web/src/pages/doctor/Income/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Button
,
Tag
,
Typography
,
Space
,
Statistic
,
...
...
@@ -12,7 +14,7 @@ import {
FileTextOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
T
itle
,
T
ext
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
interface
IncomeRecord
{
...
...
@@ -50,7 +52,7 @@ const mockMonthlyBills: MonthlyBill[] = [
const
IncomePage
:
React
.
FC
=
()
=>
{
const
[
withdrawModalVisible
,
setWithdrawModalVisible
]
=
useState
(
false
);
const
totalBalance
=
32500
;
// 可提现余额(分)
const
totalBalance
=
32500
;
const
monthIncome
=
156000
;
const
monthConsults
=
42
;
...
...
@@ -61,7 +63,7 @@ const IncomePage: React.FC = () => {
const
incomeColumns
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
date
'
,
key
:
'
date
'
,
width
:
160
},
{
title
:
'
患者, dataIndex:
'
patient_name
'
, key:
'
patient_name
'
, width: 100 },
{
title
:
'
患者
'
,
dataIndex
:
'
patient_name
'
,
key
:
'
patient_name
'
,
width
:
100
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
...
...
@@ -80,13 +82,13 @@ const IncomePage: React.FC = () => {
render
:
(
v
:
number
)
=>
<
Text
strong
style=
{
{
color
:
'
#52c41a
'
}
}
>
¥
{
(
v
/
100
).
toFixed
(
2
)
}
</
Text
>,
},
{
title:
'
状态
,
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
(
<
Tag
color=
{
v
===
'
settled
'
?
'
green
'
:
'
orange
'
}
>
{
v
===
'
settled
'
?
'
已结算
:
'
待结
}
{
v
===
'
settled
'
?
'
已结算
'
:
'
待结算
'
}
</
Tag
>
),
},
...
...
@@ -99,11 +101,10 @@ const IncomePage: React.FC = () => {
收入统计
</
h4
>
{
/* 概览卡片 */
}
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
8
}
>
<
div
className=
"rounded-lg p-3 text-white"
style=
{
{
background
:
'
linear-gradient(135deg, #52c41a 0%, #389e0d 100%)
'
}
}
>
<
div
className=
"text-xs text-white/80 mb-1"
>
可提现余
</
div
>
<
div
className=
"text-xs text-white/80 mb-1"
>
可提现余
额
</
div
>
<
div
className=
"text-2xl font-bold"
>
¥
{
(
totalBalance
/
100
).
toFixed
(
2
)
}
</
div
>
<
Button
ghost
size=
"small"
className=
"mt-2 text-white! border-white/60!"
icon=
{
<
WalletOutlined
/>
}
onClick=
{
()
=>
setWithdrawModalVisible
(
true
)
}
>
申请提现
</
Button
>
...
...
@@ -114,15 +115,15 @@ const IncomePage: React.FC = () => {
<
div
className=
"text-xs text-gray-500 mb-1"
>
本月收入
</
div
>
<
div
className=
"text-xl font-bold text-green-500"
>
¥
{
(
monthIncome
/
100
).
toFixed
(
2
)
}
</
div
>
<
div
className=
"mt-1 text-[11px] text-gray-400"
>
<
RiseOutlined
className=
"text-green-500 mr-0.5"
/>
较上
<
span
className=
"text-green-500"
>
+12.5%
</
span
>
<
RiseOutlined
className=
"text-green-500 mr-0.5"
/>
较上
月
<
span
className=
"text-green-500"
>
+12.5%
</
span
>
</
div
>
</
div
>
</
Col
>
<
Col
span=
{
8
}
>
<
div
className=
"bg-white rounded-lg p-3 border border-[#e6f0fa]"
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
本月问诊
中
</
div
>
<
div
className=
"text-xl font-bold text-[#1890ff]"
>
{
monthConsults
}
<
span
className=
"text-xs font-normal text-gray-400"
>
?
/span
>
</
div
>
<
div
className=
"mt-1 text-[11px] text-gray-400"
>
日均
{
(
monthConsults
/
25
).
toFixed
(
1
)
}
?
/div
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
本月问诊
量
</
div
>
<
div
className=
"text-xl font-bold text-[#1890ff]"
>
{
monthConsults
}
<
span
className=
"text-xs font-normal text-gray-400"
>
次
<
/
span
></
div
>
<
div
className=
"mt-1 text-[11px] text-gray-400"
>
日均
{
(
monthConsults
/
25
).
toFixed
(
1
)
}
次
<
/
div
>
</
div
>
</
Col
>
</
Row
>
...
...
@@ -165,7 +166,7 @@ const IncomePage: React.FC = () => {
</
Col
>
<
Col
span=
{
5
}
>
<
Statistic
title=
"总收入
title=
"总收入
"
value=
{
bill
.
total_income
/
100
}
precision=
{
2
}
prefix=
"¥"
...
...
@@ -173,7 +174,7 @@ const IncomePage: React.FC = () => {
/>
</
Col
>
<
Col
span=
{
3
}
>
<
Statistic
title=
"问诊量
value=
{
bill
.
consult_count
}
suffix=
"?
valueStyle=
{
{
fontSize
:
16
}
}
/>
<
Statistic
title=
"问诊量
"
value=
{
bill
.
consult_count
}
suffix=
"次"
valueStyle=
{
{
fontSize
:
16
}
}
/>
</
Col
>
<
Col
span=
{
12
}
>
<
div
style=
{
{
marginBottom
:
8
}
}
>
...
...
@@ -220,7 +221,7 @@ const IncomePage: React.FC = () => {
{
title
:
'
申请日期
'
,
dataIndex
:
'
date
'
,
key
:
'
date
'
},
{
title
:
'
金额
'
,
dataIndex
:
'
amount
'
,
key
:
'
amount
'
,
render
:
(
v
:
number
)
=>
`¥${(v / 100).toFixed(2)}`
},
{
title
:
'
提现账户
'
,
dataIndex
:
'
bank
'
,
key
:
'
bank
'
},
{
title
:
'
状态
, dataIndex:
'
status
'
, key:
'
status
'
, render: () => <Tag color="green">已到
</Tag> },
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
()
=>
<
Tag
color=
"green"
>
已到账
</
Tag
>
},
]
}
/>
</
Card
>
...
...
@@ -237,20 +238,20 @@ const IncomePage: React.FC = () => {
okText=
"确认提现"
>
<
Form
layout=
"vertical"
>
<
Form
.
Item
label=
"可提现金>
<
Form
.
Item
label=
"可提现金
额"
>
<
Text
strong
style=
{
{
fontSize
:
24
,
color
:
'
#52c41a
'
}
}
>
¥
{
(
totalBalance
/
100
).
toFixed
(
2
)
}
</
Text
>
</
Form
.
Item
>
<
Form
.
Item
label=
"提现金额"
required
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
placeholder=
"请输入提现金额
placeholder=
"请输入提现金额
"
min=
{
1
}
max=
{
totalBalance
/
100
}
precision=
{
2
}
prefix=
"¥"
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"到账银行>
<
Form
.
Item
label=
"到账银行
卡"
>
<
Input
value=
"招商银行 ****5678"
disabled
/>
</
Form
.
Item
>
</
Form
>
...
...
web/src/pages/doctor/PatientRecord/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Typography
,
Tag
,
Space
,
Tabs
,
...
...
@@ -15,7 +17,7 @@ import {
}
from
'
@ant-design/icons
'
;
import
{
doctorPortalApi
,
type
PatientListItem
,
type
PatientDetail
,
type
PatientConsultRecord
,
type
PatientPrescriptionRecord
}
from
'
../../../api/doctorPortal
'
;
const
{
T
itle
,
T
ext
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
consultTypeLabel
:
Record
<
string
,
string
>
=
{
text
:
'
图文问诊
'
,
...
...
@@ -30,10 +32,10 @@ const consultStatusColor: Record<string, string> = {
};
const
consultStatusLabel
:
Record
<
string
,
string
>
=
{
completed
:
'
已完成,
in_progress:
'
进行中
,
pending
:
'
待接诊,
cancelled:
'
已取消
,
completed
:
'
已完成
'
,
in_progress
:
'
进行中
'
,
pending
:
'
待接诊
'
,
cancelled
:
'
已取消
'
,
};
const
prescriptionStatusColor
:
Record
<
string
,
string
>
=
{
...
...
@@ -46,12 +48,18 @@ const prescriptionStatusColor: Record<string, string> = {
};
const
prescriptionStatusLabel
:
Record
<
string
,
string
>
=
{
completed
:
'
已完成,
dispensed:
'
已取消
,
approved
:
'
已审核,
signed:
'
已签名
,
pending
:
'
待审核,
rejected:
'
已拒绝
,
completed
:
'
已完成
'
,
dispensed
:
'
已发药
'
,
approved
:
'
已审核
'
,
signed
:
'
已签名
'
,
pending
:
'
待审核
'
,
rejected
:
'
已拒绝
'
,
};
const
genderLabel
=
(
g
:
string
)
=>
{
if
(
g
===
'
male
'
)
return
'
男
'
;
if
(
g
===
'
female
'
)
return
'
女
'
;
return
g
;
};
const
PatientRecordPage
:
React
.
FC
=
()
=>
{
...
...
@@ -70,13 +78,12 @@ const PatientRecordPage: React.FC = () => {
setPatients
(
res
.
data
.
list
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
{
message
.
error
(
'
获取患者列表失);
message
.
error
(
'
获取患者列表失
败
'
);
}
finally
{
setListLoading
(
false
);
}
},
[]);
// 初始加载
React
.
useEffect
(()
=>
{
fetchPatients
();
},
[
fetchPatients
]);
...
...
@@ -93,7 +100,7 @@ const PatientRecordPage: React.FC = () => {
const
res
=
await
doctorPortalApi
.
getPatientDetail
(
patientId
);
setSelectedPatient
(
res
.
data
);
}
catch
{
message.error(
'
获取患者详情失
);
message
.
error
(
'
获取患者详情失
败
'
);
}
finally
{
setDetailLoading
(
false
);
}
...
...
@@ -107,7 +114,7 @@ const PatientRecordPage: React.FC = () => {
},
{
title
:
'
主诉
'
,
dataIndex
:
'
chief_complaint
'
,
key
:
'
chief_complaint
'
,
ellipsis
:
true
},
{
title
:
'
状态, dataIndex:
'
status
'
, key:
'
status
'
, width: 80,
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
consultStatusColor
[
v
]
??
'
default
'
}
>
{
consultStatusLabel
[
v
]
??
v
}
</
Tag
>,
},
];
...
...
@@ -126,7 +133,7 @@ const PatientRecordPage: React.FC = () => {
),
},
{
title:
'
状态
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
prescriptionStatusColor
[
v
]
??
'
default
'
}
>
{
prescriptionStatusLabel
[
v
]
??
v
}
</
Tag
>,
},
];
...
...
@@ -134,12 +141,13 @@ const PatientRecordPage: React.FC = () => {
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
UserOutlined
className=
"mr-1"
/>
患者档
</
h4
>
<
UserOutlined
className=
"mr-1"
/>
患者档案
</
h4
>
<
Row
gutter=
{
8
}
>
<
Col
span=
{
8
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
患者列表
</
span
>
}
size=
"small"
extra=
{
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
{
total
}
?
/Text
>
}
>
extra=
{
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
{
total
}
人
<
/
Text
>
}
>
<
Input
prefix=
{
<
SearchOutlined
/>
}
placeholder=
"搜索姓名/手机"
size=
"small"
value=
{
searchKeyword
}
onChange=
{
(
e
)
=>
setSearchKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
(
e
)
=>
handleSearch
((
e
.
target
as
HTMLInputElement
).
value
)
}
...
...
@@ -147,7 +155,7 @@ const PatientRecordPage: React.FC = () => {
className=
"mb-2"
/>
<
Spin
spinning=
{
listLoading
}
>
<
List
size=
"small"
dataSource=
{
patients
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无患者 /> }
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无患者
"
/>
}
}
pagination=
{
total
>
20
?
{
current
:
page
,
pageSize
:
20
,
total
,
size
:
'
small
'
,
onChange
:
(
p
)
=>
{
setPage
(
p
);
fetchPatients
(
searchKeyword
,
p
);
}
}
:
false
}
renderItem=
{
(
patient
)
=>
(
...
...
@@ -159,11 +167,11 @@ const PatientRecordPage: React.FC = () => {
<
List
.
Item
.
Meta
avatar=
{
patient
.
avatar
?
<
Avatar
src=
{
patient
.
avatar
}
size=
{
32
}
/>
:
<
div
className=
"w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white font-bold text-xs"
>
{
patient
.
name
.
charAt
(
0
)
}
</
div
>
}
title=
{
<
span
className=
"text-xs"
><
Text
strong
>
{
patient
.
name
}
</
Text
>
<
Text
type=
"secondary"
>
{
patient
.
gender
===
'
male
'
?
'
? : patient.gender ===
'
female
'
?
'
?
:
patient
.
gender
}
/
{
patient
.
age
}
?
/Text
>
</
span
>
}
title=
{
<
span
className=
"text-xs"
><
Text
strong
>
{
patient
.
name
}
</
Text
>
<
Text
type=
"secondary"
>
{
genderLabel
(
patient
.
gender
)
}
/
{
patient
.
age
}
岁
<
/
Text
></
span
>
}
description=
{
<
div
className=
"text-[11px]"
>
<
div
className=
"text-gray-400"
><
PhoneOutlined
className=
"mr-0.5"
/>
{
patient
.
phone
}
</
div
>
{
patient
.
allergy_history
&&
patient
.
allergy_history
!==
'
?
&& (
{
patient
.
allergy_history
&&
patient
.
allergy_history
!==
'
无
'
&&
(
<
Tag
color=
"red"
className=
"text-[10px]!"
><
SafetyOutlined
/>
{
patient
.
allergy_history
}
</
Tag
>
)
}
<
div
className=
"text-gray-400"
>
{
patient
.
consultation_count
}
次问诊
{
patient
.
last_consult_at
&&
` · ${patient.last_consult_at}`
}
</
div
>
...
...
@@ -184,15 +192,17 @@ const PatientRecordPage: React.FC = () => {
<
Card
size=
"small"
>
<
Descriptions
title=
{
<
span
className=
"text-xs font-semibold"
>
基本信息
</
span
>
}
bordered
size=
"small"
column=
{
3
}
>
<
Descriptions
.
Item
label=
"姓名"
>
{
selectedPatient
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"性别"
>
{
selectedPatient
.
gender
===
'
male
'
?
'
? : selectedPatient.gender ===
'
female
'
?
'
?
:
selectedPatient
.
gender
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"年龄"
>
{
selectedPatient
.
age
}
?
/Descriptions.Item
>
<
Descriptions
.
Item
label=
"性别"
>
{
genderLabel
(
selectedPatient
.
gender
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"年龄"
>
{
selectedPatient
.
age
}
岁
<
/
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"手机"
>
{
selectedPatient
.
phone
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"紧急联>
{
selectedPatient
.
emergency_contact
||
<
Text
type=
"secondary"
>
未填
</
Text
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医保"
>
{
selectedPatient
.
insurance_type
||
<
Text
type=
"secondary"
>
未填
</
Text
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"过敏感 span=
{
3
}
>
{
selectedPatient
.
allergy_history
?
<
Tag
color=
{
selectedPatient
.
allergy_history
===
'
? ?
'
green
'
:
'
red
'
}
>
{
selectedPatient
.
allergy_history
}
</
Tag
>
:
<
Tag
color=
"green"
>
?/Tag
>
}
<
Descriptions
.
Item
label=
"紧急联系人"
>
{
selectedPatient
.
emergency_contact
||
<
Text
type=
"secondary"
>
未填写
</
Text
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医保类型"
>
{
selectedPatient
.
insurance_type
||
<
Text
type=
"secondary"
>
未填写
</
Text
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"过敏史"
span=
{
3
}
>
{
selectedPatient
.
allergy_history
?
<
Tag
color=
{
selectedPatient
.
allergy_history
===
'
无
'
?
'
green
'
:
'
red
'
}
>
{
selectedPatient
.
allergy_history
}
</
Tag
>
:
<
Tag
color=
"green"
>
无
</
Tag
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"
病史"
span=
{
3
}
>
{
selectedPatient
.
medical_history
||
<
Text
type=
"secondary"
>
?
/Text
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"
既往病史"
span=
{
3
}
>
{
selectedPatient
.
medical_history
||
<
Text
type=
"secondary"
>
无
<
/
Text
>
}
</
Descriptions
.
Item
>
</
Descriptions
>
</
Card
>
...
...
@@ -224,7 +234,7 @@ const PatientRecordPage: React.FC = () => {
</
div
>
)
:
(
<
Card
size=
"small"
className=
"text-center py-10"
>
<
Empty
description=
"请从左侧选择患者查看档 />
<
Empty
description=
"请从左侧选择患者查看档
案"
/>
</
Card
>
)
}
</
Spin
>
...
...
web/src/pages/doctor/Prescription/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
,
useCallback
}
from
'
react
'
;
import
{
Input
,
Button
,
Table
,
Form
,
Select
,
InputNumber
,
...
...
web/src/pages/doctor/Profile/index.tsx
View file @
4ae56601
import
React
from
'
react
'
;
import
{
Card
,
Avatar
,
Button
,
Descriptions
,
Tag
,
Typography
,
Row
,
Col
,
Divider
,
Switch
,
Space
}
from
'
antd
'
;
'
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
{
UserOutlined
,
EditOutlined
,
SafetyCertificateOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
import
{
doctorPortalApi
,
type
DoctorProfile
}
from
'
../../../api/doctorPortal
'
;
const
{
T
itle
,
T
ext
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
DoctorProfilePage
:
React
.
FC
=
()
=>
{
const
{
user
}
=
useUserStore
();
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
profile
,
setProfile
]
=
useState
<
DoctorProfile
|
null
>
(
null
);
// TODO: 从API获取医生详细信息
const
doctorProfile
=
{
name
:
user
?.
real_name
||
'
医生
'
,
avatar
:
user
?.
avatar
||
''
,
title
:
'
主任医师
'
,
department_name
:
'
内科
'
,
hospital
:
'
北京协和医院
'
,
license_no
:
'
XXXXX
'
,
introduction
:
'
从事内科临床工作20余年,擅长高血压、糖尿病等慢性病的诊治,
specialties: [
'
高血压
,
'
糖尿,
'
冠心
,
'
心律失常
'
],
price
:
5000
,
is_online
:
false
,
ai_assist_enabled
:
true
,
useEffect
(()
=>
{
const
fetchProfile
=
async
()
=>
{
try
{
const
res
=
await
doctorPortalApi
.
getProfile
();
setProfile
(
res
.
data
);
}
catch
{
// 使用默认数据
setProfile
({
id
:
user
?.
id
||
''
,
name
:
user
?.
real_name
||
'
医生
'
,
avatar
:
user
?.
avatar
||
''
,
title
:
'
主任医师
'
,
department_id
:
''
,
department_name
:
'
内科
'
,
hospital
:
'
北京协和医院
'
,
introduction
:
'
从事内科临床工作20余年,擅长高血压、糖尿病等慢性病的诊治。
'
,
specialties
:
[
'
高血压
'
,
'
糖尿病
'
,
'
冠心病
'
,
'
心律失常
'
],
price
:
5000
,
is_online
:
false
,
ai_assist_enabled
:
true
,
});
}
finally
{
setLoading
(
false
);
}
};
fetchProfile
();
},
[
user
]);
const
handleToggleAI
=
async
(
checked
:
boolean
)
=>
{
if
(
!
profile
)
return
;
try
{
await
doctorPortalApi
.
updateProfile
({
ai_assist_enabled
:
checked
});
setProfile
({
...
profile
,
ai_assist_enabled
:
checked
});
message
.
success
(
checked
?
'
AI辅助已开启
'
:
'
AI辅助已关闭
'
);
}
catch
{
message
.
error
(
'
操作失败
'
);
}
};
if
(
loading
)
{
return
<
div
className=
"text-center py-10"
><
Spin
size=
"large"
/></
div
>;
}
if
(
!
profile
)
return
null
;
return
(
<
div
className=
"max-w-[800px] mx-auto space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
个人信息
</
h4
>
...
...
@@ -34,17 +69,16 @@ const DoctorProfilePage: React.FC = () => {
<
Card
size=
"small"
>
<
Row
gutter=
{
12
}
align=
"middle"
>
<
Col
>
<
Avatar
size=
{
64
}
src=
{
doctorP
rofile
.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
<
Avatar
size=
{
64
}
src=
{
p
rofile
.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
</
Col
>
<
Col
flex=
"auto"
>
<
div
className=
"flex items-center gap-2 mb-0.5"
>
<
span
className=
"text-base font-bold"
>
{
doctorP
rofile
.
name
}
</
span
>
<
Tag
color=
"blue"
>
{
doctorP
rofile
.
title
}
</
Tag
>
<
span
className=
"text-base font-bold"
>
{
p
rofile
.
name
}
</
span
>
<
Tag
color=
"blue"
>
{
p
rofile
.
title
}
</
Tag
>
</
div
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
{
doctorProfile
.
hospital
}
·
{
doctorP
rofile
.
department_name
}
</
div
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
{
profile
.
hospital
}
·
{
p
rofile
.
department_name
}
</
div
>
<
Space
size=
{
4
}
>
<
Tag
icon=
{
<
SafetyCertificateOutlined
/>
}
color=
"success"
>
已认证
</
Tag
>
<
Tag
>
证号:
{
doctorProfile
.
license_no
}
</
Tag
>
</
Space
>
</
Col
>
<
Col
>
...
...
@@ -55,25 +89,24 @@ const DoctorProfilePage: React.FC = () => {
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
详细信息
</
span
>
}
size=
"small"
>
<
Descriptions
column=
{
2
}
size=
"small"
>
<
Descriptions
.
Item
label=
"姓名"
>
{
doctorProfile
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
doctorProfile
.
title
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医院"
>
{
doctorProfile
.
hospital
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"科室"
>
{
doctorProfile
.
department_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"价格"
>
¥
{
(
doctorProfile
.
price
/
100
).
toFixed
(
0
)
}
/?/Descriptions.Item
>
<
Descriptions
.
Item
label=
"证号"
>
{
doctorProfile
.
license_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"姓名"
>
{
profile
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
profile
.
title
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医院"
>
{
profile
.
hospital
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"科室"
>
{
profile
.
department_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"价格"
>
¥
{
(
profile
.
price
/
100
).
toFixed
(
0
)
}
/次
</
Descriptions
.
Item
>
</
Descriptions
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
擅长领域
</
span
>
}
size=
"small"
>
<
Space
wrap
size=
{
4
}
>
{
doctorP
rofile
.
specialties
.
map
((
s
,
i
)
=>
(
{
p
rofile
.
specialties
.
map
((
s
,
i
)
=>
(
<
Tag
key=
{
i
}
color=
"processing"
>
{
s
}
</
Tag
>
))
}
</
Space
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
个人简
</
span
>
}
size=
"small"
>
<
Text
className=
"text-xs!"
>
{
doctorP
rofile
.
introduction
}
</
Text
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
个人简
介
</
span
>
}
size=
"small"
>
<
Text
className=
"text-xs!"
>
{
p
rofile
.
introduction
}
</
Text
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
接诊设置
</
span
>
}
size=
"small"
>
...
...
@@ -81,9 +114,9 @@ const DoctorProfilePage: React.FC = () => {
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
Text
strong
className=
"text-xs!"
>
AI 辅助接诊
</
Text
>
<
div
className=
"text-[11px] text-gray-400"
>
开启后 AI 将协助分析患者症
</
div
>
<
div
className=
"text-[11px] text-gray-400"
>
开启后 AI 将协助分析患者症
状
</
div
>
</
div
>
<
Switch
size=
"small"
checked=
{
doctorProfile
.
ai_assist_enabled
}
/>
<
Switch
size=
"small"
checked=
{
profile
.
ai_assist_enabled
}
onChange=
{
handleToggleAI
}
/>
</
div
>
<
Divider
className=
"my-0!"
/>
<
div
className=
"flex items-center justify-between"
>
...
...
web/src/pages/doctor/Queue/index.tsx
View file @
4ae56601
'
use client
'
;
'
use client
'
;
import
React
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
Card
,
List
,
Avatar
,
Tag
,
Button
,
Typography
,
Space
,
Empty
,
Badge
}
from
'
antd
'
;
...
...
@@ -10,19 +10,18 @@ import {
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Text
,
Title
}
=
Typography
;
const
{
Text
}
=
Typography
;
const
DoctorQueuePage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
// TODO: 从API获取真实数据
const
waitingPatients
=
[
{
id
:
'
1
'
,
consult_id
:
'
c1
'
,
patient_name
:
'
张三
'
,
patient_avatar
:
''
,
chief_complaint
:
'
头痛、乏力,持续3天。既往有高血压病史
记
,
chief_complaint
:
'
头痛、乏力,持续3天。既往有高血压病史
。
'
,
type
:
'
text
'
as
const
,
waiting_time
:
300
,
created_at
:
'
2026-02-25T13:00:00Z
'
,
...
...
@@ -32,7 +31,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id
:
'
c2
'
,
patient_name
:
'
李四
'
,
patient_avatar
:
''
,
chief_complaint:
'
咳嗽有痰,偶尔发热,已持续一周
期
,
chief_complaint
:
'
咳嗽有痰,偶尔发热,已持续一周
。
'
,
type
:
'
video
'
as
const
,
waiting_time
:
180
,
created_at
:
'
2026-02-25T13:05:00Z
'
,
...
...
@@ -42,7 +41,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id
:
'
c3
'
,
patient_name
:
'
王五
'
,
patient_avatar
:
''
,
chief_complaint
:
'
复诊开药,糖尿病用药续方
案
,
chief_complaint
:
'
复诊开药,糖尿病用药续方
。
'
,
type
:
'
text
'
as
const
,
waiting_time
:
60
,
created_at
:
'
2026-02-25T13:10:00Z
'
,
...
...
@@ -52,7 +51,7 @@ const DoctorQueuePage: React.FC = () => {
consult_id
:
'
c4
'
,
patient_name
:
'
赵六
'
,
patient_avatar
:
''
,
chief_complaint:
'
皮肤瘙痒红肿,疑似过敏
感
,
chief_complaint
:
'
皮肤瘙痒红肿,疑似过敏
。
'
,
type
:
'
video
'
as
const
,
waiting_time
:
30
,
created_at
:
'
2026-02-25T13:12:00Z
'
,
...
...
@@ -66,7 +65,6 @@ const DoctorQueuePage: React.FC = () => {
};
const
handleAccept
=
(
_consultId
:
string
)
=>
{
// TODO: 调用接诊API
router
.
push
(
'
/doctor/consult-room
'
);
};
...
...
@@ -79,7 +77,7 @@ const DoctorQueuePage: React.FC = () => {
<
Card
size=
"small"
>
{
waitingPatients
.
length
===
0
?
(
<
Empty
description=
"暂无等待患者 />
<
Empty
description=
"暂无等待患者
"
/>
)
:
(
<
List
itemLayout=
"horizontal"
...
...
@@ -122,4 +120,3 @@ const DoctorQueuePage: React.FC = () => {
};
export
default
DoctorQueuePage
;
web/src/pages/doctor/Schedule/index.tsx
View file @
4ae56601
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Calendar
,
Button
,
Modal
,
Form
,
TimePicker
,
InputNumber
,
Tag
,
Typography
,
Space
,
Row
,
Col
,
Badge
,
List
}
from
'
antd
'
;
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Calendar
,
Button
,
Modal
,
Form
,
TimePicker
,
InputNumber
,
Tag
,
Typography
,
Space
,
Row
,
Col
,
Badge
,
List
,
message
,
Spin
}
from
'
antd
'
;
import
{
PlusOutlined
,
DeleteOutlined
,
CalendarOutlined
}
from
'
@ant-design/icons
'
;
import
dayjs
,
{
Dayjs
}
from
'
dayjs
'
;
import
{
doctorPortalApi
,
type
ScheduleSlot
}
from
'
../../../api/doctorPortal
'
;
const
{
Title
,
Text
}
=
Typography
;
interface
ScheduleSlot
{
id
:
string
;
date
:
string
;
start_time
:
string
;
end_time
:
string
;
max_count
:
number
;
remaining
:
number
;
}
const
{
Text
}
=
Typography
;
const
DoctorSchedulePage
:
React
.
FC
=
()
=>
{
const
[
selectedDate
,
setSelectedDate
]
=
useState
<
Dayjs
>
(
dayjs
());
const
[
isModalOpen
,
setIsModalOpen
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
scheduleData
,
setScheduleData
]
=
useState
<
Record
<
string
,
ScheduleSlot
[]
>>
({});
const
[
form
]
=
Form
.
useForm
();
// TODO: 从API获取真实数据
const
scheduleData
:
Record
<
string
,
ScheduleSlot
[]
>
=
{
'
2026-02-25
'
:
[
{
id
:
'
1
'
,
date
:
'
2026-02-25
'
,
start_time
:
'
09:00
'
,
end_time
:
'
12:00
'
,
max_count
:
10
,
remaining
:
5
},
{
id
:
'
2
'
,
date
:
'
2026-02-25
'
,
start_time
:
'
14:00
'
,
end_time
:
'
17:00
'
,
max_count
:
8
,
remaining
:
3
},
],
'
2026-02-26
'
:
[
{
id
:
'
3
'
,
date
:
'
2026-02-26
'
,
start_time
:
'
09:00
'
,
end_time
:
'
12:00
'
,
max_count
:
10
,
remaining
:
10
},
],
'
2026-02-27
'
:
[
{
id
:
'
4
'
,
date
:
'
2026-02-27
'
,
start_time
:
'
14:00
'
,
end_time
:
'
17:00
'
,
max_count
:
6
,
remaining
:
6
},
],
const
fetchSchedule
=
async
()
=>
{
setLoading
(
true
);
try
{
const
start
=
selectedDate
.
startOf
(
'
month
'
).
format
(
'
YYYY-MM-DD
'
);
const
end
=
selectedDate
.
endOf
(
'
month
'
).
format
(
'
YYYY-MM-DD
'
);
const
res
=
await
doctorPortalApi
.
getMySchedule
(
start
,
end
);
const
grouped
:
Record
<
string
,
ScheduleSlot
[]
>
=
{};
(
res
.
data
||
[]).
forEach
((
slot
)
=>
{
if
(
!
grouped
[
slot
.
date
])
grouped
[
slot
.
date
]
=
[];
grouped
[
slot
.
date
].
push
(
slot
);
});
setScheduleData
(
grouped
);
}
catch
{
// 使用默认空数据
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchSchedule
();
},
[
selectedDate
.
format
(
'
YYYY-MM
'
)]);
const
dateKey
=
selectedDate
.
format
(
'
YYYY-MM-DD
'
);
const
todaySchedule
=
scheduleData
[
dateKey
]
||
[];
...
...
@@ -43,7 +48,7 @@ const DoctorSchedulePage: React.FC = () => {
return
(
<
Badge
status=
"success"
text=
{
`${slots.length}
涓椂娈礰
}
text=
{
`${slots.length}
个时段`
}
style=
{
{
fontSize
:
10
}
}
/>
);
...
...
@@ -57,23 +62,33 @@ const DoctorSchedulePage: React.FC = () => {
};
const
handleSaveSchedule
=
async
(
values
:
any
)
=>
{
// TODO: 调用API保存排班
console
.
log
(
'
保存排班:
'
,
{
date
:
selectedDate
.
format
(
'
YYYY-MM-DD
'
),
start_time
:
values
.
time_range
[
0
].
format
(
'
HH:mm
'
),
end_time
:
values
.
time_range
[
1
].
format
(
'
HH:mm
'
),
max_count
:
values
.
max_count
,
});
setIsModalOpen
(
false
);
try
{
await
doctorPortalApi
.
createSchedule
([{
date
:
selectedDate
.
format
(
'
YYYY-MM-DD
'
),
start_time
:
values
.
time_range
[
0
].
format
(
'
HH:mm
'
),
end_time
:
values
.
time_range
[
1
].
format
(
'
HH:mm
'
),
max_count
:
values
.
max_count
,
}]);
message
.
success
(
'
排班添加成功
'
);
setIsModalOpen
(
false
);
fetchSchedule
();
}
catch
{
message
.
error
(
'
添加排班失败
'
);
}
};
const
handleDeleteSlot
=
(
slotId
:
string
)
=>
{
// TODO: 调用API删除排班
Modal
.
confirm
({
title
:
'
确删除
'
,
content
:
'
确畾瑕佸垹闄ゆ排班无舵鍚楋紵
'
,
onOk
:
()
=>
{
console
.
log
(
'
删除排班:
'
,
slotId
);
title
:
'
确认删除
'
,
content
:
'
确定要删除该排班吗?
'
,
onOk
:
async
()
=>
{
try
{
await
doctorPortalApi
.
deleteSchedule
(
slotId
);
message
.
success
(
'
删除成功
'
);
fetchSchedule
();
}
catch
{
message
.
error
(
'
删除失败
'
);
}
},
});
};
...
...
@@ -85,56 +100,58 @@ const DoctorSchedulePage: React.FC = () => {
<
Button
size=
"small"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddSchedule
}
>
添加排班
</
Button
>
</
div
>
<
Row
gutter=
{
12
}
>
<
Col
span=
{
16
}
>
<
Card
size=
"small"
>
<
Calendar
fullscreen=
{
false
}
value=
{
selectedDate
}
onSelect=
{
setSelectedDate
}
cellRender=
{
(
current
)
=>
dateCellRender
(
current
as
Dayjs
)
}
/>
</
Card
>
</
Col
>
<
Spin
spinning=
{
loading
}
>
<
Row
gutter=
{
12
}
>
<
Col
span=
{
16
}
>
<
Card
size=
"small"
>
<
Calendar
fullscreen=
{
false
}
value=
{
selectedDate
}
onSelect=
{
setSelectedDate
}
cellRender=
{
(
current
)
=>
dateCellRender
(
current
as
Dayjs
)
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
8
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
><
CalendarOutlined
className=
"mr-1"
/>
{
selectedDate
.
format
(
'
MM月DD日)
}
排班
</
span
>
}
extra=
{
<
Button
type=
"link"
size=
"small"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddSchedule
}
>
添加
</
Button
>
}
size=
"small"
>
{
todaySchedule
.
length
===
0
?
(
<
div
className=
"text-center py-6"
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
当日暂无排班
</
Text
>
<
br
/>
<
Button
type=
"link"
size=
"small"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddSchedule
}
className=
"mt-1"
>
添加排班
</
Button
>
</
div
>
)
:
(
<
List
size=
"small"
dataSource=
{
todaySchedule
}
renderItem=
{
(
slot
)
=>
(
<
List
.
Item
actions=
{
[
<
Button
type=
"text"
danger
size=
"small"
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDeleteSlot
(
slot
.
id
)
}
/>,
]
}
>
<
div
>
<
div
className=
"mb-1"
>
<
Text
strong
className=
"text-xs!"
>
{
slot
.
start_time
}
-
{
slot
.
end_time
}
</
Text
>
<
Col
span=
{
8
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
><
CalendarOutlined
className=
"mr-1"
/>
{
selectedDate
.
format
(
'
MM月DD日
'
)
}
排班
</
span
>
}
extra=
{
<
Button
type=
"link"
size=
"small"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddSchedule
}
>
添加
</
Button
>
}
size=
"small"
>
{
todaySchedule
.
length
===
0
?
(
<
div
className=
"text-center py-6"
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
当日暂无排班
</
Text
>
<
br
/>
<
Button
type=
"link"
size=
"small"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddSchedule
}
className=
"mt-1"
>
添加排班
</
Button
>
</
div
>
)
:
(
<
List
size=
"small"
dataSource=
{
todaySchedule
}
renderItem=
{
(
slot
)
=>
(
<
List
.
Item
actions=
{
[
<
Button
type=
"text"
danger
size=
"small"
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDeleteSlot
(
slot
.
id
!
)
}
/>,
]
}
>
<
div
>
<
div
className=
"mb-1"
>
<
Text
strong
className=
"text-xs!"
>
{
slot
.
start_time
}
-
{
slot
.
end_time
}
</
Text
>
</
div
>
<
Space
size=
{
4
}
>
<
Tag
color=
"blue"
>
最大
{
slot
.
max_count
}
人
</
Tag
>
<
Tag
color=
{
(
slot
.
remaining
??
slot
.
max_count
)
>
0
?
'
green
'
:
'
red
'
}
>
剩余
{
slot
.
remaining
??
slot
.
max_count
}
号
</
Tag
>
</
Space
>
</
div
>
<
Space
size=
{
4
}
>
<
Tag
color=
"blue"
>
最大
{
slot
.
max_count
}
人
</
Tag
>
<
Tag
color=
{
slot
.
remaining
>
0
?
'
green
'
:
'
red
'
}
>
剩余
{
slot
.
remaining
}
号
</
Tag
>
</
Space
>
</
div
>
</
List
.
Item
>
)
}
/>
)
}
</
Card
>
</
Col
>
</
Row
>
</
List
.
Item
>
)
}
/>
)
}
</
Card
>
</
Col
>
</
Row
>
</
Spin
>
<
Modal
title=
{
`添加排班 - ${selectedDate.format('YYYY-MM-DD')}`
}
...
...
@@ -144,7 +161,7 @@ const DoctorSchedulePage: React.FC = () => {
width=
{
400
}
>
<
Form
form=
{
form
}
onFinish=
{
handleSaveSchedule
}
layout=
"vertical"
size=
"small"
>
<
Form
.
Item
name=
"time_range"
label=
"时间段
rules={[{ required: true, message: '请选择时间段
}]}>
<
Form
.
Item
name=
"time_range"
label=
"时间段
"
rules=
{
[{
required
:
true
,
message
:
'
请选择时间段
'
}]
}
>
<
TimePicker
.
RangePicker
format=
"HH:mm"
minuteStep=
{
30
}
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"max_count"
label=
"最大接诊数"
rules=
{
[{
required
:
true
,
message
:
'
请输入最大接诊数
'
}]
}
initialValue=
{
10
}
>
...
...
web/src/pages/patient/Chronic/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Tabs
,
Button
,
Table
,
Tag
,
Space
,
Modal
,
Form
,
Input
,
Select
,
...
...
@@ -24,11 +26,11 @@ const controlStatusMap: Record<string, { color: string; label: string }> = {
};
const
metricTypeMap
:
Record
<
string
,
{
label
:
string
;
unit
:
string
;
hasValue2
:
boolean
;
v1Label
:
string
;
v2Label
?:
string
}
>
=
{
blood_pressure
:
{
label
:
'
血压
, unit:
'
mmHg
'
, hasValue2: true, v1Label:
'
收缩压
,
v2Label
:
'
舒张压
},
blood_sugar: { label:
'
血
压
,
unit
:
'
mmol/L
'
,
hasValue2
:
false
,
v1Label
:
'
血糖化
},
blood_pressure
:
{
label
:
'
血压
'
,
unit
:
'
mmHg
'
,
hasValue2
:
true
,
v1Label
:
'
收缩压
'
,
v2Label
:
'
舒张压
'
},
blood_sugar
:
{
label
:
'
血
糖
'
,
unit
:
'
mmol/L
'
,
hasValue2
:
false
,
v1Label
:
'
血糖值
'
},
weight
:
{
label
:
'
体重
'
,
unit
:
'
kg
'
,
hasValue2
:
false
,
v1Label
:
'
体重
'
},
heart_rate
:
{
label
:
'
心率
'
,
unit
:
'
bpm
'
,
hasValue2
:
false
,
v1Label
:
'
心率
'
},
temperature: { label:
'
体温
'
, unit:
'
?
,
hasValue2
:
false
,
v1Label
:
'
体温
'
},
temperature
:
{
label
:
'
体温
'
,
unit
:
'
℃
'
,
hasValue2
:
false
,
v1Label
:
'
体温
'
},
};
// ========== 慢病档案 Tab ==========
...
...
@@ -82,7 +84,7 @@ const ChronicRecordsTab: React.FC = () => {
{
title
:
'
确诊医院
'
,
dataIndex
:
'
hospital
'
,
key
:
'
hospital
'
,
ellipsis
:
true
},
{
title
:
'
主治医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
width
:
100
},
{
title
:
'
控制状态, dataIndex:
'
control_status
'
, key:
'
control_status
'
, width: 100,
title
:
'
控制状态
'
,
dataIndex
:
'
control_status
'
,
key
:
'
control_status
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
{
const
s
=
controlStatusMap
[
v
]
||
{
color
:
'
default
'
,
label
:
v
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
label
}
</
Tag
>;
...
...
@@ -97,7 +99,7 @@ const ChronicRecordsTab: React.FC = () => {
render
:
(
_
:
any
,
r
:
ChronicRecord
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
r
)
}
/>
<Popconfirm title="确认删除
外
onConfirm={() => deleteMutation.mutate(r.id)}>
<
Popconfirm
title=
"确认删除
?"
onConfirm=
{
()
=>
deleteMutation
.
mutate
(
r
.
id
)
}
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
Space
>
...
...
@@ -129,7 +131,7 @@ const ChronicRecordsTab: React.FC = () => {
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<Form.Item name="control_status" label="控制状态 initialValue="stable">
<
Form
.
Item
name=
"control_status"
label=
"控制状态
"
initialValue=
"stable"
>
<
Select
options=
{
Object
.
entries
(
controlStatusMap
).
map
(([
v
,
s
])
=>
({
value
:
v
,
label
:
s
.
label
}))
}
/>
</
Form
.
Item
>
</
Col
>
...
...
@@ -147,7 +149,7 @@ const ChronicRecordsTab: React.FC = () => {
</
Col
>
</
Row
>
<
Form
.
Item
name=
"current_meds"
label=
"当前用药"
>
<TextArea rows={2} placeholder="当前服用的药物 />
<
TextArea
rows=
{
2
}
placeholder=
"当前服用的药物
"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"notes"
label=
"备注"
>
<
TextArea
rows=
{
2
}
/>
...
...
@@ -175,7 +177,7 @@ const RenewalsTab: React.FC = () => {
medicines
:
values
.
medicines
?.
split
(
'
\n
'
).
filter
(
Boolean
)
||
[],
}),
onSuccess
:
()
=>
{
message.success(
'
续方申请已提
);
message
.
success
(
'
续方申请已提
交
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
renewals
'
]
});
setModalOpen
(
false
);
form
.
resetFields
();
...
...
@@ -196,9 +198,9 @@ const RenewalsTab: React.FC = () => {
};
const
statusMap
:
Record
<
string
,
{
color
:
string
;
label
:
string
}
>
=
{
pending
:
{
color
:
'
orange
'
,
label
:
'
待审核 },
pending
:
{
color
:
'
orange
'
,
label
:
'
待审核
'
},
approved
:
{
color
:
'
green
'
,
label
:
'
已通过
'
},
rejected: { color:
'
red
'
, label:
'
已拒绝
},
rejected
:
{
color
:
'
red
'
,
label
:
'
已拒绝
'
},
};
const
columns
=
[
...
...
@@ -212,7 +214,7 @@ const RenewalsTab: React.FC = () => {
},
{
title
:
'
申请原因
'
,
dataIndex
:
'
reason
'
,
key
:
'
reason
'
,
ellipsis
:
true
},
{
title
:
'
状态, dataIndex:
'
status
'
, key:
'
status
'
, width: 90,
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
90
,
render
:
(
v
:
string
)
=>
{
const
s
=
statusMap
[
v
]
||
{
color
:
'
default
'
,
label
:
v
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
label
}
</
Tag
>;
},
},
{
...
...
@@ -254,13 +256,13 @@ const RenewalsTab: React.FC = () => {
options=
{
(
records
?.
data
||
[]).
map
(
r
=>
({
value
:
r
.
id
,
label
:
r
.
disease_name
}))
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"disease_name"
label=
"疾病名称"
rules=
{
[{
required
:
true
}]
}
>
<Input placeholder="如:高血压 />
<
Input
placeholder=
"如:高血压
"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"medicines"
label=
"申请药品(每行一种)"
rules=
{
[{
required
:
true
}]
}
>
<TextArea rows={3} placeholder=
"苯磺酸氨氯地平片 厄贝沙坦类
/>
<
TextArea
rows=
{
3
}
placeholder=
{
'
苯磺酸氨氯地平片
\n
厄贝沙坦片
'
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"reason"
label=
"续方原因"
rules=
{
[{
required
:
true
}]
}
>
<TextArea rows={2} placeholder="请说明续方原
件
/>
<
TextArea
rows=
{
2
}
placeholder=
"请说明续方原
因"
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
...
...
@@ -323,7 +325,7 @@ const RemindersTab: React.FC = () => {
render
:
(
v
:
string
)
=>
{
try
{
return
(
JSON
.
parse
(
v
)
as
string
[]).
map
((
t
,
i
)
=>
<
Tag
key=
{
i
}
>
{
t
}
</
Tag
>);
}
catch
{
return
v
;
}
},
},
{
title:
'
状态
,
dataIndex
:
'
is_active
'
,
key
:
'
is_active
'
,
width
:
80
,
title
:
'
状态
'
,
dataIndex
:
'
is_active
'
,
key
:
'
is_active
'
,
width
:
80
,
render
:
(
v
:
boolean
,
r
:
MedicationReminder
)
=>
(
<
Switch
checked=
{
v
}
size=
"small"
onChange=
{
()
=>
toggleMutation
.
mutate
(
r
.
id
)
}
/>
),
...
...
@@ -333,7 +335,7 @@ const RemindersTab: React.FC = () => {
render
:
(
_
:
any
,
r
:
MedicationReminder
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
r
)
}
/>
<
Popconfirm
title=
"确认删除
外
onConfirm={() => deleteMutation.mutate(r.id)}>
<
Popconfirm
title=
"确认删除
?"
onConfirm=
{
()
=>
deleteMutation
.
mutate
(
r
.
id
)
}
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
Space
>
...
...
@@ -360,17 +362,17 @@ const RemindersTab: React.FC = () => {
<
Form
.
Item
name=
"medicine_name"
label=
"药品名称"
rules=
{
[{
required
:
true
}]
}
><
Input
/></
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
name=
"dosage"
label=
"剂量"
rules=
{
[{
required
:
true
}]
}
><
Input
placeholder=
"如:1
?
/></Form.Item>
<
Form
.
Item
name=
"dosage"
label=
"剂量"
rules=
{
[{
required
:
true
}]
}
><
Input
placeholder=
"如:1
片"
/></
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
name=
"frequency"
label=
"服药频次"
rules=
{
[{
required
:
true
}]
}
>
<
Select
options=
{
[
'
每日一次
,
'
每日两次
'
,
'
每日三次
'
,
'
每周一次
,
'
按需服用
'
].
map
(
v
=>
({
value
:
v
,
label
:
v
}))
}
/>
<
Select
options=
{
[
'
每日一次
'
,
'
每日两次
'
,
'
每日三次
'
,
'
每周一次
'
,
'
按需服用
'
].
map
(
v
=>
({
value
:
v
,
label
:
v
}))
}
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"remind_times"
label=
"提醒时间"
rules=
{
[{
required
:
true
}]
}
>
<
TimePicker
.
RangePicker
format=
"HH:mm"
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
><
Form
.
Item
name=
"start_date"
label=
"开始日><DatePicker style={{ width: '100%' }} /></Form.Item></Col>
<
Col
span=
{
12
}
><
Form
.
Item
name=
"start_date"
label=
"开始日
期"
><
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/></
Form
.
Item
></
Col
>
<
Col
span=
{
12
}
><
Form
.
Item
name=
"end_date"
label=
"结束日期"
><
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/></
Form
.
Item
></
Col
>
</
Row
>
<
Form
.
Item
name=
"notes"
label=
"备注"
><
Input
/></
Form
.
Item
>
...
...
@@ -420,7 +422,7 @@ const MetricsTab: React.FC = () => {
const
columns
=
[
{
title
:
'
数
量
, key:
'
value
'
,
title
:
'
数
值
'
,
key
:
'
value
'
,
render
:
(
_
:
any
,
r
:
HealthMetric
)
=>
(
<
Text
strong
style=
{
{
color
:
'
#1890ff
'
}
}
>
{
meta
.
hasValue2
?
`${r.value1}/${r.value2}`
:
r
.
value1
}
{
r
.
unit
}
...
...
@@ -435,15 +437,14 @@ const MetricsTab: React.FC = () => {
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
any
,
r
:
HealthMetric
)
=>
(
<Popconfirm title="确认删除
外
onConfirm={() => deleteMutation.mutate(r.id)}>
<
Popconfirm
title=
"确认删除
?"
onConfirm=
{
()
=>
deleteMutation
.
mutate
(
r
.
id
)
}
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
),
},
];
//
const latest = list[0];
const
latest
=
list
[
0
];
return
(
<>
...
...
@@ -459,18 +460,18 @@ const MetricsTab: React.FC = () => {
<
Row
gutter=
{
16
}
style=
{
{
marginBottom
:
16
}
}
>
<
Col
span=
{
8
}
>
<
Card
size=
"small"
style=
{
{
borderRadius
:
8
,
background
:
'
#f0f5ff
'
}
}
>
<Statistic title="最新
增
value={meta.hasValue2 ? `${latest.value1}/${latest.value2}` : latest.value1}
<
Statistic
title=
"最新
值"
value=
{
meta
.
hasValue2
?
`${latest.value1}/${latest.value2}`
:
latest
.
value1
}
suffix=
{
meta
.
unit
}
valueStyle=
{
{
color
:
'
#1890ff
'
}
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
8
}
>
<
Card
size=
"small"
style=
{
{
borderRadius
:
8
,
background
:
'
#f6ffed
'
}
}
>
<Statistic title="记录次数" value={list.length} suffix="
?
valueStyle={{ color:
'
#
52
c41a
'
}} />
<
Statistic
title=
"记录次数"
value=
{
list
.
length
}
suffix=
"
次"
valueStyle=
{
{
color
:
'
#52c41a
'
}
}
/>
</
Card
>
</
Col
>
<
Col
span=
{
8
}
>
<
Card
size=
"small"
style=
{
{
borderRadius
:
8
,
background
:
'
#fff7e6
'
}
}
>
<Statistic title="最近记录 value={dayjs(latest.recorded_at).format(
'
MM
-
DD
'
)}
<
Statistic
title=
"最近记录
"
value=
{
dayjs
(
latest
.
recorded_at
).
format
(
'
MM-DD
'
)
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
/>
</
Card
>
</
Col
>
...
...
@@ -508,7 +509,7 @@ const MetricsTab: React.FC = () => {
);
};
// ========== 主页
?
==========
// ========== 主页
面
==========
const
PatientChronicPage
:
React
.
FC
=
()
=>
{
const
tabItems
=
[
{
key
:
'
records
'
,
label
:
<><
HeartOutlined
/>
慢病档案
</>,
children
:
<
ChronicRecordsTab
/>
},
...
...
web/src/pages/patient/ConsultCreate/index.tsx
View file @
4ae56601
...
...
@@ -32,26 +32,23 @@ const ConsultCreatePage: React.FC = () => {
const
[
preConsult
,
setPreConsult
]
=
useState
<
PreConsultResponse
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
doctorId
=
searchParams
.
get
(
'
doctor_id
'
)
||
''
;
const
consultType
=
(
searchParams
.
get
(
'
type
'
)
||
'
text
'
)
as
'
text
'
|
'
video
'
;
const
preConsultId
=
searchParams
.
get
(
'
pre_consult_id
'
)
||
''
;
const
chiefComplaintParam
=
searchParams
.
get
(
'
chief_complaint
'
)
||
''
;
const
doctorId
=
searchParams
?
.
get
(
'
doctor_id
'
)
||
''
;
const
consultType
=
(
searchParams
?
.
get
(
'
type
'
)
||
'
text
'
)
as
'
text
'
|
'
video
'
;
const
preConsultId
=
searchParams
?
.
get
(
'
pre_consult_id
'
)
||
''
;
const
chiefComplaintParam
=
searchParams
?
.
get
(
'
chief_complaint
'
)
||
''
;
useEffect
(()
=>
{
const
fetchData
=
async
()
=>
{
setLoading
(
true
);
try
{
// 获取医生信息
if
(
doctorId
)
{
const
res
=
await
doctorApi
.
getDoctorDetail
(
doctorId
);
setDoctor
(
res
.
data
);
}
// 如果有预问诊ID,获取预问诊详情
if
(
preConsultId
)
{
const
res
=
await
preConsultApi
.
getDetail
(
preConsultId
);
setPreConsult
(
res
.
data
);
//
form
.
setFieldsValue
({
chief_complaint
:
res
.
data
.
chief_complaint
||
chiefComplaintParam
,
});
...
...
@@ -80,17 +77,16 @@ const ConsultCreatePage: React.FC = () => {
pre_consult_id
:
preConsultId
||
undefined
,
});
message
.
success
(
'
问诊创建成功!);
message
.
success
(
'
问诊创建成功!
'
);
//
if (consultType ===
'
video
'
) {
if
(
consultType
===
'
video
'
)
{
router
.
push
(
`/patient/consult/video/
${
res
.
data
.
id
}
`
);
}
else
{
router
.
push
(
`/patient/consult/text/
${
res
.
data
.
id
}
`
);
}
}
catch
(
error
:
any
)
{
if
(
error
?.
errorFields
)
return
;
message.error(
'
创建问诊失败
:
'
+ (error?.response?.data?.message || error?.message ||
'
请稍后重试
));
message
.
error
(
'
创建问诊失败:
'
+
(
error
?.
response
?.
data
?.
message
||
error
?.
message
||
'
请稍后重试
'
));
}
finally
{
setSubmitting
(
false
);
}
...
...
@@ -99,7 +95,7 @@ const ConsultCreatePage: React.FC = () => {
if
(
loading
)
{
return
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
80
}
}
>
<
Spin
size=
"large"
tip=
"加载中.."
/>
<
Spin
size=
"large"
tip=
"加载中..
.
"
/>
</
div
>
);
}
...
...
@@ -127,7 +123,7 @@ const ConsultCreatePage: React.FC = () => {
<
Text
type=
"secondary"
className=
"text-xs!"
>
{
doctor
.
hospital
}
</
Text
>
<
div
className=
"mt-0.5"
>
<
Tag
color=
"orange"
>
¥
{
(
doctor
.
price
/
100
).
toFixed
(
0
)
}
/次
</
Tag
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
评分
{
doctor
.
rating
}
·
{
doctor
.
consult_count
}
?
/Text
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
评分
{
doctor
.
rating
}
·
{
doctor
.
consult_count
}
次问诊
<
/
Text
>
</
div
>
</
div
>
</
Space
>
...
...
@@ -154,8 +150,8 @@ const ConsultCreatePage: React.FC = () => {
<
Card
size=
"small"
>
<
Form
form=
{
form
}
layout=
"vertical"
size=
"small"
>
<
Form
.
Item
label=
"主诉(症状描述)"
name=
"chief_complaint"
rules=
{
[{
required
:
true
,
message
:
'
请描述您的症状 }]
}
>
<
TextArea
rows=
{
3
}
placeholder=
"请详细描述您的症状.."
maxLength=
{
500
}
showCount
/>
<
Form
.
Item
label=
"主诉(症状描述)"
name=
"chief_complaint"
rules=
{
[{
required
:
true
,
message
:
'
请描述您的症状
'
}]
}
>
<
TextArea
rows=
{
3
}
placeholder=
"请详细描述您的症状..
.
"
maxLength=
{
500
}
showCount
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"既往病史"
name=
"medical_history"
>
<
TextArea
rows=
{
2
}
placeholder=
"如有慢性病、手术史等请填写"
/>
...
...
@@ -165,7 +161,7 @@ const ConsultCreatePage: React.FC = () => {
<
Space
size=
{
8
}
>
<
Button
size=
"small"
onClick=
{
()
=>
router
.
back
()
}
>
取消
</
Button
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
handleSubmit
}
loading=
{
submitting
}
>
{
submitting
?
'
提交中..
'
:
`发起${consultType === 'video' ? '视频' : '图文'}问诊`
}
{
submitting
?
'
提交中..
.
'
:
`发起${consultType === 'video' ? '视频' : '图文'}问诊`
}
</
Button
>
</
Space
>
</
Form
.
Item
>
...
...
@@ -176,4 +172,3 @@ const ConsultCreatePage: React.FC = () => {
};
export
default
ConsultCreatePage
;
web/src/pages/patient/DoctorDetail/index.tsx
View file @
4ae56601
'
use client
'
;
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
useParams
}
from
'
next/navigation
'
;
import
{
Card
,
Row
,
Col
,
Avatar
,
Tag
,
Rate
,
Button
,
Typography
,
Space
,
Divider
,
Descriptions
}
from
'
antd
'
;
...
...
@@ -14,10 +14,11 @@ import { useQuery } from '@tanstack/react-query';
import
{
doctorApi
}
from
'
../../../api/doctor
'
;
import
ConsultCreateModal
from
'
../../../components/ConsultCreateModal
'
;
const
{
T
itle
,
T
ext
,
Paragraph
}
=
Typography
;
const
{
Text
,
Paragraph
}
=
Typography
;
const
DoctorDetailPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
params
=
useParams
<
{
id
:
string
}
>
();
const
id
=
params
?.
id
;
const
[
consultModalOpen
,
setConsultModalOpen
]
=
useState
(
false
);
const
[
consultType
,
setConsultType
]
=
useState
<
'
text
'
|
'
video
'
>
(
'
text
'
);
...
...
@@ -53,7 +54,7 @@ const DoctorDetailPage: React.FC = () => {
<
Rate
disabled
value=
{
doctor
.
rating
}
style=
{
{
fontSize
:
12
}
}
/>
<
Text
type=
"secondary"
className=
"text-xs!"
>
(
{
doctor
.
rating
.
toFixed
(
1
)
}
)
</
Text
>
<
Divider
type=
"vertical"
/>
<
Text
type=
"secondary"
className=
"text-xs!"
>
问诊
{
doctor
.
consult_count
}
</
Text
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
问诊
{
doctor
.
consult_count
}
次
</
Text
>
</
Space
>
</
Col
>
<
Col
>
...
...
@@ -82,13 +83,13 @@ const DoctorDetailPage: React.FC = () => {
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
医生简介
</
span
>
}
size=
"small"
>
<
Paragraph
className=
"text-xs! mb-0!"
>
{
doctor
.
introduction
||
'
暂无简介
}
</
Paragraph
>
<
Paragraph
className=
"text-xs! mb-0!"
>
{
doctor
.
introduction
||
'
暂无简介
'
}
</
Paragraph
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
排班信息
</
span
>
}
extra=
{
<
Button
type=
"link"
size=
"small"
icon=
{
<
CalendarOutlined
/>
}
>
更多
</
Button
>
}
size=
"small"
>
<
Descriptions
column=
{
2
}
size=
"small"
>
<
Descriptions
.
Item
label=
"出诊状>
<
Descriptions
.
Item
label=
"出诊状
态"
>
{
doctor
.
is_online
?
<
Tag
color=
"green"
>
在线
</
Tag
>
:
<
Tag
color=
"default"
>
未排班
</
Tag
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"资质认证"
>
...
...
@@ -97,7 +98,6 @@ const DoctorDetailPage: React.FC = () => {
</
Descriptions
>
</
Card
>
{
/* 创建问诊弹窗 */
}
<
ConsultCreateModal
open=
{
consultModalOpen
}
onCancel=
{
()
=>
setConsultModalOpen
(
false
)
}
...
...
@@ -109,4 +109,3 @@ const DoctorDetailPage: React.FC = () => {
};
export
default
DoctorDetailPage
;
web/src/pages/patient/Doctors/index.tsx
View file @
4ae56601
'
use client
'
;
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
useRouter
,
useSearchParams
}
from
'
next/navigation
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Select
,
List
,
Avatar
,
Tag
,
Rate
,
Button
,
Space
,
Typography
}
from
'
antd
'
;
...
...
@@ -13,8 +13,8 @@ const { Text } = Typography;
const
PatientDoctorsPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
searchParams
=
useSearchParams
();
const
[
keyword
,
setKeyword
]
=
useState
(
searchParams
.
get
(
'
keyword
'
)
||
''
);
const
[
departmentId
,
setDepartmentId
]
=
useState
(
searchParams
.
get
(
'
department
'
)
||
''
);
const
[
keyword
,
setKeyword
]
=
useState
(
searchParams
?
.
get
(
'
keyword
'
)
||
''
);
const
[
departmentId
,
setDepartmentId
]
=
useState
(
searchParams
?
.
get
(
'
department
'
)
||
''
);
const
[
sortBy
,
setSortBy
]
=
useState
<
'
rating
'
|
'
consult_count
'
|
'
price
'
>
(
'
rating
'
);
const
[
consultModalOpen
,
setConsultModalOpen
]
=
useState
(
false
);
const
[
selectedDoctor
,
setSelectedDoctor
]
=
useState
<
{
id
:
string
;
type
:
'
text
'
|
'
video
'
}
|
null
>
(
null
);
...
...
@@ -68,9 +68,9 @@ const PatientDoctorsPage: React.FC = () => {
style=
{
{
width
:
110
}
}
size=
"small"
options=
{
[
{
label
:
'
评分最高, value:
'
rating
'
},
{ label:
'
问诊最
高
,
value
:
'
consult_count
'
},
{
label
:
'
价格最
高
, value:
'
price
'
},
{
label
:
'
评分最高
'
,
value
:
'
rating
'
},
{
label
:
'
问诊最
多
'
,
value
:
'
consult_count
'
},
{
label
:
'
价格最
低
'
,
value
:
'
price
'
},
]
}
/>
</
div
>
...
...
@@ -95,10 +95,10 @@ const PatientDoctorsPage: React.FC = () => {
<
div
className=
"text-xs text-gray-400 mb-1"
>
{
doctor
.
hospital
}
</
div
>
<
div
className=
"flex items-center gap-2 mb-1"
>
<
Rate
disabled
defaultValue=
{
doctor
.
rating
}
className=
"text-xs!"
/>
<
span
className=
"text-[11px] text-gray-400"
>
问诊
{
doctor
.
consult_count
}
</
span
>
<
span
className=
"text-[11px] text-gray-400"
>
问诊
{
doctor
.
consult_count
}
次
</
span
>
</
div
>
<
div
className=
"text-xs text-gray-500 line-clamp-1"
>
擅长:
{
doctor
.
specialties
?.
join
(
'
?
) || doctor.introduction
}
擅长:
{
doctor
.
specialties
?.
join
(
'
、
'
)
||
doctor
.
introduction
}
</
div
>
</
Col
>
<
Col
>
...
...
@@ -117,7 +117,7 @@ const PatientDoctorsPage: React.FC = () => {
</
Space
>
<
div
>
<
Button
type=
"link"
size=
"small"
className=
"text-xs!"
onClick=
{
()
=>
router
.
push
(
`/patient/doctors/${doctor.id}`
)
}
>
详情 ?
查看详情
>
</
Button
>
</
div
>
</
div
>
...
...
@@ -140,4 +140,3 @@ const PatientDoctorsPage: React.FC = () => {
};
export
default
PatientDoctorsPage
;
web/src/pages/patient/Payment/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Row
,
Col
}
from
'
antd
'
;
import
{
PayCircleOutlined
,
WechatOutlined
,
AlipayOutlined
,
FileTextOutlined
,
HistoryOutlined
}
from
'
@ant-design/icons
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Row
,
Col
,
Table
,
Tag
,
Space
,
Empty
,
Tabs
,
Statistic
,
message
}
from
'
antd
'
;
import
{
PayCircleOutlined
,
WalletOutlined
,
FileTextOutlined
,
HistoryOutlined
,
CheckCircleOutlined
,
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
consultApi
,
type
Consultation
}
from
'
../../../api/consult
'
;
import
{
prescriptionPatientApi
,
type
Prescription
}
from
'
../../../api/prescription
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
}
=
Typography
;
const
features
=
[
{
icon
:
<
WechatOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#07c160
'
}
}
/>,
title
:
'
微信/支付宝支付
'
,
desc
:
'
便捷的在线支付方式
'
},
{
icon
:
<
PayCircleOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#1677ff
'
}
}
/>,
title
:
'
医保扣费
'
,
desc
:
'
支持医保在线结算
'
},
{
icon
:
<
FileTextOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#fa8c16
'
}
}
/>,
title
:
'
账单明细
'
,
desc
:
'
详细的费用清单
'
},
{
icon
:
<
HistoryOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#722ed1
'
}
}
/>,
title
:
'
支付记录
'
,
desc
:
'
历史支付记录查询
'
},
];
interface
PaymentRecord
{
id
:
string
;
type
:
string
;
description
:
string
;
amount
:
number
;
status
:
'
paid
'
|
'
pending
'
|
'
refunded
'
;
created_at
:
string
;
}
const
PatientPaymentPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
consults
,
setConsults
]
=
useState
<
Consultation
[]
>
([]);
const
[
prescriptions
,
setPrescriptions
]
=
useState
<
Prescription
[]
>
([]);
useEffect
(()
=>
{
const
fetchData
=
async
()
=>
{
setLoading
(
true
);
try
{
const
[
consultRes
,
rxRes
]
=
await
Promise
.
allSettled
([
consultApi
.
getConsultList
(),
prescriptionPatientApi
.
getList
({
page
:
1
,
page_size
:
20
}),
]);
if
(
consultRes
.
status
===
'
fulfilled
'
)
setConsults
(
consultRes
.
value
.
data
||
[]);
if
(
rxRes
.
status
===
'
fulfilled
'
)
setPrescriptions
(
rxRes
.
value
.
data
?.
list
||
[]);
}
catch
{
// 静默处理
}
finally
{
setLoading
(
false
);
}
};
fetchData
();
},
[]);
const
paymentRecords
:
PaymentRecord
[]
=
[
...
consults
.
map
((
c
)
=>
({
id
:
c
.
id
,
type
:
c
.
type
===
'
video
'
?
'
视频问诊
'
:
'
图文问诊
'
,
description
:
`
${
c
.
doctor_name
}
-
${
c
.
chief_complaint
?.
substring
(
0
,
20
)
||
'
问诊
'
}
`,
amount: c.type === 'video' ? 10000 : 5000,
status: 'paid' as const,
created_at: c.created_at,
})),
...prescriptions.map((p) => ({
id: p.id,
type: '处方购药',
description: `
处方
$
{
p
.
prescription_no
}
-
$
{
p
.
diagnosis
}
`,
amount: p.total_amount,
status: (p.status === 'dispensed' || p.status === 'completed' ? 'paid' : 'pending') as 'paid' | 'pending',
created_at: p.created_at,
})),
].sort((a, b) => dayjs(b.created_at).unix() - dayjs(a.created_at).unix());
const totalPaid = paymentRecords.filter(r => r.status === 'paid').reduce((sum, r) => sum + r.amount, 0);
const totalPending = paymentRecords.filter(r => r.status === 'pending').reduce((sum, r) => sum + r.amount, 0);
const statusMap: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
pending: { color: 'orange', label: '待支付', icon: <ClockCircleOutlined /> },
refunded: { color: 'red', label: '已退款', icon: <PayCircleOutlined /> },
};
const columns = [
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 140,
render: (v: string) => <Text className="text-xs!">{dayjs(v).format('MM-DD HH:mm')}</Text>,
},
{ title: '类型', dataIndex: 'type', key: 'type', width: 100, render: (v: string) => <Tag color="blue">{v}</Tag> },
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
render: (v: number) => <Text strong style={{ color: '#f5222d' }}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => {
const s = statusMap[v];
return s ? <Tag color={s.color} icon={s.icon}>{s.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: '操作', key: 'action', width: 80,
render: (_: any, r: PaymentRecord) => (
r.status === 'pending'
? <Button type="primary" size="small" onClick={() => message.info('支付功能对接中')}>去支付</Button>
: <Button type="link" size="small">详情</Button>
),
},
];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
在线支付
</
h4
>
<h4 className="text-sm font-bold text-gray-800 m-0">
<WalletOutlined className="mr-1" />支付管理
</h4>
<Button type="primary" size="small" onClick={() => router.push('/patient/consult')}>查看问诊</Button>
</div>
<Row gutter={[8, 8]}>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #52c41a' }}>
<Statistic title="已支付总额" value={totalPaid / 100} precision={2} prefix="¥" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}>
<Statistic title="待支付" value={totalPending / 100} precision={2} prefix="¥" valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #1890ff' }}>
<Statistic title="问诊支付" value={consults.length} suffix="笔" />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #722ed1' }}>
<Statistic title="购药支付" value={prescriptions.length} suffix="笔" />
</Card>
</Col>
</Row>
<Card size="small">
<
div
className=
"text-center py-4"
>
<
PayCircleOutlined
className=
"text-4xl text-yellow-400 mb-2"
/>
<
div
className=
"text-xs text-gray-400"
>
支付方式说明
</
div
>
</
div
>
<Tabs defaultActiveKey="all" size="small" items={[
{
key: 'all',
label: <span><FileTextOutlined /> 全部记录</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords}
rowKey="id"
size="small"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: <Empty description="暂无支付记录" /> }}
/>
),
},
{
key: 'pending',
label: <span><ClockCircleOutlined /> 待支付</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords.filter(r => r.status === 'pending')}
rowKey="id"
size="small"
loading={loading}
pagination={false}
locale={{ emptyText: <Empty description="暂无待支付订单" /> }}
/>
),
},
{
key: 'paid',
label: <span><CheckCircleOutlined /> 已支付</span>,
children: (
<Table
columns={columns}
dataSource={paymentRecords.filter(r => r.status === 'paid')}
rowKey="id"
size="small"
loading={loading}
pagination={{ pageSize: 10, size: 'small' }}
locale={{ emptyText: <Empty description="暂无支付记录" /> }}
/>
),
},
]} />
</Card>
<Card size="small" title={<span className="text-xs font-semibold">支付方式</span>}>
<Row gutter={[8, 8]}>
{
features
.
map
((
f
)
=>
(
<
Col
xs=
{
24
}
sm=
{
12
}
key=
{
f
.
title
}
>
<
Card
size=
"small"
className=
"bg-gray-50"
>
<
div
className=
"flex items-center gap-2"
>
{
f
.
icon
}
<
div
>
<
div
className=
"text-xs font-medium"
>
{
f
.
title
}
</
div
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
{
f
.
desc
}
</
Text
>
</
div
>
<
/
div
>
{
[
{ icon: <PayCircleOutlined style={{ fontSize: 24, color: '#07c160' }} />, title: '微信支付', desc: '推荐' },
{ icon: <PayCircleOutlined style={{ fontSize: 24, color: '#1677ff' }} />, title: '支付宝', desc: '便捷支付' },
{ icon: <WalletOutlined style={{ fontSize: 24, color: '#fa8c16' }} />, title: '医保支付', desc: '在线结算' },
].map((item) => (
<Col span={8} key={item.title}
>
<Card size="small" hoverable className="text-center"
>
<div className="mb-1">{item.icon}</div
>
<div className="text-xs font-medium">{item.title}
</div>
<
Text type="secondary" className="text-[11px]!">{item.desc}</Text
>
</Card>
</Col>
))}
...
...
@@ -48,4 +211,3 @@ const PatientPaymentPage: React.FC = () => {
};
export default PatientPaymentPage;
web/src/pages/patient/PharmacyOrder/index.tsx
View file @
4ae56601
'
use client
'
;
import
React
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Row
,
Col
}
from
'
antd
'
;
import
{
ShoppingCartOutlined
,
EnvironmentOutlined
,
CheckCircleOutlined
,
HistoryOutlined
}
from
'
@ant-design/icons
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Row
,
Col
,
Table
,
Tag
,
Space
,
Empty
,
Tabs
,
Steps
,
message
,
Modal
,
Descriptions
}
from
'
antd
'
;
import
{
ShoppingCartOutlined
,
EnvironmentOutlined
,
CheckCircleOutlined
,
HistoryOutlined
,
MedicineBoxOutlined
,
ClockCircleOutlined
,
CarOutlined
,
InboxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
prescriptionPatientApi
,
type
Prescription
}
from
'
../../../api/prescription
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
}
=
Typography
;
const
features
=
[
{
icon
:
<
EnvironmentOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#1677ff
'
}
}
/>,
title
:
'
配送进度跟踪
'
,
desc
:
'
实时查看药品配送状态
'
},
{
icon
:
<
ShoppingCartOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#eb2f96
'
}
}
/>,
title
:
'
物流地图
'
,
desc
:
'
可视化配送路线
'
},
{
icon
:
<
CheckCircleOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#52c41a
'
}
}
/>,
title
:
'
签收确认
'
,
desc
:
'
在线确认收货
'
},
{
icon
:
<
HistoryOutlined
style=
{
{
fontSize
:
28
,
color
:
'
#fa8c16
'
}
}
/>,
title
:
'
订单历史
'
,
desc
:
'
查看历史药品订单
'
},
interface
OrderItem
{
id
:
string
;
prescription_no
:
string
;
doctor_name
:
string
;
diagnosis
:
string
;
medicines
:
string
[];
total_amount
:
number
;
status
:
'
pending
'
|
'
confirmed
'
|
'
shipping
'
|
'
delivered
'
|
'
completed
'
;
created_at
:
string
;
}
const
statusSteps
=
[
{
title
:
'
待发货
'
,
icon
:
<
InboxOutlined
/>
},
{
title
:
'
配送中
'
,
icon
:
<
CarOutlined
/>
},
{
title
:
'
已送达
'
,
icon
:
<
EnvironmentOutlined
/>
},
{
title
:
'
已完成
'
,
icon
:
<
CheckCircleOutlined
/>
},
];
const
getStepIndex
=
(
status
:
string
)
=>
{
switch
(
status
)
{
case
'
pending
'
:
return
0
;
case
'
confirmed
'
:
return
0
;
case
'
shipping
'
:
return
1
;
case
'
delivered
'
:
return
2
;
case
'
completed
'
:
return
3
;
default
:
return
0
;
}
};
const
PatientPharmacyOrderPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
prescriptions
,
setPrescriptions
]
=
useState
<
Prescription
[]
>
([]);
const
[
detailModal
,
setDetailModal
]
=
useState
<
OrderItem
|
null
>
(
null
);
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
();
},
[]);
const
orders
:
OrderItem
[]
=
prescriptions
.
filter
(
p
=>
p
.
status
===
'
dispensed
'
||
p
.
status
===
'
completed
'
||
p
.
status
===
'
signed
'
)
.
map
((
p
)
=>
({
id
:
p
.
id
,
prescription_no
:
p
.
prescription_no
,
doctor_name
:
p
.
doctor_name
,
diagnosis
:
p
.
diagnosis
,
medicines
:
(
p
.
items
||
[]).
map
(
item
=>
item
.
medicine_name
),
total_amount
:
p
.
total_amount
,
status
:
p
.
status
===
'
completed
'
?
'
completed
'
:
p
.
status
===
'
dispensed
'
?
'
shipping
'
:
'
pending
'
,
created_at
:
p
.
created_at
,
}));
const
statusMap
:
Record
<
string
,
{
color
:
string
;
label
:
string
}
>
=
{
pending
:
{
color
:
'
orange
'
,
label
:
'
待发货
'
},
confirmed
:
{
color
:
'
blue
'
,
label
:
'
已确认
'
},
shipping
:
{
color
:
'
processing
'
,
label
:
'
配送中
'
},
delivered
:
{
color
:
'
cyan
'
,
label
:
'
已送达
'
},
completed
:
{
color
:
'
green
'
,
label
:
'
已完成
'
},
};
const
columns
=
[
{
title
:
'
处方号
'
,
dataIndex
:
'
prescription_no
'
,
key
:
'
prescription_no
'
,
width
:
140
,
render
:
(
v
:
string
)
=>
<
Text
strong
className=
"text-xs!"
>
{
v
}
</
Text
>,
},
{
title
:
'
医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
width
:
80
},
{
title
:
'
诊断
'
,
dataIndex
:
'
diagnosis
'
,
key
:
'
diagnosis
'
,
width
:
120
,
ellipsis
:
true
},
{
title
:
'
药品
'
,
dataIndex
:
'
medicines
'
,
key
:
'
medicines
'
,
render
:
(
v
:
string
[])
=>
(
<
Space
size=
{
[
4
,
4
]
}
wrap
>
{
v
.
slice
(
0
,
3
).
map
((
m
,
i
)
=>
<
Tag
key=
{
i
}
color=
"blue"
>
{
m
}
</
Tag
>)
}
{
v
.
length
>
3
&&
<
Tag
>
+
{
v
.
length
-
3
}
</
Tag
>
}
</
Space
>
),
},
{
title
:
'
金额
'
,
dataIndex
:
'
total_amount
'
,
key
:
'
total_amount
'
,
width
:
90
,
render
:
(
v
:
number
)
=>
<
Text
strong
style=
{
{
color
:
'
#f5222d
'
}
}
>
¥
{
(
v
/
100
).
toFixed
(
2
)
}
</
Text
>,
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
90
,
render
:
(
v
:
string
)
=>
{
const
s
=
statusMap
[
v
];
return
s
?
<
Tag
color=
{
s
.
color
}
>
{
s
.
label
}
</
Tag
>
:
<
Tag
>
{
v
}
</
Tag
>;
},
},
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
110
,
render
:
(
v
:
string
)
=>
<
Text
className=
"text-xs!"
type=
"secondary"
>
{
dayjs
(
v
).
format
(
'
MM-DD HH:mm
'
)
}
</
Text
>,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
100
,
render
:
(
_
:
any
,
r
:
OrderItem
)
=>
(
<
Space
size=
{
4
}
>
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
setDetailModal
(
r
)
}
>
详情
</
Button
>
{
r
.
status
===
'
delivered
'
&&
(
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
message
.
success
(
'
确认收货成功
'
)
}
>
确认收货
</
Button
>
)
}
</
Space
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
药品配送
</
h4
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
ShoppingCartOutlined
className=
"mr-1"
/>
药品配送
</
h4
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/prescription
'
)
}
>
查看处方
</
Button
>
</
div
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #1890ff
'
}
}
>
<
div
className=
"flex items-center gap-2"
>
<
MedicineBoxOutlined
className=
"text-xl text-[#1890ff]"
/>
<
div
>
<
div
className=
"text-xs text-gray-500"
>
全部订单
</
div
>
<
div
className=
"text-lg font-bold"
>
{
orders
.
length
}
</
div
>
</
div
>
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #fa8c16
'
}
}
>
<
div
className=
"flex items-center gap-2"
>
<
ClockCircleOutlined
className=
"text-xl text-[#fa8c16]"
/>
<
div
>
<
div
className=
"text-xs text-gray-500"
>
待发货
</
div
>
<
div
className=
"text-lg font-bold"
>
{
orders
.
filter
(
o
=>
o
.
status
===
'
pending
'
).
length
}
</
div
>
</
div
>
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #722ed1
'
}
}
>
<
div
className=
"flex items-center gap-2"
>
<
CarOutlined
className=
"text-xl text-[#722ed1]"
/>
<
div
>
<
div
className=
"text-xs text-gray-500"
>
配送中
</
div
>
<
div
className=
"text-lg font-bold"
>
{
orders
.
filter
(
o
=>
o
.
status
===
'
shipping
'
).
length
}
</
div
>
</
div
>
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #52c41a
'
}
}
>
<
div
className=
"flex items-center gap-2"
>
<
CheckCircleOutlined
className=
"text-xl text-[#52c41a]"
/>
<
div
>
<
div
className=
"text-xs text-gray-500"
>
已完成
</
div
>
<
div
className=
"text-lg font-bold"
>
{
orders
.
filter
(
o
=>
o
.
status
===
'
completed
'
).
length
}
</
div
>
</
div
>
</
div
>
</
Card
>
</
Col
>
</
Row
>
<
Card
size=
"small"
>
<
div
className=
"text-center py-4"
>
<
ShoppingCartOutlined
className=
"text-4xl text-pink-500 mb-2"
/>
<
div
className=
"text-xs text-gray-400"
>
功能开发中,敬请期待
</
div
>
</
div
>
<
Row
gutter=
{
[
8
,
8
]
}
>
{
features
.
map
((
f
)
=>
(
<
Col
xs=
{
24
}
sm=
{
12
}
key=
{
f
.
title
}
>
<
Card
size=
"small"
className=
"bg-gray-50"
>
<
div
className=
"flex items-center gap-2"
>
{
f
.
icon
}
<
div
>
<
div
className=
"text-xs font-medium"
>
{
f
.
title
}
</
div
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
{
f
.
desc
}
</
Text
>
</
div
>
</
div
>
</
Card
>
</
Col
>
))
}
</
Row
>
<
Tabs
defaultActiveKey=
"all"
size=
"small"
items=
{
[
{
key
:
'
all
'
,
label
:
<
span
><
ShoppingCartOutlined
/>
全部订单
</
span
>,
children
:
(
<
Table
columns=
{
columns
}
dataSource=
{
orders
}
rowKey=
"id"
size=
"small"
loading=
{
loading
}
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
}
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无药品订单"
/>
}
}
/>
),
},
{
key
:
'
shipping
'
,
label
:
<
span
><
CarOutlined
/>
配送中
</
span
>,
children
:
(
<
Table
columns=
{
columns
}
dataSource=
{
orders
.
filter
(
o
=>
o
.
status
===
'
shipping
'
)
}
rowKey=
"id"
size=
"small"
loading=
{
loading
}
pagination=
{
false
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无配送中的订单"
/>
}
}
/>
),
},
{
key
:
'
completed
'
,
label
:
<
span
><
CheckCircleOutlined
/>
已完成
</
span
>,
children
:
(
<
Table
columns=
{
columns
}
dataSource=
{
orders
.
filter
(
o
=>
o
.
status
===
'
completed
'
)
}
rowKey=
"id"
size=
"small"
loading=
{
loading
}
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
}
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无已完成订单"
/>
}
}
/>
),
},
]
}
/>
</
Card
>
<
Modal
title=
"订单详情"
open=
{
!!
detailModal
}
onCancel=
{
()
=>
setDetailModal
(
null
)
}
footer=
{
null
}
width=
{
600
}
>
{
detailModal
&&
(
<
div
className=
"space-y-3"
>
<
Steps
current=
{
getStepIndex
(
detailModal
.
status
)
}
size=
"small"
items=
{
statusSteps
}
/>
<
Descriptions
bordered
size=
"small"
column=
{
2
}
>
<
Descriptions
.
Item
label=
"处方号"
>
{
detailModal
.
prescription_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医生"
>
{
detailModal
.
doctor_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"诊断"
span=
{
2
}
>
{
detailModal
.
diagnosis
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"药品"
span=
{
2
}
>
<
Space
wrap
size=
{
4
}
>
{
detailModal
.
medicines
.
map
((
m
,
i
)
=>
<
Tag
key=
{
i
}
color=
"blue"
>
{
m
}
</
Tag
>)
}
</
Space
>
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"金额"
>
<
Text
strong
style=
{
{
color
:
'
#f5222d
'
}
}
>
¥
{
(
detailModal
.
total_amount
/
100
).
toFixed
(
2
)
}
</
Text
>
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"状态"
>
<
Tag
color=
{
statusMap
[
detailModal
.
status
]?.
color
}
>
{
statusMap
[
detailModal
.
status
]?.
label
}
</
Tag
>
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"下单时间"
span=
{
2
}
>
{
dayjs
(
detailModal
.
created_at
).
format
(
'
YYYY-MM-DD HH:mm
'
)
}
</
Descriptions
.
Item
>
</
Descriptions
>
</
div
>
)
}
</
Modal
>
</
div
>
);
};
export
default
PatientPharmacyOrderPage
;
web/src/pages/patient/TextConsult/index.tsx
View file @
4ae56601
...
...
@@ -24,7 +24,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const
PatientTextConsultPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
params
=
useParams
<
{
id
:
string
}
>
();
const
id
=
params
?.
id
;
const
router
=
useRouter
();
const
{
user
}
=
useUserStore
();
const
queryClient
=
useQueryClient
();
...
...
web/src/pages/patient/VideoConsult/index.tsx
View file @
4ae56601
...
...
@@ -15,7 +15,8 @@ import { useVideoCall } from '../../../hooks/useVideoCall';
const
{
Title
}
=
Typography
;
const
PatientVideoConsultPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
params
=
useParams
<
{
id
:
string
}
>
();
const
id
=
params
?.
id
;
const
router
=
useRouter
();
const
localVideoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
const
remoteVideoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
...
...
web/src/router/index.tsx
View file @
4ae56601
import
React
,
{
Suspense
,
lazy
}
from
'
react
'
;
import
{
createBrowserRouter
,
Navigate
}
from
'
react-router-dom
'
;
import
{
Spin
}
from
'
antd
'
;
import
AuthGuard
from
'
../components/AuthGuard
'
;
import
RoleRedirect
from
'
../components/RoleRedirect
'
;
// ==================== 布局组件 ====================
const
PatientLayout
=
lazy
(()
=>
import
(
'
../components/Layout/PatientLayout
'
));
const
DoctorLayout
=
lazy
(()
=>
import
(
'
../components/Layout/DoctorLayout
'
));
const
AdminLayout
=
lazy
(()
=>
import
(
'
../components/Layout/AdminLayout
'
));
// ==================== 公共页面 ====================
const
LoginPage
=
lazy
(()
=>
import
(
'
../pages/Login
'
));
const
RegisterPage
=
lazy
(()
=>
import
(
'
../pages/Register
'
));
// ==================== 患者端页面 ====================
const
PatientHomePage
=
lazy
(()
=>
import
(
'
../pages/patient/Home
'
));
const
PatientDoctorsPage
=
lazy
(()
=>
import
(
'
../pages/patient/Doctors
'
));
const
PatientDoctorDetailPage
=
lazy
(()
=>
import
(
'
../pages/patient/DoctorDetail
'
));
const
PatientPreConsultPage
=
lazy
(()
=>
import
(
'
../pages/patient/PreConsult
'
));
const
PatientConsultListPage
=
lazy
(()
=>
import
(
'
../pages/patient/ConsultList
'
));
const
PatientTextConsultPage
=
lazy
(()
=>
import
(
'
../pages/patient/TextConsult
'
));
const
PatientVideoConsultPage
=
lazy
(()
=>
import
(
'
../pages/patient/VideoConsult
'
));
const
PatientPrescriptionPage
=
lazy
(()
=>
import
(
'
../pages/patient/Prescription
'
));
const
PatientPaymentPage
=
lazy
(()
=>
import
(
'
../pages/patient/Payment
'
));
const
PatientPharmacyOrderPage
=
lazy
(()
=>
import
(
'
../pages/patient/PharmacyOrder
'
));
const
PatientChronicPage
=
lazy
(()
=>
import
(
'
../pages/patient/Chronic
'
));
const
PatientHealthRecordsPage
=
lazy
(()
=>
import
(
'
../pages/patient/HealthRecords
'
));
const
PatientProfilePage
=
lazy
(()
=>
import
(
'
../pages/patient/Profile
'
));
const
PatientConsultCreatePage
=
lazy
(()
=>
import
(
'
../pages/patient/ConsultCreate
'
));
// ==================== 医生端页面 ====================
const
DoctorWorkbenchPage
=
lazy
(()
=>
import
(
'
../pages/doctor/Workbench
'
));
const
DoctorConsultPage
=
lazy
(()
=>
import
(
'
../pages/doctor/Consult
'
));
const
DoctorAIAssistPage
=
lazy
(()
=>
import
(
'
../pages/doctor/AIAssist
'
));
const
DoctorChronicReviewPage
=
lazy
(()
=>
import
(
'
../pages/doctor/ChronicReview
'
));
const
DoctorPatientRecordPage
=
lazy
(()
=>
import
(
'
../pages/doctor/PatientRecord
'
));
const
DoctorSchedulePage
=
lazy
(()
=>
import
(
'
../pages/doctor/Schedule
'
));
const
DoctorIncomePage
=
lazy
(()
=>
import
(
'
../pages/doctor/Income
'
));
const
DoctorProfilePage
=
lazy
(()
=>
import
(
'
../pages/doctor/Profile
'
));
const
DoctorCertificationPage
=
lazy
(()
=>
import
(
'
../pages/doctor/Certification
'
));
// ==================== 管理端页面 ====================
const
AdminDashboardPage
=
lazy
(()
=>
import
(
'
../pages/admin/Dashboard
'
));
const
AdminUsersPage
=
lazy
(()
=>
import
(
'
../pages/admin/Users
'
));
const
AdminPatientsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Patients
'
));
const
AdminDoctorsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Doctors
'
));
const
AdminAdminsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Admins
'
));
const
AdminDoctorReviewPage
=
lazy
(()
=>
import
(
'
../pages/admin/DoctorReview
'
));
const
AdminDepartmentsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Departments
'
));
const
AdminConsultationsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Consultations
'
));
const
AdminStatisticsPage
=
lazy
(()
=>
import
(
'
../pages/admin/Statistics
'
));
const
AdminPrescriptionPage
=
lazy
(()
=>
import
(
'
../pages/admin/Prescription
'
));
const
AdminPharmacyPage
=
lazy
(()
=>
import
(
'
../pages/admin/Pharmacy
'
));
const
AdminAIConfigPage
=
lazy
(()
=>
import
(
'
../pages/admin/AIConfig
'
));
const
AdminCompliancePage
=
lazy
(()
=>
import
(
'
../pages/admin/Compliance
'
));
// 全局加载组件
const
Loading
:
React
.
FC
=
()
=>
(
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
center
'
,
alignItems
:
'
center
'
,
height
:
'
100vh
'
,
background
:
'
#f0f2f5
'
,
}
}
>
<
Spin
size=
"large"
tip=
"加载中..."
/>
</
div
>
);
// 页面包装器(Suspense)
const
SuspenseWrapper
:
React
.
FC
<
{
children
:
React
.
ReactNode
}
>
=
({
children
})
=>
(
<
Suspense
fallback=
{
<
Loading
/>
}
>
{
children
}
</
Suspense
>
);
// 路由配置
export
const
router
=
createBrowserRouter
([
// ==================== 根路径重定向 ====================
{
path
:
'
/
'
,
element
:
<
RoleRedirect
/>,
},
// ==================== 公共路由 ====================
{
path
:
'
/login
'
,
element
:
(
<
SuspenseWrapper
>
<
LoginPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
/register
'
,
element
:
(
<
SuspenseWrapper
>
<
RegisterPage
/>
</
SuspenseWrapper
>
),
},
// ==================== 患者端路由 ====================
{
path
:
'
/patient
'
,
element
:
(
<
SuspenseWrapper
>
<
AuthGuard
requiredRole=
"patient"
>
<
PatientLayout
/>
</
AuthGuard
>
</
SuspenseWrapper
>
),
children
:
[
{
index
:
true
,
element
:
<
Navigate
to=
"/patient/home"
replace
/>,
},
{
path
:
'
home
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientHomePage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
doctors
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientDoctorsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
doctors/:id
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientDoctorDetailPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consult
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientConsultListPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consult/create
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientConsultCreatePage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consult/text/:id
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientTextConsultPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consult/video/:id
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientVideoConsultPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
pre-consult
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientPreConsultPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
prescription
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientPrescriptionPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
payment
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientPaymentPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
pharmacy/order
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientPharmacyOrderPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
chronic
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientChronicPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
health-records
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientHealthRecordsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
profile
'
,
element
:
(
<
SuspenseWrapper
>
<
PatientProfilePage
/>
</
SuspenseWrapper
>
),
},
],
},
// ==================== 医生端路由 ====================
{
path
:
'
/doctor
'
,
element
:
(
<
SuspenseWrapper
>
<
AuthGuard
requiredRole=
"doctor"
>
<
DoctorLayout
/>
</
AuthGuard
>
</
SuspenseWrapper
>
),
children
:
[
{
index
:
true
,
element
:
<
Navigate
to=
"/doctor/workbench"
replace
/>,
},
{
path
:
'
workbench
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorWorkbenchPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consult
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorConsultPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
ai-assist
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorAIAssistPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
chronic/review
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorChronicReviewPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
patient
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorPatientRecordPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
patient/:id
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorPatientRecordPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
schedule
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorSchedulePage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
income
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorIncomePage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
profile
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorProfilePage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
certification
'
,
element
:
(
<
SuspenseWrapper
>
<
DoctorCertificationPage
/>
</
SuspenseWrapper
>
),
},
],
},
// ==================== 管理端路由 ====================
{
path
:
'
/admin
'
,
element
:
(
<
SuspenseWrapper
>
<
AuthGuard
requiredRole=
"admin"
>
<
AdminLayout
/>
</
AuthGuard
>
</
SuspenseWrapper
>
),
children
:
[
{
index
:
true
,
element
:
<
Navigate
to=
"/admin/dashboard"
replace
/>,
},
{
path
:
'
dashboard
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminDashboardPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
users
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminUsersPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
patients
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminPatientsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
doctors
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminDoctorsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
doctor-review
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminDoctorReviewPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
admins
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminAdminsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
departments
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminDepartmentsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
consultations
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminConsultationsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
statistics
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminStatisticsPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
prescription
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminPrescriptionPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
pharmacy
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminPharmacyPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
ai-config
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminAIConfigPage
/>
</
SuspenseWrapper
>
),
},
{
path
:
'
compliance
'
,
element
:
(
<
SuspenseWrapper
>
<
AdminCompliancePage
/>
</
SuspenseWrapper
>
),
},
],
},
// ==================== 旧路由兼容重定向 ====================
{
path
:
'
/home
'
,
element
:
<
Navigate
to=
"/patient/home"
replace
/>
},
{
path
:
'
/doctors
'
,
element
:
<
Navigate
to=
"/patient/doctors"
replace
/>
},
{
path
:
'
/consult
'
,
element
:
<
Navigate
to=
"/patient/consult"
replace
/>
},
]);
/**
* 路由配置参考文件(遗留)
*
* 项目已迁移至 Next.js App Router,实际路由定义在 src/app/ 目录下。
* 此文件仅作为路由结构的参考文档,不再被任何代码引用。
*/
// 路由结构概览
export
const
routeMap
=
{
// 公共路由
login
:
'
/login
'
,
register
:
'
/register
'
,
// 患者端
patientHome
:
'
/patient/home
'
,
patientDoctors
:
'
/patient/doctors
'
,
patientDoctorDetail
:
'
/patient/doctors/:id
'
,
patientConsult
:
'
/patient/consult
'
,
patientConsultCreate
:
'
/patient/consult/create
'
,
patientConsultText
:
'
/patient/consult/text/:id
'
,
patientConsultVideo
:
'
/patient/consult/video/:id
'
,
patientPreConsult
:
'
/patient/pre-consult
'
,
patientPrescription
:
'
/patient/prescription
'
,
patientPayment
:
'
/patient/payment
'
,
patientPharmacyOrder
:
'
/patient/pharmacy/order
'
,
patientChronic
:
'
/patient/chronic
'
,
patientHealthRecords
:
'
/patient/health-records
'
,
patientProfile
:
'
/patient/profile
'
,
// 医生端
doctorWorkbench
:
'
/doctor/workbench
'
,
doctorConsult
:
'
/doctor/consult
'
,
doctorAIAssist
:
'
/doctor/ai-assist
'
,
doctorChronicReview
:
'
/doctor/chronic/review
'
,
doctorPatient
:
'
/doctor/patient
'
,
doctorPatientDetail
:
'
/doctor/patient/:id
'
,
doctorSchedule
:
'
/doctor/schedule
'
,
doctorIncome
:
'
/doctor/income
'
,
doctorProfile
:
'
/doctor/profile
'
,
doctorCertification
:
'
/doctor/certification
'
,
// 管理端
adminDashboard
:
'
/admin/dashboard
'
,
adminUsers
:
'
/admin/users
'
,
adminPatients
:
'
/admin/patients
'
,
adminDoctors
:
'
/admin/doctors
'
,
adminDoctorReview
:
'
/admin/doctor-review
'
,
adminAdmins
:
'
/admin/admins
'
,
adminDepartments
:
'
/admin/departments
'
,
adminConsultations
:
'
/admin/consultations
'
,
adminStatistics
:
'
/admin/statistics
'
,
adminPrescription
:
'
/admin/prescription
'
,
adminPharmacy
:
'
/admin/pharmacy
'
,
adminAIConfig
:
'
/admin/ai-config
'
,
adminCompliance
:
'
/admin/compliance
'
,
}
as
const
;
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