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
52e57c72
Commit
52e57c72
authored
Feb 28, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
d2f9a49c
Changes
43
Show whitespace changes
Inline
Side-by-side
Showing
43 changed files
with
9394 additions
and
0 deletions
+9394
-0
web/src/pages/admin/AIConfig/index.tsx
web/src/pages/admin/AIConfig/index.tsx
+575
-0
web/src/pages/admin/Admins/index.tsx
web/src/pages/admin/Admins/index.tsx
+326
-0
web/src/pages/admin/Compliance/index.tsx
web/src/pages/admin/Compliance/index.tsx
+241
-0
web/src/pages/admin/Consultations/index.tsx
web/src/pages/admin/Consultations/index.tsx
+202
-0
web/src/pages/admin/Dashboard/index.tsx
web/src/pages/admin/Dashboard/index.tsx
+156
-0
web/src/pages/admin/Departments/index.tsx
web/src/pages/admin/Departments/index.tsx
+145
-0
web/src/pages/admin/DoctorReview/index.tsx
web/src/pages/admin/DoctorReview/index.tsx
+195
-0
web/src/pages/admin/Doctors/index.tsx
web/src/pages/admin/Doctors/index.tsx
+323
-0
web/src/pages/admin/Pharmacy/index.tsx
web/src/pages/admin/Pharmacy/index.tsx
+283
-0
web/src/pages/admin/Prescription/index.tsx
web/src/pages/admin/Prescription/index.tsx
+245
-0
web/src/pages/admin/Statistics/index.tsx
web/src/pages/admin/Statistics/index.tsx
+77
-0
web/src/pages/admin/Users/index.tsx
web/src/pages/admin/Users/index.tsx
+190
-0
web/src/pages/doctor/AIAssist/index.tsx
web/src/pages/doctor/AIAssist/index.tsx
+165
-0
web/src/pages/doctor/Certification/index.tsx
web/src/pages/doctor/Certification/index.tsx
+186
-0
web/src/pages/doctor/ChronicReview/index.tsx
web/src/pages/doctor/ChronicReview/index.tsx
+243
-0
web/src/pages/doctor/Consult/AIPanel.tsx
web/src/pages/doctor/Consult/AIPanel.tsx
+233
-0
web/src/pages/doctor/Consult/ChatPanel.tsx
web/src/pages/doctor/Consult/ChatPanel.tsx
+229
-0
web/src/pages/doctor/Consult/PatientList.tsx
web/src/pages/doctor/Consult/PatientList.tsx
+122
-0
web/src/pages/doctor/Consult/WaitingQueue.tsx
web/src/pages/doctor/Consult/WaitingQueue.tsx
+198
-0
web/src/pages/doctor/Consult/index.tsx
web/src/pages/doctor/Consult/index.tsx
+234
-0
web/src/pages/doctor/ConsultRoom/index.tsx
web/src/pages/doctor/ConsultRoom/index.tsx
+166
-0
web/src/pages/doctor/History/index.tsx
web/src/pages/doctor/History/index.tsx
+154
-0
web/src/pages/doctor/Income/index.tsx
web/src/pages/doctor/Income/index.tsx
+262
-0
web/src/pages/doctor/PatientRecord/index.tsx
web/src/pages/doctor/PatientRecord/index.tsx
+237
-0
web/src/pages/doctor/Prescription/index.tsx
web/src/pages/doctor/Prescription/index.tsx
+324
-0
web/src/pages/doctor/Profile/index.tsx
web/src/pages/doctor/Profile/index.tsx
+102
-0
web/src/pages/doctor/Queue/index.tsx
web/src/pages/doctor/Queue/index.tsx
+125
-0
web/src/pages/doctor/Schedule/index.tsx
web/src/pages/doctor/Schedule/index.tsx
+159
-0
web/src/pages/doctor/Workbench/index.tsx
web/src/pages/doctor/Workbench/index.tsx
+187
-0
web/src/pages/patient/Chronic/index.tsx
web/src/pages/patient/Chronic/index.tsx
+532
-0
web/src/pages/patient/ConsultCreate/index.tsx
web/src/pages/patient/ConsultCreate/index.tsx
+179
-0
web/src/pages/patient/ConsultList/index.tsx
web/src/pages/patient/ConsultList/index.tsx
+181
-0
web/src/pages/patient/DoctorDetail/index.tsx
web/src/pages/patient/DoctorDetail/index.tsx
+112
-0
web/src/pages/patient/Doctors/index.tsx
web/src/pages/patient/Doctors/index.tsx
+143
-0
web/src/pages/patient/HealthRecords/index.tsx
web/src/pages/patient/HealthRecords/index.tsx
+383
-0
web/src/pages/patient/Home/index.tsx
web/src/pages/patient/Home/index.tsx
+157
-0
web/src/pages/patient/Payment/index.tsx
web/src/pages/patient/Payment/index.tsx
+51
-0
web/src/pages/patient/PharmacyOrder/index.tsx
web/src/pages/patient/PharmacyOrder/index.tsx
+51
-0
web/src/pages/patient/PreConsult/index.tsx
web/src/pages/patient/PreConsult/index.tsx
+725
-0
web/src/pages/patient/Prescription/index.tsx
web/src/pages/patient/Prescription/index.tsx
+190
-0
web/src/pages/patient/Profile/index.tsx
web/src/pages/patient/Profile/index.tsx
+93
-0
web/src/pages/patient/TextConsult/index.tsx
web/src/pages/patient/TextConsult/index.tsx
+212
-0
web/src/pages/patient/VideoConsult/index.tsx
web/src/pages/patient/VideoConsult/index.tsx
+101
-0
No files found.
web/src/pages/admin/AIConfig/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Typography
,
Space
,
Tabs
,
Form
,
Input
,
Select
,
Switch
,
Button
,
Table
,
Tag
,
Modal
,
message
,
Divider
,
Alert
,
InputNumber
,
Spin
,
DatePicker
,
Tooltip
,
}
from
'
antd
'
;
import
{
RobotOutlined
,
SettingOutlined
,
FileTextOutlined
,
SafetyOutlined
,
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
ExclamationCircleOutlined
,
CheckCircleOutlined
,
LoadingOutlined
,
HistoryOutlined
,
ReloadOutlined
,
}
from
'
@ant-design/icons
'
;
import
dayjs
from
'
dayjs
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
AIUsageStats
,
PromptTemplateData
,
AILogItem
}
from
'
../../../api/admin
'
;
const
{
Title
,
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
providerOptions
=
[
{
label
:
'
DeepSeek
'
,
value
:
'
deepseek
'
},
{
label
:
'
OpenAI
'
,
value
:
'
openai
'
},
{
label
:
'
阿里通义千问
'
,
value
:
'
qwen
'
},
{
label
:
'
百度文心一言
'
,
value
:
'
baidu
'
},
{
label
:
'
本地部署模型
'
,
value
:
'
local
'
},
];
const
providerDefaults
:
Record
<
string
,
{
baseURL
:
string
;
models
:
{
label
:
string
;
value
:
string
}[]
}
>
=
{
deepseek
:
{
baseURL
:
'
https://api.deepseek.com
'
,
models
:
[
{
label
:
'
DeepSeek-V3
'
,
value
:
'
deepseek-chat
'
},
{
label
:
'
DeepSeek-R1 (推理)
'
,
value
:
'
deepseek-reasoner
'
},
],
},
openai
:
{
baseURL
:
'
https://api.openai.com
'
,
models
:
[
{
label
:
'
GPT-4o
'
,
value
:
'
gpt-4o
'
},
{
label
:
'
GPT-4o mini
'
,
value
:
'
gpt-4o-mini
'
},
{
label
:
'
GPT-4 Turbo
'
,
value
:
'
gpt-4-turbo
'
},
],
},
qwen
:
{
baseURL
:
'
https://dashscope.aliyuncs.com/compatible-mode
'
,
models
:
[
{
label
:
'
Qwen-Max
'
,
value
:
'
qwen-max
'
},
{
label
:
'
Qwen-Plus
'
,
value
:
'
qwen-plus
'
},
],
},
baidu
:
{
baseURL
:
'
https://aip.baidubce.com
'
,
models
:
[
{
label
:
'
ERNIE-4.0
'
,
value
:
'
ernie-4.0-8k
'
},
{
label
:
'
ERNIE-3.5
'
,
value
:
'
ernie-3.5-8k
'
},
],
},
local
:
{
baseURL
:
'
http://localhost:11434
'
,
models
:
[{
label
:
'
自定义模型
'
,
value
:
'
custom
'
}],
},
};
const
sceneLabels
:
Record
<
string
,
string
>
=
{
pre_consult_chat
:
'
预问诊对话
'
,
pre_consult_analysis
:
'
预问诊报告
'
,
consult_diagnosis
:
'
鉴别诊断
'
,
consult_medication
:
'
用药建议
'
,
prescription_review
:
'
处方审核
'
,
};
const
AdminAIConfigPage
:
React
.
FC
=
()
=>
{
const
[
modelForm
]
=
Form
.
useForm
();
const
[
templateForm
]
=
Form
.
useForm
();
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
currentProvider
,
setCurrentProvider
]
=
useState
(
'
deepseek
'
);
const
[
usageStats
,
setUsageStats
]
=
useState
<
AIUsageStats
|
null
>
(
null
);
const
[
templates
,
setTemplates
]
=
useState
<
PromptTemplateData
[]
>
([]);
const
[
templatesLoading
,
setTemplatesLoading
]
=
useState
(
false
);
const
[
templateModalVisible
,
setTemplateModalVisible
]
=
useState
(
false
);
const
[
templateModalType
,
setTemplateModalType
]
=
useState
<
'
add
'
|
'
edit
'
>
(
'
add
'
);
const
[
editingTemplate
,
setEditingTemplate
]
=
useState
<
PromptTemplateData
|
null
>
(
null
);
const
[
templateSaving
,
setTemplateSaving
]
=
useState
(
false
);
const
[
logData
,
setLogData
]
=
useState
<
AILogItem
[]
>
([]);
const
[
logTotal
,
setLogTotal
]
=
useState
(
0
);
const
[
logPage
,
setLogPage
]
=
useState
(
1
);
const
[
logPageSize
,
setLogPageSize
]
=
useState
(
20
);
const
[
logLoading
,
setLogLoading
]
=
useState
(
false
);
const
[
logSceneFilter
,
setLogSceneFilter
]
=
useState
<
string
|
undefined
>
();
const
[
logSuccessFilter
,
setLogSuccessFilter
]
=
useState
<
string
|
undefined
>
();
const
[
logDateRange
,
setLogDateRange
]
=
useState
<
[
dayjs
.
Dayjs
|
null
,
dayjs
.
Dayjs
|
null
]
|
null
>
([
dayjs
(),
dayjs
()]);
const
[
logDetailVisible
,
setLogDetailVisible
]
=
useState
(
false
);
const
[
logDetailItem
,
setLogDetailItem
]
=
useState
<
AILogItem
|
null
>
(
null
);
useEffect
(()
=>
{
const
fetchConfig
=
async
()
=>
{
setLoading
(
true
);
try
{
const
[
configRes
,
statsRes
]
=
await
Promise
.
all
([
adminApi
.
getAIConfig
(),
adminApi
.
getAIUsageStats
(),
]);
const
cfg
=
configRes
.
data
;
setCurrentProvider
(
cfg
.
provider
||
'
deepseek
'
);
modelForm
.
setFieldsValue
({
provider
:
cfg
.
provider
||
'
deepseek
'
,
model
:
cfg
.
model
||
'
deepseek-chat
'
,
api_key
:
''
,
base_url
:
cfg
.
base_url
||
'
https://api.deepseek.com
'
,
temperature
:
cfg
.
temperature
||
0.3
,
max_tokens
:
cfg
.
max_tokens
||
2048
,
enable_ai
:
cfg
.
enable_ai
!==
false
,
});
setUsageStats
(
statsRes
.
data
);
}
catch
{
modelForm
.
setFieldsValue
({
provider
:
'
deepseek
'
,
model
:
'
deepseek-chat
'
,
base_url
:
'
https://api.deepseek.com
'
,
temperature
:
0.3
,
max_tokens
:
2048
,
enable_ai
:
true
,
});
}
finally
{
setLoading
(
false
);
}
};
fetchConfig
();
},
[
modelForm
]);
const
fetchAILogs
=
useCallback
(
async
()
=>
{
setLogLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getAILogs
({
scene
:
logSceneFilter
,
success
:
logSuccessFilter
,
start_date
:
logDateRange
?.[
0
]?.
format
(
'
YYYY-MM-DD
'
)
||
undefined
,
end_date
:
logDateRange
?.[
1
]?.
format
(
'
YYYY-MM-DD
'
)
||
undefined
,
page
:
logPage
,
page_size
:
logPageSize
,
});
const
result
=
res
.
data
as
any
;
setLogData
(
result
?.
list
||
[]);
setLogTotal
(
result
?.
total
||
0
);
}
catch
{
message
.
error
(
'
获取AI日志失败
'
);
}
finally
{
setLogLoading
(
false
);
}
},
[
logSceneFilter
,
logSuccessFilter
,
logDateRange
,
logPage
,
logPageSize
]);
useEffect
(()
=>
{
fetchAILogs
();
},
[
fetchAILogs
]);
const
handleProviderChange
=
(
provider
:
string
)
=>
{
setCurrentProvider
(
provider
);
const
defaults
=
providerDefaults
[
provider
];
if
(
defaults
)
{
modelForm
.
setFieldsValue
({
base_url
:
defaults
.
baseURL
,
model
:
defaults
.
models
[
0
]?.
value
||
''
,
});
}
};
const
handleSaveModel
=
async
()
=>
{
try
{
const
values
=
await
modelForm
.
validateFields
();
setSaving
(
true
);
await
adminApi
.
saveAIConfig
({
provider
:
values
.
provider
,
model
:
values
.
model
,
api_key
:
values
.
api_key
||
''
,
base_url
:
values
.
base_url
,
max_tokens
:
values
.
max_tokens
,
temperature
:
values
.
temperature
,
enable_ai
:
values
.
enable_ai
,
});
message
.
success
(
'
AI 配置已保存并生效
'
);
modelForm
.
setFieldValue
(
'
api_key
'
,
''
);
}
catch
(
error
:
any
)
{
if
(
error
?.
errorFields
)
return
;
message
.
error
(
'
保存失败:
'
+
(
error
?.
message
||
'
请稍后重试
'
));
}
finally
{
setSaving
(
false
);
}
};
const
fetchTemplates
=
async
()
=>
{
setTemplatesLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getPromptTemplates
();
setTemplates
(
res
.
data
||
[]);
}
catch
{
console
.
error
(
'
加载Prompt模板失败
'
);
}
finally
{
setTemplatesLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchTemplates
();
},
[]);
const
handleAddTemplate
=
()
=>
{
setTemplateModalType
(
'
add
'
);
setEditingTemplate
(
null
);
templateForm
.
resetFields
();
templateForm
.
setFieldsValue
({
status
:
'
active
'
});
setTemplateModalVisible
(
true
);
};
const
handleEditTemplate
=
(
record
:
PromptTemplateData
)
=>
{
setTemplateModalType
(
'
edit
'
);
setEditingTemplate
(
record
);
templateForm
.
setFieldsValue
({
template_key
:
record
.
template_key
,
name
:
record
.
name
,
scene
:
record
.
scene
,
content
:
record
.
content
,
status
:
record
.
status
,
});
setTemplateModalVisible
(
true
);
};
const
handleDeleteTemplate
=
(
record
:
PromptTemplateData
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除该模板?
'
,
content
:
`模板「
${
record
.
name
}
」(Key:
${
record
.
template_key
}
)`
,
okType
:
'
danger
'
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
deletePromptTemplate
(
record
.
id
);
message
.
success
(
'
删除成功
'
);
fetchTemplates
();
}
catch
{
message
.
error
(
'
删除失败
'
);
}
},
});
};
const
handleSaveTemplate
=
async
()
=>
{
try
{
const
values
=
await
templateForm
.
validateFields
();
setTemplateSaving
(
true
);
if
(
templateModalType
===
'
add
'
)
{
await
adminApi
.
createPromptTemplate
({
template_key
:
values
.
template_key
,
name
:
values
.
name
,
scene
:
values
.
scene
,
content
:
values
.
content
,
status
:
values
.
status
||
'
active
'
,
});
message
.
success
(
'
Prompt 模板已创建
'
);
}
else
if
(
editingTemplate
)
{
await
adminApi
.
updatePromptTemplate
(
editingTemplate
.
id
,
{
template_key
:
values
.
template_key
,
name
:
values
.
name
,
scene
:
values
.
scene
,
content
:
values
.
content
,
status
:
values
.
status
,
});
message
.
success
(
'
Prompt 模板已更新
'
);
}
setTemplateModalVisible
(
false
);
fetchTemplates
();
}
catch
(
error
:
any
)
{
if
(
!
error
?.
errorFields
)
{
message
.
error
(
'
保存失败
'
);
}
}
finally
{
setTemplateSaving
(
false
);
}
};
const
currentModelOptions
=
providerDefaults
[
currentProvider
]?.
models
||
[];
const
templateColumns
=
[
{
title
:
'
模板Key
'
,
dataIndex
:
'
template_key
'
,
key
:
'
template_key
'
,
width
:
180
,
render
:
(
v
:
string
)
=>
<
Tag
color=
"purple"
>
{
v
}
</
Tag
>
},
{
title
:
'
模板名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
v
:
string
)
=>
<
Text
strong
>
{
v
}
</
Text
>
},
{
title
:
'
应用场景
'
,
dataIndex
:
'
scene
'
,
key
:
'
scene
'
,
width
:
130
,
render
:
(
v
:
string
)
=>
<
Tag
color=
"blue"
>
{
sceneLabels
[
v
]
||
v
}
</
Tag
>,
},
{
title
:
'
Prompt 内容
'
,
dataIndex
:
'
content
'
,
key
:
'
content
'
,
render
:
(
v
:
string
)
=>
<
Text
type=
"secondary"
>
{
v
&&
v
.
length
>
50
?
v
.
substring
(
0
,
50
)
+
'
...
'
:
v
}
</
Text
>,
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
v
===
'
active
'
?
'
green
'
:
'
default
'
}
>
{
v
===
'
active
'
?
'
启用
'
:
'
停用
'
}
</
Tag
>,
},
{
title
:
'
更新时间
'
,
dataIndex
:
'
updated_at
'
,
key
:
'
updated_at
'
,
width
:
170
,
render
:
(
v
:
string
)
=>
v
?
new
Date
(
v
).
toLocaleString
(
'
zh-CN
'
)
:
'
-
'
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
180
,
render
:
(
_
:
unknown
,
record
:
PromptTemplateData
)
=>
(
<
Space
>
{
record
.
status
!==
'
active
'
&&
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
CheckCircleOutlined
/>
}
onClick=
{
async
()
=>
{
try
{
await
adminApi
.
activatePromptTemplate
(
record
.
id
);
message
.
success
(
`已激活模板「${record.name}」`
);
fetchTemplates
();
}
catch
{
message
.
error
(
'
激活失败
'
);
}
}
}
>
激活
</
Button
>
)
}
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEditTemplate
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDeleteTemplate
(
record
)
}
>
删除
</
Button
>
</
Space
>
),
},
];
const
formatTokens
=
(
tokens
:
number
)
=>
{
if
(
tokens
>=
1000000
)
return
(
tokens
/
1000000
).
toFixed
(
1
)
+
'
M
'
;
if
(
tokens
>=
1000
)
return
(
tokens
/
1000
).
toFixed
(
1
)
+
'
K
'
;
return
String
(
tokens
);
};
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
RobotOutlined
className=
"mr-1"
/>
AI 配置
</
h4
>
<
Tabs
defaultActiveKey=
"model"
size=
"small"
items=
{
[
{
key
:
'
model
'
,
label
:
<
span
><
SettingOutlined
/>
模型选择
</
span
>,
children
:
(
<
Spin
spinning=
{
loading
}
>
<
Row
gutter=
{
24
}
>
<
Col
span=
{
16
}
>
<
Card
title=
"AI 模型配置"
style=
{
{
borderRadius
:
12
}
}
>
<
Alert
message=
"此配置将全局生效,影响所有 AI 功能"
type=
"info"
showIcon
style=
{
{
marginBottom
:
20
}
}
/>
<
Form
form=
{
modelForm
}
layout=
"vertical"
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"模型提供商"
name=
"provider"
rules=
{
[{
required
:
true
}]
}
>
<
Select
options=
{
providerOptions
}
onChange=
{
handleProviderChange
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"模型版本"
name=
"model"
rules=
{
[{
required
:
true
}]
}
>
<
Select
options=
{
currentModelOptions
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"API Key"
name=
"api_key"
help=
"留空则保留已保存的 Key"
>
<
Input
.
Password
placeholder=
"请输入 API Key"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"API Base URL"
name=
"base_url"
rules=
{
[{
required
:
true
}]
}
>
<
Input
placeholder=
"https://api.deepseek.com"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"Temperature"
name=
"temperature"
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
0
}
max=
{
2
}
step=
{
0.1
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"Max Tokens"
name=
"max_tokens"
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
256
}
max=
{
32768
}
step=
{
256
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"启用 AI 辅助"
name=
"enable_ai"
valuePropName=
"checked"
>
<
Switch
checkedChildren=
"开启"
unCheckedChildren=
"关闭"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Button
type=
"primary"
onClick=
{
handleSaveModel
}
loading=
{
saving
}
icon=
{
saving
?
<
LoadingOutlined
/>
:
<
CheckCircleOutlined
/>
}
size=
"large"
style=
{
{
minWidth
:
140
}
}
>
{
saving
?
'
保存中...
'
:
'
保存配置
'
}
</
Button
>
</
Form
>
</
Card
>
</
Col
>
<
Col
span=
{
8
}
>
<
Card
title=
"使用统计"
style=
{
{
borderRadius
:
12
}
}
>
<
Space
direction=
"vertical"
size=
{
16
}
style=
{
{
width
:
'
100%
'
}
}
>
<
div
>
<
Text
type=
"secondary"
>
今日调用次数
</
Text
>
<
Title
level=
{
3
}
style=
{
{
margin
:
0
}
}
>
{
usageStats
?.
today_count
??
0
}
</
Title
>
</
div
>
<
Divider
style=
{
{
margin
:
0
}
}
/>
<
div
>
<
Text
type=
"secondary"
>
本月 Token 消耗
</
Text
>
<
Title
level=
{
3
}
style=
{
{
margin
:
0
}
}
>
{
formatTokens
(
usageStats
?.
month_tokens
??
0
)
}
</
Title
>
</
div
>
<
Divider
style=
{
{
margin
:
0
}
}
/>
<
div
>
<
Text
type=
"secondary"
>
平均响应时间
</
Text
>
<
Title
level=
{
3
}
style=
{
{
margin
:
0
}
}
>
{
usageStats
?.
avg_response_ms
?
(
usageStats
.
avg_response_ms
/
1000
).
toFixed
(
1
)
+
'
s
'
:
'
-
'
}
</
Title
>
</
div
>
</
Space
>
</
Card
>
</
Col
>
</
Row
>
</
Spin
>
),
},
{
key
:
'
prompts
'
,
label
:
<
span
><
FileTextOutlined
/>
Prompt 模板管理
</
span
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
div
style=
{
{
marginBottom
:
16
,
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
}
}
>
<
Text
type=
"secondary"
>
管理各场景的 AI Prompt 模板
</
Text
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddTemplate
}
>
新建模板
</
Button
>
</
div
>
<
Table
columns=
{
templateColumns
}
dataSource=
{
templates
}
rowKey=
"id"
loading=
{
templatesLoading
}
pagination=
{
false
}
/>
</
Card
>
),
},
{
key
:
'
safety
'
,
label
:
<
span
><
SafetyOutlined
/>
安全词过滤
</
span
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
Alert
message=
"安全词过滤规则用于防止 AI 输出不安全或不合规的内容"
type=
"info"
showIcon
style=
{
{
marginBottom
:
16
}
}
/>
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
40
,
color
:
'
#999
'
}
}
>
<
ExclamationCircleOutlined
style=
{
{
fontSize
:
48
,
marginBottom
:
16
}
}
/>
<
div
>
安全词管理功能开发中...
</
div
>
</
div
>
</
Card
>
),
},
{
key
:
'
logs
'
,
label
:
<
span
><
HistoryOutlined
/>
模型日志
</
span
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
div
style=
{
{
marginBottom
:
16
,
display
:
'
flex
'
,
justifyContent
:
'
space-between
'
,
alignItems
:
'
center
'
}
}
>
<
Space
wrap
>
<
Select
placeholder=
"场景筛选"
style=
{
{
width
:
150
}
}
allowClear
value=
{
logSceneFilter
}
onChange=
{
setLogSceneFilter
}
options=
{
Object
.
entries
(
sceneLabels
).
map
(([
k
,
v
])
=>
({
label
:
v
,
value
:
k
}))
}
/>
<
Select
placeholder=
"状态筛选"
style=
{
{
width
:
120
}
}
allowClear
value=
{
logSuccessFilter
}
onChange=
{
setLogSuccessFilter
}
options=
{
[{
label
:
'
成功
'
,
value
:
'
true
'
},
{
label
:
'
失败
'
,
value
:
'
false
'
}]
}
/>
<
DatePicker
.
RangePicker
value=
{
logDateRange
}
onChange=
{
(
dates
)
=>
setLogDateRange
(
dates
)
}
/>
<
Button
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
fetchAILogs
}
>
刷新
</
Button
>
</
Space
>
</
div
>
<
Table
columns=
{
[
{
title
:
'
ID
'
,
dataIndex
:
'
id
'
,
key
:
'
id
'
,
width
:
60
},
{
title
:
'
场景
'
,
dataIndex
:
'
scene
'
,
key
:
'
scene
'
,
width
:
130
,
render
:
(
v
:
string
)
=>
<
Tag
color=
"blue"
>
{
sceneLabels
[
v
]
||
v
}
</
Tag
>
},
{
title
:
'
模型
'
,
dataIndex
:
'
model
'
,
key
:
'
model
'
,
width
:
140
},
{
title
:
'
请求摘要
'
,
dataIndex
:
'
request_content
'
,
key
:
'
request_content
'
,
width
:
180
,
render
:
(
v
:
string
)
=>
<
Tooltip
title=
{
v
}
><
Text
ellipsis
style=
{
{
maxWidth
:
180
,
display
:
'
block
'
}
}
>
{
v
||
'
-
'
}
</
Text
></
Tooltip
>,
},
{
title
:
'
Tokens
'
,
key
:
'
tokens
'
,
width
:
100
,
render
:
(
_
:
any
,
record
:
AILogItem
)
=>
<
Text
type=
"secondary"
>
{
record
.
total_tokens
||
0
}
</
Text
>
},
{
title
:
'
耗时
'
,
dataIndex
:
'
response_time_ms
'
,
key
:
'
response_time_ms
'
,
width
:
80
,
render
:
(
v
:
number
)
=>
<
Text
type=
"secondary"
>
{
v
?
(
v
/
1000
).
toFixed
(
2
)
+
'
s
'
:
'
-
'
}
</
Text
>
},
{
title
:
'
状态
'
,
dataIndex
:
'
success
'
,
key
:
'
success
'
,
width
:
80
,
render
:
(
v
:
boolean
,
record
:
AILogItem
)
=>
(
<
Space
direction=
"vertical"
size=
{
0
}
>
<
Tag
color=
{
v
?
'
success
'
:
'
error
'
}
>
{
v
?
'
成功
'
:
'
失败
'
}
</
Tag
>
{
record
.
is_mock
&&
<
Tag
color=
"default"
>
模拟
</
Tag
>
}
</
Space
>
),
},
{
title
:
'
时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
v
?
dayjs
(
v
).
format
(
'
YYYY-MM-DD HH:mm:ss
'
)
:
'
-
'
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
any
,
record
:
AILogItem
)
=>
(
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
{
setLogDetailItem
(
record
);
setLogDetailVisible
(
true
);
}
}
>
详情
</
Button
>
),
},
]
}
dataSource=
{
logData
}
rowKey=
"id"
loading=
{
logLoading
}
pagination=
{
{
current
:
logPage
,
pageSize
:
logPageSize
,
total
:
logTotal
,
showSizeChanger
:
true
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
p
,
ps
)
=>
{
setLogPage
(
p
);
setLogPageSize
(
ps
);
},
}
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
{
templateModalType
===
'
add
'
?
'
新建 Prompt 模板
'
:
'
编辑 Prompt 模板
'
}
open=
{
templateModalVisible
}
onOk=
{
handleSaveTemplate
}
onCancel=
{
()
=>
setTemplateModalVisible
(
false
)
}
okText=
"保存"
confirmLoading=
{
templateSaving
}
width=
{
640
}
>
<
Form
form=
{
templateForm
}
layout=
"vertical"
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"模板Key"
name=
"template_key"
rules=
{
[{
required
:
true
,
message
:
'
请输入模板Key
'
}]
}
help=
"唯一标识,如 pre_consult_chat"
>
<
Input
placeholder=
"如 pre_consult_chat"
disabled=
{
templateModalType
===
'
edit
'
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"模板名称"
name=
"name"
rules=
{
[{
required
:
true
,
message
:
'
请输入模板名称
'
}]
}
>
<
Input
placeholder=
"请输入模板名称"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"应用场景"
name=
"scene"
rules=
{
[{
required
:
true
,
message
:
'
请选择场景
'
}]
}
>
<
Select
placeholder=
"选择场景"
options=
{
Object
.
entries
(
sceneLabels
).
map
(([
k
,
v
])
=>
({
label
:
v
,
value
:
k
}))
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"状态"
name=
"status"
rules=
{
[{
required
:
true
}]
}
>
<
Select
options=
{
[{
label
:
'
启用
'
,
value
:
'
active
'
},
{
label
:
'
停用
'
,
value
:
'
disabled
'
}]
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"Prompt 内容"
name=
"content"
rules=
{
[{
required
:
true
,
message
:
'
请输入Prompt内容
'
}]
}
>
<
TextArea
rows=
{
8
}
placeholder=
"请输入 Prompt 模板内容..."
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
<
Modal
title=
"AI 调用详情"
open=
{
logDetailVisible
}
onCancel=
{
()
=>
setLogDetailVisible
(
false
)
}
footer=
{
[<
Button
key=
"close"
onClick=
{
()
=>
setLogDetailVisible
(
false
)
}
>
关闭
</
Button
>]
}
width=
{
800
}
>
{
logDetailItem
&&
(
<
Space
direction=
"vertical"
size=
{
16
}
style=
{
{
width
:
'
100%
'
}
}
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
场景:
</
Text
><
Text
strong
>
{
sceneLabels
[
logDetailItem
.
scene
]
||
logDetailItem
.
scene
}
</
Text
></
Col
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
模型:
</
Text
><
Text
strong
>
{
logDetailItem
.
model
||
'
-
'
}
</
Text
></
Col
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
状态:
</
Text
><
Tag
color=
{
logDetailItem
.
success
?
'
success
'
:
'
error
'
}
>
{
logDetailItem
.
success
?
'
成功
'
:
'
失败
'
}
</
Tag
></
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
Prompt Tokens:
</
Text
><
Text
>
{
logDetailItem
.
prompt_tokens
||
0
}
</
Text
></
Col
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
Completion Tokens:
</
Text
><
Text
>
{
logDetailItem
.
completion_tokens
||
0
}
</
Text
></
Col
>
<
Col
span=
{
8
}
><
Text
type=
"secondary"
>
Total Tokens:
</
Text
><
Text
strong
>
{
logDetailItem
.
total_tokens
||
0
}
</
Text
></
Col
>
</
Row
>
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
div
>
<
Text
type=
"secondary"
>
系统提示词:
</
Text
>
<
Card
size=
"small"
style=
{
{
marginTop
:
8
,
maxHeight
:
200
,
overflow
:
'
auto
'
,
backgroundColor
:
'
#f5f5f5
'
}
}
>
<
pre
style=
{
{
margin
:
0
,
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
}
}
>
{
logDetailItem
.
prompt
||
'
无
'
}
</
pre
>
</
Card
>
</
div
>
<
div
>
<
Text
type=
"secondary"
>
响应内容:
</
Text
>
<
Card
size=
"small"
style=
{
{
marginTop
:
8
,
maxHeight
:
250
,
overflow
:
'
auto
'
}
}
>
<
pre
style=
{
{
margin
:
0
,
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
}
}
>
{
logDetailItem
.
response_content
||
'
无
'
}
</
pre
>
</
Card
>
</
div
>
{
logDetailItem
.
error_message
&&
(
<
div
>
<
Text
type=
"secondary"
>
错误信息:
</
Text
>
<
Alert
message=
{
logDetailItem
.
error_message
}
type=
"error"
style=
{
{
marginTop
:
8
}
}
/>
</
div
>
)
}
</
Space
>
)
}
</
Modal
>
</
div
>
);
};
export
default
AdminAIConfigPage
;
web/src/pages/admin/Admins/index.tsx
0 → 100644
View file @
52e57c72
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
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
UserInfo
}
from
'
../../../api/user
'
;
const
{
Title
,
Text
}
=
Typography
;
const
AdminAdminsPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
admins
,
setAdmins
]
=
useState
<
UserInfo
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
pageSize
,
setPageSize
]
=
useState
(
10
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
addModalVisible
,
setAddModalVisible
]
=
useState
(
false
);
const
[
addForm
]
=
Form
.
useForm
();
const
[
addLoading
,
setAddLoading
]
=
useState
(
false
);
const
[
editModalVisible
,
setEditModalVisible
]
=
useState
(
false
);
const
[
editForm
]
=
Form
.
useForm
();
const
[
editLoading
,
setEditLoading
]
=
useState
(
false
);
const
[
editingAdmin
,
setEditingAdmin
]
=
useState
<
UserInfo
|
null
>
(
null
);
const
fetchAdmins
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getAdminList
({
keyword
,
page
,
page_size
:
pageSize
});
setAdmins
(
res
.
data
.
list
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
(
error
)
{
message
.
error
(
'
获取管理员列表失败
'
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchAdmins
();
},
[
page
,
pageSize
]);
const
handleSearch
=
()
=>
{
setPage
(
1
);
fetchAdmins
();
};
const
handleToggleStatus
=
(
record
:
UserInfo
)
=>
{
const
newStatus
=
record
.
status
===
'
active
'
?
'
disabled
'
:
'
active
'
;
Modal
.
confirm
({
title
:
`确认
${
newStatus
===
'
disabled
'
?
'
禁用
'
:
'
启用
'
}
该管理员?`
,
content
:
`管理员:
${
record
.
real_name
||
record
.
phone
}
`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
updateAdminStatus
(
record
.
id
,
newStatus
);
message
.
success
(
'
操作成功
'
);
fetchAdmins
();
}
catch
(
error
)
{
message
.
error
(
'
操作失败
'
);
}
},
});
};
const
handleResetPassword
=
(
record
:
UserInfo
)
=>
{
Modal
.
confirm
({
title
:
'
确认重置密码?
'
,
content
:
`将重置管理员
${
record
.
real_name
||
record
.
phone
}
的密码为默认密码`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
resetAdminPassword
(
record
.
id
);
message
.
success
(
'
密码已重置为 123456
'
);
}
catch
(
error
)
{
message
.
error
(
'
重置失败
'
);
}
},
});
};
const
handleEditAdmin
=
(
record
:
UserInfo
)
=>
{
setEditingAdmin
(
record
);
editForm
.
setFieldsValue
({
real_name
:
record
.
real_name
,
username
:
record
.
phone
,
gender
:
record
.
gender
,
age
:
record
.
age
,
});
setEditModalVisible
(
true
);
};
const
handleEditSubmit
=
async
(
values
:
any
)
=>
{
if
(
!
editingAdmin
)
return
;
setEditLoading
(
true
);
try
{
await
adminApi
.
updateAdmin
(
editingAdmin
.
id
,
{
real_name
:
values
.
real_name
,
username
:
values
.
username
,
gender
:
values
.
gender
,
age
:
values
.
age
,
});
message
.
success
(
'
管理员信息更新成功
'
);
setEditModalVisible
(
false
);
editForm
.
resetFields
();
fetchAdmins
();
}
catch
(
error
)
{
message
.
error
(
'
更新失败
'
);
}
finally
{
setEditLoading
(
false
);
}
};
const
handleDeleteAdmin
=
(
record
:
UserInfo
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除该管理员?
'
,
content
:
`管理员:
${
record
.
real_name
||
record
.
phone
}
,删除后不可恢复`
,
okType
:
'
danger
'
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
deleteAdmin
(
record
.
id
);
message
.
success
(
'
删除成功
'
);
fetchAdmins
();
}
catch
(
error
)
{
message
.
error
(
'
删除失败
'
);
}
},
});
};
const
handleAddAdmin
=
async
(
values
:
any
)
=>
{
setAddLoading
(
true
);
try
{
await
adminApi
.
createAdmin
({
username
:
values
.
username
,
password
:
values
.
password
||
'
123456
'
,
real_name
:
values
.
real_name
,
gender
:
values
.
gender
,
age
:
values
.
age
,
});
message
.
success
(
'
管理员添加成功
'
);
setAddModalVisible
(
false
);
addForm
.
resetFields
();
fetchAdmins
();
}
catch
(
error
)
{
message
.
error
(
'
添加失败
'
);
}
finally
{
setAddLoading
(
false
);
}
};
const
columns
:
ColumnsType
<
UserInfo
>
=
[
{
title
:
'
管理员
'
,
key
:
'
admin
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Avatar
icon=
{
<
UserOutlined
/>
}
src=
{
record
.
avatar
}
style=
{
{
backgroundColor
:
'
#722ed1
'
}
}
/>
<
div
>
<
Text
strong
>
{
record
.
real_name
||
'
未填写
'
}
</
Text
>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
@
{
record
.
phone
}
</
Text
>
</
div
>
</
Space
>
),
},
{
title
:
'
用户名
'
,
dataIndex
:
'
phone
'
,
key
:
'
phone
'
,
},
{
title
:
'
性别
'
,
dataIndex
:
'
gender
'
,
key
:
'
gender
'
,
width
:
80
,
render
:
(
gender
:
string
)
=>
gender
||
'
-
'
,
},
{
title
:
'
年龄
'
,
dataIndex
:
'
age
'
,
key
:
'
age
'
,
width
:
80
,
render
:
(
age
:
number
)
=>
age
||
'
-
'
,
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
(
status
:
string
)
=>
(
<
Tag
color=
{
status
===
'
active
'
?
'
green
'
:
'
red
'
}
>
{
status
===
'
active
'
?
'
正常
'
:
'
已禁用
'
}
</
Tag
>
),
},
{
title
:
'
创建时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
render
:
(
time
:
string
)
=>
new
Date
(
time
).
toLocaleString
(
'
zh-CN
'
),
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
280
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEditAdmin
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger=
{
record
.
status
===
'
active
'
}
onClick=
{
()
=>
handleToggleStatus
(
record
)
}
>
{
record
.
status
===
'
active
'
?
'
禁用
'
:
'
启用
'
}
</
Button
>
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
handleResetPassword
(
record
)
}
>
重置密码
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDeleteAdmin
(
record
)
}
>
删除
</
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
>
<
span
className=
"text-xs text-gray-400"
>
共
{
total
}
条记录
</
span
>
</
div
>
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center justify-between gap-2"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder=
"搜索姓名/用户名"
prefix=
{
<
SearchOutlined
/>
}
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
handleSearch
}
style=
{
{
width
:
180
}
}
size=
"small"
/>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleSearch
}
>
搜索
</
Button
>
<
Button
size=
"small"
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
()
=>
{
setKeyword
(
''
);
setPage
(
1
);
fetchAdmins
();
}
}
>
重置
</
Button
>
</
div
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
setAddModalVisible
(
true
)
}
>
添加管理员
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
admins
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
:
pageSize
,
total
:
total
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
<
Modal
title=
"添加管理员"
open=
{
addModalVisible
}
onCancel=
{
()
=>
setAddModalVisible
(
false
)
}
footer=
{
null
}
width=
{
450
}
>
<
Form
form=
{
addForm
}
layout=
"vertical"
size=
"small"
onFinish=
{
handleAddAdmin
}
>
<
Form
.
Item
name=
"username"
label=
"用户名"
rules=
{
[{
required
:
true
,
message
:
'
请输入用户名
'
}]
}
>
<
Input
placeholder=
"用户名(用于登录)"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"real_name"
label=
"姓名"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名
'
}]
}
>
<
Input
placeholder=
"管理员姓名"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"gender"
label=
"性别"
>
<
Input
placeholder=
"性别"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"age"
label=
"年龄"
>
<
Input
type=
"number"
placeholder=
"年龄"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"password"
label=
"初始密码"
>
<
Input
.
Password
placeholder=
"默认 123456"
/>
</
Form
.
Item
>
<
Form
.
Item
>
<
Space
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
addLoading
}
>
确认添加
</
Button
>
<
Button
onClick=
{
()
=>
setAddModalVisible
(
false
)
}
>
取消
</
Button
>
</
Space
>
</
Form
.
Item
>
</
Form
>
</
Modal
>
<
Modal
title=
"编辑管理员"
open=
{
editModalVisible
}
onCancel=
{
()
=>
setEditModalVisible
(
false
)
}
footer=
{
null
}
width=
{
500
}
>
<
Form
form=
{
editForm
}
layout=
"vertical"
onFinish=
{
handleEditSubmit
}
>
<
Form
.
Item
name=
"username"
label=
"用户名"
rules=
{
[{
required
:
true
,
message
:
'
请输入用户名
'
}]
}
>
<
Input
placeholder=
"用户名"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"real_name"
label=
"姓名"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名
'
}]
}
>
<
Input
placeholder=
"管理员姓名"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"gender"
label=
"性别"
>
<
Input
placeholder=
"性别"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"age"
label=
"年龄"
>
<
Input
type=
"number"
placeholder=
"年龄"
/>
</
Form
.
Item
>
<
Form
.
Item
>
<
Space
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
editLoading
}
>
确认修改
</
Button
>
<
Button
onClick=
{
()
=>
setEditModalVisible
(
false
)
}
>
取消
</
Button
>
</
Space
>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</
div
>
);
};
export
default
AdminAdminsPage
;
web/src/pages/admin/Compliance/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Tag
,
Typography
,
Space
,
Tabs
,
Statistic
,
Button
,
Input
,
DatePicker
,
Select
,
Modal
,
message
,
Descriptions
,
Timeline
,
}
from
'
antd
'
;
import
{
AuditOutlined
,
FileSearchOutlined
,
DownloadOutlined
,
CloudUploadOutlined
,
SafetyCertificateOutlined
,
ClockCircleOutlined
,
UserOutlined
,
SettingOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
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
;
type
:
string
;
period
:
string
;
status
:
'
generated
'
|
'
pending
'
|
'
submitted
'
;
generated_at
?:
string
;
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
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
getStatusTag
=
(
status
:
string
)
=>
{
switch
(
status
)
{
case
'
generated
'
:
return
<
Tag
color=
"blue"
>
已生成
</
Tag
>;
case
'
pending
'
:
return
<
Tag
color=
"orange"
>
待生成
</
Tag
>;
case
'
submitted
'
:
return
<
Tag
color=
"green"
>
已上报
</
Tag
>;
default
:
return
<
Tag
>
{
status
}
</
Tag
>;
}
};
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
>
},
{
title
:
'
周期
'
,
dataIndex
:
'
period
'
,
key
:
'
period
'
,
width
:
100
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
getStatusTag
(
v
)
},
{
title
:
'
生成时间
'
,
dataIndex
:
'
generated_at
'
,
key
:
'
generated_at
'
,
width
:
120
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
},
{
title
:
'
上报时间
'
,
dataIndex
:
'
submitted_at
'
,
key
:
'
submitted_at
'
,
width
:
120
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
200
,
render
:
(
_
:
any
,
record
:
ReportItem
)
=>
(
<
Space
>
{
record
.
status
===
'
pending
'
&&
<
Button
size=
"small"
type=
"primary"
>
生成报告
</
Button
>
}
{
record
.
status
===
'
generated
'
&&
(
<>
<
Button
size=
"small"
icon=
{
<
DownloadOutlined
/>
}
>
下载
</
Button
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
CloudUploadOutlined
/>
}
>
上报
</
Button
>
</>
)
}
{
record
.
status
===
'
submitted
'
&&
<
Button
size=
"small"
icon=
{
<
DownloadOutlined
/>
}
>
下载
</
Button
>
}
</
Space
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
SafetyCertificateOutlined
className=
"mr-1"
/>
合规报表
</
h4
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #1890ff
'
}
}
>
<
Statistic
title=
"审计日志"
value=
{
mockLogs
.
length
}
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=
"条"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #52c41a
'
}
}
>
<
Statistic
title=
"已上报"
value=
{
mockReports
.
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=
"条"
/>
</
Card
>
</
Col
>
</
Row
>
<
Tabs
defaultActiveKey=
"logs"
size=
"small"
items=
{
[
{
key
:
'
logs
'
,
label
:
<
span
><
AuditOutlined
/>
审计日志
</
span
>,
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配置
'
},
]
}
/>
<
RangePicker
size=
"small"
/>
<
Button
type=
"primary"
size=
"small"
ghost
>
查询
</
Button
>
</
div
>
<
Table
columns=
{
logColumns
}
dataSource=
{
mockLogs
}
rowKey=
"id"
size=
"small"
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
}
}
/>
</
Card
>
),
},
{
key
:
'
export
'
,
label
:
<
span
><
DownloadOutlined
/>
数据导出
</
span
>,
children
:
(
<
Card
size=
"small"
>
<
Row
gutter=
{
[
8
,
8
]
}
>
{
[
{
title
:
'
问诊数据
'
,
desc
:
'
问诊记录、诊断、处方
'
,
icon
:
<
FileSearchOutlined
className=
"text-xl text-[#1890ff]"
/>
},
{
title
:
'
用户数据
'
,
desc
:
'
患者和医生基本信息
'
,
icon
:
<
UserOutlined
className=
"text-xl text-green-500"
/>
},
{
title
:
'
处方数据
'
,
desc
:
'
处方记录、用药统计
'
,
icon
:
<
AuditOutlined
className=
"text-xl text-purple-500"
/>
},
{
title
:
'
系统日志
'
,
desc
:
'
操作日志和审计记录
'
,
icon
:
<
SettingOutlined
className=
"text-xl text-orange-500"
/>
},
].
map
((
item
,
i
)
=>
(
<
Col
span=
{
6
}
key=
{
i
}
>
<
Card
size=
"small"
hoverable
className=
"text-center"
onClick=
{
()
=>
setExportModalVisible
(
true
)
}
>
<
div
className=
"mb-2"
>
{
item
.
icon
}
</
div
>
<
div
className=
"text-xs font-semibold mb-0.5"
>
{
item
.
title
}
</
div
>
<
div
className=
"text-[11px] text-gray-400"
>
{
item
.
desc
}
</
div
>
</
Card
>
</
Col
>
))
}
</
Row
>
</
Card
>
),
},
{
key
:
'
reports
'
,
label
:
<
span
><
CloudUploadOutlined
/>
监管上报
</
span
>,
children
:
(
<
Card
size=
"small"
>
<
div
className=
"flex items-center justify-between mb-2"
>
<
span
className=
"text-xs text-gray-400"
>
管理合规报告,支持下载和上报
</
span
>
<
Button
type=
"primary"
size=
"small"
>
生成新报告
</
Button
>
</
div
>
<
Table
columns=
{
reportColumns
}
dataSource=
{
mockReports
}
rowKey=
"id"
size=
"small"
pagination=
{
false
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
"数据导出"
open=
{
exportModalVisible
}
onOk=
{
handleExport
}
onCancel=
{
()
=>
setExportModalVisible
(
false
)
}
okText=
"确认导出"
width=
{
400
}
>
<
div
className=
"space-y-3"
>
<
div
>
<
Text
strong
className=
"text-xs!"
>
导出格式
</
Text
>
<
Select
defaultValue=
"xlsx"
size=
"small"
style=
{
{
width
:
'
100%
'
,
marginTop
:
4
}
}
options=
{
[
{
label
:
'
Excel (.xlsx)
'
,
value
:
'
xlsx
'
},
{
label
:
'
CSV
'
,
value
:
'
csv
'
},
{
label
:
'
JSON
'
,
value
:
'
json
'
},
]
}
/>
</
div
>
<
div
>
<
Text
strong
className=
"text-xs!"
>
时间范围
</
Text
>
<
RangePicker
size=
"small"
style=
{
{
width
:
'
100%
'
,
marginTop
:
4
}
}
/>
</
div
>
<
div
>
<
Text
strong
className=
"text-xs!"
>
数据脱敏
</
Text
>
<
Select
defaultValue=
"partial"
size=
"small"
style=
{
{
width
:
'
100%
'
,
marginTop
:
4
}
}
options=
{
[
{
label
:
'
部分脱敏(推荐)
'
,
value
:
'
partial
'
},
{
label
:
'
完全脱敏
'
,
value
:
'
full
'
},
{
label
:
'
不脱敏
'
,
value
:
'
none
'
},
]
}
/>
</
div
>
</
div
>
</
Modal
>
</
div
>
);
};
export
default
AdminCompliancePage
;
web/src/pages/admin/Consultations/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Input
,
Select
,
Button
,
Avatar
,
DatePicker
,
message
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
MessageOutlined
,
VideoCameraOutlined
,
EyeOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
dayjs
from
'
dayjs
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
AdminConsultItem
}
from
'
../../../api/admin
'
;
const
{
Title
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
pending
:
{
text
:
'
待支付
'
,
color
:
'
orange
'
},
waiting
:
{
text
:
'
等待接诊
'
,
color
:
'
processing
'
},
in_progress
:
{
text
:
'
进行中
'
,
color
:
'
blue
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
green
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
red
'
},
};
const
AdminConsultationsPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
data
,
setData
]
=
useState
<
AdminConsultItem
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
pageSize
,
setPageSize
]
=
useState
(
10
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
typeFilter
,
setTypeFilter
]
=
useState
<
string
|
undefined
>
();
const
[
statusFilter
,
setStatusFilter
]
=
useState
<
string
|
undefined
>
();
const
[
dateRange
,
setDateRange
]
=
useState
<
[
dayjs
.
Dayjs
|
null
,
dayjs
.
Dayjs
|
null
]
|
null
>
(
null
);
const
fetchData
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getConsultList
({
keyword
:
keyword
||
undefined
,
type
:
typeFilter
,
status
:
statusFilter
,
start_date
:
dateRange
?.[
0
]?.
format
(
'
YYYY-MM-DD
'
)
||
undefined
,
end_date
:
dateRange
?.[
1
]?.
format
(
'
YYYY-MM-DD
'
)
||
undefined
,
page
,
page_size
:
pageSize
,
});
const
result
=
res
.
data
as
any
;
setData
(
result
?.
list
||
[]);
setTotal
(
result
?.
total
||
0
);
}
catch
{
message
.
error
(
'
获取问诊列表失败
'
);
}
finally
{
setLoading
(
false
);
}
},
[
keyword
,
typeFilter
,
statusFilter
,
dateRange
,
page
,
pageSize
]);
useEffect
(()
=>
{
fetchData
();
},
[
fetchData
]);
const
handleSearch
=
()
=>
{
setPage
(
1
);
fetchData
();
};
const
columns
:
ColumnsType
<
AdminConsultItem
>
=
[
{
title
:
'
问诊ID
'
,
dataIndex
:
'
id
'
,
key
:
'
id
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
v
?
v
.
substring
(
0
,
8
)
+
'
...
'
:
'
-
'
,
},
{
title
:
'
患者
'
,
dataIndex
:
'
patient_name
'
,
key
:
'
patient_name
'
,
render
:
(
name
:
string
)
=>
(
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
/>
{
name
||
'
未知
'
}
</
Space
>
),
},
{
title
:
'
医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
render
:
(
name
:
string
)
=>
name
||
'
未分配
'
,
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
key
:
'
type
'
,
width
:
100
,
render
:
(
type
:
string
)
=>
(
<
Tag
icon=
{
type
===
'
video
'
?
<
VideoCameraOutlined
/>
:
<
MessageOutlined
/>
}
color=
{
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
),
},
{
title
:
'
主诉
'
,
dataIndex
:
'
chief_complaint
'
,
key
:
'
chief_complaint
'
,
ellipsis
:
true
,
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
100
,
render
:
(
status
:
string
)
=>
{
const
s
=
statusMap
[
status
]
||
{
text
:
status
,
color
:
'
default
'
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
text
}
</
Tag
>;
},
},
{
title
:
'
创建时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
170
,
render
:
(
t
:
string
)
=>
t
?
dayjs
(
t
).
format
(
'
YYYY-MM-DD HH:mm
'
)
:
'
-
'
,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
()
=>
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
EyeOutlined
/>
}
>
详情
</
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
>
<
span
className=
"text-xs text-gray-400"
>
共
{
total
}
条记录
</
span
>
</
div
>
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder=
"搜索患者/医生"
prefix=
{
<
SearchOutlined
/>
}
style=
{
{
width
:
160
}
}
size=
"small"
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
handleSearch
}
/>
<
Select
placeholder=
"类型"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
value=
{
typeFilter
}
onChange=
{
setTypeFilter
}
options=
{
[
{
label
:
'
图文
'
,
value
:
'
text
'
},
{
label
:
'
视频
'
,
value
:
'
video
'
},
]
}
/>
<
Select
placeholder=
"状态"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
value=
{
statusFilter
}
onChange=
{
setStatusFilter
}
options=
{
Object
.
entries
(
statusMap
).
map
(([
value
,
{
text
}])
=>
({
label
:
text
,
value
}))
}
/>
<
RangePicker
size=
"small"
value=
{
dateRange
}
onChange=
{
(
dates
)
=>
setDateRange
(
dates
)
}
/>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleSearch
}
>
搜索
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
data
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
,
total
,
showSizeChanger
:
true
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
</
div
>
);
};
export
default
AdminConsultationsPage
;
web/src/pages/admin/Dashboard/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useEffect
,
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Statistic
,
Typography
,
List
,
Avatar
,
Tag
,
Space
,
Progress
}
from
'
antd
'
;
import
{
UserOutlined
,
TeamOutlined
,
MessageOutlined
,
DollarOutlined
,
RiseOutlined
,
ClockCircleOutlined
,
MedicineBoxOutlined
,
CheckCircleOutlined
,
AuditOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
adminApi
,
type
DashboardStats
,
type
DoctorReviewItem
}
from
'
../../../api/admin
'
;
const
{
Title
,
Text
}
=
Typography
;
const
AdminDashboardPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
stats
,
setStats
]
=
useState
<
DashboardStats
>
({
total_users
:
0
,
total_doctors
:
0
,
total_consultations
:
0
,
today_consultations
:
0
,
pending_doctor_reviews
:
0
,
online_doctors
:
0
,
revenue_today
:
0
,
revenue_month
:
0
,
});
const
[
recentReviews
,
setRecentReviews
]
=
useState
<
DoctorReviewItem
[]
>
([]);
useEffect
(()
=>
{
const
fetchData
=
async
()
=>
{
setLoading
(
true
);
try
{
const
[
statsRes
,
reviewsRes
]
=
await
Promise
.
all
([
adminApi
.
getDashboardStats
(),
adminApi
.
getDoctorReviewList
(
'
pending
'
),
]);
setStats
(
statsRes
.
data
);
setRecentReviews
(
reviewsRes
.
data
.
list
?.
slice
(
0
,
3
)
||
[]);
}
catch
(
error
)
{
console
.
error
(
'
获取仪表盘数据失败
'
,
error
);
}
finally
{
setLoading
(
false
);
}
};
fetchData
();
},
[]);
return
(
<
div
className=
"space-y-3"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
运营大盘
</
h4
>
<
span
className=
"text-xs text-gray-400"
>
数据实时更新
</
span
>
</
div
>
{
/* 核心指标 */
}
<
Row
gutter=
{
[
8
,
8
]
}
>
{
[
{
title
:
'
注册用户
'
,
value
:
stats
.
total_users
,
icon
:
<
TeamOutlined
/>,
color
:
'
#1890ff
'
,
bg
:
'
#e6f7ff
'
},
{
title
:
'
注册医生
'
,
value
:
stats
.
total_doctors
,
icon
:
<
MedicineBoxOutlined
/>,
color
:
'
#52c41a
'
,
bg
:
'
#f6ffed
'
,
suffix
:
<
span
className=
"text-[11px] text-gray-400 ml-1"
>
在线
{
stats
.
online_doctors
}
</
span
>
},
{
title
:
'
今日问诊
'
,
value
:
stats
.
today_consultations
,
icon
:
<
MessageOutlined
/>,
color
:
'
#722ed1
'
,
bg
:
'
#f9f0ff
'
},
{
title
:
'
今日收入
'
,
value
:
stats
.
revenue_today
/
100
,
icon
:
<
DollarOutlined
/>,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
,
prefix
:
'
¥
'
},
].
map
((
item
,
i
)
=>
(
<
Col
xs=
{
12
}
sm=
{
6
}
key=
{
i
}
>
<
div
className=
"bg-white rounded-lg p-3 border border-[#e6f0fa] hover:shadow-md transition-shadow"
>
<
div
className=
"flex items-center gap-2 mb-2"
>
<
div
className=
"w-7 h-7 rounded-md flex items-center justify-center text-sm"
style=
{
{
background
:
item
.
bg
,
color
:
item
.
color
}
}
>
{
item
.
icon
}
</
div
>
<
span
className=
"text-xs text-gray-500"
>
{
item
.
title
}
</
span
>
</
div
>
<
div
className=
"text-xl font-bold"
style=
{
{
color
:
item
.
color
}
}
>
{
typeof
item
.
value
===
'
number
'
?
item
.
value
.
toLocaleString
()
:
item
.
value
}
{
item
.
suffix
}
</
div
>
</
div
>
</
Col
>
))
}
</
Row
>
{
/* 平台概况 + 待审核*/
}
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
xs=
{
24
}
sm=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
平台概况
</
span
>
}
size=
"small"
className=
"h-full!"
>
<
div
className=
"space-y-3"
>
<
div
>
<
div
className=
"flex justify-between mb-1"
>
<
Text
className=
"text-xs!"
>
医生在线
</
Text
>
<
Text
strong
className=
"text-xs!"
>
{
stats
.
total_doctors
>
0
?
((
stats
.
online_doctors
/
stats
.
total_doctors
)
*
100
).
toFixed
(
0
)
:
0
}
%
</
Text
>
</
div
>
<
Progress
percent=
{
stats
.
total_doctors
>
0
?
Math
.
round
((
stats
.
online_doctors
/
stats
.
total_doctors
)
*
100
)
:
0
}
strokeColor=
"#52c41a"
showInfo=
{
false
}
size=
"small"
/>
</
div
>
<
div
>
<
div
className=
"flex justify-between mb-1"
>
<
Text
className=
"text-xs!"
>
今日完成
</
Text
>
<
Text
strong
className=
"text-xs!"
>
{
stats
.
today_consultations
>
0
?
Math
.
round
((
stats
.
today_consultations
/
(
stats
.
today_consultations
+
stats
.
pending_doctor_reviews
))
*
100
)
:
0
}
%
</
Text
>
</
div
>
<
Progress
percent=
{
stats
.
today_consultations
>
0
?
Math
.
round
((
stats
.
today_consultations
/
(
stats
.
today_consultations
+
stats
.
pending_doctor_reviews
))
*
100
)
:
0
}
strokeColor=
"#1890ff"
showInfo=
{
false
}
size=
"small"
/>
</
div
>
<
div
>
<
div
className=
"flex justify-between mb-1"
>
<
Text
className=
"text-xs!"
>
患者满意度
</
Text
>
<
Text
strong
className=
"text-xs!"
>
96%
</
Text
>
</
div
>
<
Progress
percent=
{
96
}
strokeColor=
"#722ed1"
showInfo=
{
false
}
size=
"small"
/>
</
div
>
</
div
>
</
Card
>
</
Col
>
<
Col
xs=
{
24
}
sm=
{
12
}
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
待审核医生
<
Tag
color=
"orange"
className=
"ml-1"
>
{
stats
.
pending_doctor_reviews
}
</
Tag
></
span
>
}
size=
"small"
className=
"h-full!"
>
<
List
size=
"small"
dataSource=
{
recentReviews
}
renderItem=
{
(
item
)
=>
(
<
List
.
Item
className=
"py-2!"
>
<
List
.
Item
.
Meta
avatar=
{
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
}
title=
{
<
span
className=
"text-xs"
><
Text
strong
>
{
item
.
name
}
</
Text
>
<
Tag
className=
"ml-1"
>
{
item
.
title
}
</
Tag
></
span
>
}
description=
{
<
span
className=
"text-[11px]"
>
{
item
.
department_name
}
·
{
item
.
submitted_at
}
</
span
>
}
/>
<
Tag
color=
"orange"
className=
"text-[10px]!"
>
待审
</
Tag
>
</
List
.
Item
>
)
}
/>
</
Card
>
</
Col
>
</
Row
>
{
/* 底部统计 */
}
<
Row
gutter=
{
[
8
,
8
]
}
>
{
[
{
icon
:
<
CheckCircleOutlined
/>,
color
:
'
#52c41a
'
,
bg
:
'
#f6ffed
'
,
label
:
'
累计问诊
'
,
value
:
stats
.
total_consultations
.
toLocaleString
()
},
{
icon
:
<
RiseOutlined
/>,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
,
label
:
'
本月收入
'
,
value
:
`¥${(stats.revenue_month / 100).toLocaleString()}`
},
{
icon
:
<
ClockCircleOutlined
/>,
color
:
'
#1890ff
'
,
bg
:
'
#e6f7ff
'
,
label
:
'
平均响应
'
,
value
:
'
3.2分钟
'
},
{
icon
:
<
AuditOutlined
/>,
color
:
'
#722ed1
'
,
bg
:
'
#f9f0ff
'
,
label
:
'
待审核
'
,
value
:
stats
.
pending_doctor_reviews
},
].
map
((
item
,
i
)
=>
(
<
Col
xs=
{
12
}
sm=
{
6
}
key=
{
i
}
>
<
div
className=
"bg-white rounded-lg p-3 border border-[#e6f0fa] text-center"
>
<
div
className=
"w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-1 text-base"
style=
{
{
background
:
item
.
bg
,
color
:
item
.
color
}
}
>
{
item
.
icon
}
</
div
>
<
div
className=
"text-[11px] text-gray-400"
>
{
item
.
label
}
</
div
>
<
div
className=
"text-lg font-bold"
style=
{
{
color
:
item
.
color
}
}
>
{
item
.
value
}
</
div
>
</
div
>
</
Col
>
))
}
</
Row
>
</
div
>
);
};
export
default
AdminDashboardPage
;
web/src/pages/admin/Departments/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Typography
,
Space
,
Button
,
Modal
,
Form
,
Input
,
InputNumber
,
message
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
Department
}
from
'
../../../api/doctor
'
;
const
{
Text
}
=
Typography
;
const
AdminDepartmentsPage
:
React
.
FC
=
()
=>
{
const
[
isModalOpen
,
setIsModalOpen
]
=
useState
(
false
);
const
[
editingDept
,
setEditingDept
]
=
useState
<
Department
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
departments
,
setDepartments
]
=
useState
<
Department
[]
>
([]);
const
[
saveLoading
,
setSaveLoading
]
=
useState
(
false
);
const
fetchDepartments
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getDepartmentList
();
setDepartments
(
res
.
data
||
[]);
}
catch
{
message
.
error
(
'
获取科室列表失败
'
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchDepartments
();
},
[]);
const
handleAdd
=
()
=>
{
setEditingDept
(
null
);
form
.
resetFields
();
setIsModalOpen
(
true
);
};
const
handleEdit
=
(
record
:
Department
)
=>
{
setEditingDept
(
record
);
form
.
setFieldsValue
(
record
);
setIsModalOpen
(
true
);
};
const
handleDelete
=
(
record
:
Department
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除
'
,
content
:
`确定要删除科室「
${
record
.
name
}
」吗?该操作不可恢复。`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
deleteDepartment
(
record
.
id
);
message
.
success
(
'
已删除
'
);
fetchDepartments
();
}
catch
{
message
.
error
(
'
删除失败
'
);
}
},
});
};
const
handleSave
=
async
(
values
:
any
)
=>
{
setSaveLoading
(
true
);
try
{
if
(
editingDept
)
{
await
adminApi
.
updateDepartment
(
editingDept
.
id
,
values
);
message
.
success
(
'
科室更新成功
'
);
}
else
{
await
adminApi
.
createDepartment
(
values
);
message
.
success
(
'
科室创建成功
'
);
}
setIsModalOpen
(
false
);
fetchDepartments
();
}
catch
{
message
.
error
(
'
操作失败
'
);
}
finally
{
setSaveLoading
(
false
);
}
};
const
columns
:
ColumnsType
<
Department
>
=
[
{
title
:
'
图标
'
,
dataIndex
:
'
icon
'
,
key
:
'
icon
'
,
width
:
60
,
render
:
(
icon
:
string
)
=>
<
span
style=
{
{
fontSize
:
24
}
}
>
{
icon
||
'
🏥
'
}
</
span
>,
},
{
title
:
'
科室名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
render
:
(
name
:
string
)
=>
<
Text
strong
>
{
name
}
</
Text
>,
},
{
title
:
'
排序
'
,
dataIndex
:
'
sort_order
'
,
key
:
'
sort_order
'
,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEdit
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDelete
(
record
)
}
>
删除
</
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
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAdd
}
>
添加科室
</
Button
>
</
div
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
departments
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
false
}
/>
</
Card
>
<
Modal
title=
{
editingDept
?
'
编辑科室
'
:
'
添加科室
'
}
open=
{
isModalOpen
}
onCancel=
{
()
=>
setIsModalOpen
(
false
)
}
onOk=
{
()
=>
form
.
submit
()
}
confirmLoading=
{
saveLoading
}
width=
{
400
}
>
<
Form
form=
{
form
}
onFinish=
{
handleSave
}
layout=
"vertical"
size=
"small"
>
<
Form
.
Item
name=
"name"
label=
"科室名称"
rules=
{
[{
required
:
true
,
message
:
'
请输入科室名称
'
}]
}
>
<
Input
placeholder=
"请输入科室名称"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"icon"
label=
"图标"
>
<
Input
placeholder=
"请输入Emoji图标"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"sort_order"
label=
"排序号"
initialValue=
{
1
}
>
<
InputNumber
min=
{
1
}
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</
div
>
);
};
export
default
AdminDepartmentsPage
;
web/src/pages/admin/DoctorReview/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Button
,
Avatar
,
Modal
,
message
,
Descriptions
}
from
'
antd
'
;
import
{
UserOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
EyeOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
,
type
DoctorReviewItem
}
from
'
../../../api/admin
'
;
const
{
Title
,
Text
}
=
Typography
;
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
pending
:
{
text
:
'
待审核
'
,
color
:
'
orange
'
},
approved
:
{
text
:
'
已通过
'
,
color
:
'
green
'
},
rejected
:
{
text
:
'
已拒绝
'
,
color
:
'
red
'
},
};
const
AdminDoctorReviewPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
reviewData
,
setReviewData
]
=
useState
<
DoctorReviewItem
[]
>
([]);
const
fetchReviews
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getDoctorReviewList
();
setReviewData
(
res
.
data
.
list
||
[]);
}
catch
(
error
)
{
console
.
error
(
'
获取医生审核列表失败:
'
,
error
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchReviews
();
},
[]);
const
handleApprove
=
(
record
:
DoctorReviewItem
)
=>
{
Modal
.
confirm
({
title
:
'
确认通过
'
,
content
:
`确认通过
${
record
.
name
}
的医生认证申请?`
,
icon
:
<
CheckCircleOutlined
style=
{
{
color
:
'
#52c41a
'
}
}
/>,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
approveDoctorReview
(
record
.
id
);
message
.
success
(
'
审核已通过
'
);
fetchReviews
();
}
catch
(
error
)
{
message
.
error
(
'
操作失败
'
);
}
},
});
};
const
handleReject
=
(
record
:
DoctorReviewItem
)
=>
{
Modal
.
confirm
({
title
:
'
拒绝申请
'
,
content
:
`确认拒绝
${
record
.
name
}
的医生认证申请?`
,
icon
:
<
CloseCircleOutlined
style=
{
{
color
:
'
#ff4d4f
'
}
}
/>,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
rejectDoctorReview
(
record
.
id
,
'
资质不符合要求
'
);
message
.
success
(
'
已拒绝
'
);
fetchReviews
();
}
catch
(
error
)
{
message
.
error
(
'
操作失败
'
);
}
},
});
};
const
handleViewDetail
=
(
record
:
DoctorReviewItem
)
=>
{
Modal
.
info
({
title
:
'
医生审核详情
'
,
width
:
600
,
content
:
(
<
Descriptions
column=
{
2
}
style=
{
{
marginTop
:
16
}
}
>
<
Descriptions
.
Item
label=
"姓名"
>
{
record
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"电话"
>
{
record
.
phone
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执业证号"
>
{
record
.
license_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
record
.
title
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"所属医院"
>
{
record
.
hospital
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"科室"
>
{
record
.
department_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"提交时间"
>
{
record
.
submitted_at
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"审核状态"
>
<
Tag
color=
{
statusMap
[
record
.
status
].
color
}
>
{
statusMap
[
record
.
status
].
text
}
</
Tag
>
</
Descriptions
.
Item
>
</
Descriptions
>
),
});
};
const
columns
:
ColumnsType
<
DoctorReviewItem
>
=
[
{
title
:
'
医生
'
,
key
:
'
doctor
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Avatar
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<
div
>
<
Text
strong
>
{
record
.
name
}
</
Text
>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
record
.
phone
}
</
Text
>
</
div
>
</
Space
>
),
},
{
title
:
'
职称
'
,
dataIndex
:
'
title
'
,
key
:
'
title
'
,
render
:
(
title
:
string
)
=>
<
Tag
color=
"blue"
>
{
title
}
</
Tag
>,
},
{
title
:
'
医院
'
,
dataIndex
:
'
hospital
'
,
key
:
'
hospital
'
,
},
{
title
:
'
科室
'
,
dataIndex
:
'
department_name
'
,
key
:
'
department_name
'
,
},
{
title
:
'
执业证号
'
,
dataIndex
:
'
license_no
'
,
key
:
'
license_no
'
,
},
{
title
:
'
提交时间
'
,
dataIndex
:
'
submitted_at
'
,
key
:
'
submitted_at
'
,
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
(
status
:
string
)
=>
{
const
s
=
statusMap
[
status
];
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
text
}
</
Tag
>;
},
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EyeOutlined
/>
}
onClick=
{
()
=>
handleViewDetail
(
record
)
}
>
查看
</
Button
>
{
record
.
status
===
'
pending
'
&&
(
<>
<
Button
type=
"link"
size=
"small"
style=
{
{
color
:
'
#52c41a
'
}
}
icon=
{
<
CheckCircleOutlined
/>
}
onClick=
{
()
=>
handleApprove
(
record
)
}
>
通过
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
CloseCircleOutlined
/>
}
onClick=
{
()
=>
handleReject
(
record
)
}
>
拒绝
</
Button
>
</>
)
}
</
Space
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
医生审核
</
h4
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
reviewData
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
}
}
/>
</
Card
>
</
div
>
);
};
export
default
AdminDoctorReviewPage
;
web/src/pages/admin/Doctors/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Button
,
Avatar
,
Modal
,
message
,
Input
,
Select
,
Form
,
Descriptions
,
Row
,
Col
,
}
from
'
antd
'
;
import
{
UserOutlined
,
SearchOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
EyeOutlined
,
EditOutlined
,
ReloadOutlined
,
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
DoctorItem
}
from
'
../../../api/admin
'
;
const
{
Text
}
=
Typography
;
const
statusMap
:
Record
<
string
,
{
color
:
string
;
text
:
string
}
>
=
{
not_submitted
:
{
color
:
'
default
'
,
text
:
'
未提交认证
'
},
pending
:
{
color
:
'
orange
'
,
text
:
'
待审核
'
},
approved
:
{
color
:
'
green
'
,
text
:
'
已认证
'
},
rejected
:
{
color
:
'
red
'
,
text
:
'
已拒绝
'
},
};
const
AdminDoctorsPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
doctors
,
setDoctors
]
=
useState
<
DoctorItem
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
pageSize
,
setPageSize
]
=
useState
(
10
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
<
string
|
undefined
>
();
const
[
detailVisible
,
setDetailVisible
]
=
useState
(
false
);
const
[
currentDoctor
,
setCurrentDoctor
]
=
useState
<
DoctorItem
|
null
>
(
null
);
const
[
reviewModalVisible
,
setReviewModalVisible
]
=
useState
(
false
);
const
[
rejectReason
,
setRejectReason
]
=
useState
(
''
);
const
[
editModalVisible
,
setEditModalVisible
]
=
useState
(
false
);
const
[
editingDoctor
,
setEditingDoctor
]
=
useState
<
DoctorItem
|
null
>
(
null
);
const
[
editForm
]
=
Form
.
useForm
();
const
[
editLoading
,
setEditLoading
]
=
useState
(
false
);
const
fetchDoctors
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getDoctorList
({
keyword
:
keyword
||
undefined
,
status
:
statusFilter
,
page
,
page_size
:
pageSize
,
});
const
data
=
res
.
data
as
any
;
setDoctors
(
data
?.
list
||
[]);
setTotal
(
data
?.
total
||
0
);
}
catch
{
message
.
error
(
'
获取医生列表失败
'
);
}
finally
{
setLoading
(
false
);
}
},
[
keyword
,
statusFilter
,
page
,
pageSize
]);
useEffect
(()
=>
{
fetchDoctors
();
},
[
fetchDoctors
]);
const
handleSearch
=
()
=>
{
setPage
(
1
);
fetchDoctors
();
};
const
handleViewDetail
=
(
record
:
DoctorItem
)
=>
{
setCurrentDoctor
(
record
);
setDetailVisible
(
true
);
};
const
handleReview
=
(
record
:
DoctorItem
)
=>
{
setCurrentDoctor
(
record
);
setReviewModalVisible
(
true
);
};
const
handleApprove
=
async
()
=>
{
if
(
!
currentDoctor
?.
review_id
)
return
;
try
{
await
adminApi
.
approveDoctorReview
(
currentDoctor
.
review_id
);
message
.
success
(
'
审核通过
'
);
setReviewModalVisible
(
false
);
fetchDoctors
();
}
catch
(
error
:
any
)
{
message
.
error
(
error
.
response
?.
data
?.
message
||
'
审核失败
'
);
}
};
const
handleReject
=
async
()
=>
{
if
(
!
currentDoctor
?.
review_id
||
!
rejectReason
)
{
message
.
error
(
'
请输入拒绝原因
'
);
return
;
}
try
{
await
adminApi
.
rejectDoctorReview
(
currentDoctor
.
review_id
,
rejectReason
);
message
.
success
(
'
已拒绝
'
);
setReviewModalVisible
(
false
);
setRejectReason
(
''
);
fetchDoctors
();
}
catch
(
error
:
any
)
{
message
.
error
(
error
.
response
?.
data
?.
message
||
'
操作失败
'
);
}
};
const
handleToggleStatus
=
(
record
:
DoctorItem
)
=>
{
const
newStatus
=
record
.
user_status
===
'
active
'
?
'
disabled
'
:
'
active
'
;
Modal
.
confirm
({
title
:
`确认
${
newStatus
===
'
disabled
'
?
'
禁用
'
:
'
启用
'
}
该医生?`
,
content
:
`医生「
${
record
.
name
}
」`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
updateUserStatus
(
record
.
user_id
,
newStatus
);
message
.
success
(
'
操作成功
'
);
fetchDoctors
();
}
catch
{
message
.
error
(
'
操作失败
'
);
}
},
});
};
const
handleResetPassword
=
(
record
:
DoctorItem
)
=>
{
Modal
.
confirm
({
title
:
'
确认重置密码?
'
,
content
:
`将重置医生
${
record
.
name
}
的密码为默认密码 123456`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
resetUserPassword
(
record
.
user_id
);
message
.
success
(
'
密码已重置为 123456
'
);
}
catch
{
message
.
error
(
'
重置失败
'
);
}
},
});
};
const
handleEditDoctor
=
(
record
:
DoctorItem
)
=>
{
setEditingDoctor
(
record
);
editForm
.
setFieldsValue
({
real_name
:
record
.
name
,
phone
:
record
.
phone
,
title
:
record
.
title
,
department_id
:
record
.
department_id
,
hospital
:
record
.
hospital
,
});
setEditModalVisible
(
true
);
};
const
handleSaveEdit
=
async
()
=>
{
try
{
const
values
=
await
editForm
.
validateFields
();
setEditLoading
(
true
);
await
adminApi
.
updateDoctor
(
editingDoctor
!
.
id
,
values
);
message
.
success
(
'
医生信息已更新
'
);
setEditModalVisible
(
false
);
fetchDoctors
();
}
catch
(
error
:
any
)
{
if
(
!
error
?.
errorFields
)
{
message
.
error
(
'
更新失败
'
);
}
}
finally
{
setEditLoading
(
false
);
}
};
const
getStatusTag
=
(
status
:
string
)
=>
{
const
config
=
statusMap
[
status
]
||
{
color
:
'
default
'
,
text
:
status
};
return
<
Tag
color=
{
config
.
color
}
>
{
config
.
text
}
</
Tag
>;
};
const
columns
:
ColumnsType
<
DoctorItem
>
=
[
{
title
:
'
医生
'
,
key
:
'
doctor
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Avatar
icon=
{
<
UserOutlined
/>
}
src=
{
record
.
avatar
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<
div
>
<
Text
strong
>
{
record
.
name
}
</
Text
>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
record
.
phone
}
</
Text
>
</
div
>
</
Space
>
),
},
{
title
:
'
职称
'
,
dataIndex
:
'
title
'
,
key
:
'
title
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
v
?
<
Tag
color=
"blue"
>
{
v
}
</
Tag
>
:
'
-
'
},
{
title
:
'
科室
'
,
dataIndex
:
'
department_name
'
,
key
:
'
department_name
'
,
width
:
100
},
{
title
:
'
医院
'
,
dataIndex
:
'
hospital
'
,
key
:
'
hospital
'
,
width
:
150
,
ellipsis
:
true
},
{
title
:
'
认证状态
'
,
dataIndex
:
'
review_status
'
,
key
:
'
review_status
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
getStatusTag
(
v
)
},
{
title
:
'
账号状态
'
,
dataIndex
:
'
user_status
'
,
key
:
'
user_status
'
,
width
:
90
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
v
===
'
active
'
?
'
green
'
:
'
red
'
}
>
{
v
===
'
active
'
?
'
正常
'
:
'
已禁用
'
}
</
Tag
>,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
220
,
render
:
(
_
,
record
)
=>
(
<
Space
size=
{
0
}
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EyeOutlined
/>
}
onClick=
{
()
=>
handleViewDetail
(
record
)
}
>
详情
</
Button
>
{
record
.
review_status
===
'
pending
'
&&
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
CheckCircleOutlined
/>
}
onClick=
{
()
=>
handleReview
(
record
)
}
>
审核
</
Button
>
)
}
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEditDoctor
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
danger=
{
record
.
user_status
===
'
active
'
}
onClick=
{
()
=>
handleToggleStatus
(
record
)
}
>
{
record
.
user_status
===
'
active
'
?
'
禁用
'
:
'
启用
'
}
</
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
>
<
span
className=
"text-xs text-gray-400"
>
共
{
total
}
位医生
</
span
>
</
div
>
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder=
"搜索姓名/手机号"
prefix=
{
<
SearchOutlined
/>
}
style=
{
{
width
:
160
}
}
size=
"small"
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
handleSearch
}
/>
<
Select
placeholder=
"认证状态"
style=
{
{
width
:
120
}
}
size=
"small"
allowClear
value=
{
statusFilter
}
onChange=
{
setStatusFilter
}
options=
{
Object
.
entries
(
statusMap
).
map
(([
k
,
v
])
=>
({
label
:
v
.
text
,
value
:
k
}))
}
/>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleSearch
}
>
搜索
</
Button
>
<
Button
size=
"small"
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
()
=>
{
setKeyword
(
''
);
setStatusFilter
(
undefined
);
setPage
(
1
);
}
}
>
重置
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
doctors
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
,
total
,
showSizeChanger
:
true
,
size
:
'
small
'
,
showTotal
:
(
t
)
=>
`共 ${t} 条`
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
<
Modal
title=
"医生详情"
open=
{
detailVisible
}
onCancel=
{
()
=>
setDetailVisible
(
false
)
}
footer=
{
null
}
width=
{
600
}
>
{
currentDoctor
&&
(
<
Descriptions
column=
{
2
}
bordered
size=
"small"
>
<
Descriptions
.
Item
label=
"姓名"
>
{
currentDoctor
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"手机号"
>
{
currentDoctor
.
phone
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
currentDoctor
.
title
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"科室"
>
{
currentDoctor
.
department_name
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医院"
span=
{
2
}
>
{
currentDoctor
.
hospital
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执业证号"
>
{
currentDoctor
.
license_no
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"认证状态"
>
{
getStatusTag
(
currentDoctor
.
review_status
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"账号状态"
>
<
Tag
color=
{
currentDoctor
.
user_status
===
'
active
'
?
'
green
'
:
'
red
'
}
>
{
currentDoctor
.
user_status
===
'
active
'
?
'
正常
'
:
'
已禁用
'
}
</
Tag
>
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"简介"
span=
{
2
}
>
{
currentDoctor
.
introduction
||
'
-
'
}
</
Descriptions
.
Item
>
</
Descriptions
>
)
}
</
Modal
>
<
Modal
title=
"审核医生认证"
open=
{
reviewModalVisible
}
onCancel=
{
()
=>
{
setReviewModalVisible
(
false
);
setRejectReason
(
''
);
}
}
footer=
{
[
<
Button
key=
"reject"
danger
onClick=
{
handleReject
}
disabled=
{
!
rejectReason
}
>
拒绝
</
Button
>,
<
Button
key=
"approve"
type=
"primary"
onClick=
{
handleApprove
}
>
通过
</
Button
>,
]
}
width=
{
500
}
>
{
currentDoctor
&&
(
<
div
className=
"space-y-4"
>
<
Descriptions
column=
{
1
}
size=
"small"
>
<
Descriptions
.
Item
label=
"姓名"
>
{
currentDoctor
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
currentDoctor
.
title
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执业证号"
>
{
currentDoctor
.
license_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医院"
>
{
currentDoctor
.
hospital
}
</
Descriptions
.
Item
>
</
Descriptions
>
<
div
>
<
Text
type=
"secondary"
className=
"text-xs"
>
拒绝原因(拒绝时必填):
</
Text
>
<
Input
.
TextArea
rows=
{
3
}
placeholder=
"请输入拒绝原因..."
value=
{
rejectReason
}
onChange=
{
(
e
)
=>
setRejectReason
(
e
.
target
.
value
)
}
/>
</
div
>
</
div
>
)
}
</
Modal
>
<
Modal
title=
"编辑医生信息"
open=
{
editModalVisible
}
onCancel=
{
()
=>
setEditModalVisible
(
false
)
}
onOk=
{
handleSaveEdit
}
okText=
"保存"
confirmLoading=
{
editLoading
}
width=
{
500
}
>
<
Form
form=
{
editForm
}
layout=
"vertical"
size=
"small"
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"姓名"
name=
"real_name"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名
'
}]
}
>
<
Input
placeholder=
"请输入姓名"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"手机号"
name=
"phone"
>
<
Input
placeholder=
"请输入手机号"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"职称"
name=
"title"
>
<
Select
placeholder=
"选择职称"
options=
{
[
{
label
:
'
主任医师
'
,
value
:
'
主任医师
'
},
{
label
:
'
副主任医师
'
,
value
:
'
副主任医师
'
},
{
label
:
'
主治医师
'
,
value
:
'
主治医师
'
},
{
label
:
'
住院医师
'
,
value
:
'
住院医师
'
},
]
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"科室"
name=
"department_id"
>
<
Input
placeholder=
"科室ID"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"医院"
name=
"hospital"
>
<
Input
placeholder=
"请输入医院名称"
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</
div
>
);
};
export
default
AdminDoctorsPage
;
web/src/pages/admin/Pharmacy/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Tag
,
Typography
,
Space
,
Tabs
,
Statistic
,
Button
,
Input
,
Modal
,
Form
,
InputNumber
,
Select
,
message
,
Badge
,
Alert
,
Popconfirm
,
}
from
'
antd
'
;
import
{
MedicineBoxOutlined
,
PlusOutlined
,
SearchOutlined
,
WarningOutlined
,
EditOutlined
,
DeleteOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
medicineApi
}
from
'
../../../api/prescription
'
;
import
type
{
Medicine
,
MedicineStats
}
from
'
../../../api/prescription
'
;
const
{
Text
}
=
Typography
;
const
categoryOptions
=
[
{
label
:
'
抗生素
'
,
value
:
'
抗生素
'
},
{
label
:
'
解热镇痛
'
,
value
:
'
解热镇痛
'
},
{
label
:
'
降糖药
'
,
value
:
'
降糖药
'
},
{
label
:
'
降压药
'
,
value
:
'
降压药
'
},
{
label
:
'
消化系统
'
,
value
:
'
消化系统
'
},
{
label
:
'
心血管
'
,
value
:
'
心血管
'
},
{
label
:
'
呼吸系统
'
,
value
:
'
呼吸系统
'
},
{
label
:
'
维生素
'
,
value
:
'
维生素
'
},
{
label
:
'
中成药
'
,
value
:
'
中成药
'
},
{
label
:
'
其他
'
,
value
:
'
其他
'
},
];
const
AdminPharmacyPage
:
React
.
FC
=
()
=>
{
const
[
drugModalVisible
,
setDrugModalVisible
]
=
useState
(
false
);
const
[
editingDrug
,
setEditingDrug
]
=
useState
<
Medicine
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
[
drugs
,
setDrugs
]
=
useState
<
Medicine
[]
>
([]);
const
[
stats
,
setStats
]
=
useState
<
MedicineStats
>
({
total_count
:
0
,
normal_count
:
0
,
low_stock_count
:
0
,
out_of_stock_count
:
0
});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
filterCategory
,
setFilterCategory
]
=
useState
<
string
>
(
''
);
const
[
filterStatus
,
setFilterStatus
]
=
useState
<
string
>
(
''
);
const
fetchStats
=
useCallback
(
async
()
=>
{
try
{
const
res
=
await
medicineApi
.
getStats
();
setStats
(
res
.
data
);
}
catch
{
/* ignore */
}
},
[]);
const
fetchList
=
useCallback
(
async
(
statusOverride
?:
string
)
=>
{
setLoading
(
true
);
try
{
const
res
=
await
medicineApi
.
getList
({
keyword
,
category
:
filterCategory
,
status
:
statusOverride
!==
undefined
?
statusOverride
:
filterStatus
,
page
,
page_size
:
10
,
});
const
data
=
res
.
data
as
any
;
setDrugs
(
data
?.
list
||
[]);
setTotal
(
data
?.
total
||
0
);
}
catch
{
/* ignore */
}
setLoading
(
false
);
},
[
keyword
,
filterCategory
,
filterStatus
,
page
]);
useEffect
(()
=>
{
fetchStats
();
},
[
fetchStats
]);
useEffect
(()
=>
{
fetchList
();
},
[
fetchList
]);
const
handleAddDrug
=
()
=>
{
setEditingDrug
(
null
);
form
.
resetFields
();
setDrugModalVisible
(
true
);
};
const
handleEditDrug
=
(
drug
:
Medicine
)
=>
{
setEditingDrug
(
drug
);
form
.
setFieldsValue
({
name
:
drug
.
name
,
generic_name
:
drug
.
generic_name
,
specification
:
drug
.
specification
,
category
:
drug
.
category
,
manufacturer
:
drug
.
manufacturer
,
unit
:
drug
.
unit
,
price
:
drug
.
price
/
100
,
stock
:
drug
.
stock
,
stock_warning
:
drug
.
stock_warning
,
});
setDrugModalVisible
(
true
);
};
const
handleDeleteDrug
=
async
(
id
:
string
)
=>
{
try
{
await
medicineApi
.
delete
(
id
);
message
.
success
(
'
药品已删除
'
);
fetchList
();
fetchStats
();
}
catch
(
err
:
any
)
{
message
.
error
(
err
?.
message
||
'
删除失败
'
);
}
};
const
handleSaveDrug
=
async
()
=>
{
try
{
const
values
=
await
form
.
validateFields
();
const
priceInCents
=
Math
.
round
((
values
.
price
||
0
)
*
100
);
if
(
editingDrug
)
{
await
medicineApi
.
update
(
editingDrug
.
id
,
{
...
values
,
price
:
priceInCents
});
message
.
success
(
'
药品已更新
'
);
}
else
{
await
medicineApi
.
create
({
...
values
,
price
:
priceInCents
});
message
.
success
(
'
药品已添加
'
);
}
setDrugModalVisible
(
false
);
fetchList
();
fetchStats
();
}
catch
(
err
:
any
)
{
if
(
err
?.
message
)
message
.
error
(
err
.
message
);
}
};
const
getStockTag
=
(
status
:
string
)
=>
{
switch
(
status
)
{
case
'
normal
'
:
return
<
Tag
color=
"green"
>
库存充足
</
Tag
>;
case
'
low_stock
'
:
return
<
Tag
color=
"orange"
icon=
{
<
WarningOutlined
/>
}
>
库存预警
</
Tag
>;
case
'
out_of_stock
'
:
return
<
Tag
color=
"red"
>
缺货
</
Tag
>;
default
:
return
<
Tag
>
{
status
}
</
Tag
>;
}
};
const
drugColumns
=
[
{
title
:
'
药品名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
width
:
150
,
render
:
(
v
:
string
)
=>
<
Text
strong
>
{
v
}
</
Text
>
},
{
title
:
'
通用名
'
,
dataIndex
:
'
generic_name
'
,
key
:
'
generic_name
'
,
width
:
120
},
{
title
:
'
规格
'
,
dataIndex
:
'
specification
'
,
key
:
'
specification
'
,
width
:
140
},
{
title
:
'
类别
'
,
dataIndex
:
'
category
'
,
key
:
'
category
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
>
{
v
}
</
Tag
>
},
{
title
:
'
厂商
'
,
dataIndex
:
'
manufacturer
'
,
key
:
'
manufacturer
'
,
width
:
100
},
{
title
:
'
价格
'
,
dataIndex
:
'
price
'
,
key
:
'
price
'
,
width
:
80
,
render
:
(
v
:
number
)
=>
`¥
${(
v
/
100
).
toFixed
(
2
)}
`
},
{
title
:
'
库存
'
,
dataIndex
:
'
stock
'
,
key
:
'
stock
'
,
width
:
80
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
getStockTag
(
v
)
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
140
,
render
:
(
_
:
any
,
record
:
Medicine
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEditDrug
(
record
)
}
>
编辑
</
Button
>
<
Popconfirm
title=
"确定删除该药品?"
onConfirm=
{
()
=>
handleDeleteDrug
(
record
.
id
)
}
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
>
删除
</
Button
>
</
Popconfirm
>
</
Space
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
MedicineBoxOutlined
className=
"mr-1"
/>
药品库管理
</
h4
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #1890ff
'
}
}
>
<
Statistic
title=
"药品总数"
value=
{
stats
.
total_count
}
suffix=
"种"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #52c41a
'
}
}
>
<
Statistic
title=
"库存充足"
value=
{
stats
.
normal_count
}
valueStyle=
{
{
color
:
'
#52c41a
'
}
}
suffix=
"种"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #fa8c16
'
}
}
>
<
Statistic
title=
"库存预警"
value=
{
stats
.
low_stock_count
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
suffix=
"种"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #f5222d
'
}
}
>
<
Statistic
title=
"缺货药品"
value=
{
stats
.
out_of_stock_count
}
valueStyle=
{
{
color
:
'
#f5222d
'
}
}
suffix=
"种"
/>
</
Card
>
</
Col
>
</
Row
>
{
(
stats
.
low_stock_count
>
0
||
stats
.
out_of_stock_count
>
0
)
&&
(
<
Alert
message=
{
`当前有 ${stats.low_stock_count} 种药品库存不足,${stats.out_of_stock_count} 种药品缺货,请及时补货`
}
type=
"warning"
showIcon
className=
"text-xs!"
/>
)
}
<
Tabs
defaultActiveKey=
"drugs"
size=
"small"
items=
{
[
{
key
:
'
drugs
'
,
label
:
<
span
><
MedicineBoxOutlined
/>
药品信息
</
span
>,
children
:
(
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center justify-between gap-2 mb-2"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
prefix=
{
<
SearchOutlined
/>
}
placeholder=
"搜索药品"
style=
{
{
width
:
180
}
}
size=
"small"
allowClear
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
()
=>
{
setPage
(
1
);
fetchList
();
}
}
/>
<
Select
placeholder=
"类别"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
options=
{
categoryOptions
}
value=
{
filterCategory
||
undefined
}
onChange=
{
(
v
)
=>
{
setFilterCategory
(
v
||
''
);
setPage
(
1
);
}
}
/>
<
Select
placeholder=
"库存"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
value=
{
filterStatus
||
undefined
}
onChange=
{
(
v
)
=>
{
setFilterStatus
(
v
||
''
);
setPage
(
1
);
}
}
options=
{
[{
label
:
'
充足
'
,
value
:
'
normal
'
},
{
label
:
'
预警
'
,
value
:
'
low_stock
'
},
{
label
:
'
缺货
'
,
value
:
'
out_of_stock
'
}]
}
/>
</
div
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAddDrug
}
>
添加药品
</
Button
>
</
div
>
<
Table
columns=
{
drugColumns
}
dataSource=
{
drugs
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
total
,
pageSize
:
10
,
size
:
'
small
'
,
onChange
:
setPage
,
showTotal
:
(
t
)
=>
`共 ${t} 种`
}
}
/>
</
Card
>
),
},
{
key
:
'
stock
'
,
label
:
<
Badge
count=
{
stats
.
low_stock_count
+
stats
.
out_of_stock_count
}
offset=
{
[
10
,
0
]
}
>
库存预警
</
Badge
>,
children
:
(
<
Card
size=
"small"
>
<
Table
columns=
{
drugColumns
}
size=
"small"
dataSource=
{
drugs
.
filter
((
d
)
=>
d
.
status
!==
'
normal
'
)
}
rowKey=
"id"
pagination=
{
false
}
loading=
{
loading
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
{
editingDrug
?
'
编辑药品
'
:
'
添加药品
'
}
open=
{
drugModalVisible
}
onOk=
{
handleSaveDrug
}
onCancel=
{
()
=>
setDrugModalVisible
(
false
)
}
okText=
"保存"
width=
{
500
}
>
<
Form
form=
{
form
}
layout=
"vertical"
size=
"small"
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"药品名称"
name=
"name"
rules=
{
[{
required
:
true
,
message
:
'
请输入药品名称
'
}]
}
>
<
Input
placeholder=
"请输入药品名称"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"通用名"
name=
"generic_name"
>
<
Input
placeholder=
"请输入通用名"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"规格"
name=
"specification"
rules=
{
[{
required
:
true
,
message
:
'
请输入规格
'
}]
}
>
<
Input
placeholder=
"如 0.5g×24粒/盒"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"类别"
name=
"category"
rules=
{
[{
required
:
true
,
message
:
'
请选择类别
'
}]
}
>
<
Select
placeholder=
"选择类别"
options=
{
categoryOptions
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"生产厂商"
name=
"manufacturer"
>
<
Input
placeholder=
"请输入厂商"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"单位"
name=
"unit"
>
<
Select
placeholder=
"选择单位"
options=
{
[
{
label
:
'
盒
'
,
value
:
'
盒
'
},
{
label
:
'
瓶
'
,
value
:
'
瓶
'
},
{
label
:
'
袋
'
,
value
:
'
袋
'
},
{
label
:
'
支
'
,
value
:
'
支
'
},
]
}
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
8
}
>
<
Form
.
Item
label=
"价格(元)"
name=
"price"
rules=
{
[{
required
:
true
,
message
:
'
请输入价格
'
}]
}
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
0
}
precision=
{
2
}
placeholder=
"0.00"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"库存数量"
name=
"stock"
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
0
}
placeholder=
"0"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"库存预警线"
name=
"stock_warning"
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
0
}
placeholder=
"低于此数量预警"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
</
Form
>
</
Modal
>
</
div
>
);
};
export
default
AdminPharmacyPage
;
web/src/pages/admin/Prescription/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Tag
,
Typography
,
Space
,
Tabs
,
Statistic
,
Button
,
Input
,
DatePicker
,
Badge
,
Alert
,
List
,
Modal
,
Descriptions
,
Divider
,
message
,
}
from
'
antd
'
;
import
{
FileTextOutlined
,
WarningOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
SearchOutlined
,
ExclamationCircleOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
prescriptionAdminApi
}
from
'
../../../api/prescription
'
;
import
type
{
Prescription
,
PrescriptionItem
,
PrescriptionStats
}
from
'
../../../api/prescription
'
;
const
{
Title
,
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
const
AdminPrescriptionPage
:
React
.
FC
=
()
=>
{
const
[
detailVisible
,
setDetailVisible
]
=
useState
(
false
);
const
[
currentRecord
,
setCurrentRecord
]
=
useState
<
Prescription
|
null
>
(
null
);
const
[
prescriptions
,
setPrescriptions
]
=
useState
<
Prescription
[]
>
([]);
const
[
stats
,
setStats
]
=
useState
<
PrescriptionStats
>
({
total_count
:
0
,
normal_count
:
0
,
warning_count
:
0
,
rejected_count
:
0
});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
warningLevel
,
setWarningLevel
]
=
useState
(
''
);
const
[
rejectModalVisible
,
setRejectModalVisible
]
=
useState
(
false
);
const
[
rejectReason
,
setRejectReason
]
=
useState
(
''
);
const
fetchStats
=
useCallback
(
async
()
=>
{
try
{
const
res
=
await
prescriptionAdminApi
.
getStats
();
setStats
(
res
.
data
);
}
catch
{
/* ignore */
}
},
[]);
const
fetchList
=
useCallback
(
async
(
wl
?:
string
)
=>
{
setLoading
(
true
);
try
{
const
res
=
await
prescriptionAdminApi
.
getList
({
keyword
,
warning_level
:
wl
!==
undefined
?
wl
:
warningLevel
,
page
,
page_size
:
10
,
});
const
data
=
res
.
data
as
any
;
setPrescriptions
(
data
?.
list
||
[]);
setTotal
(
data
?.
total
||
0
);
}
catch
{
/* ignore */
}
setLoading
(
false
);
},
[
keyword
,
warningLevel
,
page
]);
useEffect
(()
=>
{
fetchStats
();
},
[
fetchStats
]);
useEffect
(()
=>
{
fetchList
();
},
[
fetchList
]);
const
handleView
=
async
(
record
:
Prescription
)
=>
{
try
{
const
res
=
await
prescriptionAdminApi
.
getDetail
(
record
.
id
);
setCurrentRecord
(
res
.
data
);
setDetailVisible
(
true
);
}
catch
{
message
.
error
(
'
获取处方详情失败
'
);
}
};
const
handleApprove
=
async
(
id
:
string
)
=>
{
try
{
await
prescriptionAdminApi
.
review
(
id
,
{
action
:
'
approve
'
});
message
.
success
(
'
处方审核通过
'
);
setDetailVisible
(
false
);
fetchList
();
fetchStats
();
}
catch
(
err
:
any
)
{
message
.
error
(
err
?.
message
||
'
操作失败
'
);
}
};
const
handleReject
=
async
()
=>
{
if
(
!
currentRecord
)
return
;
try
{
await
prescriptionAdminApi
.
review
(
currentRecord
.
id
,
{
action
:
'
reject
'
,
reason
:
rejectReason
});
message
.
success
(
'
处方已驳回
'
);
setRejectModalVisible
(
false
);
setDetailVisible
(
false
);
setRejectReason
(
''
);
fetchList
();
fetchStats
();
}
catch
(
err
:
any
)
{
message
.
error
(
err
?.
message
||
'
操作失败
'
);
}
};
const
getWarningTag
=
(
level
:
string
)
=>
{
switch
(
level
)
{
case
'
normal
'
:
return
<
Tag
icon=
{
<
CheckCircleOutlined
/>
}
color=
"green"
>
合规
</
Tag
>;
case
'
warning
'
:
return
<
Tag
icon=
{
<
WarningOutlined
/>
}
color=
"orange"
>
预警
</
Tag
>;
case
'
rejected
'
:
return
<
Tag
icon=
{
<
CloseCircleOutlined
/>
}
color=
"red"
>
异常
</
Tag
>;
default
:
return
<
Tag
>
{
level
}
</
Tag
>;
}
};
const
columns
=
[
{
title
:
'
处方编号
'
,
dataIndex
:
'
prescription_no
'
,
key
:
'
prescription_no
'
,
width
:
160
},
{
title
:
'
患者
'
,
dataIndex
:
'
patient_name
'
,
key
:
'
patient_name
'
,
width
:
80
},
{
title
:
'
医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
width
:
80
},
{
title
:
'
诊断
'
,
dataIndex
:
'
diagnosis
'
,
key
:
'
diagnosis
'
,
width
:
140
,
ellipsis
:
true
},
{
title
:
'
药品
'
,
key
:
'
drugs
'
,
render
:
(
_
:
any
,
r
:
Prescription
)
=>
(
<
Space
size=
{
[
4
,
4
]
}
wrap
>
{
(
r
.
items
||
[]).
slice
(
0
,
3
).
map
((
item
)
=>
<
Tag
key=
{
item
.
id
}
color=
"blue"
>
{
item
.
medicine_name
}
</
Tag
>)
}
{
(
r
.
items
||
[]).
length
>
3
&&
<
Tag
>
+
{
r
.
items
.
length
-
3
}
</
Tag
>
}
</
Space
>
),
},
{
title
:
'
金额
'
,
dataIndex
:
'
total_amount
'
,
key
:
'
total_amount
'
,
width
:
100
,
render
:
(
v
:
number
)
=>
`¥
${(
v
/
100
).
toFixed
(
2
)}
`
},
{
title
:
'
状态
'
,
dataIndex
:
'
warning_level
'
,
key
:
'
warning_level
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
getWarningTag
(
v
)
},
{
title
:
'
开方时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
160
,
render
:
(
v
:
string
)
=>
v
?
new
Date
(
v
).
toLocaleString
(
'
zh-CN
'
)
:
'
-
'
,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
80
,
render
:
(
_
:
any
,
record
:
Prescription
)
=>
(
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
handleView
(
record
)
}
>
详情
</
Button
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
FileTextOutlined
className=
"mr-1"
/>
处方监管
</
h4
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #1890ff
'
}
}
>
<
Statistic
title=
"处方总数"
value=
{
stats
.
total_count
}
suffix=
"张"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #52c41a
'
}
}
>
<
Statistic
title=
"合规"
value=
{
stats
.
normal_count
}
valueStyle=
{
{
color
:
'
#52c41a
'
}
}
suffix=
"张"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #fa8c16
'
}
}
>
<
Statistic
title=
"预警"
value=
{
stats
.
warning_count
}
valueStyle=
{
{
color
:
'
#fa8c16
'
}
}
suffix=
"张"
/>
</
Card
>
</
Col
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #f5222d
'
}
}
>
<
Statistic
title=
"异常"
value=
{
stats
.
rejected_count
}
valueStyle=
{
{
color
:
'
#f5222d
'
}
}
suffix=
"张"
/>
</
Card
>
</
Col
>
</
Row
>
<
Tabs
defaultActiveKey=
"all"
size=
"small"
onChange=
{
(
key
)
=>
{
if
(
key
===
'
warning
'
)
{
setWarningLevel
(
''
);
fetchList
(
'
warning
'
);
}
else
{
setWarningLevel
(
''
);
fetchList
(
''
);
}
}
}
items=
{
[
{
key
:
'
all
'
,
label
:
'
全部处方
'
,
children
:
(
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2 mb-2"
>
<
Input
prefix=
{
<
SearchOutlined
/>
}
placeholder=
"搜索处方/患者/医生"
style=
{
{
width
:
180
}
}
size=
"small"
allowClear
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
()
=>
{
setPage
(
1
);
fetchList
();
}
}
/>
<
RangePicker
size=
"small"
/>
<
Button
type=
"primary"
size=
"small"
ghost
onClick=
{
()
=>
{
setPage
(
1
);
fetchList
();
}
}
>
查询
</
Button
>
</
div
>
<
Table
columns=
{
columns
}
dataSource=
{
prescriptions
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
total
,
pageSize
:
10
,
size
:
'
small
'
,
onChange
:
setPage
,
showTotal
:
(
t
)
=>
`共 ${t} 张`
}
}
/>
</
Card
>
),
},
{
key
:
'
warning
'
,
label
:
<
Badge
count=
{
stats
.
warning_count
+
stats
.
rejected_count
}
offset=
{
[
10
,
0
]
}
>
异常预警
</
Badge
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
Alert
message=
"以下处方存在合规风险,请及时审查处理"
type=
"warning"
showIcon
style=
{
{
marginBottom
:
16
}
}
/>
<
List
dataSource=
{
prescriptions
.
filter
((
d
)
=>
d
.
warning_level
!==
'
normal
'
)
}
renderItem=
{
(
item
)
=>
(
<
Card
style=
{
{
marginBottom
:
12
,
borderRadius
:
8
,
borderLeft
:
`4px solid ${item.warning_level === 'rejected' ? '#f5222d' : '#fa8c16'}`
}
}
hoverable
>
<
Row
align=
"middle"
>
<
Col
flex=
"auto"
>
<
Space
><
Text
strong
>
{
item
.
prescription_no
}
</
Text
>
{
getWarningTag
(
item
.
warning_level
)
}
</
Space
><
br
/>
<
Text
type=
"secondary"
>
{
item
.
doctor_name
}
→
{
item
.
patient_name
}
|
{
item
.
diagnosis
}
</
Text
><
br
/>
{
item
.
warning_reason
&&
<
Text
type=
"danger"
><
ExclamationCircleOutlined
style=
{
{
marginRight
:
4
}
}
/>
{
item
.
warning_reason
}
</
Text
>
}
</
Col
>
<
Col
>
<
Space
>
<
Button
size=
"small"
onClick=
{
()
=>
handleView
(
item
)
}
>
查看
</
Button
>
<
Button
size=
"small"
type=
"primary"
ghost
onClick=
{
()
=>
handleApprove
(
item
.
id
)
}
>
审核通过
</
Button
>
<
Button
size=
"small"
danger
onClick=
{
()
=>
{
setCurrentRecord
(
item
);
setRejectModalVisible
(
true
);
}
}
>
驳回
</
Button
>
</
Space
>
</
Col
>
</
Row
>
</
Card
>
)
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
"处方详情"
open=
{
detailVisible
}
onCancel=
{
()
=>
setDetailVisible
(
false
)
}
width=
{
700
}
footer=
{
currentRecord
?.
warning_level
!==
'
normal
'
?
[
<
Button
key=
"approve"
type=
"primary"
onClick=
{
()
=>
currentRecord
&&
handleApprove
(
currentRecord
.
id
)
}
>
审核通过
</
Button
>,
<
Button
key=
"reject"
danger
onClick=
{
()
=>
setRejectModalVisible
(
true
)
}
>
驳回处方
</
Button
>,
]
:
null
}
>
{
currentRecord
&&
(
<
div
>
<
Descriptions
bordered
size=
"small"
column=
{
2
}
>
<
Descriptions
.
Item
label=
"处方编号"
>
{
currentRecord
.
prescription_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"状态"
>
{
getWarningTag
(
currentRecord
.
warning_level
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"患者"
>
{
currentRecord
.
patient_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"医生"
>
{
currentRecord
.
doctor_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"诊断"
span=
{
2
}
>
{
currentRecord
.
diagnosis
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"金额"
>
¥
{
(
currentRecord
.
total_amount
/
100
).
toFixed
(
2
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"开方时间"
>
{
new
Date
(
currentRecord
.
created_at
).
toLocaleString
(
'
zh-CN
'
)
}
</
Descriptions
.
Item
>
</
Descriptions
>
<
Card
title=
{
<><
MedicineBoxOutlined
/>
药品明细
</>
}
size=
"small"
style=
{
{
marginTop
:
16
,
borderRadius
:
8
}
}
>
{
(
currentRecord
.
items
||
[]).
map
((
item
:
PrescriptionItem
,
idx
:
number
)
=>
(
<
div
key=
{
item
.
id
}
style=
{
{
padding
:
'
8px 0
'
,
borderBottom
:
idx
<
(
currentRecord
.
items
||
[]).
length
-
1
?
'
1px solid #f0f0f0
'
:
'
none
'
}
}
>
<
Space
><
Text
strong
>
{
idx
+
1
}
.
{
item
.
medicine_name
}
</
Text
><
Text
type=
"secondary"
>
{
item
.
specification
}
</
Text
></
Space
><
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
item
.
usage
}
|
{
item
.
dosage
}
|
{
item
.
frequency
}
|
{
item
.
days
}
天 | ×
{
item
.
quantity
}{
item
.
unit
}
</
Text
>
<
div
style=
{
{
textAlign
:
'
right
'
}
}
><
Text
type=
"secondary"
>
¥
{
(
item
.
amount
/
100
).
toFixed
(
2
)
}
</
Text
></
div
>
</
div
>
))
}
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
div
style=
{
{
textAlign
:
'
right
'
}
}
>
<
Text
strong
style=
{
{
fontSize
:
16
,
color
:
'
#f5222d
'
}
}
>
合计:¥
{
(
currentRecord
.
total_amount
/
100
).
toFixed
(
2
)
}
</
Text
>
</
div
>
</
Card
>
{
currentRecord
.
warning_reason
&&
(
<
Alert
message=
{
currentRecord
.
warning_reason
}
type=
"warning"
showIcon
style=
{
{
marginTop
:
16
}
}
/>
)
}
</
div
>
)
}
</
Modal
>
<
Modal
title=
"驳回处方"
open=
{
rejectModalVisible
}
onCancel=
{
()
=>
{
setRejectModalVisible
(
false
);
setRejectReason
(
''
);
}
}
onOk=
{
handleReject
}
okText=
"确认驳回"
okButtonProps=
{
{
danger
:
true
}
}
>
<
Input
.
TextArea
rows=
{
3
}
placeholder=
"请输入驳回原因..."
value=
{
rejectReason
}
onChange=
{
(
e
)
=>
setRejectReason
(
e
.
target
.
value
)
}
/>
</
Modal
>
</
div
>
);
};
export
default
AdminPrescriptionPage
;
web/src/pages/admin/Statistics/index.tsx
0 → 100644
View file @
52e57c72
import
React
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Typography
,
Space
,
Statistic
,
Select
,
DatePicker
}
from
'
antd
'
;
import
{
RiseOutlined
,
FallOutlined
,
UserOutlined
,
MessageOutlined
,
DollarOutlined
,
TeamOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
Text
}
=
Typography
;
const
AdminStatisticsPage
:
React
.
FC
=
()
=>
{
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:
'
?
0
?,
value
:
'
month
'
},
{
label
:
'
?0?, value:
'
quarter
'
}, { label:
'
??,
value
:
'
year
'
}]
}
/>
<
DatePicker
.
RangePicker
size=
"small"
/>
</
div
>
</
div
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
xs=
{
12
}
sm=
{
6
}
>
<
Card
size=
"small"
><
Statistic
title=
"新增用户"
value=
{
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
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"
>
科室问诊分布
</
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
>
);
};
export
default
AdminStatisticsPage
;
web/src/pages/admin/Users/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Input
,
Select
,
Button
,
Avatar
,
Modal
,
message
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
LockOutlined
,
StopOutlined
,
EditOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
dayjs
from
'
dayjs
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
UserInfo
}
from
'
../../../api/user
'
;
const
{
Title
,
Text
}
=
Typography
;
interface
UserRecord
extends
UserInfo
{
status
?:
'
active
'
|
'
disabled
'
;
}
const
roleMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
patient
:
{
text
:
'
患者, color:
'
blue
'
},
doctor: { text:
'
医生
'
, color:
'
green
'
},
admin: { text:
'
管理员
,
color
:
'
purple
'
},
};
const
AdminUsersPage
:
React
.
FC
=
()
=>
{
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
roleFilter
,
setRoleFilter
]
=
useState
<
string
|
undefined
>
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
usersData
,
setUsersData
]
=
useState
<
UserRecord
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
pageSize
,
setPageSize
]
=
useState
(
10
);
const
fetchUsers
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getUserList
({
keyword
:
keyword
||
undefined
,
role
:
roleFilter
,
page
,
page_size
:
pageSize
,
});
setUsersData
(
res
.
data
.
list
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
(
error
)
{
console
.
error
(
'
获取用户列表失败:
'
,
error
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchUsers
();
},
[
page
,
pageSize
]);
const
handleSearch
=
()
=>
{
setPage
(
1
);
fetchUsers
();
};
const
handleResetPassword
=
(
userId
:
string
)
=>
{
Modal
.
confirm
({
title
:
'
确认重置
'
,
content
:
'
确定要重置该用户的密码吗?重置后密码为 123456
'
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
resetUserPassword
(
userId
);
message
.
success
(
'
密码已重);
} catch (error) {
message.error(
'
重置密码失败
'
);
}
},
});
};
const handleToggleStatus = (userId: string, currentStatus: string) => {
const action = currentStatus ===
'
active
'
?
'
禁用
'
:
'
启用
'
;
const newStatus = currentStatus ===
'
active
'
?
'
disabled
'
:
'
active
'
;
Modal.confirm({
title: `确认${action}`,
content: `确定${action}该用户吗?`,
onOk: async () => {
try {
await adminApi.updateUserStatus(userId, newStatus);
message.success(`?{action}`);
fetchUsers();
} catch (error) {
message.error(`${action}失败`);
}
},
});
};
const columns: ColumnsType<UserRecord> = [
{
title:
'
用户
'
,
key:
'
user
'
,
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} src={record.avatar} />
<div>
<Text strong>{record.real_name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
</div>
</Space>
),
},
{
title:
'
角色
'
,
dataIndex:
'
role
'
,
key:
'
role
'
,
render: (role: string) => {
const r = roleMap[role];
return <Tag color={r.color}>{r.text}</Tag>;
},
},
{
title:
'
实名认证
'
,
dataIndex:
'
is_verified
'
,
key:
'
is_verified
'
,
render: (v: boolean) => v ? <Tag color="success">已认证</Tag> : <Tag color="warning">未认证</Tag>,
},
{
title:
'
状态
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
(
status
:
string
)
=>
(
<
Tag
color=
{
status
===
'
active
'
?
'
green
'
:
'
red
'
}
>
{
status
===
'
active
'
?
'
正常
'
:
'
已禁
}
</
Tag
>
),
},
{
title
:
'
注册时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
render
:
(
t
:
string
)
=>
dayjs
(
t
).
format
(
'
YYYY-MM-DD HH:mm
'
),
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
LockOutlined
/>
}
onClick=
{
()
=>
handleResetPassword
(
record
.
id
)
}
>
重置密码
</
Button
>
<
Button
type=
"link"
size=
"small"
danger=
{
record
.
status
===
'
active
'
}
icon=
{
<
StopOutlined
/>
}
onClick=
{
()
=>
handleToggleStatus
(
record
.
id
,
record
.
status
||
'
active
'
)
}
>
{
record
.
status
===
'
active
'
?
'
禁用
'
:
'
启用
'
}
</
Button
>
</
Space
>
),
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
用户管理
</
h4
>
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder=
"搜索用户名/手机号 prefix={<SearchOutlined />} value={keyword}
onChange={(e) => setKeyword(e.target.value)} style={{ width: 180 }} size="
small
"
/>
<
Select
placeholder=
"角色"
style=
{
{
width
:
100
}
}
size=
"small"
allowClear
value=
{
roleFilter
}
onChange=
{
(
v
)
=>
setRoleFilter
(
v
)
}
options=
{
[
{
label
:
'
患者, value:
'
patient
'
}, { label:
'
医生
'
, value:
'
doctor
'
}, { label:
'
管理员
,
value
:
'
admin
'
},
]
}
/>
<
Select
placeholder=
"状态 style={{ width: 100 }} size="
small
"
allowClear
options=
{
[{
label
:
'
正常
'
,
value
:
'
active
'
},
{
label
:
'
已禁用, value:
'
disabled
'
}]
}
/>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleSearch
}
>
搜索
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
usersData
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
,
total
,
size
:
'
small
'
,
showSizeChanger
:
true
,
showTotal
:
(
t
)
=>
`?${t} 条`
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
</
div
>
);
};
export
default
AdminUsersPage
;
web/src/pages/doctor/AIAssist/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Button
,
List
,
Tag
,
Typography
,
Tabs
,
Empty
,
Spin
,
Divider
,
Alert
}
from
'
antd
'
;
import
{
RobotOutlined
,
SearchOutlined
,
MedicineBoxOutlined
,
FileTextOutlined
,
ExperimentOutlined
,
BulbOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
Text
,
Paragraph
}
=
Typography
;
const
{
TextArea
}
=
Input
;
interface
DiagnosisSuggestion
{
disease
:
string
;
probability
:
number
;
description
:
string
;
icd_code
:
string
;
}
interface
SimilarCase
{
id
:
string
;
summary
:
string
;
diagnosis
:
string
;
treatment
:
string
;
similarity
:
number
;
}
interface
DrugSuggestion
{
name
:
string
;
specification
:
string
;
usage
:
string
;
dosage
:
string
;
caution
:
string
;
}
const
AIAssistPage
:
React
.
FC
=
()
=>
{
const
[
symptoms
,
setSymptoms
]
=
useState
(
''
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
diagnoses
,
setDiagnoses
]
=
useState
<
DiagnosisSuggestion
[]
>
([]);
const
[
similarCases
,
setSimilarCases
]
=
useState
<
SimilarCase
[]
>
([]);
const
[
drugSuggestions
,
setDrugSuggestions
]
=
useState
<
DrugSuggestion
[]
>
([]);
const
handleAnalyze
=
async
()
=>
{
if
(
!
symptoms
.
trim
())
return
;
setLoading
(
true
);
// TODO: 调用后端 AI 诊断接口
// 模拟数据
setTimeout
(()
=>
{
setDiagnoses
([
{
disease
:
'
急性上呼吸道感染
'
,
probability
:
85
,
description
:
'
鼻黏膜非感染性炎性疾病,以阵发性喷嚏、清水样鼻涕等为特征
'
,
icd_code
:
'
J06.9
'
},
{
disease
:
'
急性支气管炎
'
,
probability
:
45
,
description
:
'
支气管黏膜非感染性炎性疾病,以咳嗽、咳痰等为特征
'
,
icd_code
:
'
J20.9
'
},
{
disease
:
'
过敏性鼻炎
'
,
probability
:
30
,
description
:
'
鼻黏膜非感染性炎性疾病,以阵发性喷嚏、清水样鼻涕等为特征
'
,
icd_code
:
'
J30.4
'
},
]);
setSimilarCases
([
{
id
:
'
1
'
,
summary
:
'
患者,男,35岁,咳嗽、流涕5天,体温37.8°C
'
,
diagnosis
:
'
急性上呼吸道感染
'
,
treatment
:
'
对症治疗,口服布洛芬退热,氨溴索化痰
'
,
similarity
:
92
},
{
id
:
'
2
'
,
summary
:
'
患者,女,28岁,咽痛、鼻塞3天,伴轻微咳嗽
'
,
diagnosis
:
'
急性上呼吸道感染
'
,
treatment
:
'
口服清开灵颗粒,含服西瓜霜含片
'
,
similarity
:
87
},
{
id
:
'
3
'
,
summary
:
'
患者,男,42岁,咳嗽1周,痰液黄色
'
,
diagnosis
:
'
急性支气管炎
'
,
treatment
:
'
口服阿莫西林+氨溴索,雾化吸入布地奈德
'
,
similarity
:
75
},
]);
setDrugSuggestions
([
{
name
:
'
布洛芬缓释胶囊
'
,
specification
:
'
0.3g/粒
'
,
usage
:
'
口服
'
,
dosage
:
'
每次1粒,每日2次
'
,
caution
:
'
胃溃疡患者慎用
'
},
{
name
:
'
氨溴索口服液
'
,
specification
:
'
100ml/瓶
'
,
usage
:
'
口服
'
,
dosage
:
'
每次10ml,每日3次
'
,
caution
:
'
孕早期禁用
'
},
{
name
:
'
对乙酰氨基酚片
'
,
specification
:
'
0.5g/片
'
,
usage
:
'
口服
'
,
dosage
:
'
每次1片,发热时服用,间隔4-6小时
'
,
caution
:
'
肝功能不全者慎用
'
},
]);
setLoading
(
false
);
},
1500
);
};
const
getProbabilityColor
=
(
p
:
number
)
=>
{
if
(
p
>=
70
)
return
'
#f5222d
'
;
if
(
p
>=
40
)
return
'
#fa8c16
'
;
return
'
#52c41a
'
;
};
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
RobotOutlined
className=
"mr-1"
/>
AI 辅助诊断
</
h4
>
<
Alert
message=
"AI 辅助诊断仅供参考,最终诊断以医生临床判断为准"
type=
"info"
showIcon
className=
"text-xs!"
/>
<
Card
size=
"small"
>
<
div
className=
"text-xs font-semibold mb-1"
>
症状描述
</
div
>
<
TextArea
rows=
{
3
}
placeholder=
"请输入患者的主诉和症状描述.."
value=
{
symptoms
}
onChange=
{
(
e
)
=>
setSymptoms
(
e
.
target
.
value
)
}
className=
"mb-2"
size=
"small"
/>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleAnalyze
}
loading=
{
loading
}
style=
{
{
background
:
'
#52c41a
'
,
borderColor
:
'
#52c41a
'
}
}
>
AI 智能分析
</
Button
>
</
Card
>
{
loading
?
(
<
div
className=
"text-center py-10"
><
Spin
tip=
"AI 正在分析中.."
/></
div
>
)
:
(
<
Tabs
defaultActiveKey=
"diagnosis"
size=
"small"
items=
{
[
{
key
:
'
diagnosis
'
,
label
:
<
span
><
ExperimentOutlined
/>
閴村埆诊断
</
span
>,
children
:
diagnoses
.
length
>
0
?
(
<
List
size=
"small"
dataSource=
{
diagnoses
}
renderItem=
{
(
item
)
=>
(
<
Card
size=
"small"
className=
"mb-2"
hoverable
>
<
Row
align=
"middle"
gutter=
{
8
}
>
<
Col
flex=
"auto"
>
<
div
className=
"mb-1"
>
<
Text
strong
className=
"text-sm! mr-1"
>
{
item
.
disease
}
</
Text
>
<
Tag
color=
"blue"
>
{
item
.
icd_code
}
</
Tag
>
</
div
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
{
item
.
description
}
</
Text
>
</
Col
>
<
Col
>
<
div
className=
"text-center"
>
<
div
className=
"text-xl font-bold"
style=
{
{
color
:
getProbabilityColor
(
item
.
probability
)
}
}
>
{
item
.
probability
}
%
</
div
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
匹配度
</
Text
>
</
div
>
</
Col
>
</
Row
>
</
Card
>
)
}
/>
)
:
<
Empty
description=
"请输入症状描述后进行分析"
/>,
},
{
key
:
'
cases
'
,
label
:
<
span
><
FileTextOutlined
/>
相似病例
</
span
>,
children
:
similarCases
.
length
>
0
?
(
<
List
size=
"small"
dataSource=
{
similarCases
}
renderItem=
{
(
item
)
=>
(
<
Card
size=
"small"
className=
"mb-2"
hoverable
>
<
Row
gutter=
{
8
}
>
<
Col
flex=
"auto"
>
<
div
className=
"text-xs mb-0.5"
><
Text
strong
>
摘要:
</
Text
>
{
item
.
summary
}
</
div
>
<
div
className=
"text-xs mb-0.5"
><
Text
strong
>
诊断:
</
Text
><
Tag
color=
"blue"
>
{
item
.
diagnosis
}
</
Tag
></
div
>
<
div
className=
"text-xs"
><
Text
strong
>
治疗:
</
Text
>
{
item
.
treatment
}
</
div
>
</
Col
>
<
Col
>
<
Tag
color=
{
item
.
similarity
>=
85
?
'
red
'
:
'
orange
'
}
>
{
item
.
similarity
}
%
</
Tag
>
</
Col
>
</
Row
>
</
Card
>
)
}
/>
)
:
<
Empty
description=
"请输入症状描述后进行分析"
/>,
},
{
key
:
'
drugs
'
,
label
:
<
span
><
MedicineBoxOutlined
/>
用药建议
</
span
>,
children
:
drugSuggestions
.
length
>
0
?
(
<
List
size=
"small"
dataSource=
{
drugSuggestions
}
renderItem=
{
(
item
)
=>
(
<
Card
size=
"small"
className=
"mb-2"
hoverable
>
<
Row
gutter=
{
8
}
align=
"middle"
>
<
Col
flex=
"auto"
>
<
div
className=
"text-xs mb-0.5"
><
Text
strong
>
{
item
.
name
}
</
Text
>
<
Text
type=
"secondary"
>
{
item
.
specification
}
</
Text
></
div
>
<
div
className=
"text-xs mb-0.5"
><
Tag
color=
"green"
>
{
item
.
usage
}
</
Tag
>
{
item
.
dosage
}
</
div
>
<
div
className=
"text-xs"
><
BulbOutlined
className=
"text-yellow-500 mr-0.5"
/><
Text
type=
"warning"
>
{
item
.
caution
}
</
Text
></
div
>
</
Col
>
<
Col
><
Button
type=
"primary"
ghost
size=
"small"
>
添加处方
</
Button
></
Col
>
</
Row
>
</
Card
>
)
}
/>
)
:
<
Empty
description=
"请输入症状描述后进行分析"
/>,
},
]
}
/>
)
}
</
div
>
);
};
export
default
AIAssistPage
;
web/src/pages/doctor/Certification/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Form
,
Input
,
Select
,
Button
,
Upload
,
message
,
Typography
,
Steps
}
from
'
antd
'
;
import
{
UploadOutlined
,
CheckCircleOutlined
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
type
{
UploadFile
}
from
'
antd
'
;
import
request
from
'
../../../api/request
'
;
const
{
Title
,
Text
,
Paragraph
}
=
Typography
;
interface
Department
{
id
:
string
;
name
:
string
;
}
const
DoctorCertificationPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
[
form
]
=
Form
.
useForm
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
currentStep
,
setCurrentStep
]
=
useState
(
0
);
const
[
licenseFileList
,
setLicenseFileList
]
=
useState
<
UploadFile
[]
>
([]);
const
[
qualificationFileList
,
setQualificationFileList
]
=
useState
<
UploadFile
[]
>
([]);
const
[
departments
,
setDepartments
]
=
useState
<
Department
[]
>
([]);
useEffect
(()
=>
{
fetchDepartments
();
},
[]);
const
fetchDepartments
=
async
()
=>
{
try
{
const
res
=
await
request
.
get
(
'
/doctor/departments
'
);
if
(
res
.
data
.
code
===
0
)
{
setDepartments
(
res
.
data
.
data
||
[]);
}
}
catch
(
error
)
{
console
.
error
(
'
获取科室列表失败
'
,
error
);
}
};
const
handleSubmit
=
async
(
values
:
any
)
=>
{
setLoading
(
true
);
try
{
const
token
=
localStorage
.
getItem
(
'
access_token
'
);
if
(
!
token
)
{
message
.
error
(
'
请先登录
'
);
router
.
push
(
'
/login
'
);
return
;
}
const
res
=
await
request
.
post
(
'
/doctor-portal/certification
'
,
{
license_no
:
values
.
license_no
,
title
:
values
.
title
,
hospital
:
values
.
hospital
,
department_name
:
values
.
department_name
,
license_image
:
licenseFileList
[
0
]?.
url
||
''
,
qualification_image
:
qualificationFileList
[
0
]?.
url
||
''
,
});
if
(
res
.
data
.
code
===
0
)
{
message
.
success
(
'
资质认证已提交,请等待管理员审核
'
);
setCurrentStep
(
1
);
setTimeout
(()
=>
{
router
.
push
(
'
/doctor/workbench
'
);
},
2000
);
}
else
{
message
.
error
(
res
.
data
.
message
||
'
提交失败,请重试
'
);
}
}
catch
(
error
:
any
)
{
console
.
error
(
'
提交认证失败
'
,
error
);
message
.
error
(
error
.
message
||
'
提交失败,请重试
'
);
}
finally
{
setLoading
(
false
);
}
};
const
beforeUpload
=
(
file
:
File
)
=>
{
const
isImage
=
file
.
type
.
startsWith
(
'
image/
'
);
if
(
!
isImage
)
{
message
.
error
(
'
只能上传图片文件
'
);
}
const
isLt5M
=
file
.
size
/
1024
/
1024
<
5
;
if
(
!
isLt5M
)
{
message
.
error
(
'
图片大小不能超过 5MB
'
);
}
return
isImage
&&
isLt5M
;
};
if
(
currentStep
===
1
)
{
return
(
<
div
className=
"max-w-[500px] mx-auto mt-20 text-center"
>
<
Card
size=
"small"
>
<
div
className=
"py-6"
>
<
div
className=
"w-14 h-14 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-4"
>
<
CheckCircleOutlined
className=
"text-2xl text-green-500"
/>
</
div
>
<
h3
className=
"text-base font-bold mb-2"
>
认证申请已提
</
h3
>
<
Paragraph
type=
"secondary"
className=
"text-xs!"
>
您的资质认证申请已成功提交,管理员将在 1-3 个工作日内完成审核。
</
Paragraph
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/doctor/workbench
'
)
}
>
返回工作台
</
Button
>
</
div
>
</
Card
>
</
div
>
);
}
return
(
<
div
className=
"max-w-[700px] mx-auto pb-6 space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
医生资质认证
</
h4
>
<
Steps
current=
{
currentStep
}
size=
"small"
className=
"mb-4"
items=
{
[
{
title
:
'
填写资质信息
'
},
{
title
:
'
等待审核
'
},
{
title
:
'
审核通过
'
},
]
}
/>
<
Card
size=
"small"
>
<
Form
form=
{
form
}
layout=
"vertical"
size=
"small"
onFinish=
{
handleSubmit
}
>
<
Form
.
Item
name=
"license_no"
label=
"执业证号"
rules=
{
[{
required
:
true
,
message
:
'
请输入执业证书 }]
}
>
<
Input
placeholder=
"请输入医师执业证书 />
</Form.Item>
<Form.Item name="
title
"
label=
"职称"
rules=
{
[{
required
:
true
,
message
:
'
请选择职称
'
}]
}
>
<
Select
placeholder=
"请选择职称"
>
<
Select
.
Option
value=
"主任医师"
>
主任医师
</
Select
.
Option
>
<
Select
.
Option
value=
"副主任医>副主任医院/Select.Option>
<Select.Option value="
主治医师"
>
主治医师
</
Select
.
Option
>
<
Select
.
Option
value=
"住院医师"
>
住院医师
</
Select
.
Option
>
</
Select
>
</
Form
.
Item
>
<
Form
.
Item
name=
"hospital"
label=
"所属医院 rules={[{ required: true, message: '请输入所属医院 }]}>
<Input placeholder="
请输入您所在的医院全称"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"department_name"
label=
"科室"
rules=
{
[{
required
:
true
,
message
:
'
请选择科室
'
}]
}
>
<
Select
placeholder=
"请选择科室"
showSearch
optionFilterProp=
"children"
>
{
departments
.
map
((
dept
)
=>
(
<
Select
.
Option
key=
{
dept
.
id
}
value=
{
dept
.
name
}
>
{
dept
.
name
}
</
Select
.
Option
>
))
}
</
Select
>
</
Form
.
Item
>
<
Form
.
Item
label=
"执业证照片 required>
<Upload
listType="
picture
-
card
"
fileList=
{
licenseFileList
}
beforeUpload=
{
beforeUpload
}
onChange=
{
({
fileList
})
=>
setLicenseFileList
(
fileList
)
}
maxCount=
{
1
}
>
{
licenseFileList
.
length
===
0
&&
(
<
div
><
UploadOutlined
/><
div
className=
"mt-1 text-xs"
>
上传执业
</
div
></
div
>
)
}
</
Upload
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
支持 JPG、PNG,不超过 5MB
</
Text
>
</
Form
.
Item
>
<
Form
.
Item
label=
"资格证照片 required>
<Upload
listType="
picture
-
card
"
fileList=
{
qualificationFileList
}
beforeUpload=
{
beforeUpload
}
onChange=
{
({
fileList
})
=>
setQualificationFileList
(
fileList
)
}
maxCount=
{
1
}
>
{
qualificationFileList
.
length
===
0
&&
(
<
div
><
UploadOutlined
/><
div
className=
"mt-1 text-xs"
>
上传资格
</
div
></
div
>
)
}
</
Upload
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
支持 JPG、PNG,不超过 5MB
</
Text
>
</
Form
.
Item
>
<
Form
.
Item
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
loading
}
block
>
提交认证申请
</
Button
>
</
Form
.
Item
>
</
Form
>
<
/
Card
>
</
div
>
);
};
export
default
DoctorCertificationPage
;
web/src/pages/doctor/ChronicReview/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Button
,
Tag
,
Typography
,
Space
,
Modal
,
Form
,
Input
,
message
,
Tabs
,
Badge
,
Descriptions
,
Timeline
,
}
from
'
antd
'
;
import
{
CheckCircleOutlined
,
CloseCircleOutlined
,
EditOutlined
,
FileTextOutlined
,
SafetyCertificateOutlined
,
MedicineBoxOutlined
,
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
interface
ChronicPrescription
{
id
:
string
;
patient_name
:
string
;
patient_age
:
number
;
patient_gender
:
string
;
disease
:
string
;
last_prescription_date
:
string
;
ai_draft
:
{
drugs
:
{
name
:
string
;
spec
:
string
;
dosage
:
string
;
frequency
:
string
;
days
:
number
}[];
note
:
string
;
};
status
:
'
pending
'
|
'
approved
'
|
'
rejected
'
|
'
modified
'
;
submitted_at
:
string
;
}
const
mockData
:
ChronicPrescription
[]
=
[
{
id
:
'
1
'
,
patient_name
:
'
张三
'
,
patient_age
:
58
,
patient_gender
:
'
?,
disease:
'
2
型糖尿病
'
,
last_prescription_date:
'
2026
-
01
-
25
'
,
ai_draft: {
drugs: [
{ name:
'
二甲双胍缓释放
,
spec
:
'
0.5g/?, dosage:
'
每次
1
?,
frequency
:
'
每日2?, days: 30 },
{ name:
'
格列美脲类
,
spec
:
'
2mg/?, dosage:
'
每次
1
?,
frequency
:
'
每日1?, days: 30 },
],
note:
'
患者血糖控制良好,建议继续当前方案。近期
HbA1c
:
6.8
%
'
,
},
status:
'
pending
'
,
submitted_at:
'
2026
-
02
-
25
09
:
30
:
00
'
,
},
{
id:
'
2
'
,
patient_name:
'
李四
'
,
patient_age: 65,
patient_gender:
'
?,
disease
:
'
高血压,
last_prescription_date:
'
2026
-
01
-
20
'
,
ai_draft: {
drugs: [
{ name:
'
氨氯地平均
,
spec
:
'
5mg/?, dosage:
'
每次
1
?,
frequency
:
'
每日1?, days: 30 },
{ name:
'
缬沙坦胶囊
,
spec
:
'
80mg/?, dosage:
'
每次
1
?,
frequency
:
'
每日1?, days: 30 },
],
note:
'
患者血压控制稳定,建议继续联合用药方案。近期血压
130
/
85
mmHg
'
,
},
status:
'
pending
'
,
submitted_at:
'
2026
-
02
-
25
10
:
15
:
00
'
,
},
];
const ChronicReviewPage: React.FC = () => {
const [data, setData] = useState<ChronicPrescription[]>(mockData);
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [currentRecord, setCurrentRecord] = useState<ChronicPrescription | null>(null);
const [modifyForm] = Form.useForm();
const handleView = (record: ChronicPrescription) => {
setCurrentRecord(record);
setDetailModalVisible(true);
};
const handleApprove = (id: string) => {
setData(data.map((d) => (d.id === id ? { ...d, status:
'
approved
'
as const } : d)));
message.success(
'
续方已审核通过并签名发送
);
setDetailModalVisible
(
false
);
};
const
handleReject
=
(
id
:
string
)
=>
{
Modal
.
confirm
({
title
:
'
拒绝续方申请
'
,
content
:
(
<
TextArea
placeholder=
"请输入拒绝理.."
rows=
{
3
}
/>
),
onOk
:
()
=>
{
setData
(
data
.
map
((
d
)
=>
(
d
.
id
===
id
?
{
...
d
,
status
:
'
rejected
'
as
const
}
:
d
)));
message
.
info
(
'
续方申请已拒);
setDetailModalVisible(false);
},
});
};
const getStatusTag = (status: string) => {
const map: Record<string, { color: string; text: string }> = {
pending: { color:
'
orange
'
, text:
'
待审核
},
approved
:
{
color
:
'
green
'
,
text
:
'
已通过
'
},
rejected
:
{
color
:
'
red
'
,
text
:
'
已拒绝 },
modified: { color:
'
blue
'
, text:
'
已修
},
};
const
s
=
map
[
status
]
||
{
color
:
'
default
'
,
text
:
status
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
text
}
</
Tag
>;
};
const
columns
=
[
{
title
:
'
患者姓名, dataIndex:
'
patient_name
'
, key:
'
patient_name
'
, width: 100 },
{
title:
'
基本信息
'
,
key:
'
info
'
,
width: 120,
render: (_: any, r: ChronicPrescription) => `${r.patient_gender} / ${r.patient_age}岁`,
},
{ title:
'
慢病诊断
'
, dataIndex:
'
disease
'
, key:
'
disease
'
, width: 120 },
{ title:
'
上次开始
,
dataIndex
:
'
last_prescription_date
'
,
key
:
'
last_prescription_date
'
,
width
:
120
},
{
title
:
'
药品数量
'
,
key
:
'
drug_count
'
,
width
:
80
,
render
:
(
_
:
any
,
r
:
ChronicPrescription
)
=>
<
Tag
color=
"blue"
>
{
r
.
ai_draft
.
drugs
.
length
}
?/Tag
>
,
},
{
title
:
'
状态,
dataIndex:
'
status
'
,
key:
'
status
'
,
width: 100,
render: (status: string) => getStatusTag(status),
}
,
{
title
:
'
提交时间
'
,
dataIndex
:
'
submitted_at
'
,
key
:
'
submitted_at
'
,
width
:
160
}
,
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
150
,
render
:
(
_
:
any
,
record
:
ChronicPrescription
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
handleView
(
record
)
}
>
查看详情
</
Button
>
{
record
.
status
===
'
pending
'
&&
(
<
Button
type=
"link"
size=
"small"
style=
{
{
color
:
'
#52c41a
'
}
}
onClick=
{
()
=>
handleApprove
(
record
.
id
)
}
>
通过
</
Button
>
)
}
</
Space
>
),
}
,
];
const pendingCount = data.filter((d) =
>
d.status === 'pending').length;
return (
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
MedicineBoxOutlined
className=
"mr-1"
/>
慢病续方审核
</
h4
>
<
Tabs
defaultActiveKey=
"pending"
size=
"small"
items=
{
[
{
key
:
'
pending
'
,
label
:
<
Badge
count=
{
pendingCount
}
offset=
{
[
10
,
0
]
}
>
待审
</
Badge
>,
children
:
(
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
data
.
filter
((
d
)
=>
d
.
status
===
'
pending
'
)
}
rowKey=
"id"
size=
"small"
pagination=
{
false
}
/>
</
Card
>
),
},
{
key
:
'
all
'
,
label
:
'
全部记录
'
,
children
:
(
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
data
}
rowKey=
"id"
size=
"small"
pagination=
{
{
pageSize
:
10
,
size
:
'
small
'
}
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
"续方详情审核"
open=
{
detailModalVisible
}
onCancel=
{
()
=>
setDetailModalVisible
(
false
)
}
width=
{
700
}
footer=
{
currentRecord
?.
status
===
'
pending
'
?
[
<
Button
key=
"reject"
danger
onClick=
{
()
=>
handleReject
(
currentRecord
.
id
)
}
>
<
CloseCircleOutlined
/>
拒绝
</
Button
>,
<
Button
key=
"modify"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
message
.
info
(
'
修改功能开发中
'
)
}
>
修改后签名
</
Button
>,
<
Button
key=
"approve"
type=
"primary"
style=
{
{
background
:
'
#52c41a
'
,
borderColor
:
'
#52c41a
'
}
}
onClick=
{
()
=>
handleApprove
(
currentRecord
.
id
)
}
>
<
SafetyCertificateOutlined
/>
签名发布
</
Button
>,
]
:
null
}
>
{
currentRecord
&&
(
<
div
>
<
Descriptions
bordered
size=
"small"
column=
{
2
}
style=
{
{
marginBottom
:
16
}
}
>
<
Descriptions
.
Item
label=
"患者姓>
{
currentRecord
.
patient_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"性别/年龄"
>
{
currentRecord
.
patient_gender
}
/
{
currentRecord
.
patient_age
}
?/Descriptions.Item
>
<
Descriptions
.
Item
label=
"慢病诊断"
>
{
currentRecord
.
disease
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"上次开>
{
currentRecord
.
last_prescription_date
}
</
Descriptions
.
Item
>
</
Descriptions
>
<
Card
title=
"AI 续方草"
size=
"small"
style=
{
{
marginBottom
:
16
,
borderColor
:
'
#52c41a
'
}
}
>
<
Table
size=
"small"
pagination=
{
false
}
dataSource=
{
currentRecord
.
ai_draft
.
drugs
.
map
((
d
,
i
)
=>
({
...
d
,
key
:
i
}))
}
columns=
{
[
{
title
:
'
药品
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
},
{
title
:
'
规格
'
,
dataIndex
:
'
spec
'
,
key
:
'
spec
'
},
{
title
:
'
剂量
'
,
dataIndex
:
'
dosage
'
,
key
:
'
dosage
'
},
{
title
:
'
频次
'
,
dataIndex
:
'
frequency
'
,
key
:
'
frequency
'
},
{
title
:
'
天数
'
,
dataIndex
:
'
days
'
,
key
:
'
days
'
,
render
:
(
v
:
number
)
=>
`${v}天`
},
]
}
/>
<
div
style=
{
{
marginTop
:
12
,
padding
:
8
,
background
:
'
#f6ffed
'
,
borderRadius
:
6
}
}
>
<
Text
type=
"secondary"
>
AI 备注
</
Text
>
<
Text
>
{
currentRecord
.
ai_draft
.
note
}
</
Text
>
</
div
>
</
Card
>
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Text
strong
>
状态:
</
Text
>
{
getStatusTag
(
currentRecord
.
status
)
}
</
div
>
</
div
>
)
}
</
Modal
>
</
div
>
);
};
export default ChronicReviewPage;
web/src/pages/doctor/Consult/AIPanel.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Tabs
,
Typography
,
Space
,
Empty
,
Tag
,
Divider
,
Alert
,
Spin
,
Badge
,
Button
,
}
from
'
antd
'
;
import
{
RobotOutlined
,
FileTextOutlined
,
UserOutlined
,
MessageOutlined
,
MedicineBoxOutlined
,
ThunderboltOutlined
,
}
from
'
@ant-design/icons
'
;
import
type
{
PreConsultResponse
,
ChatMessage
}
from
'
../../../api/preConsult
'
;
import
{
consultApi
}
from
'
../../../api/consult
'
;
import
MarkdownRenderer
from
'
../../../components/MarkdownRenderer
'
;
const
{
Text
}
=
Typography
;
interface
AIPanelProps
{
hasActiveConsult
:
boolean
;
activeConsultId
?:
string
;
preConsultReport
:
PreConsultResponse
|
null
;
preConsultLoading
:
boolean
;
}
const
AIPanel
:
React
.
FC
<
AIPanelProps
>
=
({
hasActiveConsult
,
activeConsultId
,
preConsultReport
,
preConsultLoading
,
})
=>
{
const
[
diagnosisContent
,
setDiagnosisContent
]
=
useState
(
''
);
const
[
diagnosisLoading
,
setDiagnosisLoading
]
=
useState
(
false
);
const
[
medicationContent
,
setMedicationContent
]
=
useState
(
''
);
const
[
medicationLoading
,
setMedicationLoading
]
=
useState
(
false
);
const
[
preConsultSubTab
,
setPreConsultSubTab
]
=
useState
(
'
chat
'
);
const
handleAIAssist
=
async
(
scene
:
'
consult_diagnosis
'
|
'
consult_medication
'
)
=>
{
if
(
!
activeConsultId
)
return
;
const
setLoading
=
scene
===
'
consult_diagnosis
'
?
setDiagnosisLoading
:
setMedicationLoading
;
const
setContent
=
scene
===
'
consult_diagnosis
'
?
setDiagnosisContent
:
setMedicationContent
;
setLoading
(
true
);
try
{
const
res
=
await
consultApi
.
aiAssist
(
activeConsultId
,
scene
);
setContent
(
res
.
data
?.
content
||
'
暂无分析结果
'
);
}
catch
(
err
:
any
)
{
setContent
(
'
AI分析失败:
'
+
(
err
?.
message
||
'
请稍后重试
'
));
}
finally
{
setLoading
(
false
);
}
};
const
hasPreConsultData
=
preConsultReport
&&
(
preConsultReport
.
ai_analysis
||
(
preConsultReport
.
chat_messages
&&
preConsultReport
.
chat_messages
.
length
>
0
)
);
// 渲染完整对话记录
const
renderFullChatHistory
=
(
chatMsgs
:
ChatMessage
[])
=>
{
if
(
!
chatMsgs
||
chatMsgs
.
length
===
0
)
{
return
<
Alert
message=
"暂无对话记录"
type=
"info"
showIcon
style=
{
{
fontSize
:
12
}
}
/>;
}
return
(
<
div
style=
{
{
maxHeight
:
300
,
overflow
:
'
auto
'
,
padding
:
8
,
background
:
'
#f9f9f9
'
,
borderRadius
:
6
}
}
>
<
Text
strong
style=
{
{
fontSize
:
12
,
color
:
'
#666
'
,
marginBottom
:
8
,
display
:
'
block
'
}
}
>
<
MessageOutlined
style=
{
{
marginRight
:
4
}
}
/>
对话记录(共
{
Math
.
floor
(
chatMsgs
.
length
/
2
)
}
轮)
</
Text
>
{
chatMsgs
.
map
((
msg
,
i
)
=>
(
<
div
key=
{
i
}
style=
{
{
fontSize
:
12
,
marginTop
:
6
,
paddingLeft
:
8
,
borderLeft
:
msg
.
role
===
'
user
'
?
'
2px solid #1890ff
'
:
'
2px solid #52c41a
'
}
}
>
<
Text
strong
style=
{
{
fontSize
:
12
,
color
:
msg
.
role
===
'
user
'
?
'
#1890ff
'
:
'
#52c41a
'
}
}
>
{
msg
.
role
===
'
user
'
?
'
患者
'
:
'
AI
'
}
</
Text
>
<
div
style=
{
{
marginTop
:
2
,
color
:
'
#333
'
}
}
>
{
msg
.
content
}
</
div
>
</
div
>
))
}
</
div
>
);
};
const
renderPreConsultContent
=
()
=>
{
if
(
!
preConsultReport
)
return
null
;
const
patientInfo
=
(
<
div
style=
{
{
padding
:
8
,
background
:
'
#f9f0ff
'
,
borderRadius
:
6
,
marginBottom
:
8
}
}
>
<
Text
strong
style=
{
{
fontSize
:
13
,
color
:
'
#722ed1
'
}
}
>
<
UserOutlined
style=
{
{
marginRight
:
4
}
}
/>
{
preConsultReport
.
patient_name
||
'
患者
'
}
</
Text
>
{
(
preConsultReport
.
patient_gender
||
preConsultReport
.
patient_age
)
&&
(
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
marginLeft
:
8
}
}
>
{
preConsultReport
.
patient_gender
}
{
preConsultReport
.
patient_age
?
`· ${preConsultReport.patient_age}岁`
:
''
}
</
Text
>
)
}
</
div
>
);
const
tags
=
(
preConsultReport
.
ai_severity
||
preConsultReport
.
ai_department
)
&&
(
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
4
,
marginBottom
:
8
,
flexWrap
:
'
wrap
'
}
}
>
{
preConsultReport
.
ai_severity
&&
(
<
Tag
color=
{
preConsultReport
.
ai_severity
===
'
severe
'
?
'
red
'
:
preConsultReport
.
ai_severity
===
'
moderate
'
?
'
orange
'
:
'
green
'
}
>
{
preConsultReport
.
ai_severity
===
'
severe
'
?
'
重度
'
:
preConsultReport
.
ai_severity
===
'
moderate
'
?
'
中度
'
:
'
轻度
'
}
</
Tag
>
)
}
{
preConsultReport
.
ai_department
&&
<
Tag
color=
"blue"
>
{
preConsultReport
.
ai_department
}
</
Tag
>
}
</
div
>
);
return
(
<
div
>
{
patientInfo
}
{
tags
}
{
preConsultReport
.
chief_complaint
&&
(
<
div
style=
{
{
fontSize
:
12
,
marginBottom
:
8
}
}
>
<
Text
strong
>
主诉
</
Text
>
{
preConsultReport
.
chief_complaint
}
</
div
>
)
}
<
Tabs
size=
"small"
activeKey=
{
preConsultSubTab
}
onChange=
{
setPreConsultSubTab
}
items=
{
[
{
key
:
'
chat
'
,
label
:
<
span
><
MessageOutlined
/>
对话记录
</
span
>,
children
:
renderFullChatHistory
(
preConsultReport
.
chat_messages
||
[]),
},
{
key
:
'
analysis
'
,
label
:
<
span
><
FileTextOutlined
/>
AI分析报告
</
span
>,
children
:
preConsultReport
.
ai_analysis
?
(
<
div
style=
{
{
maxHeight
:
300
,
overflow
:
'
auto
'
,
padding
:
8
,
background
:
'
#f6ffed
'
,
borderRadius
:
6
}
}
>
<
MarkdownRenderer
content=
{
preConsultReport
.
ai_analysis
}
fontSize=
{
12
}
lineHeight=
{
1.6
}
/>
</
div
>
)
:
(
<
Alert
message=
"暂无AI分析报告"
type=
"info"
showIcon
style=
{
{
fontSize
:
12
}
}
/>
),
},
]
}
/>
</
div
>
);
};
return
(
<
Card
title=
{
<
Space
>
<
RobotOutlined
style=
{
{
color
:
'
#52c41a
'
}
}
/>
<
span
>
AI 辅助
</
span
>
</
Space
>
}
style=
{
{
borderRadius
:
12
,
height
:
'
100%
'
}
}
styles=
{
{
body
:
{
padding
:
12
}
}
}
size=
"small"
>
{
hasActiveConsult
?
(
<
Tabs
size=
"small"
defaultActiveKey=
{
hasPreConsultData
?
'
preConsult
'
:
'
diagnosis
'
}
items=
{
[
{
key
:
'
preConsult
'
,
label
:
(
<
span
>
<
FileTextOutlined
/>
{
'
预问诊
'
}{
hasPreConsultData
&&
<
Badge
dot
offset=
{
[
4
,
-
2
]
}
/>
}
</
span
>
),
children
:
preConsultLoading
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
20
}
}
><
Spin
tip=
"加载中.."
/></
div
>
)
:
hasPreConsultData
?
(
renderPreConsultContent
()
)
:
(
<
Alert
message=
"该患者未进行AI预问诊"
type=
"info"
showIcon
style=
{
{
fontSize
:
12
}
}
/>
),
},
{
key
:
'
diagnosis
'
,
label
:
'
鉴别诊断
'
,
children
:
diagnosisLoading
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
20
}
}
><
Spin
tip=
"AI分析.."
/></
div
>
)
:
diagnosisContent
?
(
<
div
>
<
div
style=
{
{
maxHeight
:
400
,
overflow
:
'
auto
'
}
}
>
<
MarkdownRenderer
content=
{
diagnosisContent
}
fontSize=
{
12
}
lineHeight=
{
1.6
}
/>
</
div
>
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
Button
size=
"small"
icon=
{
<
ThunderboltOutlined
/>
}
onClick=
{
()
=>
handleAIAssist
(
'
consult_diagnosis
'
)
}
>
重新分析
</
Button
>
</
div
>
)
:
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
16
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
display
:
'
block
'
,
marginBottom
:
12
}
}
>
基于问诊对话内容,AI将辅助生成鉴别诊断
</
Text
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
ThunderboltOutlined
/>
}
onClick=
{
()
=>
handleAIAssist
(
'
consult_diagnosis
'
)
}
>
生成鉴别诊断
</
Button
>
</
div
>
),
},
{
key
:
'
drugs
'
,
label
:
'
用药建议
'
,
children
:
medicationLoading
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
20
}
}
><
Spin
tip=
"AI分析.."
/></
div
>
)
:
medicationContent
?
(
<
div
>
<
div
style=
{
{
maxHeight
:
400
,
overflow
:
'
auto
'
}
}
>
<
MarkdownRenderer
content=
{
medicationContent
}
fontSize=
{
12
}
lineHeight=
{
1.6
}
/>
</
div
>
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
Button
size=
"small"
icon=
{
<
MedicineBoxOutlined
/>
}
onClick=
{
()
=>
handleAIAssist
(
'
consult_medication
'
)
}
>
重新生成
</
Button
>
</
div
>
)
:
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
16
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
display
:
'
block
'
,
marginBottom
:
12
}
}
>
基于问诊对话内容,AI将辅助生成用药建议
</
Text
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
MedicineBoxOutlined
/>
}
onClick=
{
()
=>
handleAIAssist
(
'
consult_medication
'
)
}
>
生成用药建议
</
Button
>
</
div
>
),
},
]
}
/>
)
:
(
<
Empty
description=
"接诊后自动开始AI分析"
style=
{
{
marginTop
:
40
}
}
/>
)
}
</
Card
>
);
};
export
default
AIPanel
;
web/src/pages/doctor/Consult/ChatPanel.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useEffect
,
useRef
}
from
'
react
'
;
import
{
Card
,
Avatar
,
Tag
,
Button
,
Typography
,
Space
,
Empty
,
Input
,
Modal
,
}
from
'
antd
'
;
import
{
UserOutlined
,
VideoCameraOutlined
,
SendOutlined
,
StopOutlined
,
FileTextOutlined
,
RobotOutlined
,
}
from
'
@ant-design/icons
'
;
import
type
{
ConsultMessage
}
from
'
../../../api/consult
'
;
import
PrescriptionModal
from
'
../Prescription
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
interface
ActiveConsult
{
consult_id
:
string
;
patient_id
?:
string
;
patient_name
:
string
;
patient_gender
?:
string
;
patient_age
?:
number
;
type
:
'
text
'
|
'
video
'
;
}
interface
ChatPanelProps
{
activeConsult
:
ActiveConsult
|
null
;
consultStatus
?:
string
;
messages
:
ConsultMessage
[];
onSend
:
(
content
:
string
)
=>
void
;
onEndConsult
:
()
=>
void
;
onToggleAI
:
()
=>
void
;
aiPanelVisible
:
boolean
;
sending
:
boolean
;
}
const
ChatPanel
:
React
.
FC
<
ChatPanelProps
>
=
({
activeConsult
,
consultStatus
,
messages
,
onSend
,
onEndConsult
,
onToggleAI
,
aiPanelVisible
,
sending
,
})
=>
{
const
[
inputValue
,
setInputValue
]
=
useState
(
''
);
const
[
prescriptionOpen
,
setPrescriptionOpen
]
=
useState
(
false
);
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
useEffect
(()
=>
{
messagesEndRef
.
current
?.
scrollIntoView
({
behavior
:
'
smooth
'
});
},
[
messages
]);
const
handleSend
=
()
=>
{
if
(
!
inputValue
.
trim
())
return
;
onSend
(
inputValue
.
trim
());
setInputValue
(
''
);
};
const
handleEndConsult
=
()
=>
{
Modal
.
confirm
({
title
:
'
确认结束问诊
'
,
content
:
'
结束后患者将无法继续发送消息,确认结束本次问诊?
'
,
okText
:
'
确认结束
'
,
okButtonProps
:
{
danger
:
true
},
cancelText
:
'
取消
'
,
onOk
:
onEndConsult
,
});
};
const
formatTime
=
(
timeStr
:
string
)
=>
{
try
{
return
new
Date
(
timeStr
).
toLocaleTimeString
(
'
zh-CN
'
,
{
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
});
}
catch
{
return
''
;
}
};
const
isCompleted
=
consultStatus
===
'
completed
'
;
return
(
<>
<
Card
title=
{
activeConsult
?
(
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<
Text
strong
>
{
activeConsult
.
patient_name
}
</
Text
>
<
Tag
color=
{
activeConsult
.
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
activeConsult
.
type
===
'
video
'
?
'
视频问诊
'
:
'
图文问诊
'
}
</
Tag
>
{
isCompleted
&&
<
Tag
color=
"default"
>
已结束
</
Tag
>
}
</
Space
>
)
:
'
问诊对话
'
}
style=
{
{
borderRadius
:
12
,
height
:
'
100%
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
}
}
styles=
{
{
body
:
{
flex
:
1
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
padding
:
0
}
}
}
extra=
{
activeConsult
&&
(
<
Space
>
<
Button
type=
"text"
icon=
{
<
RobotOutlined
/>
}
onClick=
{
onToggleAI
}
style=
{
{
color
:
aiPanelVisible
?
'
#52c41a
'
:
undefined
}
}
>
AI面板
</
Button
>
{
activeConsult
.
type
===
'
video
'
&&
!
isCompleted
&&
(
<
Button
type=
"primary"
icon=
{
<
VideoCameraOutlined
/>
}
>
发起视频
</
Button
>
)
}
<
Button
icon=
{
<
FileTextOutlined
/>
}
onClick=
{
()
=>
setPrescriptionOpen
(
true
)
}
>
开处方
</
Button
>
{
!
isCompleted
&&
(
<
Button
danger
icon=
{
<
StopOutlined
/>
}
onClick=
{
handleEndConsult
}
>
结束问诊
</
Button
>
)
}
</
Space
>
)
}
>
{
activeConsult
?
(
<>
<
div
style=
{
{
flex
:
1
,
overflow
:
'
auto
'
,
padding
:
16
,
background
:
'
#f9f9f9
'
}
}
>
{
messages
.
map
((
msg
)
=>
(
<
div
key=
{
msg
.
id
}
style=
{
{
display
:
'
flex
'
,
justifyContent
:
msg
.
sender_type
===
'
doctor
'
?
'
flex-end
'
:
msg
.
sender_type
===
'
system
'
?
'
center
'
:
'
flex-start
'
,
marginBottom
:
12
,
}
}
>
{
msg
.
sender_type
===
'
system
'
?
(
<
Tag
color=
"default"
style=
{
{
fontSize
:
12
}
}
>
{
msg
.
content
}
</
Tag
>
)
:
(
<
div
style=
{
{
maxWidth
:
'
70%
'
}
}
>
<
div
style=
{
{
textAlign
:
msg
.
sender_type
===
'
doctor
'
?
'
right
'
:
'
left
'
,
marginBottom
:
4
,
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
msg
.
sender_type
===
'
doctor
'
?
'
我
'
:
activeConsult
.
patient_name
}
{
formatTime
(
msg
.
created_at
)
}
</
Text
>
</
div
>
<
div
style=
{
{
padding
:
'
8px 12px
'
,
borderRadius
:
8
,
background
:
msg
.
sender_type
===
'
doctor
'
?
'
#52c41a
'
:
'
#fff
'
,
color
:
msg
.
sender_type
===
'
doctor
'
?
'
#fff
'
:
'
#333
'
,
boxShadow
:
'
0 1px 2px rgba(0,0,0,0.1)
'
,
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
,
}
}
>
{
msg
.
content
}
</
div
>
</
div
>
)
}
</
div
>
))
}
<
div
ref=
{
messagesEndRef
}
/>
</
div
>
{
/* 已结束时显示提示,进行中时显示输入框 */
}
{
isCompleted
?
(
<
div
style=
{
{
padding
:
'
12px 16px
'
,
borderTop
:
'
1px solid #f0f0f0
'
,
textAlign
:
'
center
'
,
background
:
'
#fafafa
'
,
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
13
}
}
>
本次问诊已结束
</
Text
>
</
div
>
)
:
(
<
div
style=
{
{
padding
:
12
,
borderTop
:
'
1px solid #f0f0f0
'
}
}
>
<
Space
.
Compact
style=
{
{
width
:
'
100%
'
}
}
>
<
TextArea
value=
{
inputValue
}
onChange=
{
(
e
)
=>
setInputValue
(
e
.
target
.
value
)
}
placeholder=
"输入消息..."
autoSize=
{
{
minRows
:
1
,
maxRows
:
3
}
}
onPressEnter=
{
(
e
)
=>
{
if
(
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
style=
{
{
borderRadius
:
'
8px 0 0 8px
'
}
}
/>
<
Button
type=
"primary"
icon=
{
<
SendOutlined
/>
}
onClick=
{
handleSend
}
loading=
{
sending
}
style=
{
{
height
:
'
auto
'
,
borderRadius
:
'
0 8px 8px 0
'
,
background
:
'
#52c41a
'
,
borderColor
:
'
#52c41a
'
}
}
>
发送
</
Button
>
</
Space
.
Compact
>
</
div
>
)
}
</>
)
:
(
<
div
style=
{
{
flex
:
1
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
center
'
}
}
>
<
Empty
description=
"请从左侧列表中选择患者接诊"
/>
</
div
>
)
}
</
Card
>
<
PrescriptionModal
open=
{
prescriptionOpen
}
onClose=
{
()
=>
setPrescriptionOpen
(
false
)
}
consultId=
{
activeConsult
?.
consult_id
}
patientId=
{
activeConsult
?.
patient_id
}
patientName=
{
activeConsult
?.
patient_name
}
patientGender=
{
activeConsult
?.
patient_gender
}
patientAge=
{
activeConsult
?.
patient_age
}
/>
</>
);
};
export
default
ChatPanel
;
web/src/pages/doctor/Consult/PatientList.tsx
0 → 100644
View file @
52e57c72
import
React
from
'
react
'
;
import
{
List
,
Typography
,
Tag
,
Button
,
Card
}
from
'
antd
'
;
import
{
UserOutlined
,
MessageOutlined
,
VideoCameraOutlined
,
CheckOutlined
,
CloseOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
PatientListItem
}
from
'
../../../api/consult
'
;
const
{
Text
}
=
Typography
;
interface
PatientListProps
{
patients
:
PatientListItem
[];
onSelectPatient
:
(
patient
:
PatientListItem
)
=>
void
;
onAccept
:
(
patient
:
PatientListItem
)
=>
void
;
onReject
:
(
patient
:
PatientListItem
)
=>
void
;
selectedConsultId
?:
string
;
}
const
statusConfig
:
Record
<
string
,
{
color
:
string
;
bgColor
:
string
;
text
:
string
}
>
=
{
in_progress
:
{
color
:
'
#52c41a
'
,
bgColor
:
'
#f6ffed
'
,
text
:
'
进行中
'
},
pending
:
{
color
:
'
#1890ff
'
,
bgColor
:
'
#e6f7ff
'
,
text
:
'
待接诊
'
},
waiting
:
{
color
:
'
#1890ff
'
,
bgColor
:
'
#e6f7ff
'
,
text
:
'
待接诊
'
},
completed
:
{
color
:
'
#8c8c8c
'
,
bgColor
:
'
#fafafa
'
,
text
:
'
已完成
'
},
};
const
PatientList
:
React
.
FC
<
PatientListProps
>
=
({
patients
,
onSelectPatient
,
onAccept
,
onReject
,
selectedConsultId
})
=>
{
const
isWaiting
=
(
status
:
string
)
=>
status
===
'
pending
'
||
status
===
'
waiting
'
;
return
(
<
Card
title=
{
<
span
className=
"text-xs"
>
<
UserOutlined
className=
"text-[#1890ff] mr-1"
/>
<
Text
strong
className=
"text-xs!"
>
患者列表
</
Text
>
<
Tag
className=
"ml-2 text-[10px]!"
>
{
patients
.
length
}
</
Tag
>
</
span
>
}
style=
{
{
height
:
'
100%
'
}
}
styles=
{
{
body
:
{
padding
:
0
,
height
:
'
calc(100% - 40px)
'
,
overflow
:
'
auto
'
}
}
}
size=
"small"
>
<
List
dataSource=
{
patients
}
locale=
{
{
emptyText
:
(
<
div
className=
"text-center py-8 text-gray-400"
>
<
UserOutlined
className=
"text-3xl text-gray-300 mb-2 block"
/>
<
div
className=
"text-xs"
>
暂无患者
</
div
>
</
div
>
),
}
}
renderItem=
{
(
patient
:
PatientListItem
)
=>
{
const
config
=
statusConfig
[
patient
.
status
]
||
statusConfig
.
pending
;
const
isSelected
=
patient
.
consult_id
===
selectedConsultId
;
return
(
<
List
.
Item
key=
{
patient
.
consult_id
}
onClick=
{
()
=>
{
if
(
!
isWaiting
(
patient
.
status
))
onSelectPatient
(
patient
);
}
}
style=
{
{
cursor
:
isWaiting
(
patient
.
status
)
?
'
default
'
:
'
pointer
'
,
padding
:
'
8px 12px
'
,
background
:
isSelected
?
'
#e6f7ff
'
:
'
#fff
'
,
borderLeft
:
isSelected
?
'
3px solid #1890ff
'
:
'
3px solid transparent
'
,
borderBottom
:
'
1px solid #f0f0f0
'
,
transition
:
'
all 0.2s
'
,
}
}
>
<
div
className=
"w-full"
>
<
div
className=
"flex justify-between items-center mb-1"
>
<
span
className=
"text-xs"
>
<
UserOutlined
className=
"text-[#1890ff] mr-1"
/>
<
Text
strong
className=
"text-xs!"
>
{
patient
.
patient_name
}
</
Text
>
<
Tag
color=
{
config
.
color
}
className=
"ml-1 text-[10px]! leading-4! px-1!"
>
{
config
.
text
}
</
Tag
>
</
span
>
{
patient
.
type
===
'
video
'
?
(
<
VideoCameraOutlined
className=
"text-purple-500 text-xs"
/>
)
:
(
<
MessageOutlined
className=
"text-green-500 text-xs"
/>
)
}
</
div
>
<
div
className=
"text-[11px] text-gray-500 mb-1"
>
<
Text
ellipsis
className=
"text-[11px]! text-gray-500!"
>
{
'
主诉:
'
}{
patient
.
chief_complaint
||
'
暂无
'
}
</
Text
>
</
div
>
<
div
className=
"flex justify-between items-center"
>
<
span
className=
"text-[10px] text-gray-400"
>
{
patient
.
patient_gender
}{
'
·
'
}{
patient
.
patient_age
}{
'
岁
'
}
</
span
>
</
div
>
{
isWaiting
(
patient
.
status
)
&&
(
<
div
className=
"mt-1.5 flex gap-1.5"
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
CheckOutlined
/>
}
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
onAccept
(
patient
);
}
}
className=
"flex-1 text-[11px]!"
>
接诊
</
Button
>
<
Button
danger
size=
"small"
icon=
{
<
CloseOutlined
/>
}
onClick=
{
(
e
)
=>
{
e
.
stopPropagation
();
onReject
(
patient
);
}
}
className=
"flex-1 text-[11px]!"
>
拒绝
</
Button
>
</
div
>
)
}
</
div
>
</
List
.
Item
>
);
}
}
/>
</
Card
>
);
};
export
default
PatientList
;
web/src/pages/doctor/Consult/WaitingQueue.tsx
0 → 100644
View file @
52e57c72
import
React
from
'
react
'
;
import
{
Card
,
Avatar
,
Tag
,
Button
,
Typography
,
Space
,
Empty
,
Badge
,
Tabs
,
}
from
'
antd
'
;
import
{
UserOutlined
,
ClockCircleOutlined
,
MessageOutlined
,
CheckCircleOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
WaitingPatient
,
DoctorConsultItem
}
from
'
../../../api/consult
'
;
const
{
Text
}
=
Typography
;
interface
WaitingQueueProps
{
waitingList
:
WaitingPatient
[];
inProgressList
:
DoctorConsultItem
[];
completedList
:
DoctorConsultItem
[];
activeConsultId
:
string
|
null
;
onAccept
:
(
patient
:
WaitingPatient
)
=>
void
;
onReject
:
(
patient
:
WaitingPatient
)
=>
void
;
onSelectConsult
:
(
consult
:
DoctorConsultItem
)
=>
void
;
}
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
}
分;
};
const WaitingQueue: React.FC<WaitingQueueProps> = ({
waitingList,
inProgressList,
completedList,
activeConsultId,
onAccept,
onReject,
onSelectConsult,
}) => {
const renderWaitingItem = (patient: WaitingPatient) => (
<Card
key={patient.consult_id}
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
border: activeConsultId === patient.consult_id ? '2px solid #52c41a' : undefined,
}}
hoverable
>
<div style={{ marginBottom: 8 }}>
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
<Text strong>{patient.patient_name || '患者}</Text>
<Tag color={patient.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{patient.type === 'video' ? '视频' : '图文'}
</Tag>
</Space>
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
{patient.chief_complaint
? 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 }}>
{formatWaitingTime(patient.waiting_seconds)}
</Tag>
<Space size={4}>
<Button size="small" type="primary" onClick={() => onAccept(patient)}>
接诊
</Button>
<Button size="small" danger onClick={() => onReject(patient)}>
拒诊
</Button>
</Space>
</div>
</Card>
);
const renderInProgressItem = (consult: DoctorConsultItem) => (
<Card
key={consult.id}
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
border: activeConsultId === consult.id ? '2px solid #52c41a' : undefined,
cursor: 'pointer',
}}
hoverable
onClick={() => onSelectConsult(consult)}
>
<div style={{ marginBottom: 8 }}>
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<Text strong>{consult.patient_name || '患者}</Text>
<Tag color={consult.type === 'video' ? 'blue' : 'green'} style={{ fontSize: 11 }}>
{consult.type === 'video' ? '视频' : '图文'}
</Tag>
</Space>
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{consult.chief_complaint
? consult.chief_complaint.length > 30
? consult.chief_complaint.substring(0, 30) + '...'
: consult.chief_complaint
: '鏈~鍐欎富璇}
</Text>
</Card>
);
const tabItems = [
{
key: 'waiting',
label: (
<Space>
<span>待接诊/span>
<Badge count={waitingList.length} style={{ backgroundColor: '#fa8c16' }} />
</Space>
),
children: waitingList.length === 0 ? (
<Empty description="暂无等待患者 style={{ marginTop: 40 }} />
) : (
<div>{waitingList.map(renderWaitingItem)}</div>
),
},
{
key: 'in_progress',
label: (
<Space>
<MessageOutlined />
<span>杩涜涓</span>
<Badge count={inProgressList.length} style={{ backgroundColor: '#52c41a' }} />
</Space>
),
children: inProgressList.length === 0 ? (
<Empty description="暂无杩涜涓棶璇 style={{ marginTop: 40 }} />
) : (
<div>{inProgressList.map(renderInProgressItem)}</div>
),
},
{
key: 'completed',
label: (
<Space>
<CheckCircleOutlined />
<span>已完成/span>
<Badge count={completedList.length} style={{ backgroundColor: '#999' }} />
</Space>
),
children: completedList.length === 0 ? (
<Empty description="暂无已完成愰棶璇 style={{ marginTop: 40 }} />
) : (
<div>{completedList.map((consult) => (
<Card
key={consult.id}
size="small"
style={{
marginBottom: 8,
borderRadius: 8,
border: activeConsultId === consult.id ? '2px solid #52c41a' : undefined,
cursor: 'pointer',
opacity: 0.8,
}}
hoverable
onClick={() => onSelectConsult(consult)}
>
<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>
</Space>
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{consult.chief_complaint
? consult.chief_complaint.length > 30
? consult.chief_complaint.substring(0, 30) + '...'
: consult.chief_complaint
: '鏈~鍐欎富璇}
</Text>
</Card>
))}</div>
),
},
];
return (
<Card
style={{ borderRadius: 12, height: '100%', overflow: 'auto' }}
styles={{ body: { padding: 12 } }}
size="small"
>
<Tabs items={tabItems} size="small" />
</Card>
);
};
export default WaitingQueue;
web/src/pages/doctor/Consult/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Row
,
Col
,
Typography
,
message
,
Modal
}
from
'
antd
'
;
import
{
MessageOutlined
}
from
'
@ant-design/icons
'
;
import
{
consultApi
,
type
PatientListItem
,
type
ConsultMessage
}
from
'
../../../api/consult
'
;
import
{
preConsultApi
}
from
'
../../../api/preConsult
'
;
import
type
{
PreConsultResponse
}
from
'
../../../api/preConsult
'
;
import
PatientList
from
'
./PatientList
'
;
import
ChatPanel
from
'
./ChatPanel
'
;
import
AIPanel
from
'
./AIPanel
'
;
const
{
Title
}
=
Typography
;
interface
ActiveConsult
{
consult_id
:
string
;
patient_id
?:
string
;
patient_name
:
string
;
patient_gender
?:
string
;
patient_age
?:
number
;
chief_complaint
?:
string
;
type
:
'
text
'
|
'
video
'
;
status
?:
string
;
}
const
ConsultPage
:
React
.
FC
=
()
=>
{
const
[
patientList
,
setPatientList
]
=
useState
<
PatientListItem
[]
>
([]);
const
[
activeConsult
,
setActiveConsult
]
=
useState
<
ActiveConsult
|
null
>
(
null
);
const
[
messages
,
setMessages
]
=
useState
<
ConsultMessage
[]
>
([]);
const
[
aiPanelVisible
,
setAiPanelVisible
]
=
useState
(
true
);
const
[
preConsultReport
,
setPreConsultReport
]
=
useState
<
PreConsultResponse
|
null
>
(
null
);
const
[
preConsultLoading
,
setPreConsultLoading
]
=
useState
(
false
);
const
[
sending
,
setSending
]
=
useState
(
false
);
const
fetchPatientList
=
useCallback
(
async
()
=>
{
try
{
const
res
=
await
consultApi
.
getPatientList
();
setPatientList
(
res
.
data
||
[]);
}
catch
{
// 静默失败
}
},
[]);
// 获取消息列表
const
fetchMessages
=
useCallback
(
async
(
consultId
:
string
)
=>
{
try
{
const
res
=
await
consultApi
.
getConsultMessages
(
consultId
);
setMessages
(
res
.
data
||
[]);
}
catch
{
// 静默失败
}
},
[]);
useEffect
(()
=>
{
fetchPatientList
();
const
timer
=
setInterval
(()
=>
{
fetchPatientList
();
},
5000
);
return
()
=>
clearInterval
(
timer
);
},
[
fetchPatientList
]);
// 定时刷新消息
useEffect
(()
=>
{
if
(
!
activeConsult
)
return
;
fetchMessages
(
activeConsult
.
consult_id
);
const
timer
=
setInterval
(()
=>
fetchMessages
(
activeConsult
.
consult_id
),
3000
);
return
()
=>
clearInterval
(
timer
);
},
[
activeConsult
,
fetchMessages
]);
// 加载预问诊报告(先通过问诊ID查,查不到再通过患者ID查)
const
fetchPreConsultReport
=
async
(
consultId
:
string
,
patientId
?:
string
)
=>
{
setPreConsultLoading
(
true
);
try
{
const
res
=
await
preConsultApi
.
getByConsultationId
(
consultId
);
setPreConsultReport
(
res
.
data
);
}
catch
{
// 问诊没有关联预问诊,尝试通过患者ID获取最新预问诊
if
(
patientId
)
{
try
{
const
res
=
await
preConsultApi
.
getByPatientId
(
patientId
);
setPreConsultReport
(
res
.
data
);
}
catch
{
setPreConsultReport
(
null
);
}
}
else
{
setPreConsultReport
(
null
);
}
}
finally
{
setPreConsultLoading
(
false
);
}
};
// 接诊
const
handleAccept
=
async
(
patient
:
PatientListItem
)
=>
{
console
.
log
(
'
handleAccept 被调用
'
,
patient
);
try
{
console
.
log
(
'
调用 acceptConsult API
'
,
patient
.
consult_id
);
await
consultApi
.
acceptConsult
(
patient
.
consult_id
);
console
.
log
(
'
acceptConsult API 调用成功
'
);
setActiveConsult
({
consult_id
:
patient
.
consult_id
,
patient_id
:
patient
.
patient_id
,
patient_name
:
patient
.
patient_name
||
'
患者
'
,
patient_gender
:
patient
.
patient_gender
,
patient_age
:
patient
.
patient_age
,
chief_complaint
:
patient
.
chief_complaint
,
type
:
patient
.
type
,
status
:
'
in_progress
'
,
});
setPreConsultReport
(
null
);
message
.
success
(
`已接诊患者
${
patient
.
patient_name
||
'
未知
'
}
`
);
fetchPreConsultReport
(
patient
.
consult_id
,
patient
.
patient_id
);
fetchPatientList
();
fetchMessages
(
patient
.
consult_id
);
}
catch
(
err
:
any
)
{
console
.
error
(
'
接诊失败
'
,
err
);
message
.
error
(
err
?.
message
||
'
接诊失败
'
);
}
};
// 选择患者(仅用于进行中和已完成的问诊)
const
handleSelectPatient
=
(
patient
:
PatientListItem
)
=>
{
setActiveConsult
({
consult_id
:
patient
.
consult_id
,
patient_id
:
patient
.
patient_id
,
patient_name
:
patient
.
patient_name
||
'
患者
'
,
patient_gender
:
patient
.
patient_gender
,
patient_age
:
patient
.
patient_age
,
chief_complaint
:
patient
.
chief_complaint
,
type
:
patient
.
type
,
status
:
patient
.
status
,
});
setPreConsultReport
(
null
);
fetchPreConsultReport
(
patient
.
consult_id
,
patient
.
patient_id
);
fetchMessages
(
patient
.
consult_id
);
};
// 拒诊
const
handleReject
=
(
patient
:
PatientListItem
)
=>
{
console
.
log
(
'
handleReject 被调用
'
,
patient
);
Modal
.
confirm
({
title
:
'
确认拒诊
'
,
content
:
`确定拒绝接诊患者
${
patient
.
patient_name
||
'
未知
'
}
?`
,
onOk
:
async
()
=>
{
try
{
console
.
log
(
'
调用 rejectConsult API
'
,
patient
.
consult_id
);
await
consultApi
.
rejectConsult
(
patient
.
consult_id
);
console
.
log
(
'
rejectConsult API 调用成功
'
);
message
.
info
(
'
已拒诊
'
);
fetchPatientList
();
}
catch
(
err
:
any
)
{
console
.
error
(
'
拒诊失败
'
,
err
);
message
.
error
(
'
拒诊失败
'
);
}
},
});
};
const
handleSendMessage
=
async
(
content
:
string
)
=>
{
if
(
!
activeConsult
)
return
;
setSending
(
true
);
try
{
await
consultApi
.
sendMessage
(
activeConsult
.
consult_id
,
content
);
fetchMessages
(
activeConsult
.
consult_id
);
}
catch
{
message
.
error
(
'
发送失败
'
);
}
finally
{
setSending
(
false
);
}
};
// 结束问诊
const
handleEndConsult
=
async
()
=>
{
if
(
!
activeConsult
)
return
;
try
{
await
consultApi
.
endConsult
(
activeConsult
.
consult_id
);
message
.
success
(
'
问诊已结束
'
);
setActiveConsult
(
null
);
setMessages
([]);
setPreConsultReport
(
null
);
fetchPatientList
();
}
catch
(
err
:
any
)
{
message
.
error
(
'
结束问诊失败:
'
+
(
err
?.
message
||
'
未知错误
'
));
}
};
return
(
<
div
>
<
Title
level=
{
4
}
style=
{
{
marginBottom
:
16
}
}
>
<
MessageOutlined
style=
{
{
marginRight
:
8
}
}
/>
问诊大厅
</
Title
>
<
Row
gutter=
{
16
}
style=
{
{
height
:
'
calc(100vh - 160px)
'
}
}
>
<
Col
span=
{
6
}
>
<
PatientList
patients=
{
patientList
}
onSelectPatient=
{
handleSelectPatient
}
onAccept=
{
handleAccept
}
onReject=
{
handleReject
}
selectedConsultId=
{
activeConsult
?.
consult_id
}
/>
</
Col
>
<
Col
span=
{
aiPanelVisible
?
12
:
18
}
>
<
ChatPanel
activeConsult=
{
activeConsult
}
messages=
{
messages
}
onSend=
{
handleSendMessage
}
onEndConsult=
{
handleEndConsult
}
onToggleAI=
{
()
=>
setAiPanelVisible
(
!
aiPanelVisible
)
}
aiPanelVisible=
{
aiPanelVisible
}
sending=
{
sending
}
/>
</
Col
>
{
aiPanelVisible
&&
(
<
Col
span=
{
6
}
>
<
AIPanel
hasActiveConsult=
{
!!
activeConsult
}
activeConsultId=
{
activeConsult
?.
consult_id
}
preConsultReport=
{
preConsultReport
}
preConsultLoading=
{
preConsultLoading
}
/>
</
Col
>
)
}
</
Row
>
</
div
>
);
};
export
default
ConsultPage
;
web/src/pages/doctor/ConsultRoom/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useRef
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Button
,
Avatar
,
Typography
,
Space
,
Descriptions
,
Tag
,
Divider
}
from
'
antd
'
;
import
{
SendOutlined
,
UserOutlined
,
VideoCameraOutlined
,
FileTextOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Text
,
Title
}
=
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天数,
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
'
,
},
];
const
patientInfo
=
{
name
:
'
张三
'
,
age
:
45
,
gender
:
'
?,
phone:
'
138
****
8888
'
,
medical_history:
'
高血压病史
5
年
,
allergy_history
:
'
青霉素过敏,
consultation_count: 3,
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior:
'
smooth
'
});
}, [messages]);
const handleSend = () => {
if (!inputValue.trim()) return;
// TODO: 发送消息到API
setInputValue(
''
);
};
return (
<div style={{ height:
'
calc
(
100
vh
-
120
px
)
'
}}>
<Row gutter={8} style={{ height:
'
100
%
'
}}>
<Col span={16} style={{ height:
'
100
%
'
}}>
<Card
size="small"
title={
<span className="text-xs">
<Avatar size={24} icon={<UserOutlined />} style={{ backgroundColor:
'
#
87
d068
'
}} className="mr-1" />
{currentConsult.patient_name}
<Tag className="ml-1" color={currentConsult.type ===
'
video
'
?
'
blue
'
:
'
green
'
}>
{currentConsult.type ===
'
video
'
?
'
视频
'
:
'
图文
'
}
</Tag>
<Tag color="processing">问诊中</Tag>
</span>
}
extra={
<Space size={4}>
<Button size="small" icon={<FileTextOutlined />}>开处方</Button>
<Button size="small" danger>结束</Button>
</Space>
}
style={{ height:
'
100
%
'
, display:
'
flex
'
, flexDirection:
'
column
'
}}
styles={{ body: { flex: 1, display:
'
flex
'
, flexDirection:
'
column
'
, padding: 0 } }}
>
<div style={{ flex: 1, overflow:
'
auto
'
, padding: 12 }}>
{messages.map((msg) => {
if (msg.sender_type ===
'
system
'
) {
return (
<div key={msg.id} className="text-center my-1">
<Text type="secondary" className="text-[11px]!">{msg.content}</Text>
</div>
);
}
const isDoctor = msg.sender_type ===
'
doctor
'
;
return (
<div key={msg.id} className={`flex mb-3 ${isDoctor ?
'
justify
-
end
'
:
'
justify
-
start
'
}`}>
{!isDoctor && <Avatar size={28} className="mr-1.5" icon={<UserOutlined />} src={currentConsult.patient_avatar} />}
<div style={{ maxWidth:
'
70
%
'
}}>
<div className="text-xs rounded-lg px-3 py-2" style={{
background: isDoctor ?
'
#
52
c41a
'
:
'
#
f5f5f5
'
,
color: isDoctor ?
'
#
fff
'
:
'
#
000
'
,
}}>{msg.content}</div>
</div>
{isDoctor && <Avatar size={28} className="ml-1.5" icon={<MedicineBoxOutlined />} />}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-gray-100 p-2">
<Space.Compact style={{ width:
'
100
%
'
}}>
<TextArea value={inputValue} onChange={(e) => setInputValue(e.target.value)}
placeholder="输入回复..." autoSize={{ minRows: 1, maxRows: 3 }} size="small"
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend(); } }} style={{ flex: 1 }} />
<Button type="primary" size="small" icon={<SendOutlined />} onClick={handleSend}>发送</Button>
</Space.Compact>
</div>
</Card>
</Col>
<Col span={8} style={{ height:
'
100
%
'
}}>
<Card title={<span className="text-xs font-semibold">患者信息</span>} size="small"
style={{ height:
'
100
%
'
, overflow:
'
auto
'
}}>
<div className="text-center mb-2">
<Avatar size={48} icon={<UserOutlined />} style={{ backgroundColor:
'
#
87
d068
'
}} />
<div className="mt-1">
<div className="text-sm font-bold">{patientInfo.name}</div>
<Text type="secondary" className="text-xs!">{patientInfo.age}?· {patientInfo.gender}</Text>
</div>
</div>
<Divider className="my-1!" />
<Descriptions column={1} size="small">
<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>
<Divider className="my-1!" />
<div className="text-xs font-semibold mb-1">本次主诉</div>
<Text className="text-xs!">{currentConsult.chief_complaint}</Text>
<Divider className="my-1!" />
<div className="text-xs font-semibold mb-1">病史描述</div>
<Text type="secondary" className="text-xs!">{currentConsult.medical_history ||
'
暂无
'
}</Text>
<Divider className="my-1!" />
<Space direction="vertical" className="w-full" size={4}>
<Button block size="small" icon={<FileTextOutlined />}>开电子处方</Button>
<Button block size="small" icon={<VideoCameraOutlined />}>发起视频</Button>
</Space>
</Card>
</Col>
</Row>
</div>
);
};
export default DoctorConsultRoomPage;
web/src/pages/doctor/History/index.tsx
0 → 100644
View file @
52e57c72
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
{
Title
,
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
interface
ConsultRecord
{
id
:
string
;
patient_name
:
string
;
patient_avatar
:
string
;
type
:
'
text
'
|
'
video
'
;
status
:
string
;
chief_complaint
:
string
;
created_at
:
string
;
ended_at
:
string
|
null
;
}
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
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
'
,
patient_name:
'
张三
'
,
patient_avatar:
''
,
type:
'
text
'
,
status:
'
completed
'
,
chief_complaint:
'
头痛、乏力
,
created_at
:
'
2026-02-24T10:00:00Z
'
,
ended_at
:
'
2026-02-24T10:30:00Z
'
,
},
{
id
:
'
2
'
,
patient_name
:
'
李四
'
,
patient_avatar
:
''
,
type
:
'
video
'
,
status
:
'
completed
'
,
chief_complaint
:
'
咳嗽发热
'
,
created_at
:
'
2026-02-24T14:00:00Z
'
,
ended_at
:
'
2026-02-24T14:25:00Z
'
,
},
{
id
:
'
3
'
,
patient_name
:
'
王五
'
,
patient_avatar
:
''
,
type
:
'
text
'
,
status
:
'
cancelled
'
,
chief_complaint
:
'
复诊开始,
created_at:
'
2026
-
02
-
23
T09
:
00
:
00
Z
'
,
ended_at: null,
},
];
const columns: ColumnsType<ConsultRecord> = [
{
title:
'
患者
,
dataIndex
:
'
patient_name
'
,
key
:
'
patient_name
'
,
render
:
(
name
:
string
,
record
)
=>
(
<
Space
>
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
src=
{
record
.
patient_avatar
}
/>
<
Text
>
{
name
}
</
Text
>
</
Space
>
),
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
key
:
'
type
'
,
render
:
(
type
:
string
)
=>
(
<
Tag
icon=
{
type
===
'
video
'
?
<
VideoCameraOutlined
/>
:
<
MessageOutlined
/>
}
color=
{
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
type
===
'
video
'
?
'
视频问诊
'
:
'
图文问诊
'
}
</
Tag
>
),
},
{
title
:
'
主诉
'
,
dataIndex
:
'
chief_complaint
'
,
key
:
'
chief_complaint
'
,
ellipsis
:
true
,
},
{
title
:
'
状态,
dataIndex:
'
status
'
,
key:
'
status
'
,
render: (status: string) => {
const s = statusMap[status] || { text: status, color:
'
default
'
};
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title:
'
问诊时间
'
,
dataIndex:
'
created_at
'
,
key:
'
created_at
'
,
render: (t: string) => dayjs(t).format(
'
YYYY
-
MM
-
DD
HH
:
mm
'
),
},
{
title:
'
操作
'
,
key:
'
action
'
,
render: () => (
<Button type="link" icon={<EyeOutlined />}>查看详情</Button>
),
},
];
return (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">历史问诊</h4>
<Card size="small">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="搜索患者姓名
prefix={<SearchOutlined />}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
style={{ width: 160 }}
size="small"
/>
<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
'
}]} />
<RangePicker size="small" />
</div>
</Card>
<Card size="small">
<Table
columns={columns}
dataSource={historyData}
rowKey="id"
size="small"
pagination={{ pageSize: 10, size:
'
small
'
, showSizeChanger: true, showTotal: (total) => `?${total} 条` }}
/>
</Card>
</div>
);
};
export default DoctorHistoryPage;
web/src/pages/doctor/Income/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Table
,
Button
,
Tag
,
Typography
,
Space
,
Statistic
,
DatePicker
,
Tabs
,
Progress
,
List
,
Modal
,
Form
,
InputNumber
,
Input
,
message
,
}
from
'
antd
'
;
import
{
DollarOutlined
,
RiseOutlined
,
WalletOutlined
,
BankOutlined
,
CalendarOutlined
,
FileTextOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
Text
}
=
Typography
;
const
{
RangePicker
}
=
DatePicker
;
interface
IncomeRecord
{
id
:
string
;
date
:
string
;
patient_name
:
string
;
type
:
string
;
amount
:
number
;
status
:
string
;
}
interface
MonthlyBill
{
month
:
string
;
total_income
:
number
;
consult_count
:
number
;
text_income
:
number
;
video_income
:
number
;
prescription_income
:
number
;
}
const
mockIncomeRecords
:
IncomeRecord
[]
=
[
{
id
:
'
1
'
,
date
:
'
2026-02-25 09:30
'
,
patient_name
:
'
张三
'
,
type
:
'
图文问诊
'
,
amount
:
5000
,
status
:
'
settled
'
},
{
id
:
'
2
'
,
date
:
'
2026-02-25 10:15
'
,
patient_name
:
'
李四
'
,
type
:
'
视频问诊
'
,
amount
:
10000
,
status
:
'
settled
'
},
{
id
:
'
3
'
,
date
:
'
2026-02-24 14:20
'
,
patient_name
:
'
王五
'
,
type
:
'
图文问诊
'
,
amount
:
5000
,
status
:
'
settled
'
},
{
id
:
'
4
'
,
date
:
'
2026-02-24 16:00
'
,
patient_name
:
'
赵六
'
,
type
:
'
续方审核
'
,
amount
:
2000
,
status
:
'
pending
'
},
{
id
:
'
5
'
,
date
:
'
2026-02-23 11:30
'
,
patient_name
:
'
孙七
'
,
type
:
'
视频问诊
'
,
amount
:
10000
,
status
:
'
settled
'
},
];
const
mockMonthlyBills
:
MonthlyBill
[]
=
[
{
month
:
'
2026-02
'
,
total_income
:
156000
,
consult_count
:
42
,
text_income
:
80000
,
video_income
:
60000
,
prescription_income
:
16000
},
{
month
:
'
2026-01
'
,
total_income
:
189000
,
consult_count
:
55
,
text_income
:
95000
,
video_income
:
75000
,
prescription_income
:
19000
},
{
month
:
'
2025-12
'
,
total_income
:
142000
,
consult_count
:
38
,
text_income
:
72000
,
video_income
:
55000
,
prescription_income
:
15000
},
];
const
IncomePage
:
React
.
FC
=
()
=>
{
const
[
withdrawModalVisible
,
setWithdrawModalVisible
]
=
useState
(
false
);
const
totalBalance
=
32500
;
// 可提现余额(分)
const
monthIncome
=
156000
;
const
monthConsults
=
42
;
const
handleWithdraw
=
()
=>
{
message
.
success
(
'
提现申请已提交,预计1-3个工作日到账
'
);
setWithdrawModalVisible
(
false
);
};
const
incomeColumns
=
[
{
title
:
'
时间
'
,
dataIndex
:
'
date
'
,
key
:
'
date
'
,
width
:
160
},
{
title
:
'
患者, dataIndex:
'
patient_name
'
, key:
'
patient_name
'
, width: 100 },
{
title:
'
类型
'
,
dataIndex:
'
type
'
,
key:
'
type
'
,
width: 100,
render: (v: string) => {
const colorMap: Record<string, string> = {
'
图文问诊
'
:
'
blue
'
,
'
视频问诊
'
:
'
green
'
,
'
续方审核
'
:
'
orange
'
};
return <Tag color={colorMap[v] ||
'
default
'
}>{v}</Tag>;
},
},
{
title:
'
金额
'
,
dataIndex:
'
amount
'
,
key:
'
amount
'
,
width: 100,
render: (v: number) => <Text strong style={{ color:
'
#
52
c41a
'
}}>¥{(v / 100).toFixed(2)}</Text>,
},
{
title:
'
状态
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
(
<
Tag
color=
{
v
===
'
settled
'
?
'
green
'
:
'
orange
'
}
>
{
v
===
'
settled
'
?
'
已结算 :
'
待结
}
</
Tag
>
),
},
];
return
(
<
div
className=
"space-y-3"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
DollarOutlined
className=
"mr-1"
/>
收入统计
</
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-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
>
</
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-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
>
</
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
>
</
Col
>
</
Row
>
<
Tabs
defaultActiveKey=
"records"
size=
"small"
items=
{
[
{
key
:
'
records
'
,
label
:
<
span
><
FileTextOutlined
/>
收入明细
</
span
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
Space
>
<
RangePicker
/>
<
Button
type=
"primary"
ghost
>
查询
</
Button
>
</
Space
>
</
div
>
<
Table
columns=
{
incomeColumns
}
dataSource=
{
mockIncomeRecords
}
rowKey=
"id"
pagination=
{
{
pageSize
:
10
}
}
/>
</
Card
>
),
},
{
key
:
'
monthly
'
,
label
:
<
span
><
CalendarOutlined
/>
月度账单
</
span
>,
children
:
(
<
List
dataSource=
{
mockMonthlyBills
}
renderItem=
{
(
bill
)
=>
(
<
Card
style=
{
{
marginBottom
:
16
,
borderRadius
:
12
}
}
hoverable
>
<
Row
gutter=
{
24
}
align=
"middle"
>
<
Col
span=
{
4
}
>
<
Text
strong
style=
{
{
fontSize
:
18
}
}
>
{
bill
.
month
}
</
Text
>
</
Col
>
<
Col
span=
{
5
}
>
<
Statistic
title=
"总收入
value=
{
bill
.
total_income
/
100
}
precision=
{
2
}
prefix=
"¥"
valueStyle=
{
{
fontSize
:
20
,
color
:
'
#52c41a
'
}
}
/>
</
Col
>
<
Col
span=
{
3
}
>
<
Statistic
title=
"问诊量 value=
{
bill
.
consult_count
}
suffix=
"? valueStyle=
{
{
fontSize
:
16
}
}
/>
</
Col
>
<
Col
span=
{
12
}
>
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Space
style=
{
{
width
:
'
100%
'
,
justifyContent
:
'
space-between
'
}
}
>
<
Text
type=
"secondary"
>
图文问诊
</
Text
>
<
Text
>
¥
{
(
bill
.
text_income
/
100
).
toFixed
(
0
)
}
</
Text
>
</
Space
>
<
Progress
percent=
{
Math
.
round
(
bill
.
text_income
/
bill
.
total_income
*
100
)
}
size=
"small"
strokeColor=
"#1890ff"
/>
</
div
>
<
div
style=
{
{
marginBottom
:
8
}
}
>
<
Space
style=
{
{
width
:
'
100%
'
,
justifyContent
:
'
space-between
'
}
}
>
<
Text
type=
"secondary"
>
视频问诊
</
Text
>
<
Text
>
¥
{
(
bill
.
video_income
/
100
).
toFixed
(
0
)
}
</
Text
>
</
Space
>
<
Progress
percent=
{
Math
.
round
(
bill
.
video_income
/
bill
.
total_income
*
100
)
}
size=
"small"
strokeColor=
"#52c41a"
/>
</
div
>
<
div
>
<
Space
style=
{
{
width
:
'
100%
'
,
justifyContent
:
'
space-between
'
}
}
>
<
Text
type=
"secondary"
>
续方收入
</
Text
>
<
Text
>
¥
{
(
bill
.
prescription_income
/
100
).
toFixed
(
0
)
}
</
Text
>
</
Space
>
<
Progress
percent=
{
Math
.
round
(
bill
.
prescription_income
/
bill
.
total_income
*
100
)
}
size=
"small"
strokeColor=
"#faad14"
/>
</
div
>
</
Col
>
</
Row
>
</
Card
>
)
}
/>
),
},
{
key
:
'
withdraw
'
,
label
:
<
span
><
BankOutlined
/>
提现记录
</
span
>,
children
:
(
<
Card
style=
{
{
borderRadius
:
12
}
}
>
<
Table
dataSource=
{
[
{
id
:
'
1
'
,
date
:
'
2026-02-15
'
,
amount
:
150000
,
bank
:
'
招商银行 ****5678
'
,
status
:
'
success
'
},
{
id
:
'
2
'
,
date
:
'
2026-01-15
'
,
amount
:
180000
,
bank
:
'
招商银行 ****5678
'
,
status
:
'
success
'
},
]
}
rowKey=
"id"
pagination=
{
false
}
columns=
{
[
{
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> },
]
}
/>
</
Card
>
),
},
]
}
/>
<
Modal
title=
"申请提现"
open=
{
withdrawModalVisible
}
onOk=
{
handleWithdraw
}
onCancel=
{
()
=>
setWithdrawModalVisible
(
false
)
}
okText=
"确认提现"
>
<
Form
layout=
"vertical"
>
<
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=
"请输入提现金额
min={1}
max={totalBalance / 100}
precision={2}
prefix="
¥"
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"到账银行>
<Input value="
招商银行
****5678"
disabled
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</
div
>
);
};
export default IncomePage;
web/src/pages/doctor/PatientRecord/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Typography
,
Tag
,
Space
,
Tabs
,
Descriptions
,
Empty
,
List
,
Avatar
,
Table
,
Spin
,
message
,
}
from
'
antd
'
;
import
{
SearchOutlined
,
UserOutlined
,
FileTextOutlined
,
MedicineBoxOutlined
,
HeartOutlined
,
LineChartOutlined
,
PhoneOutlined
,
SafetyOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
doctorPortalApi
,
type
PatientListItem
,
type
PatientDetail
,
type
PatientConsultRecord
,
type
PatientPrescriptionRecord
}
from
'
../../../api/doctorPortal
'
;
const
{
Title
,
Text
}
=
Typography
;
const
consultTypeLabel
:
Record
<
string
,
string
>
=
{
text
:
'
图文问诊
'
,
video
:
'
视频问诊
'
,
};
const
consultStatusColor
:
Record
<
string
,
string
>
=
{
completed
:
'
green
'
,
in_progress
:
'
blue
'
,
pending
:
'
orange
'
,
cancelled
:
'
default
'
,
};
const
consultStatusLabel
:
Record
<
string
,
string
>
=
{
completed
:
'
已完成,
in_progress:
'
进行中
,
pending
:
'
待接诊,
cancelled:
'
已取消
,
};
const
prescriptionStatusColor
:
Record
<
string
,
string
>
=
{
completed
:
'
green
'
,
dispensed
:
'
cyan
'
,
approved
:
'
blue
'
,
signed
:
'
purple
'
,
pending
:
'
orange
'
,
rejected
:
'
red
'
,
};
const
prescriptionStatusLabel
:
Record
<
string
,
string
>
=
{
completed
:
'
已完成,
dispensed:
'
已取消
,
approved
:
'
已审核,
signed:
'
已签名
,
pending
:
'
待审核,
rejected:
'
已拒绝
,
};
const
PatientRecordPage
:
React
.
FC
=
()
=>
{
const
[
patients
,
setPatients
]
=
useState
<
PatientListItem
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
listLoading
,
setListLoading
]
=
useState
(
false
);
const
[
detailLoading
,
setDetailLoading
]
=
useState
(
false
);
const
[
selectedPatient
,
setSelectedPatient
]
=
useState
<
PatientDetail
|
null
>
(
null
);
const
[
searchKeyword
,
setSearchKeyword
]
=
useState
(
''
);
const
fetchPatients
=
useCallback
(
async
(
keyword
=
''
,
p
=
1
)
=>
{
setListLoading
(
true
);
try
{
const
res
=
await
doctorPortalApi
.
getPatientList
({
keyword
,
page
:
p
,
page_size
:
20
});
setPatients
(
res
.
data
.
list
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
{
message
.
error
(
'
获取患者列表失);
} finally {
setListLoading(false);
}
}, []);
// 初始加载
React.useEffect(() => {
fetchPatients();
}, [fetchPatients]);
const handleSearch = (value: string) => {
setSearchKeyword(value);
setPage(1);
fetchPatients(value, 1);
};
const handleSelectPatient = async (patientId: string) => {
setDetailLoading(true);
try {
const res = await doctorPortalApi.getPatientDetail(patientId);
setSelectedPatient(res.data);
} catch {
message.error(
'
获取患者详情失
);
}
finally
{
setDetailLoading
(
false
);
}
};
const
consultColumns
=
[
{
title
:
'
日期
'
,
dataIndex
:
'
date
'
,
key
:
'
date
'
,
width
:
110
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
key
:
'
type
'
,
width
:
100
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
v
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
consultTypeLabel
[
v
]
??
v
}
</
Tag
>,
},
{
title
:
'
主诉
'
,
dataIndex
:
'
chief_complaint
'
,
key
:
'
chief_complaint
'
,
ellipsis
:
true
},
{
title
:
'
状态, dataIndex:
'
status
'
, key:
'
status
'
, width: 80,
render: (v: string) => <Tag color={consultStatusColor[v] ??
'
default
'
}>{consultStatusLabel[v] ?? v}</Tag>,
},
];
const prescriptionColumns = [
{ title:
'
日期
'
, dataIndex:
'
date
'
, key:
'
date
'
, width: 110 },
{ title:
'
诊断
'
, dataIndex:
'
diagnosis
'
, key:
'
diagnosis
'
, width: 140, ellipsis: true },
{
title:
'
药品
'
,
dataIndex:
'
drugs
'
,
key:
'
drugs
'
,
render: (drugs: string[]) => (
<Space size={[4, 4]} wrap>
{drugs.map((d) => <Tag key={d} color="blue">{d}</Tag>)}
</Space>
),
},
{
title:
'
状态
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
<
Tag
color=
{
prescriptionStatusColor
[
v
]
??
'
default
'
}
>
{
prescriptionStatusLabel
[
v
]
??
v
}
</
Tag
>,
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
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
>
}
>
<
Input
prefix=
{
<
SearchOutlined
/>
}
placeholder=
"搜索姓名/手机"
size=
"small"
value=
{
searchKeyword
}
onChange=
{
(
e
)
=>
setSearchKeyword
(
e
.
target
.
value
)
}
onPressEnter=
{
(
e
)
=>
handleSearch
((
e
.
target
as
HTMLInputElement
).
value
)
}
onBlur=
{
(
e
)
=>
handleSearch
(
e
.
target
.
value
)
}
allowClear
onClear=
{
()
=>
handleSearch
(
''
)
}
className=
"mb-2"
/>
<
Spin
spinning=
{
listLoading
}
>
<
List
size=
"small"
dataSource=
{
patients
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无患者 /> }
}
pagination=
{
total
>
20
?
{
current
:
page
,
pageSize
:
20
,
total
,
size
:
'
small
'
,
onChange
:
(
p
)
=>
{
setPage
(
p
);
fetchPatients
(
searchKeyword
,
p
);
}
}
:
false
}
renderItem=
{
(
patient
)
=>
(
<
List
.
Item
style=
{
{
cursor
:
'
pointer
'
,
padding
:
'
6px 8px
'
,
borderRadius
:
6
,
background
:
selectedPatient
?.
id
===
patient
.
id
?
'
#f6ffed
'
:
'
transparent
'
,
border
:
selectedPatient
?.
id
===
patient
.
id
?
'
1px solid #b7eb8f
'
:
'
1px solid transparent
'
,
marginBottom
:
2
}
}
onClick=
{
()
=>
handleSelectPatient
(
patient
.
id
)
}
>
<
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
>
}
description=
{
<
div
className=
"text-[11px]"
>
<
div
className=
"text-gray-400"
><
PhoneOutlined
className=
"mr-0.5"
/>
{
patient
.
phone
}
</
div
>
{
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
>
</
div
>
}
/>
</
List
.
Item
>
)
}
/>
</
Spin
>
</
Card
>
</
Col
>
<
Col
span=
{
16
}
>
<
Spin
spinning=
{
detailLoading
}
>
{
selectedPatient
?
(
<
div
className=
"space-y-2"
>
<
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=
"手机"
>
{
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
>
<
Descriptions
.
Item
label=
"病史"
span=
{
3
}
>
{
selectedPatient
.
medical_history
||
<
Text
type=
"secondary"
>
?/Text
>
}
</
Descriptions
.
Item
>
</
Descriptions
>
</
Card
>
<
Card
size=
"small"
>
<
Tabs
defaultActiveKey=
"consult"
size=
"small"
items=
{
[
{
key
:
'
consult
'
,
label
:
<
span
><
FileTextOutlined
/>
问诊(
{
selectedPatient
.
consults
.
length
}
)
</
span
>,
children
:
<
Table
<
PatientConsultRecord
>
dataSource=
{
selectedPatient
.
consults
}
rowKey="id" size="small" pagination=
{
false
}
columns=
{
consultColumns
}
locale=
{
{
emptyText
:
'
暂无
'
}
}
/
>
,
},
{
key
:
'
prescription
'
,
label
:
<
span
>
<
MedicineBoxOutlined
/>
处方
({
selectedPatient
.
prescriptions
.
length
})
<
/span>
,
children
:
<
Table
<
PatientPrescriptionRecord
>
dataSource
=
{
selectedPatient
.
prescriptions
}
rowKey
=
"
id
"
size
=
"
small
"
pagination
=
{
false
}
columns
=
{
prescriptionColumns
}
locale
=
{{
emptyText
:
'
暂无
'
}}
/>
,
}
,
{
key
:
'
health
'
,
label
:
<
span
>
<
LineChartOutlined
/>
趋势
<
/span>
,
children
:
(
<
div
className=
"text-center py-6"
>
<
HeartOutlined
className=
"text-3xl text-red-400 mb-2"
/>
<
div
className=
"text-xs font-semibold mb-1"
>
健康趋势图表
</
div
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
功能开发中...
</
Text
>
</
div
>
),
}
,
]
}
/>
</
Card
>
</
div
>
) : (
<
Card
size=
"small"
className=
"text-center py-10"
>
<
Empty
description=
"请从左侧选择患者查看档 />
</Card>
)
}
</
Spin
>
</
Col
>
</
Row
>
</
div
>
);
};
export
default
PatientRecordPage
;
web/src/pages/doctor/Prescription/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
,
useCallback
}
from
'
react
'
;
import
{
Input
,
Button
,
Table
,
Form
,
Select
,
InputNumber
,
Typography
,
Tag
,
Space
,
Modal
,
message
,
AutoComplete
,
Empty
,
}
from
'
antd
'
;
import
{
DeleteOutlined
,
SearchOutlined
,
SafetyCertificateOutlined
,
PrinterOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
medicineApi
,
prescriptionDoctorApi
}
from
'
../../../api/prescription
'
;
import
type
{
Medicine
}
from
'
../../../api/prescription
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
interface
DrugItem
{
key
:
string
;
medicine_id
:
string
;
name
:
string
;
specification
:
string
;
usage
:
string
;
dosage
:
string
;
frequency
:
string
;
days
:
number
;
quantity
:
number
;
unit
:
string
;
price
:
number
;
note
:
string
;
}
interface
PrescriptionModalProps
{
open
:
boolean
;
onClose
:
()
=>
void
;
consultId
?:
string
;
patientId
?:
string
;
patientName
?:
string
;
patientGender
?:
string
;
patientAge
?:
number
;
}
const
PrescriptionModal
:
React
.
FC
<
PrescriptionModalProps
>
=
({
open
,
onClose
,
consultId
,
patientId
,
patientName
,
patientGender
,
patientAge
,
})
=>
{
const
[
drugs
,
setDrugs
]
=
useState
<
DrugItem
[]
>
([]);
const
[
searchValue
,
setSearchValue
]
=
useState
(
''
);
const
[
searchResults
,
setSearchResults
]
=
useState
<
Medicine
[]
>
([]);
const
[
signModalVisible
,
setSignModalVisible
]
=
useState
(
false
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
remarkText
,
setRemarkText
]
=
useState
(
''
);
const
[
form
]
=
Form
.
useForm
();
React
.
useEffect
(()
=>
{
if
(
open
)
{
form
.
setFieldsValue
({
patient_name
:
patientName
||
''
,
patient_gender
:
patientGender
||
undefined
,
patient_age
:
patientAge
||
undefined
,
});
}
},
[
open
,
patientName
,
patientGender
,
patientAge
,
form
]);
const
handleClose
=
()
=>
{
setDrugs
([]);
setSearchValue
(
''
);
setSearchResults
([]);
setRemarkText
(
''
);
form
.
resetFields
();
onClose
();
};
const
handleSearch
=
useCallback
(
async
(
value
:
string
)
=>
{
setSearchValue
(
value
);
if
(
value
.
length
<
1
)
{
setSearchResults
([]);
return
;
}
try
{
const
res
=
await
medicineApi
.
search
(
value
);
setSearchResults
(
res
.
data
||
[]);
}
catch
{
setSearchResults
([]);
}
},
[]);
const
handleAddDrug
=
(
medicineId
:
string
)
=>
{
const
med
=
searchResults
.
find
((
m
)
=>
m
.
id
===
medicineId
);
if
(
!
med
)
return
;
if
(
drugs
.
find
((
d
)
=>
d
.
medicine_id
===
med
.
id
))
{
message
.
warning
(
'
该药品已添加
'
);
return
;
}
setDrugs
([...
drugs
,
{
key
:
med
.
id
,
medicine_id
:
med
.
id
,
name
:
med
.
name
,
specification
:
med
.
specification
,
usage
:
'
口服
'
,
dosage
:
''
,
frequency
:
'
每日3次
'
,
days
:
3
,
quantity
:
1
,
unit
:
med
.
unit
||
'
盒
'
,
price
:
med
.
price
,
note
:
''
,
}]);
setSearchValue
(
''
);
setSearchResults
([]);
};
const
handleRemoveDrug
=
(
key
:
string
)
=>
setDrugs
(
drugs
.
filter
((
d
)
=>
d
.
key
!==
key
));
const
handleDrugChange
=
(
key
:
string
,
field
:
string
,
value
:
any
)
=>
setDrugs
(
drugs
.
map
((
d
)
=>
(
d
.
key
===
key
?
{
...
d
,
[
field
]:
value
}
:
d
)));
const
totalAmount
=
drugs
.
reduce
((
sum
,
d
)
=>
sum
+
d
.
price
*
d
.
quantity
,
0
);
const
handleSign
=
async
()
=>
{
if
(
drugs
.
length
===
0
)
{
message
.
warning
(
'
请至少添加一种药品
'
);
return
;
}
try
{
await
form
.
validateFields
();
}
catch
{
return
;
}
setSignModalVisible
(
true
);
};
const
handleConfirmSign
=
async
()
=>
{
setSubmitting
(
true
);
try
{
const
values
=
form
.
getFieldsValue
();
await
prescriptionDoctorApi
.
create
({
consult_id
:
consultId
||
''
,
patient_id
:
patientId
||
''
,
patient_name
:
values
.
patient_name
,
patient_gender
:
values
.
patient_gender
,
patient_age
:
values
.
patient_age
,
diagnosis
:
values
.
diagnosis
,
allergy_history
:
values
.
allergy
,
remark
:
remarkText
,
items
:
drugs
.
map
((
d
)
=>
({
medicine_id
:
d
.
medicine_id
,
medicine_name
:
d
.
name
,
specification
:
d
.
specification
,
usage
:
d
.
usage
,
dosage
:
d
.
dosage
,
frequency
:
d
.
frequency
,
days
:
d
.
days
,
quantity
:
d
.
quantity
,
unit
:
d
.
unit
,
price
:
d
.
price
,
note
:
d
.
note
,
})),
});
message
.
success
(
'
处方已签名并提交
'
);
setSignModalVisible
(
false
);
handleClose
();
}
catch
(
err
:
any
)
{
message
.
error
(
err
?.
message
||
'
提交处方失败
'
);
}
finally
{
setSubmitting
(
false
);
}
};
const
columns
=
[
{
title
:
'
药品名称
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
,
width
:
140
,
render
:
(
t
:
string
)
=>
<
Text
strong
>
{
t
}
</
Text
>
},
{
title
:
'
规格
'
,
dataIndex
:
'
specification
'
,
key
:
'
specification
'
,
width
:
90
},
{
title
:
'
用法
'
,
dataIndex
:
'
usage
'
,
key
:
'
usage
'
,
width
:
100
,
render
:
(
_
:
string
,
r
:
DrugItem
)
=>
(
<
Select
value=
{
r
.
usage
}
size=
"small"
style=
{
{
width
:
90
}
}
onChange=
{
(
v
)
=>
handleDrugChange
(
r
.
key
,
'
usage
'
,
v
)
}
options=
{
[
{
label
:
'
口服
'
,
value
:
'
口服
'
},
{
label
:
'
外用
'
,
value
:
'
外用
'
},
{
label
:
'
静脉注射
'
,
value
:
'
静脉注射
'
},
{
label
:
'
肌肉注射
'
,
value
:
'
肌肉注射
'
},
{
label
:
'
雾化吸入
'
,
value
:
'
雾化吸入
'
},
]
}
/>
),
},
{
title
:
'
单次剂量
'
,
dataIndex
:
'
dosage
'
,
key
:
'
dosage
'
,
width
:
110
,
render
:
(
_
:
string
,
r
:
DrugItem
)
=>
(
<
Input
size=
"small"
placeholder=
"如 1粒"
value=
{
r
.
dosage
}
onChange=
{
(
e
)
=>
handleDrugChange
(
r
.
key
,
'
dosage
'
,
e
.
target
.
value
)
}
/>
),
},
{
title
:
'
频次
'
,
dataIndex
:
'
frequency
'
,
key
:
'
frequency
'
,
width
:
110
,
render
:
(
_
:
string
,
r
:
DrugItem
)
=>
(
<
Select
value=
{
r
.
frequency
}
size=
"small"
style=
{
{
width
:
100
}
}
onChange=
{
(
v
)
=>
handleDrugChange
(
r
.
key
,
'
frequency
'
,
v
)
}
options=
{
[
{
label
:
'
每日1次
'
,
value
:
'
每日1次
'
},
{
label
:
'
每日2次
'
,
value
:
'
每日2次
'
},
{
label
:
'
每日3次
'
,
value
:
'
每日3次
'
},
{
label
:
'
必要时
'
,
value
:
'
必要时
'
},
{
label
:
'
睡前
'
,
value
:
'
睡前
'
},
]
}
/>
),
},
{
title
:
'
天数
'
,
dataIndex
:
'
days
'
,
key
:
'
days
'
,
width
:
70
,
render
:
(
_
:
number
,
r
:
DrugItem
)
=>
(
<
InputNumber
size=
"small"
min=
{
1
}
max=
{
30
}
value=
{
r
.
days
}
onChange=
{
(
v
)
=>
handleDrugChange
(
r
.
key
,
'
days
'
,
v
)
}
style=
{
{
width
:
60
}
}
/>
),
},
{
title
:
'
数量
'
,
dataIndex
:
'
quantity
'
,
key
:
'
quantity
'
,
width
:
100
,
render
:
(
_
:
number
,
r
:
DrugItem
)
=>
(
<
Space
size=
{
4
}
>
<
InputNumber
size=
"small"
min=
{
1
}
value=
{
r
.
quantity
}
onChange=
{
(
v
)
=>
handleDrugChange
(
r
.
key
,
'
quantity
'
,
v
)
}
style=
{
{
width
:
55
}
}
/>
<
Text
type=
"secondary"
>
{
r
.
unit
}
</
Text
>
</
Space
>
),
},
{
title
:
'
单价
'
,
dataIndex
:
'
price
'
,
key
:
'
price
'
,
width
:
70
,
render
:
(
v
:
number
)
=>
`¥
${(
v
/
100
).
toFixed
(
2
)}
`
,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
50
,
render
:
(
_
:
any
,
r
:
DrugItem
)
=>
(
<
Button
type=
"text"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleRemoveDrug
(
r
.
key
)
}
/>
),
},
];
return
(
<>
<
Modal
title=
{
<
span
><
MedicineBoxOutlined
className=
"text-green-500 mr-1"
/>
开具处方
</
span
>
}
open=
{
open
}
onCancel=
{
handleClose
}
width=
{
960
}
footer=
{
null
}
destroyOnClose
>
<
Form
layout=
"inline"
form=
{
form
}
size=
"small"
className=
"mb-2 p-2 bg-gray-50 rounded"
>
<
Form
.
Item
label=
"姓名"
name=
"patient_name"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名
'
}]
}
>
<
Input
placeholder=
"请输入姓名"
style=
{
{
width
:
100
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"性别"
name=
"patient_gender"
>
<
Select
placeholder=
"性别"
style=
{
{
width
:
70
}
}
options=
{
[{
label
:
'
男
'
,
value
:
'
男
'
},
{
label
:
'
女
'
,
value
:
'
女
'
}]
}
allowClear
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"年龄"
name=
"patient_age"
>
<
InputNumber
placeholder=
"岁"
style=
{
{
width
:
60
}
}
min=
{
0
}
max=
{
150
}
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"诊断"
name=
"diagnosis"
rules=
{
[{
required
:
true
,
message
:
'
请输入诊断
'
}]
}
>
<
Input
placeholder=
"诊断"
style=
{
{
width
:
160
}
}
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"过敏"
name=
"allergy"
>
<
Input
placeholder=
"过敏史"
style=
{
{
width
:
110
}
}
/>
</
Form
.
Item
>
</
Form
>
<
div
className=
"flex items-center gap-2 mb-2"
>
<
Space
size=
{
4
}
>
<
MedicineBoxOutlined
/>
<
Text
strong
>
药品
</
Text
>
<
Tag
color=
"blue"
>
{
drugs
.
length
}
种
</
Tag
>
</
Space
>
<
AutoComplete
value=
{
searchValue
}
options=
{
searchResults
.
map
((
m
)
=>
({
value
:
m
.
id
,
label
:
`${m.name} (${m.specification}) 库存:${m.stock}`
,
}))
}
onSelect=
{
handleAddDrug
}
onSearch=
{
handleSearch
}
onChange=
{
setSearchValue
}
style=
{
{
width
:
260
}
}
>
<
Input
prefix=
{
<
SearchOutlined
/>
}
placeholder=
"搜索药品..."
allowClear
size=
"small"
/>
</
AutoComplete
>
</
div
>
{
drugs
.
length
>
0
?
(
<
Table
columns=
{
columns
}
dataSource=
{
drugs
}
pagination=
{
false
}
size=
"small"
rowKey=
"key"
scroll=
{
{
x
:
800
}
}
className=
"mb-2"
/>
)
:
(
<
Empty
description=
"请搜索并添加药品"
className=
"my-3"
/>
)
}
{
drugs
.
length
>
0
&&
(
<
div
className=
"text-right mb-1"
>
<
Text
strong
className=
"text-red-500"
>
合计:¥
{
(
totalAmount
/
100
).
toFixed
(
2
)
}
</
Text
>
</
div
>
)
}
<
TextArea
rows=
{
2
}
placeholder=
"医嘱备注..."
size=
"small"
value=
{
remarkText
}
onChange=
{
(
e
)
=>
setRemarkText
(
e
.
target
.
value
)
}
className=
"mb-2"
/>
<
div
className=
"flex justify-end gap-1"
>
<
Button
size=
"small"
icon=
{
<
PrinterOutlined
/>
}
onClick=
{
()
=>
message
.
info
(
'
打印功能开发中
'
)
}
>
打印
</
Button
>
<
Button
size=
"small"
onClick=
{
handleClose
}
>
取消
</
Button
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
SafetyCertificateOutlined
/>
}
style=
{
{
background
:
'
#52c41a
'
,
borderColor
:
'
#52c41a
'
}
}
onClick=
{
handleSign
}
>
签名提交
</
Button
>
</
div
>
</
Modal
>
<
Modal
title=
"CA 签名确认"
open=
{
signModalVisible
}
onOk=
{
handleConfirmSign
}
onCancel=
{
()
=>
setSignModalVisible
(
false
)
}
okText=
"确认签名"
cancelText=
"取消"
confirmLoading=
{
submitting
}
width=
{
380
}
>
<
div
className=
"text-center py-3"
>
<
SafetyCertificateOutlined
className=
"text-3xl text-green-500 mb-2"
/>
<
div
className=
"font-semibold mb-1"
>
确认使用 CA 数字证书签署此处方?
</
div
>
<
Text
type=
"secondary"
>
签署后处方将具有法律效力
</
Text
>
<
div
className=
"mt-3 p-2 bg-green-50 rounded"
>
<
div
>
药品:
<
Text
strong
>
{
drugs
.
length
}
</
Text
>
种
</
div
>
<
div
>
金额:
<
Text
strong
className=
"text-red-500"
>
¥
{
(
totalAmount
/
100
).
toFixed
(
2
)
}
</
Text
></
div
>
</
div
>
</
div
>
</
Modal
>
</>
);
};
export
default
PrescriptionModal
;
web/src/pages/doctor/Profile/index.tsx
0 → 100644
View file @
52e57c72
import
React
from
'
react
'
;
import
{
Card
,
Avatar
,
Button
,
Descriptions
,
Tag
,
Typography
,
Row
,
Col
,
Divider
,
Switch
,
Space
}
from
'
antd
'
;
import
{
UserOutlined
,
EditOutlined
,
SafetyCertificateOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
const
{
Title
,
Text
}
=
Typography
;
const
DoctorProfilePage
:
React
.
FC
=
()
=>
{
const
{
user
}
=
useUserStore
();
// 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
,
};
return
(
<
div
className=
"max-w-[800px] mx-auto space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
个人信息
</
h4
>
<
Card
size=
"small"
>
<
Row
gutter=
{
12
}
align=
"middle"
>
<
Col
>
<
Avatar
size=
{
64
}
src=
{
doctorProfile
.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
</
Col
>
<
Col
flex=
"auto"
>
<
div
className=
"flex items-center gap-2 mb-0.5"
>
<
span
className=
"text-base font-bold"
>
{
doctorProfile
.
name
}
</
span
>
<
Tag
color=
"blue"
>
{
doctorProfile
.
title
}
</
Tag
>
</
div
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
{
doctorProfile
.
hospital
}
·
{
doctorProfile
.
department_name
}
</
div
>
<
Space
size=
{
4
}
>
<
Tag
icon=
{
<
SafetyCertificateOutlined
/>
}
color=
"success"
>
已认证
</
Tag
>
<
Tag
>
证号:
{
doctorProfile
.
license_no
}
</
Tag
>
</
Space
>
</
Col
>
<
Col
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
EditOutlined
/>
}
>
编辑资料
</
Button
>
</
Col
>
</
Row
>
</
Card
>
<
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
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
擅长领域
</
span
>
}
size=
"small"
>
<
Space
wrap
size=
{
4
}
>
{
doctorProfile
.
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!"
>
{
doctorProfile
.
introduction
}
</
Text
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
接诊设置
</
span
>
}
size=
"small"
>
<
div
className=
"space-y-2"
>
<
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
>
<
Switch
size=
"small"
checked=
{
doctorProfile
.
ai_assist_enabled
}
/>
</
div
>
<
Divider
className=
"my-0!"
/>
<
div
className=
"flex items-center justify-between"
>
<
div
>
<
Text
strong
className=
"text-xs!"
>
自动接诊
</
Text
>
<
div
className=
"text-[11px] text-gray-400"
>
开启后图文问诊将自动接诊
</
div
>
</
div
>
<
Switch
size=
"small"
/>
</
div
>
</
div
>
</
Card
>
</
div
>
);
};
export default DoctorProfilePage;
web/src/pages/doctor/Queue/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
Card
,
List
,
Avatar
,
Tag
,
Button
,
Typography
,
Space
,
Empty
,
Badge
}
from
'
antd
'
;
import
{
UserOutlined
,
MessageOutlined
,
VideoCameraOutlined
,
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Text
,
Title
}
=
Typography
;
const
DoctorQueuePage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
// TODO: 从API获取真实数据
const
waitingPatients
=
[
{
id
:
'
1
'
,
consult_id
:
'
c1
'
,
patient_name
:
'
张三
'
,
patient_avatar
:
''
,
chief_complaint
:
'
头痛、乏力,持续3天。既往有高血压病史记,
type:
'
text
'
as const,
waiting_time: 300,
created_at:
'
2026
-
02
-
25
T13
:
00
:
00
Z
'
,
},
{
id:
'
2
'
,
consult_id:
'
c2
'
,
patient_name:
'
李四
'
,
patient_avatar:
''
,
chief_complaint:
'
咳嗽有痰,偶尔发热,已持续一周期
,
type
:
'
video
'
as
const
,
waiting_time
:
180
,
created_at
:
'
2026-02-25T13:05:00Z
'
,
},
{
id
:
'
3
'
,
consult_id
:
'
c3
'
,
patient_name
:
'
王五
'
,
patient_avatar
:
''
,
chief_complaint
:
'
复诊开药,糖尿病用药续方案,
type:
'
text
'
as const,
waiting_time: 60,
created_at:
'
2026
-
02
-
25
T13
:
10
:
00
Z
'
,
},
{
id:
'
4
'
,
consult_id:
'
c4
'
,
patient_name:
'
赵六
'
,
patient_avatar:
''
,
chief_complaint:
'
皮肤瘙痒红肿,疑似过敏感
,
type
:
'
video
'
as
const
,
waiting_time
:
30
,
created_at
:
'
2026-02-25T13:12:00Z
'
,
},
];
const
formatWaitingTime
=
(
seconds
:
number
)
=>
{
const
minutes
=
Math
.
floor
(
seconds
/
60
);
if
(
minutes
<
1
)
return
'
刚刚
'
;
return
`
${
minutes
}
分钟`
;
};
const
handleAccept
=
(
_consultId
:
string
)
=>
{
// TODO: 调用接诊API
router
.
push
(
'
/doctor/consult-room
'
);
};
return
(
<
div
className=
"space-y-2"
>
<
div
className=
"flex items-center gap-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
接诊队列
</
h4
>
<
Badge
count=
{
waitingPatients
.
length
}
style=
{
{
backgroundColor
:
'
#fa8c16
'
}
}
/>
</
div
>
<
Card
size=
"small"
>
{
waitingPatients
.
length
===
0
?
(
<
Empty
description=
"暂无等待患者 />
) : (
<List
itemLayout="
horizontal
"
size=
"small"
dataSource=
{
waitingPatients
}
renderItem=
{
(
patient
,
index
)
=>
(
<
List
.
Item
actions=
{
[
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
handleAccept
(
patient
.
consult_id
)
}
>
接诊
</
Button
>,
<
Button
size=
"small"
>
详情
</
Button
>,
]
}
>
<
List
.
Item
.
Meta
avatar=
{
<
Badge
count=
{
index
+
1
}
offset=
{
[
-
5
,
5
]
}
color=
"#1890ff"
>
<
Avatar
size=
{
36
}
icon=
{
<
UserOutlined
/>
}
src=
{
patient
.
patient_avatar
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
</
Badge
>
}
title=
{
<
span
className=
"text-xs"
>
<
Text
strong
>
{
patient
.
patient_name
}
</
Text
>
<
Tag
className=
"ml-1"
icon=
{
patient
.
type
===
'
video
'
?
<
VideoCameraOutlined
/>
:
<
MessageOutlined
/>
}
color=
{
patient
.
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
patient
.
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
<
Tag
icon=
{
<
ClockCircleOutlined
/>
}
color=
"orange"
>
等待
{
formatWaitingTime
(
patient
.
waiting_time
)
}
</
Tag
>
</
span
>
}
description=
{
<
div
className=
"text-xs text-gray-600 mt-0.5"
>
主诉:
{
patient
.
chief_complaint
}
</
div
>
}
/>
</
List
.
Item
>
)
}
/>
)
}
</
Card
>
</
div
>
);
};
export
default
DoctorQueuePage
;
web/src/pages/doctor/Schedule/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Calendar
,
Button
,
Modal
,
Form
,
TimePicker
,
InputNumber
,
Tag
,
Typography
,
Space
,
Row
,
Col
,
Badge
,
List
}
from
'
antd
'
;
import
{
PlusOutlined
,
DeleteOutlined
,
CalendarOutlined
}
from
'
@ant-design/icons
'
;
import
dayjs
,
{
Dayjs
}
from
'
dayjs
'
;
const
{
Title
,
Text
}
=
Typography
;
interface
ScheduleSlot
{
id
:
string
;
date
:
string
;
start_time
:
string
;
end_time
:
string
;
max_count
:
number
;
remaining
:
number
;
}
const
DoctorSchedulePage
:
React
.
FC
=
()
=>
{
const
[
selectedDate
,
setSelectedDate
]
=
useState
<
Dayjs
>
(
dayjs
());
const
[
isModalOpen
,
setIsModalOpen
]
=
useState
(
false
);
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
dateKey
=
selectedDate
.
format
(
'
YYYY-MM-DD
'
);
const
todaySchedule
=
scheduleData
[
dateKey
]
||
[];
const
dateCellRender
=
(
value
:
Dayjs
)
=>
{
const
key
=
value
.
format
(
'
YYYY-MM-DD
'
);
const
slots
=
scheduleData
[
key
];
if
(
slots
&&
slots
.
length
>
0
)
{
return
(
<
Badge
status=
"success"
text=
{
`${slots.length}涓椂娈礰
}
style=
{
{
fontSize
:
10
}
}
/>
);
}
return
null
;
};
const
handleAddSchedule
=
()
=>
{
form
.
resetFields
();
setIsModalOpen
(
true
);
};
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
);
};
const
handleDeleteSlot
=
(
slotId
:
string
)
=>
{
// TODO: 调用API删除排班
Modal
.
confirm
({
title
:
'
确删除
'
,
content
:
'
确畾瑕佸垹闄ゆ排班无舵鍚楋紵
'
,
onOk
:
()
=>
{
console
.
log
(
'
删除排班:
'
,
slotId
);
},
});
};
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
>
<
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
>
<
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
>
0
?
'
green
'
:
'
red
'
}
>
剩余
{
slot
.
remaining
}
号
</
Tag
>
</
Space
>
</
div
>
</
List
.
Item
>
)
}
/>
)
}
</
Card
>
</
Col
>
</
Row
>
<
Modal
title=
{
`添加排班 - ${selectedDate.format('YYYY-MM-DD')}`
}
open=
{
isModalOpen
}
onCancel=
{
()
=>
setIsModalOpen
(
false
)
}
onOk=
{
()
=>
form
.
submit
()
}
width=
{
400
}
>
<
Form
form=
{
form
}
onFinish=
{
handleSaveSchedule
}
layout=
"vertical"
size=
"small"
>
<
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
}
>
<
InputNumber
min=
{
1
}
max=
{
50
}
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
<
/
div
>
);
};
export default DoctorSchedulePage;
web/src/pages/doctor/Workbench/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
Card
,
Row
,
Col
,
Statistic
,
Typography
,
List
,
Avatar
,
Tag
,
Button
,
Space
,
Badge
,
Alert
}
from
'
antd
'
;
import
{
UserOutlined
,
MessageOutlined
,
ClockCircleOutlined
,
CheckCircleOutlined
,
DollarOutlined
,
RiseOutlined
,
StarOutlined
,
TeamOutlined
,
VideoCameraOutlined
,
ExclamationCircleOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
import
{
consultApi
,
type
DoctorWorkbenchStats
,
type
PatientListItem
}
from
'
../../../api/consult
'
;
const
{
Title
,
Text
}
=
Typography
;
const
DoctorWorkbenchPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
{
user
}
=
useUserStore
();
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
stats
,
setStats
]
=
useState
<
DoctorWorkbenchStats
>
({
today_consult_count
:
0
,
waiting_count
:
0
,
in_progress_count
:
0
,
completed_today
:
0
,
total_patients
:
0
,
rating
:
0
,
income_today
:
0
,
income_month
:
0
,
});
const
[
waitingPatients
,
setWaitingPatients
]
=
useState
<
PatientListItem
[]
>
([]);
// 检查医生是否已认证
const
isVerified
=
user
?.
is_verified
;
// 获取工作台数量
const
fetchData
=
useCallback
(
async
()
=>
{
try
{
const
[
statsRes
,
patientsRes
]
=
await
Promise
.
all
([
consultApi
.
getDoctorWorkbenchStats
(),
consultApi
.
getPatientList
(),
]);
setStats
(
statsRes
.
data
);
// 只显示待接诊的患者
const
waiting
=
(
patientsRes
.
data
||
[]).
filter
(
p
=>
p
.
status
===
'
pending
'
||
p
.
status
===
'
waiting
'
);
setWaitingPatients
(
waiting
.
slice
(
0
,
5
));
}
catch
(
error
)
{
console
.
error
(
'
获取工作台数据失败
'
,
error
);
}
finally
{
setLoading
(
false
);
}
},
[]);
useEffect
(()
=>
{
if
(
isVerified
)
{
fetchData
();
const
timer
=
setInterval
(
fetchData
,
10000
);
return
()
=>
clearInterval
(
timer
);
}
},
[
isVerified
,
fetchData
]);
const
formatWaitingTime
=
(
seconds
:
number
)
=>
{
const
minutes
=
Math
.
floor
(
seconds
/
60
);
return
`
${
minutes
}
分钟`
;
};
if
(
!
isVerified
)
{
return
(
<
div
className=
"space-y-3"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
医生工作台
</
h4
>
<
Alert
message=
"请先完成资质认证"
description=
{
<
div
className=
"text-xs"
>
<
p
className=
"mb-1"
>
您的账号尚未完成医生资质认证,无法接诊患者
</
p
>
<
p
className=
"mb-2"
>
请提交您的执业证、资格证等相关资料,审核通过后即可开始接诊量
</
p
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
ExclamationCircleOutlined
/>
}
onClick=
{
()
=>
router
.
push
(
'
/doctor/certification
'
)
}
>
立即认证
</
Button
>
</
div
>
}
type=
"warning"
showIcon
/>
</
div
>
);
}
return
(
<
div
className=
"space-y-3"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
医生工作台
</
h4
>
<
span
className=
"text-xs text-gray-400"
>
每10秒自动刷新
</
span
>
</
div
>
{
/* 核心指标 */
}
<
Row
gutter=
{
[
8
,
8
]
}
>
{
[
{
title
:
'
今日问诊
'
,
value
:
stats
.
today_consult_count
,
icon
:
<
MessageOutlined
/>,
color
:
'
#1890ff
'
,
bg
:
'
#e6f7ff
'
,
suffix
:
'
人次
'
},
{
title
:
'
等待接诊
'
,
value
:
stats
.
waiting_count
,
icon
:
<
ClockCircleOutlined
/>,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
,
suffix
:
'
人
'
},
{
title
:
'
今日完成
'
,
value
:
stats
.
completed_today
,
icon
:
<
CheckCircleOutlined
/>,
color
:
'
#52c41a
'
,
bg
:
'
#f6ffed
'
,
suffix
:
'
例
'
},
{
title
:
'
今日收入
'
,
value
:
stats
.
income_today
,
icon
:
<
DollarOutlined
/>,
color
:
'
#722ed1
'
,
bg
:
'
#f9f0ff
'
,
suffix
:
'
元
'
},
].
map
((
item
,
i
)
=>
(
<
Col
xs=
{
12
}
sm=
{
6
}
key=
{
i
}
>
<
div
className=
"bg-white rounded-lg p-3 border border-[#e6f0fa] hover:shadow-md transition-shadow"
>
<
div
className=
"flex items-center gap-2 mb-2"
>
<
div
className=
"w-7 h-7 rounded-md flex items-center justify-center text-sm"
style=
{
{
background
:
item
.
bg
,
color
:
item
.
color
}
}
>
{
item
.
icon
}
</
div
>
<
span
className=
"text-xs text-gray-500"
>
{
item
.
title
}
</
span
>
</
div
>
<
div
className=
"text-xl font-bold"
style=
{
{
color
:
item
.
color
}
}
>
{
item
.
value
}
<
span
className=
"text-[11px] text-gray-400 font-normal"
>
{
item
.
suffix
}
</
span
>
</
div
>
</
div
>
</
Col
>
))
}
</
Row
>
{
/* 综合数据 */
}
<
Row
gutter=
{
[
8
,
8
]
}
>
{
[
{
icon
:
<
TeamOutlined
/>,
color
:
'
#1890ff
'
,
bg
:
'
#e6f7ff
'
,
label
:
'
累计患者
'
,
value
:
stats
.
total_patients
},
{
icon
:
<
StarOutlined
/>,
color
:
'
#fadb14
'
,
bg
:
'
#fffbe6
'
,
label
:
'
综合评分
'
,
value
:
stats
.
rating
},
{
icon
:
<
RiseOutlined
/>,
color
:
'
#52c41a
'
,
bg
:
'
#f6ffed
'
,
label
:
'
本月收入
'
,
value
:
`¥${stats.income_month}`
},
{
icon
:
<
MessageOutlined
/>,
color
:
'
#13c2c2
'
,
bg
:
'
#e6fffb
'
,
label
:
'
进行中
'
,
value
:
stats
.
in_progress_count
},
].
map
((
item
,
i
)
=>
(
<
Col
xs=
{
12
}
sm=
{
6
}
key=
{
i
}
>
<
div
className=
"bg-white rounded-lg p-3 border border-[#e6f0fa] text-center"
>
<
div
className=
"w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-1 text-base"
style=
{
{
background
:
item
.
bg
,
color
:
item
.
color
}
}
>
{
item
.
icon
}
</
div
>
<
div
className=
"text-[11px] text-gray-400"
>
{
item
.
label
}
</
div
>
<
div
className=
"text-lg font-bold"
style=
{
{
color
:
item
.
color
}
}
>
{
item
.
value
}
</
div
>
</
div
>
</
Col
>
))
}
</
Row
>
{
/* 等待接诊列表 */
}
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
等待接诊
<
Badge
count=
{
stats
.
waiting_count
}
style=
{
{
backgroundColor
:
'
#fa8c16
'
}
}
/></
span
>
}
extra=
{
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/doctor/consult
'
)
}
>
查看全部
</
Button
>
}
size=
"small"
loading=
{
loading
}
>
<
List
size=
"small"
dataSource=
{
waitingPatients
}
locale=
{
{
emptyText
:
'
暂无等待接诊的患者
'
}
}
renderItem=
{
(
patient
)
=>
(
<
List
.
Item
actions=
{
[
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/doctor/consult
'
)
}
>
接诊
</
Button
>,
]
}
>
<
List
.
Item
.
Meta
avatar=
{
<
Avatar
size=
"small"
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
}
title=
{
<
span
className=
"text-xs"
>
<
Text
strong
>
{
patient
.
patient_name
}
</
Text
>
<
Tag
className=
"ml-1"
icon=
{
patient
.
type
===
'
video
'
?
<
VideoCameraOutlined
/>
:
<
MessageOutlined
/>
}
color=
{
patient
.
type
===
'
video
'
?
'
blue
'
:
'
green
'
}
>
{
patient
.
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
<
span
className=
"text-gray-400 text-[11px] ml-1"
>
等待
{
formatWaitingTime
(
patient
.
waiting_seconds
)
}
</
span
>
</
span
>
}
description=
{
<
span
className=
"text-[11px]"
>
主诉:
{
patient
.
chief_complaint
||
'
暂无
'
}
</
span
>
}
/>
</
List
.
Item
>
)
}
/>
</
Card
>
</
div
>
);
};
export
default
DoctorWorkbenchPage
;
web/src/pages/patient/Chronic/index.tsx
0 → 100644
View file @
52e57c72
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Tabs
,
Button
,
Table
,
Tag
,
Space
,
Modal
,
Form
,
Input
,
Select
,
DatePicker
,
Switch
,
message
,
Popconfirm
,
Typography
,
Row
,
Col
,
TimePicker
,
Statistic
,
Empty
,
}
from
'
antd
'
;
import
{
PlusOutlined
,
DeleteOutlined
,
EditOutlined
,
RobotOutlined
,
HeartOutlined
,
FileAddOutlined
,
BellOutlined
,
LineChartOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
chronicApi
}
from
'
../../../api/chronic
'
;
import
type
{
ChronicRecord
,
RenewalRequest
,
MedicationReminder
,
HealthMetric
}
from
'
../../../api/chronic
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
controlStatusMap
:
Record
<
string
,
{
color
:
string
;
label
:
string
}
>
=
{
stable
:
{
color
:
'
green
'
,
label
:
'
控制良好
'
},
unstable
:
{
color
:
'
orange
'
,
label
:
'
控制不稳
'
},
worsening
:
{
color
:
'
red
'
,
label
:
'
病情加重
'
},
};
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
:
'
血糖化 },
weight: { label:
'
体重
'
, unit:
'
kg
'
, hasValue2: false, v1Label:
'
体重
'
},
heart_rate: { label:
'
心率
'
, unit:
'
bpm
'
, hasValue2: false, v1Label:
'
心率
'
},
temperature: { label:
'
体温
'
, unit:
'
?,
hasValue2
:
false
,
v1Label
:
'
体温
'
},
};
// ========== 慢病档案 Tab ==========
const
ChronicRecordsTab
:
React
.
FC
=
()
=>
{
const
qc
=
useQueryClient
();
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
editing
,
setEditing
]
=
useState
<
ChronicRecord
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
chronic-records
'
],
queryFn
:
()
=>
chronicApi
.
listRecords
(),
});
const
saveMutation
=
useMutation
({
mutationFn
:
(
values
:
any
)
=>
{
const
payload
=
{
...
values
,
diagnosis_date
:
values
.
diagnosis_date
?.
toISOString
()
};
return
editing
?
chronicApi
.
updateRecord
(
editing
.
id
,
payload
)
:
chronicApi
.
createRecord
(
payload
);
},
onSuccess
:
()
=>
{
message
.
success
(
editing
?
'
更新成功
'
:
'
添加成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
chronic-records
'
]
});
setModalOpen
(
false
);
form
.
resetFields
();
setEditing
(
null
);
},
onError
:
()
=>
message
.
error
(
'
操作失败
'
),
});
const
deleteMutation
=
useMutation
({
mutationFn
:
(
id
:
string
)
=>
chronicApi
.
deleteRecord
(
id
),
onSuccess
:
()
=>
{
message
.
success
(
'
删除成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
chronic-records
'
]
});
},
});
const
openEdit
=
(
record
:
ChronicRecord
)
=>
{
setEditing
(
record
);
form
.
setFieldsValue
({
...
record
,
diagnosis_date
:
record
.
diagnosis_date
?
dayjs
(
record
.
diagnosis_date
)
:
null
,
});
setModalOpen
(
true
);
};
const
columns
=
[
{
title
:
'
疾病名称
'
,
dataIndex
:
'
disease_name
'
,
key
:
'
disease_name
'
,
width
:
140
},
{
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,
render: (v: string) => {
const s = controlStatusMap[v] || { color:
'
default
'
, label: v };
return <Tag color={s.color}>{s.label}</Tag>;
},
},
{
title:
'
确诊时间
'
, dataIndex:
'
diagnosis_date
'
, key:
'
diagnosis_date
'
, width: 110,
render: (v: string) => v ? dayjs(v).format(
'
YYYY
-
MM
-
DD
'
) :
'
-
'
,
},
{
title:
'
操作
'
, key:
'
action
'
, width: 100,
render: (_: any, r: ChronicRecord) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)} />
<Popconfirm title="确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display:
'
flex
'
, justifyContent:
'
flex
-
end
'
, marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
添加慢病档案
</Button>
</div>
<Table columns={columns} dataSource={data?.data || []} rowKey="id" loading={isLoading}
locale={{ emptyText: <Empty description="暂无慢病档案" /> }} />
<Modal title={editing ?
'
编辑慢病档案
'
:
'
添加慢病档案
'
} open={modalOpen}
onCancel={() => { setModalOpen(false); setEditing(null); form.resetFields(); }}
onOk={() => form.validateFields().then(saveMutation.mutate)} confirmLoading={saveMutation.isPending}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="disease_name" label="疾病名称" rules={[{ required: true }]}>
<Input placeholder="如:高血压、糖尿病" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="diagnosis_date" label="确诊时间">
<DatePicker style={{ width:
'
100
%
'
}} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="control_status" label="控制状态 initialValue="stable">
<Select options={Object.entries(controlStatusMap).map(([v, s]) => ({ value: v, label: s.label }))} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="hospital" label="确诊医院">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="doctor_name" label="主治医生">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item name="current_meds" label="当前用药">
<TextArea rows={2} placeholder="当前服用的药物 />
</Form.Item>
<Form.Item name="notes" label="备注">
<TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
);
};
// ========== 续方申请 Tab ==========
const RenewalsTab: React.FC = () => {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [aiLoading, setAiLoading] = useState<string | null>(null);
const [aiResult, setAiResult] = useState<{ id: string; advice: string } | null>(null);
const [form] = Form.useForm();
const { data: records } = useQuery({ queryKey: [
'
chronic
-
records
'
], queryFn: () => chronicApi.listRecords() });
const { data, isLoading } = useQuery({ queryKey: [
'
renewals
'
], queryFn: () => chronicApi.listRenewals() });
const createMutation = useMutation({
mutationFn: (values: any) => chronicApi.createRenewal({
...values,
medicines: values.medicines?.split(
'
\
n
'
).filter(Boolean) || [],
}),
onSuccess: () => {
message.success(
'
续方申请已提
);
qc
.
invalidateQueries
({
queryKey
:
[
'
renewals
'
]
});
setModalOpen
(
false
);
form
.
resetFields
();
},
onError
:
()
=>
message
.
error
(
'
提交失败
'
),
});
const
handleAIAdvice
=
async
(
id
:
string
)
=>
{
setAiLoading
(
id
);
try
{
const
res
=
await
chronicApi
.
getAIAdvice
(
id
);
setAiResult
({
id
,
advice
:
res
.
data
.
advice
});
}
catch
{
message
.
error
(
'
获取AI建议失败
'
);
}
finally
{
setAiLoading
(
null
);
}
};
const
statusMap
:
Record
<
string
,
{
color
:
string
;
label
:
string
}
>
=
{
pending
:
{
color
:
'
orange
'
,
label
:
'
待审核 },
approved: { color:
'
green
'
, label:
'
已通过
'
},
rejected: { color:
'
red
'
, label:
'
已拒绝
},
};
const
columns
=
[
{
title
:
'
疾病
'
,
dataIndex
:
'
disease_name
'
,
key
:
'
disease_name
'
,
width
:
120
},
{
title
:
'
申请药品
'
,
dataIndex
:
'
medicines
'
,
key
:
'
medicines
'
,
render
:
(
v
:
string
)
=>
{
try
{
return
(
JSON
.
parse
(
v
)
as
string
[]).
map
((
m
,
i
)
=>
<
Tag
key=
{
i
}
color=
"blue"
>
{
m
}
</
Tag
>);
}
catch
{
return
v
;
}
},
},
{
title
:
'
申请原因
'
,
dataIndex
:
'
reason
'
,
key
:
'
reason
'
,
ellipsis
:
true
},
{
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>; },
},
{
title:
'
申请时间
'
, dataIndex:
'
created_at
'
, key:
'
created_at
'
, width: 120,
render: (v: string) => dayjs(v).format(
'
MM
-
DD
HH
:
mm
'
),
},
{
title:
'
操作
'
, key:
'
action
'
, width: 120,
render: (_: any, r: RenewalRequest) => (
<Button size="small" icon={<RobotOutlined />} loading={aiLoading === r.id}
onClick={() => handleAIAdvice(r.id)}>AI建议</Button>
),
},
];
return (
<>
<div style={{ display:
'
flex
'
, justifyContent:
'
flex
-
end
'
, marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>申请续方</Button>
</div>
<Table columns={columns} dataSource={data?.data || []} rowKey="id" loading={isLoading}
locale={{ emptyText: <Empty description="暂无续方申请" /> }}
expandable={{
expandedRowRender: (r: RenewalRequest) => r.ai_advice || (aiResult?.id === r.id ? aiResult.advice : null)
? <Card size="small" style={{ background:
'
#
f9f0ff
'
, borderColor:
'
#
d3adf7
'
}}>
<Space><RobotOutlined style={{ color:
'
#
722
ed1
'
}} /><Text strong>AI续方建议</Text></Space>
<div style={{ marginTop: 8, whiteSpace:
'
pre
-
wrap
'
, fontSize: 13 }}>{r.ai_advice || aiResult?.advice}</div>
</Card>
: null,
rowExpandable: (r: RenewalRequest) => !!(r.ai_advice || (aiResult?.id === r.id)),
}}
/>
<Modal title="申请续方" open={modalOpen} onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={() => form.validateFields().then(createMutation.mutate)} confirmLoading={createMutation.isPending}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="chronic_id" label="关联慢病档案">
<Select allowClear placeholder="选择慢病档案(可选)"
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="如:高血压 />
</Form.Item>
<Form.Item name="medicines" label="申请药品(每行一种)" rules={[{ required: true }]}>
<TextArea rows={3} placeholder="苯磺酸氨氯地平片 厄贝沙坦类 />
</Form.Item>
<Form.Item name="reason" label="续方原因" rules={[{ required: true }]}>
<TextArea rows={2} placeholder="请说明续方原件 />
</Form.Item>
</Form>
</Modal>
</>
);
};
// ========== 用药提醒 Tab ==========
const RemindersTab: React.FC = () => {
const qc = useQueryClient();
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<MedicationReminder | null>(null);
const [form] = Form.useForm();
const { data, isLoading } = useQuery({ queryKey: [
'
reminders
'
], queryFn: () => chronicApi.listReminders() });
const saveMutation = useMutation({
mutationFn: (values: any) => {
const payload = {
...values,
remind_times: values.remind_times?.map((t: any) => t.format(
'
HH
:
mm
'
)) || [],
start_date: values.start_date?.toISOString(),
end_date: values.end_date?.toISOString(),
};
return editing ? chronicApi.updateReminder(editing.id, payload) : chronicApi.createReminder(payload);
},
onSuccess: () => {
message.success(editing ?
'
更新成功
'
:
'
添加成功
'
);
qc.invalidateQueries({ queryKey: [
'
reminders
'
] });
setModalOpen(false); form.resetFields(); setEditing(null);
},
onError: () => message.error(
'
操作失败
'
),
});
const toggleMutation = useMutation({
mutationFn: (id: string) => chronicApi.toggleReminder(id),
onSuccess: () => qc.invalidateQueries({ queryKey: [
'
reminders
'
] }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => chronicApi.deleteReminder(id),
onSuccess: () => { message.success(
'
删除成功
'
); qc.invalidateQueries({ queryKey: [
'
reminders
'
] }); },
});
const openEdit = (r: MedicationReminder) => {
setEditing(r);
try {
const times = JSON.parse(r.remind_times ||
'
[]
'
) as string[];
form.setFieldsValue({ ...r, remind_times: times.map(t => dayjs(t,
'
HH
:
mm
'
)), start_date: r.start_date ? dayjs(r.start_date) : null, end_date: r.end_date ? dayjs(r.end_date) : null });
} catch { form.setFieldsValue(r); }
setModalOpen(true);
};
const columns = [
{ title:
'
药品名称
'
, dataIndex:
'
medicine_name
'
, key:
'
medicine_name
'
, width: 140 },
{ title:
'
剂量
'
, dataIndex:
'
dosage
'
, key:
'
dosage
'
, width: 100 },
{ title:
'
频次
'
, dataIndex:
'
frequency
'
, key:
'
frequency
'
, width: 100 },
{
title:
'
提醒时间
'
, dataIndex:
'
remind_times
'
, key:
'
remind_times
'
,
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
,
render
:
(
v
:
boolean
,
r
:
MedicationReminder
)
=>
(
<
Switch
checked=
{
v
}
size=
"small"
onChange=
{
()
=>
toggleMutation
.
mutate
(
r
.
id
)
}
/>
),
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
100
,
render
:
(
_
:
any
,
r
:
MedicationReminder
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
r
)
}
/>
<
Popconfirm
title=
"确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="
link
"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
<
/
Space
>
),
},
];
return (
<>
<
div
style=
{
{
display
:
'
flex
'
,
justifyContent
:
'
flex-end
'
,
marginBottom
:
16
}
}
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
{
setEditing
(
null
);
form
.
resetFields
();
setModalOpen
(
true
);
}
}
>
添加提醒
</
Button
>
</
div
>
<
Table
columns=
{
columns
}
dataSource=
{
data
?.
data
||
[]
}
rowKey=
"id"
loading=
{
isLoading
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无用药提醒"
/>
}
}
/>
<
Modal
title=
{
editing
?
'
编辑用药提醒
'
:
'
添加用药提醒
'
}
open=
{
modalOpen
}
onCancel=
{
()
=>
{
setModalOpen
(
false
);
setEditing
(
null
);
form
.
resetFields
();
}
}
onOk=
{
()
=>
form
.
validateFields
().
then
(
saveMutation
.
mutate
)
}
confirmLoading=
{
saveMutation
.
isPending
}
>
<
Form
form=
{
form
}
layout=
"vertical"
style=
{
{
marginTop
:
16
}
}
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
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>
</Col>
</Row>
<Form.Item name="
frequency
"
label=
"服药频次"
rules=
{
[{
required
:
true
}]
}
>
<
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="
end_date
"
label=
"结束日期"
><
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/></
Form
.
Item
></
Col
>
</
Row
>
<
Form
.
Item
name=
"notes"
label=
"备注"
><
Input
/></
Form
.
Item
>
</
Form
>
</
Modal
>
</>
);
};
// ========== 健康指标 Tab ==========
const MetricsTab: React.FC = () =
>
{
const
qc
=
useQueryClient
();
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
activeType
,
setActiveType
]
=
useState
(
'
blood_pressure
'
);
const
[
form
]
=
Form
.
useForm
();
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
metrics
'
,
activeType
],
queryFn
:
()
=>
chronicApi
.
listMetrics
(
activeType
),
});
const
createMutation
=
useMutation
({
mutationFn
:
(
values
:
any
)
=>
{
const
meta
=
metricTypeMap
[
activeType
];
return
chronicApi
.
createMetric
({
...
values
,
metric_type
:
activeType
,
unit
:
meta
.
unit
,
recorded_at
:
values
.
recorded_at
?.
toISOString
()
||
new
Date
().
toISOString
(),
});
},
onSuccess
:
()
=>
{
message
.
success
(
'
记录成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
metrics
'
,
activeType
]
});
setModalOpen
(
false
);
form
.
resetFields
();
},
onError
:
()
=>
message
.
error
(
'
记录失败
'
),
});
const
deleteMutation
=
useMutation
({
mutationFn
:
(
id
:
string
)
=>
chronicApi
.
deleteMetric
(
id
),
onSuccess
:
()
=>
{
message
.
success
(
'
删除成功
'
);
qc
.
invalidateQueries
({
queryKey
:
[
'
metrics
'
,
activeType
]
});
},
});
const
list
=
data
?.
data
||
[];
const
meta
=
metricTypeMap
[
activeType
];
const
columns
=
[
{
title
:
'
数量, key:
'
value
'
,
render: (_: any, r: HealthMetric) => (
<Text strong style={{ color:
'
#
1890
ff
'
}}>
{meta.hasValue2 ? `${r.value1}/${r.value2}` : r.value1} {r.unit}
</Text>
),
},
{
title:
'
记录时间
'
, dataIndex:
'
recorded_at
'
, key:
'
recorded_at
'
, width: 160,
render: (v: string) => dayjs(v).format(
'
YYYY
-
MM
-
DD
HH
:
mm
'
),
},
{ title:
'
备注
'
, dataIndex:
'
notes
'
, key:
'
notes
'
, ellipsis: true },
{
title:
'
操作
'
, key:
'
action
'
, width: 80,
render: (_: any, r: HealthMetric) => (
<Popconfirm title="确认删除外 onConfirm={() => deleteMutation.mutate(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
//
const latest = list[0];
return (
<>
<div style={{ display:
'
flex
'
, justifyContent:
'
space
-
between
'
, alignItems:
'
center
'
, marginBottom: 16 }}>
<Select value={activeType} onChange={setActiveType} style={{ width: 140 }}
options={Object.entries(metricTypeMap).map(([v, m]) => ({ value: v, label: m.label }))} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
记录指标
</Button>
</div>
{latest && (
<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}
suffix={meta.unit} valueStyle={{ color:
'
#
1890
ff
'
}} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ borderRadius: 8, background:
'
#
f6ffed
'
}}>
<Statistic title="记录次数" value={list.length} suffix="? valueStyle={{ color:
'
#
52
c41a
'
}} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ borderRadius: 8, background:
'
#
fff7e6
'
}}>
<Statistic title="最近记录 value={dayjs(latest.recorded_at).format(
'
MM
-
DD
'
)}
valueStyle={{ color:
'
#
fa8c16
'
}} />
</Card>
</Col>
</Row>
)}
<Table columns={columns} dataSource={list} rowKey="id" loading={isLoading}
locale={{ emptyText: <Empty description={`暂无${meta.label}记录`} /> }} />
<Modal title={`记录${meta.label}`} open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={() => form.validateFields().then(createMutation.mutate)} confirmLoading={createMutation.isPending}>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
<Col span={meta.hasValue2 ? 12 : 24}>
<Form.Item name="value1" label={meta.v1Label} rules={[{ required: true }]}>
<Input type="number" suffix={meta.unit} />
</Form.Item>
</Col>
{meta.hasValue2 && (
<Col span={12}>
<Form.Item name="value2" label={meta.v2Label} rules={[{ required: true }]}>
<Input type="number" suffix={meta.unit} />
</Form.Item>
</Col>
)}
</Row>
<Form.Item name="recorded_at" label="记录时间">
<DatePicker showTime style={{ width:
'
100
%
'
}} />
</Form.Item>
<Form.Item name="notes" label="备注"><Input /></Form.Item>
</Form>
</Modal>
</>
);
}
;
// ========== 主页?==========
const PatientChronicPage: React.FC = () =
>
{
const
tabItems
=
[
{
key
:
'
records
'
,
label
:
<><
HeartOutlined
/>
慢病档案
</>,
children
:
<
ChronicRecordsTab
/>
},
{
key
:
'
renewals
'
,
label
:
<><
FileAddOutlined
/>
续方申请
</>,
children
:
<
RenewalsTab
/>
},
{
key
:
'
reminders
'
,
label
:
<><
BellOutlined
/>
用药提醒
</>,
children
:
<
RemindersTab
/>
},
{
key
:
'
metrics
'
,
label
:
<><
LineChartOutlined
/>
健康指标
</>,
children
:
<
MetricsTab
/>
},
];
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
MedicineBoxOutlined
className=
"mr-1"
/>
慢病管理
</
h4
>
<
Card
size=
"small"
>
<
Tabs
items=
{
tabItems
}
size=
"small"
/>
</
Card
>
</
div
>
);
}
;
export default PatientChronicPage;
web/src/pages/patient/ConsultCreate/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useEffect
,
useState
}
from
'
react
'
;
import
{
Card
,
Form
,
Input
,
Button
,
Typography
,
Space
,
Alert
,
Avatar
,
Tag
,
Divider
,
message
,
Spin
,
Descriptions
,
}
from
'
antd
'
;
import
{
UserOutlined
,
MessageOutlined
,
VideoCameraOutlined
,
RobotOutlined
,
CheckCircleOutlined
,
ArrowLeftOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
,
useSearchParams
}
from
'
next/navigation
'
;
import
{
consultApi
}
from
'
../../../api/consult
'
;
import
{
doctorApi
}
from
'
../../../api/doctor
'
;
import
{
preConsultApi
}
from
'
../../../api/preConsult
'
;
import
type
{
Doctor
}
from
'
../../../api/doctor
'
;
import
type
{
PreConsultResponse
}
from
'
../../../api/preConsult
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
ConsultCreatePage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
searchParams
=
useSearchParams
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
submitting
,
setSubmitting
]
=
useState
(
false
);
const
[
doctor
,
setDoctor
]
=
useState
<
Doctor
|
null
>
(
null
);
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
'
)
||
''
;
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
,
});
}
else
if
(
chiefComplaintParam
)
{
form
.
setFieldsValue
({
chief_complaint
:
chiefComplaintParam
});
}
}
catch
(
error
)
{
console
.
error
(
'
获取数据失败:
'
,
error
);
}
finally
{
setLoading
(
false
);
}
};
fetchData
();
},
[
doctorId
,
preConsultId
]);
const
handleSubmit
=
async
()
=>
{
try
{
const
values
=
await
form
.
validateFields
();
setSubmitting
(
true
);
const
res
=
await
consultApi
.
createConsult
({
doctor_id
:
doctorId
,
type
:
consultType
,
chief_complaint
:
values
.
chief_complaint
,
medical_history
:
values
.
medical_history
||
''
,
pre_consult_id
:
preConsultId
||
undefined
,
});
message
.
success
(
'
问诊创建成功!);
//
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 ||
'
请稍后重试
));
}
finally
{
setSubmitting
(
false
);
}
};
if
(
loading
)
{
return
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
80
}
}
>
<
Spin
size=
"large"
tip=
"加载中.."
/>
</
div
>
);
}
return
(
<
div
className=
"space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
{
consultType
===
'
video
'
?
<
VideoCameraOutlined
className=
"mr-1"
/>
:
<
MessageOutlined
className=
"mr-1"
/>
}
创建
{
consultType
===
'
video
'
?
'
视频
'
:
'
图文
'
}
问诊
</
h4
>
<
Button
size=
"small"
icon=
{
<
ArrowLeftOutlined
/>
}
onClick=
{
()
=>
router
.
back
()
}
>
返回
</
Button
>
</
div
>
{
doctor
&&
(
<
Card
size=
"small"
>
<
Space
size=
{
8
}
>
<
Avatar
size=
{
48
}
src=
{
doctor
.
avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
<
div
>
<
div
className=
"flex items-center gap-1"
>
<
Text
strong
className=
"text-sm!"
>
{
doctor
.
name
}
</
Text
>
<
Tag
color=
"blue"
>
{
doctor
.
title
}
</
Tag
>
<
Tag
>
{
doctor
.
department_name
}
</
Tag
>
</
div
>
<
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
>
</
div
>
</
div
>
</
Space
>
</
Card
>
)
}
{
preConsult
&&
preConsult
.
ai_analysis
&&
(
<
Card
size=
"small"
style=
{
{
borderLeft
:
'
3px solid #722ed1
'
}
}
title=
{
<
span
className=
"text-xs"
><
RobotOutlined
className=
"text-purple-600 mr-1"
/>
AI 预问诊已关联
<
CheckCircleOutlined
className=
"text-green-500"
/></
span
>
}
>
<
Descriptions
size=
"small"
column=
{
2
}
>
{
preConsult
.
ai_department
&&
<
Descriptions
.
Item
label=
"科室"
><
Tag
color=
"green"
>
{
preConsult
.
ai_department
}
</
Tag
></
Descriptions
.
Item
>
}
{
preConsult
.
ai_severity
&&
<
Descriptions
.
Item
label=
"程度"
>
<
Tag
color=
{
preConsult
.
ai_severity
===
'
severe
'
?
'
red
'
:
preConsult
.
ai_severity
===
'
moderate
'
?
'
orange
'
:
'
green
'
}
>
{
preConsult
.
ai_severity
===
'
severe
'
?
'
重度
'
:
preConsult
.
ai_severity
===
'
moderate
'
?
'
中度
'
:
'
轻度
'
}
</
Tag
>
</
Descriptions
.
Item
>
}
<
Descriptions
.
Item
label=
"分析"
span=
{
2
}
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
{
preConsult
.
ai_analysis
.
substring
(
0
,
100
)
}
...</
Text
>
</
Descriptions
.
Item
>
</
Descriptions
>
<
Alert
message=
"医生可查看完整AI报告"
type=
"success"
showIcon
className=
"mt-1 text-xs!"
icon=
{
<
CheckCircleOutlined
/>
}
/>
</
Card
>
)
}
<
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
>
<
Form
.
Item
label=
"既往病史"
name=
"medical_history"
>
<
TextArea
rows=
{
2
}
placeholder=
"如有慢性病、手术史等请填写"
/>
</
Form
.
Item
>
<
Divider
className=
"my-2!"
/>
<
Form
.
Item
className=
"text-center mb-0!"
>
<
Space
size=
{
8
}
>
<
Button
size=
"small"
onClick=
{
()
=>
router
.
back
()
}
>
取消
</
Button
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
handleSubmit
}
loading=
{
submitting
}
>
{
submitting
?
'
提交中..
'
:
`发起${consultType === 'video' ? '视频' : '图文'}问诊`
}
</
Button
>
</
Space
>
</
Form
.
Item
>
</
Form
>
</
Card
>
</
div
>
);
};
export
default
ConsultCreatePage
;
web/src/pages/patient/ConsultList/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
Card
,
List
,
Tag
,
Avatar
,
Button
,
Empty
,
Typography
,
Space
,
Tabs
,
message
,
Spin
,
}
from
'
antd
'
;
import
{
VideoCameraOutlined
,
MessageOutlined
,
ClockCircleOutlined
,
CheckCircleOutlined
,
CloseCircleOutlined
,
MedicineBoxOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
consultApi
,
type
Consultation
,
type
ConsultStatus
}
from
'
../../../api/consult
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
,
Title
}
=
Typography
;
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
pending
:
{
text
:
'
待接诊
'
,
color
:
'
orange
'
},
waiting
:
{
text
:
'
等待中
'
,
color
:
'
blue
'
},
in_progress
:
{
text
:
'
问诊中
'
,
color
:
'
green
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
default
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
red
'
},
};
const
statusIcon
:
Record
<
string
,
React
.
ReactNode
>
=
{
pending
:
<
ClockCircleOutlined
/>,
waiting
:
<
ClockCircleOutlined
/>,
in_progress
:
<
MessageOutlined
/>,
completed
:
<
CheckCircleOutlined
/>,
cancelled
:
<
CloseCircleOutlined
/>,
};
const
PatientConsultListPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
consultList
,
setConsultList
]
=
useState
<
Consultation
[]
>
([]);
const
[
activeTab
,
setActiveTab
]
=
useState
<
string
>
(
'
all
'
);
const
fetchList
=
async
(
status
?:
ConsultStatus
)
=>
{
setLoading
(
true
);
try
{
const
res
=
await
consultApi
.
getConsultList
(
status
);
setConsultList
(
res
.
data
||
[]);
}
catch
{
message
.
error
(
'
获取问诊列表失败
'
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
const
statusFilter
=
activeTab
===
'
all
'
?
undefined
:
activeTab
as
ConsultStatus
;
fetchList
(
statusFilter
);
},
[
activeTab
]);
const
handleEnterConsult
=
(
consult
:
Consultation
)
=>
{
if
(
consult
.
type
===
'
video
'
)
{
router
.
push
(
`/patient/consult/video/
${
consult
.
id
}
`
);
}
else
{
router
.
push
(
`/patient/consult/text/
${
consult
.
id
}
`
);
}
};
const
handleCancel
=
async
(
consult
:
Consultation
)
=>
{
try
{
await
consultApi
.
cancelConsult
(
consult
.
id
);
message
.
success
(
'
问诊已取消
'
);
fetchList
(
activeTab
===
'
all
'
?
undefined
:
activeTab
as
ConsultStatus
);
}
catch
{
message
.
error
(
'
取消失败
'
);
}
};
const
renderActions
=
(
consult
:
Consultation
)
=>
{
const
actions
:
React
.
ReactNode
[]
=
[];
if
(
consult
.
status
===
'
in_progress
'
)
{
actions
.
push
(
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
handleEnterConsult
(
consult
)
}
>
进入问诊
</
Button
>
);
}
if
(
consult
.
status
===
'
pending
'
||
consult
.
status
===
'
waiting
'
)
{
actions
.
push
(
<
Button
size=
"small"
onClick=
{
()
=>
handleEnterConsult
(
consult
)
}
>
查看详情
</
Button
>
);
actions
.
push
(
<
Button
size=
"small"
danger
onClick=
{
()
=>
handleCancel
(
consult
)
}
>
取消
</
Button
>
);
}
if
(
consult
.
status
===
'
completed
'
&&
consult
.
prescription_id
)
{
actions
.
push
(
<
Button
size=
"small"
onClick=
{
()
=>
router
.
push
(
`/patient/prescription/${consult.prescription_id}`
)
}
>
查看处方
</
Button
>
);
}
return
actions
;
};
return
(
<
div
className=
"max-w-[1200px] mx-auto space-y-2"
>
<
div
className=
"flex items-center justify-between"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
MedicineBoxOutlined
className=
"mr-1 text-[#1890ff]"
/>
我的问诊
</
h4
>
<
Button
size=
"small"
type=
"primary"
onClick=
{
()
=>
router
.
push
(
'
/patient/doctors
'
)
}
>
发起问诊
</
Button
>
</
div
>
<
Card
size=
"small"
>
<
Tabs
activeKey=
{
activeTab
}
onChange=
{
setActiveTab
}
size=
"small"
items=
{
[
{
key
:
'
all
'
,
label
:
'
全部
'
},
{
key
:
'
in_progress
'
,
label
:
'
进行中
'
},
{
key
:
'
pending
'
,
label
:
'
待接诊
'
},
{
key
:
'
completed
'
,
label
:
'
已完成
'
},
{
key
:
'
cancelled
'
,
label
:
'
已取消
'
},
]
}
/>
<
Spin
spinning=
{
loading
}
>
<
List
size=
"small"
dataSource=
{
consultList
}
locale=
{
{
emptyText
:
<
Empty
description=
"暂无问诊记录"
/>
}
}
renderItem=
{
(
consult
:
Consultation
)
=>
{
const
status
=
statusMap
[
consult
.
status
]
||
{
text
:
consult
.
status
,
color
:
'
default
'
};
return
(
<
List
.
Item
actions=
{
renderActions
(
consult
)
}
className=
"py-2!"
>
<
List
.
Item
.
Meta
avatar=
{
<
Avatar
src=
{
consult
.
doctor_avatar
}
size=
{
36
}
style=
{
{
backgroundColor
:
'
#1890ff
'
}
}
icon=
{
<
MedicineBoxOutlined
/>
}
/>
}
title=
{
<
span
className=
"text-xs"
>
<
Text
strong
>
{
consult
.
doctor_name
}
</
Text
>
<
Tag
className=
"ml-1"
icon=
{
consult
.
type
===
'
video
'
?
<
VideoCameraOutlined
/>
:
<
MessageOutlined
/>
}
color=
{
consult
.
type
===
'
video
'
?
'
blue
'
:
'
cyan
'
}
>
{
consult
.
type
===
'
video
'
?
'
视频
'
:
'
图文
'
}
</
Tag
>
<
Tag
icon=
{
statusIcon
[
consult
.
status
]
}
color=
{
status
.
color
}
>
{
status
.
text
}
</
Tag
>
</
span
>
}
description=
{
<
div
className=
"mt-0.5"
>
<
div
className=
"text-xs text-gray-600 mb-0.5"
>
主诉:
{
consult
.
chief_complaint
||
'
无
'
}
</
div
>
<
span
className=
"text-[11px] text-gray-400"
>
{
dayjs
(
consult
.
created_at
).
format
(
'
YYYY-MM-DD HH:mm
'
)
}
{
consult
.
started_at
&&
` · 接诊 ${dayjs(consult.started_at).format('HH:mm')}`
}
{
consult
.
ended_at
&&
` · 结束 ${dayjs(consult.ended_at).format('HH:mm')}`
}
</
span
>
</
div
>
}
/>
</
List
.
Item
>
);
}
}
/>
</
Spin
>
</
Card
>
</
div
>
);
};
export
default
PatientConsultListPage
;
web/src/pages/patient/DoctorDetail/index.tsx
0 → 100644
View file @
52e57c72
'
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
'
;
import
{
VideoCameraOutlined
,
MessageOutlined
,
CalendarOutlined
,
EnvironmentOutlined
,
SafetyCertificateOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
}
from
'
@tanstack/react-query
'
;
import
{
doctorApi
}
from
'
../../../api/doctor
'
;
import
ConsultCreateModal
from
'
../../../components/ConsultCreateModal
'
;
const
{
Title
,
Text
,
Paragraph
}
=
Typography
;
const
DoctorDetailPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
[
consultModalOpen
,
setConsultModalOpen
]
=
useState
(
false
);
const
[
consultType
,
setConsultType
]
=
useState
<
'
text
'
|
'
video
'
>
(
'
text
'
);
const
{
data
:
doctorData
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
doctor
'
,
id
],
queryFn
:
()
=>
doctorApi
.
getDoctorDetail
(
id
!
),
enabled
:
!!
id
,
});
const
doctor
=
doctorData
?.
data
;
if
(
isLoading
||
!
doctor
)
{
return
<
Card
loading
style=
{
{
borderRadius
:
12
}
}
/>;
}
return
(
<
div
className=
"max-w-[1000px] mx-auto space-y-2"
>
<
Card
size=
"small"
>
<
Row
gutter=
{
12
}
>
<
Col
flex=
"80px"
>
<
Avatar
size=
{
64
}
src=
{
doctor
.
avatar
}
/>
</
Col
>
<
Col
flex=
"auto"
>
<
div
className=
"flex items-center gap-2 mb-0.5"
>
<
span
className=
"text-base font-bold"
>
{
doctor
.
name
}
</
span
>
<
Tag
color=
"blue"
>
{
doctor
.
title
}
</
Tag
>
{
doctor
.
is_online
&&
<
Tag
color=
"green"
>
在线
</
Tag
>
}
</
div
>
<
div
className=
"text-xs text-gray-500 mb-1"
>
<
EnvironmentOutlined
className=
"mr-1"
/>
{
doctor
.
hospital
}
·
{
doctor
.
department_name
}
</
div
>
<
Space
size=
{
4
}
>
<
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
>
</
Space
>
</
Col
>
<
Col
>
<
div
className=
"text-right"
>
<
div
className=
"mb-2"
>
<
Text
strong
className=
"text-xl! text-red-500!"
>
¥
{
(
doctor
.
price
/
100
).
toFixed
(
0
)
}
</
Text
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
/次
</
Text
>
</
div
>
<
Space
>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
VideoCameraOutlined
/>
}
onClick=
{
()
=>
{
setConsultType
(
'
video
'
);
setConsultModalOpen
(
true
);
}
}
>
视频问诊
</
Button
>
<
Button
size=
"small"
icon=
{
<
MessageOutlined
/>
}
onClick=
{
()
=>
{
setConsultType
(
'
text
'
);
setConsultModalOpen
(
true
);
}
}
>
图文问诊
</
Button
>
</
Space
>
</
div
>
</
Col
>
</
Row
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
擅长领域
</
span
>
}
size=
"small"
>
<
Space
wrap
size=
{
4
}
>
{
doctor
.
specialties
?.
map
((
s
,
i
)
=>
(
<
Tag
key=
{
i
}
color=
"processing"
>
{
s
}
</
Tag
>
))
}
</
Space
>
</
Card
>
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
医生简介
</
span
>
}
size=
"small"
>
<
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=
"出诊状>
{doctor.is_online ? <Tag color="
green
"
>
在线
</
Tag
>
:
<
Tag
color=
"default"
>
未排班
</
Tag
>
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"资质认证"
>
<
Space
size=
{
4
}
><
SafetyCertificateOutlined
className=
"text-green-500"
/><
Text
className=
"text-xs!"
>
已认证
</
Text
></
Space
>
</
Descriptions
.
Item
>
</
Descriptions
>
</
Card
>
{
/* 创建问诊弹窗 */
}
<
ConsultCreateModal
open=
{
consultModalOpen
}
onCancel=
{
()
=>
setConsultModalOpen
(
false
)
}
doctorId=
{
id
!
}
consultType=
{
consultType
}
/>
<
/
div
>
);
};
export default DoctorDetailPage;
web/src/pages/patient/Doctors/index.tsx
0 → 100644
View file @
52e57c72
'
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
'
;
import
{
SearchOutlined
,
VideoCameraOutlined
,
MessageOutlined
}
from
'
@ant-design/icons
'
;
import
{
useQuery
}
from
'
@tanstack/react-query
'
;
import
{
doctorApi
,
type
Doctor
}
from
'
../../../api/doctor
'
;
import
ConsultCreateModal
from
'
../../../components/ConsultCreateModal
'
;
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
[
sortBy
,
setSortBy
]
=
useState
<
'
rating
'
|
'
consult_count
'
|
'
price
'
>
(
'
rating
'
);
const
[
consultModalOpen
,
setConsultModalOpen
]
=
useState
(
false
);
const
[
selectedDoctor
,
setSelectedDoctor
]
=
useState
<
{
id
:
string
;
type
:
'
text
'
|
'
video
'
}
|
null
>
(
null
);
const
{
data
:
departments
}
=
useQuery
({
queryKey
:
[
'
departments
'
],
queryFn
:
()
=>
doctorApi
.
getDepartments
(),
});
const
{
data
:
doctorData
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
doctors
'
,
keyword
,
departmentId
,
sortBy
],
queryFn
:
()
=>
doctorApi
.
getDoctorList
({
keyword
,
department_id
:
departmentId
,
sort_by
:
sortBy
,
page
:
1
,
page_size
:
20
,
}),
});
const
handleConsult
=
(
doctor
:
Doctor
,
type
:
'
video
'
|
'
text
'
)
=>
{
setSelectedDoctor
({
id
:
doctor
.
id
,
type
});
setConsultModalOpen
(
true
);
};
return
(
<
div
className=
"max-w-[1200px] mx-auto space-y-2"
>
<
Card
size=
"small"
>
<
div
className=
"flex flex-wrap items-center gap-2"
>
<
Input
placeholder=
"搜索医生姓名"
prefix=
{
<
SearchOutlined
/>
}
value=
{
keyword
}
onChange=
{
(
e
)
=>
setKeyword
(
e
.
target
.
value
)
}
style=
{
{
width
:
180
}
}
size=
"small"
/>
<
Select
placeholder=
"选择科室"
style=
{
{
width
:
140
}
}
size=
"small"
allowClear
value=
{
departmentId
||
undefined
}
onChange=
{
setDepartmentId
}
options=
{
departments
?.
data
?.
map
((
d
)
=>
({
label
:
d
.
name
,
value
:
d
.
id
}))
||
[]
}
/>
<
Select
value=
{
sortBy
}
onChange=
{
setSortBy
}
style=
{
{
width
:
110
}
}
size=
"small"
options=
{
[
{
label
:
'
评分最高, value:
'
rating
'
},
{ label:
'
问诊最高
,
value
:
'
consult_count
'
},
{
label
:
'
价格最高, value:
'
price
'
},
]
}
/>
</
div
>
</
Card
>
<
List
loading=
{
isLoading
}
dataSource=
{
doctorData
?.
data
?.
list
||
[]
}
renderItem=
{
(
doctor
:
Doctor
)
=>
(
<
div
className=
"bg-white rounded-lg border border-[#e6f0fa] p-3 mb-2 hover:shadow-md transition-shadow cursor-pointer"
>
<
Row
gutter=
{
12
}
align=
"middle"
>
<
Col
flex=
"56px"
>
<
Avatar
size=
{
48
}
src=
{
doctor
.
avatar
}
/>
</
Col
>
<
Col
flex=
"auto"
>
<
div
className=
"mb-1"
>
<
Text
strong
className=
"text-sm! mr-1"
>
{
doctor
.
name
}
</
Text
>
<
Tag
color=
"blue"
>
{
doctor
.
title
}
</
Tag
>
<
Tag
>
{
doctor
.
department_name
}
</
Tag
>
{
doctor
.
is_online
&&
<
Tag
color=
"green"
>
在线
</
Tag
>
}
</
div
>
<
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
>
</
div
>
<
div
className=
"text-xs text-gray-500 line-clamp-1"
>
擅长:
{
doctor
.
specialties
?.
join
(
'
?) || doctor.introduction
}
</
div
>
</
Col
>
<
Col
>
<
div
className=
"text-right"
>
<
div
className=
"mb-2"
>
<
span
className=
"text-base font-bold text-red-500"
>
¥
{
(
doctor
.
price
/
100
).
toFixed
(
0
)
}
</
span
>
<
span
className=
"text-[11px] text-gray-400"
>
/次
</
span
>
</
div
>
<
Space
size=
{
4
}
>
<
Button
size=
"small"
icon=
{
<
MessageOutlined
/>
}
onClick=
{
()
=>
handleConsult
(
doctor
,
'
text
'
)
}
>
图文
</
Button
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
VideoCameraOutlined
/>
}
onClick=
{
()
=>
handleConsult
(
doctor
,
'
video
'
)
}
>
视频
</
Button
>
</
Space
>
<
div
>
<
Button
type=
"link"
size=
"small"
className=
"text-xs!"
onClick=
{
()
=>
router
.
push
(
`/patient/doctors/${doctor.id}`
)
}
>
详情 ?
</
Button
>
</
div
>
</
div
>
</
Col
>
</
Row
>
</
div
>
)
}
/>
{
selectedDoctor
&&
(
<
ConsultCreateModal
open=
{
consultModalOpen
}
onCancel=
{
()
=>
setConsultModalOpen
(
false
)
}
doctorId=
{
selectedDoctor
.
id
}
consultType=
{
selectedDoctor
.
type
}
/>
)
}
</
div
>
);
};
export
default
PatientDoctorsPage
;
web/src/pages/patient/HealthRecords/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
}
from
'
react
'
;
import
{
Card
,
Tabs
,
Form
,
Input
,
Select
,
DatePicker
,
Button
,
Table
,
Tag
,
message
,
Spin
,
Modal
,
Popconfirm
,
Empty
,
Row
,
Col
,
Statistic
,
Typography
}
from
'
antd
'
;
import
{
PlusOutlined
,
DeleteOutlined
,
RobotOutlined
,
FileTextOutlined
,
TeamOutlined
,
BarChartOutlined
,
MedicineBoxOutlined
,
EditOutlined
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
dayjs
from
'
dayjs
'
;
import
{
healthApi
}
from
'
../../../api/health
'
;
import
type
{
UpdateProfileParams
,
ConsultHistoryItem
,
FamilyMemberParams
,
CreateReportParams
,
LabReport
,
FamilyMember
}
from
'
../../../api/health
'
;
const
{
Text
}
=
Typography
;
// ==================== 基本健康信息 ====================
const
HealthProfileTab
:
React
.
FC
=
()
=>
{
const
[
form
]
=
Form
.
useForm
();
const
queryClient
=
useQueryClient
();
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
health-profile
'
],
queryFn
:
()
=>
healthApi
.
getProfile
(),
select
:
(
res
)
=>
res
.
data
,
});
const
mutation
=
useMutation
({
mutationFn
:
(
values
:
UpdateProfileParams
)
=>
healthApi
.
updateProfile
(
values
),
onSuccess
:
()
=>
{
message
.
success
(
'
健康档案已更新
'
);
queryClient
.
invalidateQueries
({
queryKey
:
[
'
health-profile
'
]
});
},
onError
:
()
=>
message
.
error
(
'
更新失败,请重试
'
),
});
React
.
useEffect
(()
=>
{
if
(
data
)
form
.
setFieldsValue
({
...
data
,
birth_date
:
data
.
birth_date
?
dayjs
(
data
.
birth_date
)
:
null
});
},
[
data
,
form
]);
if
(
isLoading
)
return
<
Spin
style=
{
{
display
:
'
block
'
,
margin
:
'
40px auto
'
}
}
/>;
return
(
<
Form
form=
{
form
}
layout=
"vertical"
onFinish=
{
(
v
)
=>
mutation
.
mutate
({
...
v
,
birth_date
:
v
.
birth_date
?
v
.
birth_date
.
toISOString
()
:
null
})
}
style=
{
{
maxWidth
:
600
}
}
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"性别"
name=
"gender"
>
<
Select
placeholder=
"请选择"
allowClear
>
<
Select
.
Option
value=
"male"
>
男
</
Select
.
Option
>
<
Select
.
Option
value=
"female"
>
女
</
Select
.
Option
>
</
Select
>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"出生日期"
name=
"birth_date"
>
<
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"既往病史"
name=
"medical_history"
>
<
Input
.
TextArea
rows=
{
3
}
placeholder=
"如高血压、糖尿病史"
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"过敏史"
name=
"allergy_history"
>
<
Input
.
TextArea
rows=
{
2
}
placeholder=
"过敏药物或食物"
/>
</
Form
.
Item
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"紧急联系人电话"
name=
"emergency_contact"
>
<
Input
placeholder=
"紧急联系人手机号"
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"医保类型"
name=
"insurance_type"
>
<
Select
placeholder=
"请选择"
allowClear
>
{
[
'
城镇职工医保
'
,
'
城乡居民医保
'
,
'
商业保险
'
,
'
自费
'
].
map
(
v
=>
<
Select
.
Option
key=
{
v
}
value=
{
v
}
>
{
v
}
</
Select
.
Option
>)
}
</
Select
>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"医保卡号"
name=
"insurance_no"
>
<
Input
placeholder=
"医保卡号"
/>
</
Form
.
Item
>
<
Form
.
Item
>
<
Button
type=
"primary"
htmlType=
"submit"
loading=
{
mutation
.
isPending
}
>
保存
</
Button
>
</
Form
.
Item
>
</
Form
>
);
};
// ==================== 检验报告 ====================
const
LabReportsTab
:
React
.
FC
=
()
=>
{
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
interpretId
,
setInterpretId
]
=
useState
<
string
|
null
>
(
null
);
const
[
interpretText
,
setInterpretText
]
=
useState
(
''
);
const
[
form
]
=
Form
.
useForm
();
const
queryClient
=
useQueryClient
();
const
{
data
:
reports
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
lab-reports
'
],
queryFn
:
()
=>
healthApi
.
listReports
(),
select
:
(
res
)
=>
res
.
data
,
});
const
createMutation
=
useMutation
({
mutationFn
:
(
v
:
CreateReportParams
)
=>
healthApi
.
createReport
(
v
),
onSuccess
:
()
=>
{
message
.
success
(
'
报告已添加
'
);
setModalOpen
(
false
);
form
.
resetFields
();
queryClient
.
invalidateQueries
({
queryKey
:
[
'
lab-reports
'
]
});
},
});
const
deleteMutation
=
useMutation
({
mutationFn
:
(
id
:
string
)
=>
healthApi
.
deleteReport
(
id
),
onSuccess
:
()
=>
{
message
.
success
(
'
已删除
'
);
queryClient
.
invalidateQueries
({
queryKey
:
[
'
lab-reports
'
]
});
},
});
const
interpretMutation
=
useMutation
({
mutationFn
:
(
id
:
string
)
=>
healthApi
.
aiInterpret
(
id
),
onSuccess
:
(
res
)
=>
{
setInterpretText
(
res
.
data
.
interpret
);
queryClient
.
invalidateQueries
({
queryKey
:
[
'
lab-reports
'
]
});
},
onError
:
()
=>
message
.
error
(
'
AI解读失败
'
),
});
const
columns
=
[
{
title
:
'
报告名称
'
,
dataIndex
:
'
title
'
,
key
:
'
title
'
},
{
title
:
'
类别
'
,
dataIndex
:
'
category
'
,
key
:
'
category
'
,
render
:
(
v
:
string
)
=>
v
?
<
Tag
color=
"blue"
>
{
v
}
</
Tag
>
:
'
-
'
},
{
title
:
'
报告日期
'
,
dataIndex
:
'
report_date
'
,
key
:
'
report_date
'
,
render
:
(
v
:
string
)
=>
v
?
dayjs
(
v
).
format
(
'
YYYY-MM-DD
'
)
:
'
-
'
},
{
title
:
'
AI解读
'
,
dataIndex
:
'
ai_interpret
'
,
key
:
'
ai_interpret
'
,
render
:
(
v
:
string
)
=>
v
?
<
Text
type=
"success"
style=
{
{
fontSize
:
12
}
}
>
已解读
</
Text
>
:
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
未解读
</
Text
>
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
render
:
(
_
:
any
,
r
:
LabReport
)
=>
(
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
8
}
}
>
<
Button
size=
"small"
icon=
{
<
RobotOutlined
/>
}
loading=
{
interpretMutation
.
isPending
&&
interpretId
===
r
.
id
}
onClick=
{
()
=>
{
setInterpretId
(
r
.
id
);
interpretMutation
.
mutate
(
r
.
id
,
{
onSuccess
:
()
=>
setInterpretId
(
null
)
});
}
}
>
AI解读
</
Button
>
{
r
.
ai_interpret
&&
<
Button
size=
"small"
onClick=
{
()
=>
{
setInterpretText
(
r
.
ai_interpret
);
setInterpretId
(
r
.
id
);
}
}
>
查看解读
</
Button
>
}
<
Popconfirm
title=
"确认删除?"
onConfirm=
{
()
=>
deleteMutation
.
mutate
(
r
.
id
)
}
>
<
Button
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
div
>
),
},
];
return
(
<>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
setModalOpen
(
true
)
}
>
添加报告
</
Button
>
</
div
>
<
Table
columns=
{
columns
}
dataSource=
{
reports
||
[]
}
loading=
{
isLoading
}
rowKey=
"id"
pagination=
{
{
pageSize
:
10
}
}
/>
<
Modal
title=
"添加检验报告"
open=
{
modalOpen
}
onCancel=
{
()
=>
setModalOpen
(
false
)
}
onOk=
{
()
=>
form
.
submit
()
}
confirmLoading=
{
createMutation
.
isPending
}
>
<
Form
form=
{
form
}
layout=
"vertical"
onFinish=
{
(
v
)
=>
createMutation
.
mutate
({
...
v
,
report_date
:
v
.
report_date
?
v
.
report_date
.
toISOString
()
:
null
})
}
>
<
Form
.
Item
label=
"报告名称"
name=
"title"
rules=
{
[{
required
:
true
}]
}
>
<
Input
placeholder=
"如:血常规检验报告"
/>
</
Form
.
Item
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"类别"
name=
"category"
>
<
Select
placeholder=
"请选择"
allowClear
>
{
[
'
血常规
'
,
'
尿常规
'
,
'
生化
'
,
'
影像
'
,
'
其他
'
].
map
(
v
=>
<
Select
.
Option
key=
{
v
}
value=
{
v
}
>
{
v
}
</
Select
.
Option
>)
}
</
Select
>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"报告日期"
name=
"report_date"
>
<
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"文件链接"
name=
"file_url"
>
<
Input
placeholder=
"报告文件URL(可选)"
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
<
Modal
title=
"AI解读结果"
open=
{
!!
interpretText
}
onCancel=
{
()
=>
setInterpretText
(
''
)
}
footer=
{
<
Button
onClick=
{
()
=>
setInterpretText
(
''
)
}
>
关闭
</
Button
>
}
width=
{
600
}
>
<
div
style=
{
{
whiteSpace
:
'
pre-wrap
'
,
lineHeight
:
1.8
}
}
>
{
interpretText
}
</
div
>
</
Modal
>
</>
);
};
// ==================== 就诊历史 ====================
const
statusMap
:
Record
<
string
,
{
color
:
string
;
text
:
string
}
>
=
{
pending
:
{
color
:
'
orange
'
,
text
:
'
待接诊
'
},
waiting
:
{
color
:
'
blue
'
,
text
:
'
等待中
'
},
in_progress
:
{
color
:
'
processing
'
,
text
:
'
进行中
'
},
completed
:
{
color
:
'
success
'
,
text
:
'
已完成
'
},
cancelled
:
{
color
:
'
default
'
,
text
:
'
已取消
'
},
};
const
ConsultHistoryTab
:
React
.
FC
=
()
=>
{
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
consult-history
'
],
queryFn
:
()
=>
healthApi
.
getConsultHistory
(),
select
:
(
res
)
=>
res
.
data
,
});
const
columns
=
[
{
title
:
'
就诊时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
render
:
(
v
:
string
)
=>
dayjs
(
v
).
format
(
'
YYYY-MM-DD HH:mm
'
)
},
{
title
:
'
医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
},
{
title
:
'
科室
'
,
dataIndex
:
'
department_name
'
,
key
:
'
department_name
'
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
},
{
title
:
'
主诉
'
,
dataIndex
:
'
chief_complaint
'
,
key
:
'
chief_complaint
'
,
ellipsis
:
true
},
{
title
:
'
类型
'
,
dataIndex
:
'
type
'
,
key
:
'
type
'
,
render
:
(
v
:
string
)
=>
v
===
'
video
'
?
'
视频问诊
'
:
'
图文问诊
'
},
{
title
:
'
状态
'
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
render
:
(
v
:
string
)
=>
{
const
s
=
statusMap
[
v
]
||
{
color
:
'
default
'
,
text
:
v
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
text
}
</
Tag
>;
}
},
];
return
<
Table
<
ConsultHistoryItem
>
columns=
{
columns
}
dataSource=
{
data
||
[]
}
loading=
{
isLoading
}
rowKey="id" pagination=
{
{
pageSize
:
10
}
}
/
>
;
};
// ==================== 健康趋势 ====================
const HealthTrendTab: React.FC = () =
>
{
const
{
data
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
health-trend
'
],
queryFn
:
()
=>
healthApi
.
getHealthTrend
(),
select
:
(
res
)
=>
res
.
data
,
});
if
(
isLoading
)
return
<
Spin
style=
{
{
display
:
'
block
'
,
margin
:
'
40px auto
'
}
}
/>;
if
(
!
data
?.
length
)
return
<
Empty
description=
"暂无数据"
/>;
const
total
=
data
.
reduce
((
acc
,
d
)
=>
({
consult
:
acc
.
consult
+
d
.
consult_count
,
report
:
acc
.
report
+
d
.
report_count
}),
{
consult
:
0
,
report
:
0
});
return
(
<>
<
Row
gutter=
{
16
}
style=
{
{
marginBottom
:
24
}
}
>
<
Col
span=
{
8
}
><
Card
><
Statistic
title=
"近6个月问诊次数"
value=
{
total
.
consult
}
prefix=
{
<
MedicineBoxOutlined
/>
}
valueStyle=
{
{
color
:
'
#1677ff
'
}
}
/></
Card
></
Col
>
<
Col
span=
{
8
}
><
Card
><
Statistic
title=
"近6个月检验报告"
value=
{
total
.
report
}
prefix=
{
<
FileTextOutlined
/>
}
valueStyle=
{
{
color
:
'
#52c41a
'
}
}
/></
Card
></
Col
>
<
Col
span=
{
8
}
><
Card
><
Statistic
title=
"健康记录月份"
value=
{
data
.
filter
(
d
=>
d
.
consult_count
+
d
.
report_count
>
0
).
length
}
suffix=
"/ 6"
valueStyle=
{
{
color
:
'
#722ed1
'
}
}
/></
Card
></
Col
>
</
Row
>
<
Table
dataSource=
{
data
}
rowKey=
"month"
pagination=
{
false
}
columns=
{
[
{
title
:
'
月份
'
,
dataIndex
:
'
month
'
,
key
:
'
month
'
},
{
title
:
'
问诊次数
'
,
dataIndex
:
'
consult_count
'
,
key
:
'
consult_count
'
,
render
:
(
v
:
number
)
=>
<
Tag
color=
{
v
>
0
?
'
blue
'
:
'
default
'
}
>
{
v
}
次
</
Tag
>
},
{
title
:
'
检验报告
'
,
dataIndex
:
'
report_count
'
,
key
:
'
report_count
'
,
render
:
(
v
:
number
)
=>
<
Tag
color=
{
v
>
0
?
'
green
'
:
'
default
'
}
>
{
v
}
份
</
Tag
>
},
{
title
:
'
活跃度
'
,
key
:
'
active
'
,
render
:
(
_
:
any
,
r
:
any
)
=>
{
const
total
=
r
.
consult_count
+
r
.
report_count
;
return
total
>
0
?
<
Tag
color=
"success"
>
有记录
</
Tag
>
:
<
Tag
>
无记录
</
Tag
>;
}
},
]
}
/>
</>
);
}
;
// ==================== 家庭成员 ====================
const FamilyTab: React.FC = () =
>
{
const
[
modalOpen
,
setModalOpen
]
=
useState
(
false
);
const
[
editing
,
setEditing
]
=
useState
<
FamilyMember
|
null
>
(
null
);
const
[
form
]
=
Form
.
useForm
();
const
queryClient
=
useQueryClient
();
const
{
data
:
members
,
isLoading
}
=
useQuery
({
queryKey
:
[
'
family-members
'
],
queryFn
:
()
=>
healthApi
.
listFamily
(),
select
:
(
res
)
=>
res
.
data
,
});
const
saveMutation
=
useMutation
({
mutationFn
:
(
v
:
FamilyMemberParams
)
=>
editing
?
healthApi
.
updateFamily
(
editing
.
id
,
v
)
:
healthApi
.
createFamily
(
v
),
onSuccess
:
()
=>
{
message
.
success
(
editing
?
'
已更新
'
:
'
已添加
'
);
setModalOpen
(
false
);
setEditing
(
null
);
form
.
resetFields
();
queryClient
.
invalidateQueries
({
queryKey
:
[
'
family-members
'
]
});
},
});
const
deleteMutation
=
useMutation
({
mutationFn
:
(
id
:
string
)
=>
healthApi
.
deleteFamily
(
id
),
onSuccess
:
()
=>
{
message
.
success
(
'
已删除
'
);
queryClient
.
invalidateQueries
({
queryKey
:
[
'
family-members
'
]
});
},
});
const
openEdit
=
(
m
:
FamilyMember
)
=>
{
setEditing
(
m
);
form
.
setFieldsValue
({
...
m
,
birth_date
:
m
.
birth_date
?
dayjs
(
m
.
birth_date
)
:
null
});
setModalOpen
(
true
);
};
const
openAdd
=
()
=>
{
setEditing
(
null
);
form
.
resetFields
();
setModalOpen
(
true
);
};
const
columns
=
[
{
title
:
'
姓名
'
,
dataIndex
:
'
name
'
,
key
:
'
name
'
},
{
title
:
'
关系
'
,
dataIndex
:
'
relation
'
,
key
:
'
relation
'
,
render
:
(
v
:
string
)
=>
<
Tag
color=
"purple"
>
{
v
}
</
Tag
>
},
{
title
:
'
性别
'
,
dataIndex
:
'
gender
'
,
key
:
'
gender
'
,
render
:
(
v
:
string
)
=>
v
===
'
male
'
?
'
男
'
:
v
===
'
female
'
?
'
女
'
:
'
-
'
},
{
title
:
'
出生日期
'
,
dataIndex
:
'
birth_date
'
,
key
:
'
birth_date
'
,
render
:
(
v
:
string
)
=>
v
?
dayjs
(
v
).
format
(
'
YYYY-MM-DD
'
)
:
'
-
'
},
{
title
:
'
联系电话
'
,
dataIndex
:
'
phone
'
,
key
:
'
phone
'
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
render
:
(
_
:
any
,
r
:
FamilyMember
)
=>
(
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
8
}
}
>
<
Button
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
openEdit
(
r
)
}
>
编辑
</
Button
>
<
Popconfirm
title=
"确认删除?"
onConfirm=
{
()
=>
deleteMutation
.
mutate
(
r
.
id
)
}
>
<
Button
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
/>
</
Popconfirm
>
</
div
>
),
},
];
return
(
<>
<
div
style=
{
{
marginBottom
:
16
}
}
>
<
Button
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
openAdd
}
>
添加成员
</
Button
>
</
div
>
<
Table
columns=
{
columns
}
dataSource=
{
members
||
[]
}
loading=
{
isLoading
}
rowKey=
"id"
pagination=
{
false
}
/>
<
Modal
title=
{
editing
?
'
编辑家庭成员
'
:
'
添加家庭成员
'
}
open=
{
modalOpen
}
onCancel=
{
()
=>
{
setModalOpen
(
false
);
setEditing
(
null
);
}
}
onOk=
{
()
=>
form
.
submit
()
}
confirmLoading=
{
saveMutation
.
isPending
}
>
<
Form
form=
{
form
}
layout=
"vertical"
onFinish=
{
(
v
)
=>
saveMutation
.
mutate
({
...
v
,
birth_date
:
v
.
birth_date
?
v
.
birth_date
.
toISOString
()
:
null
})
}
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"姓名"
name=
"name"
rules=
{
[{
required
:
true
}]
}
>
<
Input
/>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"关系"
name=
"relation"
rules=
{
[{
required
:
true
}]
}
>
<
Select
placeholder=
"请选择"
>
{
[
'
配偶
'
,
'
父亲
'
,
'
母亲
'
,
'
子女
'
,
'
兄弟姐妹
'
,
'
其他
'
].
map
(
v
=>
<
Select
.
Option
key=
{
v
}
value=
{
v
}
>
{
v
}
</
Select
.
Option
>)
}
</
Select
>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"性别"
name=
"gender"
>
<
Select
placeholder=
"请选择"
allowClear
>
<
Select
.
Option
value=
"male"
>
男
</
Select
.
Option
>
<
Select
.
Option
value=
"female"
>
女
</
Select
.
Option
>
</
Select
>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
label=
"出生日期"
name=
"birth_date"
>
<
DatePicker
style=
{
{
width
:
'
100%
'
}
}
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
<
Form
.
Item
label=
"联系电话"
name=
"phone"
>
<
Input
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"既往病史"
name=
"medical_history"
>
<
Input
.
TextArea
rows=
{
2
}
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"过敏史"
name=
"allergy_history"
>
<
Input
.
TextArea
rows=
{
2
}
/>
</
Form
.
Item
>
</
Form
>
</
Modal
>
</>
);
}
;
// ==================== 主页面 ====================
const PatientHealthRecordsPage: React.FC = () =
>
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
健康档案
</
h4
>
<
Card
size=
"small"
>
<
Tabs
defaultActiveKey=
"profile"
size=
"small"
items=
{
[
{
key
:
'
profile
'
,
label
:
<
span
><
MedicineBoxOutlined
/>
健康信息
</
span
>,
children
:
<
HealthProfileTab
/>
},
{
key
:
'
reports
'
,
label
:
<
span
><
FileTextOutlined
/>
检验报告
</
span
>,
children
:
<
LabReportsTab
/>
},
{
key
:
'
history
'
,
label
:
<
span
><
MedicineBoxOutlined
/>
就诊历史
</
span
>,
children
:
<
ConsultHistoryTab
/>
},
{
key
:
'
trend
'
,
label
:
<
span
><
BarChartOutlined
/>
健康趋势
</
span
>,
children
:
<
HealthTrendTab
/>
},
{
key
:
'
family
'
,
label
:
<
span
><
TeamOutlined
/>
家庭成员
</
span
>,
children
:
<
FamilyTab
/>
},
]
}
/>
</
Card
>
</
div
>
);
export default PatientHealthRecordsPage;
web/src/pages/patient/Home/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
from
'
react
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
Card
,
Row
,
Col
,
Input
,
Button
,
Typography
,
Space
,
Statistic
}
from
'
antd
'
;
import
{
SearchOutlined
,
VideoCameraOutlined
,
MessageOutlined
,
MedicineBoxOutlined
,
FileTextOutlined
,
HeartOutlined
,
RobotOutlined
,
TeamOutlined
,
SafetyCertificateOutlined
,
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
const
{
Title
,
Text
,
Paragraph
}
=
Typography
;
const
PatientHomePage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
services
=
[
{
icon
:
<
VideoCameraOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#1890ff
'
}
}
/>,
title
:
'
视频问诊
'
,
desc
:
'
与医生面对面视频交流
'
,
path
:
'
/patient/doctors?type=video
'
,
color
:
'
#e6f7ff
'
,
},
{
icon
:
<
MessageOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#52c41a
'
}
}
/>,
title
:
'
图文问诊
'
,
desc
:
'
随时随地在线咨询
'
,
path
:
'
/patient/doctors?type=text
'
,
color
:
'
#f6ffed
'
,
},
{
icon
:
<
RobotOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#722ed1
'
}
}
/>,
title
:
'
AI 智能问诊
'
,
desc
:
'
AI 辅助症状分析
'
,
path
:
'
/patient/pre-consult
'
,
color
:
'
#f9f0ff
'
,
},
{
icon
:
<
MedicineBoxOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#fa8c16
'
}
}
/>,
title
:
'
找医生
'
,
desc
:
'
按科室找专家
'
,
path
:
'
/patient/doctors
'
,
color
:
'
#fff7e6
'
,
},
{
icon
:
<
FileTextOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#13c2c2
'
}
}
/>,
title
:
'
电子处方
'
,
desc
:
'
查看历史处方
'
,
path
:
'
/patient/prescription
'
,
color
:
'
#e6fffb
'
,
},
{
icon
:
<
HeartOutlined
style=
{
{
fontSize
:
36
,
color
:
'
#eb2f96
'
}
}
/>,
title
:
'
慢病管理
'
,
desc
:
'
慢性病续方服务
'
,
path
:
'
/patient/chronic
'
,
color
:
'
#fff0f6
'
,
},
];
const
departments
=
[
'
内科
'
,
'
外科
'
,
'
妇科
'
,
'
儿科
'
,
'
皮肤科
'
,
'
眼科
'
,
'
耳鼻喉科
'
,
'
口腔科
'
,
'
骨科
'
,
'
神经内科
'
,
'
心血管内科
'
,
'
消化内科
'
,
];
return
(
<
div
className=
"max-w-[1200px] mx-auto space-y-3"
>
{
/* 顶部 Banner */
}
<
div
className=
"rounded-xl overflow-hidden"
style=
{
{
background
:
'
linear-gradient(135deg, #001529 0%, #003a8c 40%, #0050b3 70%, #1890ff 100%)
'
}
}
>
<
div
className=
"text-center px-6 py-8 relative"
>
<
div
className=
"absolute inset-0 overflow-hidden pointer-events-none"
>
<
div
className=
"absolute -top-10 -right-10 w-40 h-40 bg-white/5 rounded-full blur-2xl"
/>
<
div
className=
"absolute -bottom-10 -left-10 w-48 h-48 bg-blue-400/8 rounded-full blur-2xl"
/>
</
div
>
<
div
className=
"relative z-10"
>
<
h2
className=
"text-xl font-bold text-white mb-1"
>
互联网医院 · 在线问诊平台
</
h2
>
<
p
className=
"text-blue-200/80 text-sm mb-5"
>
专业医生在线服务,足不出户享受优质医疗
</
p
>
<
div
className=
"max-w-[440px] mx-auto"
>
<
Input
.
Search
placeholder=
"搜索医生、科室、疾病"
enterButton=
{
<><
SearchOutlined
/>
搜索
</>
}
onSearch=
{
(
value
)
=>
router
.
push
(
`/patient/doctors?keyword=${value}`
)
}
className=
"shadow-lg shadow-black/10"
/>
</
div
>
<
div
className=
"flex justify-center gap-12 mt-5"
>
<
div
className=
"text-center"
>
<
div
className=
"text-white text-xl font-bold"
><
TeamOutlined
className=
"mr-1"
/>
1,280
</
div
>
<
div
className=
"text-blue-200/60 text-xs"
>
注册医生
</
div
>
</
div
>
<
div
className=
"text-center"
>
<
div
className=
"text-white text-xl font-bold"
><
SafetyCertificateOutlined
className=
"mr-1"
/>
56,800
</
div
>
<
div
className=
"text-blue-200/60 text-xs"
>
服务患者
</
div
>
</
div
>
<
div
className=
"text-center"
>
<
div
className=
"text-white text-xl font-bold"
><
ClockCircleOutlined
className=
"mr-1"
/>
3分钟
</
div
>
<
div
className=
"text-blue-200/60 text-xs"
>
平均响应
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
</
div
>
{
/* 服务入口 */
}
<
Card
title=
{
<
span
className=
"text-sm font-semibold"
>
医疗服务
</
span
>
}
size=
"small"
>
<
Row
gutter=
{
[
8
,
8
]
}
>
{
services
.
map
((
service
,
index
)
=>
(
<
Col
xs=
{
12
}
sm=
{
8
}
md=
{
4
}
key=
{
index
}
>
<
div
className=
"text-center rounded-lg p-3 cursor-pointer transition-all hover:scale-105 hover:shadow-md"
style=
{
{
background
:
service
.
color
}
}
onClick=
{
()
=>
router
.
push
(
service
.
path
)
}
>
{
service
.
icon
}
<
div
className=
"mt-2"
>
<
Text
strong
className=
"text-[13px]!"
>
{
service
.
title
}
</
Text
>
</
div
>
<
Text
type=
"secondary"
className=
"text-[11px]!"
>
{
service
.
desc
}
</
Text
>
</
div
>
</
Col
>
))
}
</
Row
>
</
Card
>
{
/* 科室导航 */
}
<
Card
title=
{
<
span
className=
"text-sm font-semibold"
>
科室导航
</
span
>
}
size=
"small"
>
<
div
className=
"flex flex-wrap gap-2"
>
{
departments
.
map
((
dept
)
=>
(
<
Button
key=
{
dept
}
size=
"small"
className=
"rounded-full!"
onClick=
{
()
=>
router
.
push
(
`/patient/doctors?department=${dept}`
)
}
>
{
dept
}
</
Button
>
))
}
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/doctors
'
)
}
>
全部科室
>
</
Button
>
</
div
>
</
Card
>
</
div
>
);
};
export
default
PatientHomePage
;
web/src/pages/patient/Payment/index.tsx
0 → 100644
View file @
52e57c72
'
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
{
useRouter
}
from
'
next/navigation
'
;
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
:
'
历史支付记录查询
'
},
];
const
PatientPaymentPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
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
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/consult
'
)
}
>
查看问诊
</
Button
>
</
div
>
<
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
>
<
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
>
</
Card
>
</
div
>
);
};
export
default
PatientPaymentPage
;
web/src/pages/patient/PharmacyOrder/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Row
,
Col
}
from
'
antd
'
;
import
{
ShoppingCartOutlined
,
EnvironmentOutlined
,
CheckCircleOutlined
,
HistoryOutlined
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
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
:
'
查看历史药品订单
'
},
];
const
PatientPharmacyOrderPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
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
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/prescription
'
)
}
>
查看处方
</
Button
>
</
div
>
<
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
>
</
Card
>
</
div
>
);
};
export
default
PatientPharmacyOrderPage
;
web/src/pages/patient/PreConsult/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useRef
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Input
,
Button
,
Typography
,
Space
,
Avatar
,
Tag
,
Card
,
List
,
Divider
,
Alert
,
message
,
Spin
,
Drawer
,
Empty
,
}
from
'
antd
'
;
import
{
RobotOutlined
,
UserOutlined
,
SendOutlined
,
MedicineBoxOutlined
,
ArrowRightOutlined
,
FileTextOutlined
,
LoadingOutlined
,
InfoCircleOutlined
,
HistoryOutlined
,
ClockCircleOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
preConsultApi
}
from
'
../../../api/preConsult
'
;
import
type
{
DoctorBrief
,
ChatDoneInfo
,
ChatMessage
,
PreConsultResponse
}
from
'
../../../api/preConsult
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
import
ConsultCreateModal
from
'
../../../components/ConsultCreateModal
'
;
import
MarkdownRenderer
from
'
../../../components/MarkdownRenderer
'
;
const
{
Text
}
=
Typography
;
const
{
TextArea
}
=
Input
;
// 显示用的消息类型(扩展了loading等状态)
interface
DisplayMessage
{
id
:
string
;
role
:
'
user
'
|
'
assistant
'
|
'
system
'
;
content
:
string
;
loading
?:
boolean
;
timestamp
:
number
;
}
const
PatientPreConsultPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
{
user
}
=
useUserStore
();
const
[
messages
,
setMessages
]
=
useState
<
DisplayMessage
[]
>
([]);
const
[
inputValue
,
setInputValue
]
=
useState
(
''
);
const
[
sending
,
setSending
]
=
useState
(
false
);
const
[
sessionId
,
setSessionId
]
=
useState
<
string
|
null
>
(
null
);
const
[
sessionStatus
,
setSessionStatus
]
=
useState
<
string
>
(
'
new
'
);
// new, in_progress, completed
const
[
turnCount
,
setTurnCount
]
=
useState
(
0
);
const
[
initialLoading
,
setInitialLoading
]
=
useState
(
true
);
const
[
reportContent
,
setReportContent
]
=
useState
(
''
);
const
[
generating
,
setGenerating
]
=
useState
(
false
);
const
[
reportDone
,
setReportDone
]
=
useState
(
false
);
const
[
doneInfo
,
setDoneInfo
]
=
useState
<
ChatDoneInfo
|
null
>
(
null
);
const
[
consultModalOpen
,
setConsultModalOpen
]
=
useState
(
false
);
const
[
selectedDoctor
,
setSelectedDoctor
]
=
useState
<
DoctorBrief
|
null
>
(
null
);
const
[
historyDrawerOpen
,
setHistoryDrawerOpen
]
=
useState
(
false
);
const
[
historyList
,
setHistoryList
]
=
useState
<
PreConsultResponse
[]
>
([]);
const
[
historyLoading
,
setHistoryLoading
]
=
useState
(
false
);
// 患者信息(从用户信息自动获取)
const
patientInfo
=
{
name
:
user
?.
real_name
||
'
患者
'
,
gender
:
user
?.
gender
||
'
未知
'
,
age
:
user
?.
age
||
0
,
};
// refs
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
abortRef
=
useRef
<
AbortController
|
null
>
(
null
);
const
inputRef
=
useRef
<
any
>
(
null
);
const
scrollToBottom
=
useCallback
(()
=>
{
messagesEndRef
.
current
?.
scrollIntoView
({
behavior
:
'
smooth
'
});
},
[]);
useEffect
(()
=>
{
scrollToBottom
();
},
[
messages
,
reportContent
,
scrollToBottom
]);
// 将ChatMessage[] 转换为DisplayMessage[]
const
chatMessagesToDisplay
=
(
chatMsgs
:
ChatMessage
[]):
DisplayMessage
[]
=>
{
return
chatMsgs
.
map
((
msg
,
idx
)
=>
({
id
:
`history-
${
idx
}
`
,
role
:
msg
.
role
,
content
:
msg
.
content
,
timestamp
:
Date
.
now
()
-
(
chatMsgs
.
length
-
idx
)
*
1000
,
}));
};
// 加载历史对话列表
const
loadHistoryList
=
async
()
=>
{
setHistoryLoading
(
true
);
try
{
const
res
=
await
preConsultApi
.
getList
();
setHistoryList
(
res
.
data
||
[]);
}
catch
(
error
)
{
message
.
error
(
'
加载历史对话失败
'
);
}
finally
{
setHistoryLoading
(
false
);
}
};
// 恢复某个历史会话
const
restoreSession
=
(
session
:
PreConsultResponse
)
=>
{
setSessionId
(
session
.
id
);
setSessionStatus
(
session
.
status
);
// 恢复对话历史
const
chatMsgs
=
session
.
chat_messages
||
[];
if
(
chatMsgs
.
length
>
0
)
{
setMessages
(
chatMessagesToDisplay
(
chatMsgs
));
setTurnCount
(
Math
.
floor
(
chatMsgs
.
length
/
2
));
}
else
{
setMessages
([]);
setTurnCount
(
0
);
}
// 如果已完成,恢复报告
if
(
session
.
status
===
'
completed
'
&&
session
.
ai_analysis
)
{
setReportContent
(
session
.
ai_analysis
);
setReportDone
(
true
);
if
(
session
.
recommended_doctors
&&
session
.
recommended_doctors
.
length
>
0
)
{
setDoneInfo
({
session_id
:
session
.
id
,
status
:
'
completed
'
,
ai_department
:
session
.
ai_department
,
ai_severity
:
session
.
ai_severity
,
recommended_doctors
:
session
.
recommended_doctors
,
});
}
}
else
{
setReportContent
(
''
);
setReportDone
(
false
);
setDoneInfo
(
null
);
}
setHistoryDrawerOpen
(
false
);
setTimeout
(()
=>
inputRef
.
current
?.
focus
(),
300
);
};
// 初始化:优先恢复最新的会话(包括已完成的)
useEffect
(()
=>
{
const
initSession
=
async
()
=>
{
try
{
const
latestRes
=
await
preConsultApi
.
getLatest
();
if
(
latestRes
.
data
)
{
restoreSession
(
latestRes
.
data
);
return
;
}
const
listRes
=
await
preConsultApi
.
getList
();
if
(
listRes
.
data
&&
listRes
.
data
.
length
>
0
)
{
const
latestCompleted
=
listRes
.
data
.
find
(
s
=>
s
.
status
===
'
completed
'
);
if
(
latestCompleted
)
{
restoreSession
(
latestCompleted
);
return
;
}
}
}
catch
{
// 没有历史对话,正常初始化
}
// 无历史对话,显示欢迎消息
const
welcomeMsg
:
DisplayMessage
=
{
id
:
'
welcome
'
,
role
:
'
assistant
'
,
content
:
`您好,
${
patientInfo
.
name
}
!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`
,
timestamp
:
Date
.
now
(),
};
setMessages
([
welcomeMsg
]);
setTimeout
(()
=>
inputRef
.
current
?.
focus
(),
300
);
};
setInitialLoading
(
true
);
initSession
().
finally
(()
=>
setInitialLoading
(
false
));
},
[]);
const
handleSend
=
()
=>
{
const
text
=
inputValue
.
trim
();
if
(
!
text
||
sending
||
generating
)
return
;
// 添加用户消息
const
userMsg
:
DisplayMessage
=
{
id
:
`user-
${
Date
.
now
()}
`
,
role
:
'
user
'
,
content
:
text
,
timestamp
:
Date
.
now
(),
};
// 添加AI占位消息
const
aiMsgId
=
`ai-
${
Date
.
now
()}
`
;
const
aiMsg
:
DisplayMessage
=
{
id
:
aiMsgId
,
role
:
'
assistant
'
,
content
:
''
,
loading
:
true
,
timestamp
:
Date
.
now
(),
};
setMessages
(
prev
=>
[...
prev
,
userMsg
,
aiMsg
]);
setInputValue
(
''
);
setSending
(
true
);
// 构建请求参数
const
params
:
any
=
{
message
:
text
,
patient_name
:
patientInfo
.
name
,
patient_gender
:
patientInfo
.
gender
,
patient_age
:
patientInfo
.
age
,
};
if
(
sessionId
)
{
params
.
session_id
=
sessionId
;
}
// SSE流式对话
abortRef
.
current
=
preConsultApi
.
chat
(
params
,
{
onSession
:
(
info
)
=>
{
setSessionId
(
info
.
session_id
);
setSessionStatus
(
'
in_progress
'
);
},
onChunk
:
(
content
)
=>
{
setMessages
(
prev
=>
prev
.
map
(
m
=>
m
.
id
===
aiMsgId
?
{
...
m
,
content
:
m
.
content
+
content
,
loading
:
true
}
:
m
));
},
onDone
:
(
info
)
=>
{
setMessages
(
prev
=>
prev
.
map
(
m
=>
m
.
id
===
aiMsgId
?
{
...
m
,
loading
:
false
}
:
m
));
setSending
(
false
);
setTurnCount
(
info
.
turn_count
||
turnCount
+
1
);
if
(
!
sessionId
&&
info
.
session_id
)
{
setSessionId
(
info
.
session_id
);
}
setTimeout
(()
=>
inputRef
.
current
?.
focus
(),
100
);
},
onError
:
(
error
)
=>
{
setMessages
(
prev
=>
prev
.
map
(
m
=>
m
.
id
===
aiMsgId
?
{
...
m
,
content
:
`抱歉,出现了错误:
${
error
}
`
,
loading
:
false
}
:
m
));
setSending
(
false
);
message
.
error
(
error
);
},
});
};
const
handleFinishChat
=
()
=>
{
if
(
!
sessionId
||
generating
)
return
;
setGenerating
(
true
);
setReportContent
(
''
);
setReportDone
(
false
);
// 添加系统消息
const
sysMsg
:
DisplayMessage
=
{
id
:
`sys-report-
${
Date
.
now
()}
`
,
role
:
'
system
'
,
content
:
'
正在根据对话内容生成综合分析报告...
'
,
timestamp
:
Date
.
now
(),
};
setMessages
(
prev
=>
[...
prev
,
sysMsg
]);
abortRef
.
current
=
preConsultApi
.
finishChat
(
sessionId
,
{
onChunk
:
(
content
)
=>
{
setReportContent
(
prev
=>
prev
+
content
);
},
onDone
:
(
info
)
=>
{
setGenerating
(
false
);
setReportDone
(
true
);
setSessionStatus
(
'
completed
'
);
setDoneInfo
(
info
);
},
onError
:
(
error
)
=>
{
setGenerating
(
false
);
message
.
error
(
'
报告生成失败:
'
+
error
);
},
});
};
// 选择医生发起问诊
const
handleSelectDoctor
=
(
doctor
:
DoctorBrief
)
=>
{
setSelectedDoctor
(
doctor
);
setConsultModalOpen
(
true
);
};
const
handleKeyDown
=
(
e
:
React
.
KeyboardEvent
)
=>
{
if
(
e
.
key
===
'
Enter
'
&&
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
};
// 清理
useEffect
(()
=>
{
return
()
=>
{
abortRef
.
current
?.
abort
();
};
},
[]);
// =============== 渲染 ===============
// 渲染单条消息
const
renderMessage
=
(
msg
:
DisplayMessage
)
=>
{
const
isUser
=
msg
.
role
===
'
user
'
;
const
isSystem
=
msg
.
role
===
'
system
'
;
if
(
isSystem
)
{
return
(
<
div
key=
{
msg
.
id
}
style=
{
{
textAlign
:
'
center
'
,
margin
:
'
16px 0
'
}
}
>
<
Tag
icon=
{
<
InfoCircleOutlined
/>
}
color=
"processing"
>
{
msg
.
content
}
</
Tag
>
</
div
>
);
}
return
(
<
div
key=
{
msg
.
id
}
style=
{
{
display
:
'
flex
'
,
flexDirection
:
isUser
?
'
row-reverse
'
:
'
row
'
,
alignItems
:
'
flex-start
'
,
marginBottom
:
20
,
gap
:
12
,
}
}
>
<
Avatar
size=
{
40
}
icon=
{
isUser
?
<
UserOutlined
/>
:
<
RobotOutlined
/>
}
style=
{
{
backgroundColor
:
isUser
?
'
#1890ff
'
:
'
#722ed1
'
,
flexShrink
:
0
,
}
}
/>
<
div
style=
{
{
maxWidth
:
'
75%
'
,
padding
:
'
12px 16px
'
,
borderRadius
:
isUser
?
'
16px 16px 4px 16px
'
:
'
16px 16px 16px 4px
'
,
backgroundColor
:
isUser
?
'
#1890ff
'
:
'
#f5f5f5
'
,
color
:
isUser
?
'
#fff
'
:
'
#333
'
,
fontSize
:
15
,
lineHeight
:
1.7
,
boxShadow
:
'
0 1px 2px rgba(0,0,0,0.06)
'
,
position
:
'
relative
'
,
}
}
>
{
msg
.
content
&&
(
isUser
?
(
<
span
style=
{
{
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
}
}
>
{
msg
.
content
}
</
span
>
)
:
(
<
MarkdownRenderer
content=
{
msg
.
content
}
fontSize=
{
15
}
lineHeight=
{
1.7
}
textColor=
"#333"
/>
)
)
}
{
msg
.
loading
&&
(
<
span
style=
{
{
display
:
'
inline-block
'
,
marginLeft
:
4
}
}
>
<
LoadingOutlined
style=
{
{
color
:
isUser
?
'
#fff
'
:
'
#722ed1
'
}
}
/>
</
span
>
)
}
</
div
>
</
div
>
);
};
// 分析报告区域
const
renderReport
=
()
=>
{
if
(
!
reportContent
&&
!
generating
)
return
null
;
return
(
<
div
style=
{
{
margin
:
'
24px 0
'
}
}
>
<
Card
title=
{
<
Space
>
<
FileTextOutlined
style=
{
{
color
:
'
#722ed1
'
}
}
/>
<
span
>
AI 预问诊分析报告
</
span
>
{
generating
&&
<
LoadingOutlined
style=
{
{
color
:
'
#722ed1
'
}
}
/>
}
{
reportDone
&&
<
Tag
color=
"success"
>
生成完成
</
Tag
>
}
</
Space
>
}
style=
{
{
borderRadius
:
12
,
borderTop
:
'
3px solid #722ed1
'
,
}
}
>
<
div
style=
{
{
padding
:
'
8px 0
'
}
}
>
<
MarkdownRenderer
content=
{
reportContent
}
fontSize=
{
15
}
lineHeight=
{
1.8
}
/>
{
generating
&&
<
LoadingOutlined
style=
{
{
marginLeft
:
4
,
color
:
'
#722ed1
'
}
}
/>
}
</
div
>
</
Card
>
</
div
>
);
};
// 推荐医生列表
const
renderDoctors
=
()
=>
{
const
doctors
=
doneInfo
?.
recommended_doctors
;
if
(
!
doctors
||
doctors
.
length
===
0
)
return
null
;
return
(
<
Card
style=
{
{
borderRadius
:
12
,
marginTop
:
16
}
}
title=
{
<
Space
>
<
MedicineBoxOutlined
style=
{
{
color
:
'
#52c41a
'
}
}
/>
<
span
>
AI 推荐医生
</
span
>
{
doneInfo
?.
ai_department
&&
<
Tag
color=
"green"
>
{
doneInfo
.
ai_department
}
</
Tag
>
}
</
Space
>
}
>
<
List
dataSource=
{
doctors
}
renderItem=
{
(
doctor
)
=>
(
<
List
.
Item
actions=
{
[
<
Button
type=
"primary"
icon=
{
<
ArrowRightOutlined
/>
}
onClick=
{
()
=>
handleSelectDoctor
(
doctor
)
}
style=
{
{
background
:
'
#52c41a
'
,
borderColor
:
'
#52c41a
'
}
}
>
发起问诊
</
Button
>,
]
}
>
<
List
.
Item
.
Meta
avatar=
{
<
Avatar
size=
{
48
}
src=
{
doctor
.
avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#87d068
'
}
}
/>
}
title=
{
<
Space
>
<
Text
strong
>
{
doctor
.
name
}
</
Text
>
<
Tag
color=
"blue"
>
{
doctor
.
title
}
</
Tag
>
<
Tag
>
{
doctor
.
department_name
}
</
Tag
>
</
Space
>
}
description=
{
<
Space
split=
{
<
Divider
type=
"vertical"
/>
}
>
<
span
>
评分
{
doctor
.
rating
}
</
span
>
<
span
>
问诊
{
doctor
.
consult_count
}
次
</
span
>
<
span
style=
{
{
color
:
'
#fa8c16
'
}
}
>
¥
{
(
doctor
.
price
/
100
).
toFixed
(
0
)
}
/次
</
span
>
</
Space
>
}
/>
</
List
.
Item
>
)
}
/>
</
Card
>
);
};
return
(
<
div
style=
{
{
height
:
'
100%
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
maxWidth
:
800
,
margin
:
'
0 auto
'
}
}
>
{
/* 头部 */
}
<
div
style=
{
{
padding
:
'
16px 20px
'
,
background
:
'
linear-gradient(135deg, #722ed1 0%, #9254de 100%)
'
,
borderRadius
:
'
12px 12px 0 0
'
,
color
:
'
#fff
'
,
display
:
'
flex
'
,
alignItems
:
'
center
'
,
justifyContent
:
'
space-between
'
,
}
}
>
<
Space
>
<
RobotOutlined
style=
{
{
fontSize
:
24
}
}
/>
<
div
>
<
div
style=
{
{
fontSize
:
16
,
fontWeight
:
600
}
}
>
AI 智能预问诊
</
div
>
<
div
style=
{
{
fontSize
:
12
,
opacity
:
0.85
}
}
>
{
sessionStatus
===
'
completed
'
?
'
问诊已完成
'
:
sessionStatus
===
'
in_progress
'
?
`对话中 · 第 ${turnCount} 轮`
:
'
请先填写基本信息
'
}
</
div
>
</
div
>
</
Space
>
<
Space
>
<
Button
ghost
icon=
{
<
HistoryOutlined
/>
}
onClick=
{
()
=>
{
setHistoryDrawerOpen
(
true
);
loadHistoryList
();
}
}
style=
{
{
borderColor
:
'
#fff
'
,
color
:
'
#fff
'
}
}
>
历史对话
</
Button
>
{
sessionStatus
===
'
in_progress
'
&&
turnCount
>=
2
&&
!
generating
&&
!
reportDone
&&
(
<
Button
type=
"primary"
ghost
onClick=
{
handleFinishChat
}
icon=
{
<
FileTextOutlined
/>
}
style=
{
{
borderColor
:
'
#fff
'
,
color
:
'
#fff
'
}
}
>
结束对话 · 生成报告
</
Button
>
)
}
{
reportDone
&&
(
<
Button
ghost
onClick=
{
()
=>
router
.
push
(
'
/patient/doctors
'
)
}
style=
{
{
borderColor
:
'
#fff
'
,
color
:
'
#fff
'
}
}
>
浏览全部医生
</
Button
>
)
}
</
Space
>
</
div
>
{
/* 消息区域 */
}
<
div
style=
{
{
flex
:
1
,
overflowY
:
'
auto
'
,
padding
:
'
20px 20px 0 20px
'
,
background
:
'
#fff
'
,
minHeight
:
400
,
maxHeight
:
'
calc(100vh - 320px)
'
,
}
}
>
{
initialLoading
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
'
60px 0
'
}
}
>
<
Spin
tip=
"正在加载对话记录..."
/>
</
div
>
)
:
messages
.
length
===
0
&&
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
'
60px 0
'
,
color
:
'
#bfbfbf
'
}
}
>
<
RobotOutlined
style=
{
{
fontSize
:
48
,
marginBottom
:
16
}
}
/>
<
div
>
请在下方输入您的症状,开始智能预问诊
</
div
>
</
div
>
)
}
{
messages
.
map
(
renderMessage
)
}
{
/* 分析报告 */
}
{
renderReport
()
}
{
/* 推荐医生 */
}
{
reportDone
&&
renderDoctors
()
}
{
/* 底部操作区(对话完成后) */
}
{
reportDone
&&
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
'
24px 0
'
}
}
>
<
Space
size=
{
16
}
>
<
Button
size=
"large"
onClick=
{
()
=>
{
setSessionId
(
null
);
setSessionStatus
(
'
new
'
);
setTurnCount
(
0
);
setReportContent
(
''
);
setReportDone
(
false
);
setDoneInfo
(
null
);
// 显示欢迎消息
const
welcomeMsg
:
DisplayMessage
=
{
id
:
'
welcome
'
,
role
:
'
assistant
'
,
content
:
`您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`
,
timestamp
:
Date
.
now
(),
};
setMessages
([
welcomeMsg
]);
setTimeout
(()
=>
inputRef
.
current
?.
focus
(),
300
);
}
}
style=
{
{
borderColor
:
'
#722ed1
'
,
color
:
'
#722ed1
'
}
}
>
重新预问诊
</
Button
>
</
Space
>
</
div
>
)
}
<
div
ref=
{
messagesEndRef
}
/>
</
div
>
{
/* 输入区域 */
}
{
sessionStatus
!==
'
completed
'
&&
(
<
div
style=
{
{
padding
:
'
16px 20px
'
,
background
:
'
#fff
'
,
borderTop
:
'
1px solid #f0f0f0
'
,
borderRadius
:
'
0 0 12px 12px
'
,
}
}
>
{
turnCount
>=
2
&&
!
generating
&&
!
reportDone
&&
(
<
Alert
message=
"对话信息已足够,您可以点击右上角「结束对话 · 生成报告」获取分析结果,或继续补充信息。"
type=
"info"
showIcon
style=
{
{
marginBottom
:
12
,
borderRadius
:
8
}
}
closable
/>
)
}
<
div
style=
{
{
display
:
'
flex
'
,
gap
:
12
,
alignItems
:
'
flex-end
'
}
}
>
<
TextArea
ref=
{
inputRef
}
value=
{
inputValue
}
onChange=
{
(
e
)
=>
setInputValue
(
e
.
target
.
value
)
}
onKeyDown=
{
handleKeyDown
}
placeholder=
{
sending
?
'
AI 正在回复...
'
:
'
请描述您的症状或回答AI的问题... (Enter发送,Shift+Enter换行)
'
}
autoSize=
{
{
minRows
:
1
,
maxRows
:
4
}
}
disabled=
{
sending
||
generating
}
style=
{
{
flex
:
1
,
borderRadius
:
12
,
fontSize
:
15
,
padding
:
'
10px 16px
'
,
resize
:
'
none
'
,
}
}
/>
<
Button
type=
"primary"
icon=
{
sending
?
<
LoadingOutlined
/>
:
<
SendOutlined
/>
}
onClick=
{
handleSend
}
disabled=
{
!
inputValue
.
trim
()
||
sending
||
generating
}
style=
{
{
height
:
44
,
width
:
44
,
borderRadius
:
12
,
background
:
'
#722ed1
'
,
borderColor
:
'
#722ed1
'
,
}
}
/>
</
div
>
</
div
>
)
}
{
/* 创建问诊弹窗 */
}
{
selectedDoctor
&&
sessionId
&&
(
<
ConsultCreateModal
open=
{
consultModalOpen
}
onCancel=
{
()
=>
setConsultModalOpen
(
false
)
}
doctorId=
{
selectedDoctor
.
id
}
consultType=
"text"
preConsultId=
{
sessionId
}
chiefComplaint=
{
messages
.
find
(
m
=>
m
.
role
===
'
user
'
)?.
content
||
''
}
/>
)
}
{
/* 历史对话抽屉 */
}
<
Drawer
title=
{
<
Space
>
<
HistoryOutlined
/>
<
span
>
历史对话记录
</
span
>
</
Space
>
}
placement=
"right"
width=
{
400
}
open=
{
historyDrawerOpen
}
onClose=
{
()
=>
setHistoryDrawerOpen
(
false
)
}
>
{
historyLoading
?
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
60
}
}
>
<
Spin
tip=
"加载中.."
/>
</
div
>
)
:
historyList
.
length
===
0
?
(
<
Empty
description=
"暂无历史对话"
/>
)
:
(
<
List
dataSource=
{
historyList
}
renderItem=
{
(
item
)
=>
(
<
List
.
Item
style=
{
{
cursor
:
'
pointer
'
,
borderRadius
:
8
,
marginBottom
:
8
,
padding
:
12
,
background
:
item
.
id
===
sessionId
?
'
#f0f5ff
'
:
'
#fafafa
'
}
}
onClick=
{
()
=>
restoreSession
(
item
)
}
>
<
List
.
Item
.
Meta
avatar=
{
<
Avatar
icon=
{
item
.
status
===
'
completed
'
?
<
FileTextOutlined
/>
:
<
ClockCircleOutlined
/>
}
style=
{
{
backgroundColor
:
item
.
status
===
'
completed
'
?
'
#52c41a
'
:
'
#faad14
'
}
}
/>
}
title=
{
<
Space
>
<
Text
strong
ellipsis
style=
{
{
maxWidth
:
200
}
}
>
{
item
.
chief_complaint
||
'
预问诊对话
'
}
</
Text
>
{
item
.
id
===
sessionId
&&
<
Tag
color=
"blue"
>
当前
</
Tag
>
}
</
Space
>
}
description=
{
<
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
#666
'
,
marginBottom
:
4
}
}
>
{
item
.
status
===
'
completed
'
?
(
<>
<
Tag
color=
"green"
style=
{
{
marginRight
:
4
}
}
>
已完成
</
Tag
>
{
item
.
ai_department
&&
<
Tag
>
{
item
.
ai_department
}
</
Tag
>
}
</>
)
:
(
<
Tag
color=
"orange"
>
进行中
</
Tag
>
)
}
</
div
>
<
div
style=
{
{
fontSize
:
12
,
color
:
'
#999
'
}
}
>
{
item
.
chat_messages
?
`${Math.floor(item.chat_messages.length / 2)}轮对话`
:
'
无对话
'
}
·
{
new
Date
(
item
.
created_at
).
toLocaleString
(
'
zh-CN
'
,
{
month
:
'
2-digit
'
,
day
:
'
2-digit
'
,
hour
:
'
2-digit
'
,
minute
:
'
2-digit
'
})
}
</
div
>
</
div
>
}
/>
</
List
.
Item
>
)
}
/>
)
}
</
Drawer
>
</
div
>
);
};
export
default
PatientPreConsultPage
;
web/src/pages/patient/Prescription/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useCallback
}
from
'
react
'
;
import
{
Card
,
Typography
,
Button
,
Table
,
Tag
,
Space
,
Empty
,
Modal
,
Descriptions
,
Divider
,
Spin
,
message
,
}
from
'
antd
'
;
import
{
FileTextOutlined
,
MedicineBoxOutlined
,
SafetyCertificateOutlined
,
EyeOutlined
,
ShoppingCartOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
prescriptionPatientApi
}
from
'
../../../api/prescription
'
;
import
type
{
Prescription
,
PrescriptionItem
}
from
'
../../../api/prescription
'
;
const
{
Title
,
Text
}
=
Typography
;
const
PatientPrescriptionPage
:
React
.
FC
=
()
=>
{
const
router
=
useRouter
();
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
prescriptions
,
setPrescriptions
]
=
useState
<
Prescription
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
detailVisible
,
setDetailVisible
]
=
useState
(
false
);
const
[
currentRx
,
setCurrentRx
]
=
useState
<
Prescription
|
null
>
(
null
);
const
fetchList
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
prescriptionPatientApi
.
getList
({
page
,
page_size
:
10
});
const
data
=
res
.
data
as
any
;
setPrescriptions
(
data
?.
list
||
[]);
setTotal
(
data
?.
total
||
0
);
}
catch
{
/* ignore */
}
setLoading
(
false
);
},
[
page
]);
useEffect
(()
=>
{
fetchList
();
},
[
fetchList
]);
const
handleViewDetail
=
async
(
rx
:
Prescription
)
=>
{
try
{
const
res
=
await
prescriptionPatientApi
.
getDetail
(
rx
.
id
);
setCurrentRx
(
res
.
data
);
setDetailVisible
(
true
);
}
catch
{
message
.
error
(
'
获取处方详情失败
'
);
}
};
const
getStatusTag
=
(
status
:
string
)
=>
{
const
map
:
Record
<
string
,
{
color
:
string
;
label
:
string
}
>
=
{
pending
:
{
color
:
'
orange
'
,
label
:
'
待签名
'
},
signed
:
{
color
:
'
blue
'
,
label
:
'
已签名
'
},
approved
:
{
color
:
'
green
'
,
label
:
'
已审核
'
},
rejected
:
{
color
:
'
red
'
,
label
:
'
已驳回
'
},
dispensed
:
{
color
:
'
cyan
'
,
label
:
'
已发药
'
},
completed
:
{
color
:
'
default
'
,
label
:
'
已完成
'
},
};
const
s
=
map
[
status
]
||
{
color
:
'
default
'
,
label
:
status
};
return
<
Tag
color=
{
s
.
color
}
>
{
s
.
label
}
</
Tag
>;
};
const
columns
=
[
{
title
:
'
处方编号
'
,
dataIndex
:
'
prescription_no
'
,
key
:
'
prescription_no
'
,
width
:
170
},
{
title
:
'
医生
'
,
dataIndex
:
'
doctor_name
'
,
key
:
'
doctor_name
'
,
width
:
80
},
{
title
:
'
诊断
'
,
dataIndex
:
'
diagnosis
'
,
key
:
'
diagnosis
'
,
ellipsis
:
true
},
{
title
:
'
药品
'
,
key
:
'
drugs
'
,
render
:
(
_
:
any
,
r
:
Prescription
)
=>
(
<
Space
size=
{
[
4
,
4
]
}
wrap
>
{
(
r
.
items
||
[]).
slice
(
0
,
3
).
map
((
item
)
=>
<
Tag
key=
{
item
.
id
}
color=
"blue"
>
{
item
.
medicine_name
}
</
Tag
>)
}
{
(
r
.
items
||
[]).
length
>
3
&&
<
Tag
>
+
{
r
.
items
.
length
-
3
}
</
Tag
>
}
</
Space
>
),
},
{
title
:
'
金额
'
,
dataIndex
:
'
total_amount
'
,
key
:
'
total_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
)
=>
getStatusTag
(
v
)
},
{
title
:
'
开方时间
'
,
dataIndex
:
'
created_at
'
,
key
:
'
created_at
'
,
width
:
170
,
render
:
(
v
:
string
)
=>
v
?
new
Date
(
v
).
toLocaleString
(
'
zh-CN
'
)
:
'
-
'
,
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
100
,
render
:
(
_
:
any
,
r
:
Prescription
)
=>
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
EyeOutlined
/>
}
onClick=
{
()
=>
handleViewDetail
(
r
)
}
>
查看
</
Button
>
),
},
];
if
(
!
loading
&&
prescriptions
.
length
===
0
)
{
return
(
<
div
className=
"max-w-[600px] mx-auto pt-10"
>
<
Card
size=
"small"
className=
"text-center py-6"
>
<
FileTextOutlined
className=
"text-4xl text-[#1890ff] mb-2"
/>
<
div
className=
"text-sm font-bold mb-1"
>
电子处方
</
div
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
暂无处方记录,完成问诊后医生会为您开具处方
</
Text
>
<
div
className=
"mt-4"
>
<
Button
type=
"primary"
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/doctors
'
)
}
>
去问诊
</
Button
>
</
div
>
</
Card
>
</
div
>
);
}
return
(
<
div
className=
"space-y-2"
>
<
h4
className=
"text-sm font-bold text-gray-800 m-0"
>
<
FileTextOutlined
className=
"mr-1"
/>
电子处方
</
h4
>
<
Spin
spinning=
{
loading
}
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
prescriptions
}
rowKey=
"id"
size=
"small"
pagination=
{
{
current
:
page
,
total
,
pageSize
:
10
,
size
:
'
small
'
,
onChange
:
setPage
,
showTotal
:
(
t
)
=>
`共 ${t} 张`
}
}
/>
</
Card
>
</
Spin
>
<
Modal
title=
"处方详情"
open=
{
detailVisible
}
onCancel=
{
()
=>
setDetailVisible
(
false
)
}
width=
{
700
}
footer=
{
currentRx
&&
[
'
approved
'
,
'
signed
'
].
includes
(
currentRx
.
status
)
?
[
<
Button
key=
"buy"
type=
"primary"
icon=
{
<
ShoppingCartOutlined
/>
}
onClick=
{
()
=>
{
message
.
info
(
'
购药功能即将上线
'
);
}
}
>
一键购药
</
Button
>,
]
:
null
}
>
{
currentRx
&&
(
<
div
>
<
div
style=
{
{
textAlign
:
'
center
'
,
marginBottom
:
16
}
}
>
<
SafetyCertificateOutlined
style=
{
{
fontSize
:
32
,
color
:
'
#52c41a
'
}
}
/>
<
Title
level=
{
5
}
style=
{
{
margin
:
'
8px 0 0
'
}
}
>
电子处方
</
Title
>
<
Text
type=
"secondary"
>
{
currentRx
.
prescription_no
}
</
Text
>
</
div
>
<
Descriptions
bordered
size=
"small"
column=
{
2
}
>
<
Descriptions
.
Item
label=
"患者姓名"
>
{
currentRx
.
patient_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"性别/年龄"
>
{
currentRx
.
patient_gender
||
'
-
'
}
/
{
currentRx
.
patient_age
?
`${currentRx.patient_age}岁`
:
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"开方医生"
>
{
currentRx
.
doctor_name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"状态"
>
{
getStatusTag
(
currentRx
.
status
)
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"临床诊断"
span=
{
2
}
>
{
currentRx
.
diagnosis
}
</
Descriptions
.
Item
>
{
currentRx
.
allergy_history
&&
(
<
Descriptions
.
Item
label=
"过敏史"
span=
{
2
}
>
<
Text
type=
"danger"
>
{
currentRx
.
allergy_history
}
</
Text
>
</
Descriptions
.
Item
>
)
}
</
Descriptions
>
<
Card
title=
{
<><
MedicineBoxOutlined
/>
药品明细
</>
}
size=
"small"
style=
{
{
marginTop
:
16
,
borderRadius
:
8
}
}
>
{
(
currentRx
.
items
||
[]).
map
((
item
:
PrescriptionItem
,
idx
:
number
)
=>
(
<
div
key=
{
item
.
id
}
style=
{
{
padding
:
'
8px 0
'
,
borderBottom
:
idx
<
currentRx
.
items
.
length
-
1
?
'
1px solid #f0f0f0
'
:
'
none
'
}
}
>
<
Space
>
<
Text
strong
>
{
idx
+
1
}
.
{
item
.
medicine_name
}
</
Text
>
<
Text
type=
"secondary"
>
{
item
.
specification
}
</
Text
>
</
Space
>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
item
.
usage
}
|
{
item
.
dosage
}
|
{
item
.
frequency
}
|
{
item
.
days
}
天 | 数量:
{
item
.
quantity
}{
item
.
unit
}
</
Text
>
<
div
style=
{
{
textAlign
:
'
right
'
}
}
>
<
Text
type=
"secondary"
>
¥
{
(
item
.
amount
/
100
).
toFixed
(
2
)
}
</
Text
>
</
div
>
</
div
>
))
}
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
div
style=
{
{
textAlign
:
'
right
'
}
}
>
<
Text
strong
style=
{
{
fontSize
:
16
,
color
:
'
#f5222d
'
}
}
>
合计:¥
{
(
currentRx
.
total_amount
/
100
).
toFixed
(
2
)
}
</
Text
>
</
div
>
</
Card
>
{
currentRx
.
remark
&&
(
<
Card
title=
"医嘱备注"
size=
"small"
style=
{
{
marginTop
:
12
,
borderRadius
:
8
}
}
>
<
Text
>
{
currentRx
.
remark
}
</
Text
>
</
Card
>
)
}
{
currentRx
.
signed_at
&&
(
<
div
style=
{
{
marginTop
:
16
,
textAlign
:
'
center
'
,
color
:
'
#52c41a
'
}
}
>
<
SafetyCertificateOutlined
style=
{
{
marginRight
:
4
}
}
/>
<
Text
type=
"secondary"
>
CA 数字签名时间:
{
new
Date
(
currentRx
.
signed_at
).
toLocaleString
(
'
zh-CN
'
)
}
</
Text
>
</
div
>
)
}
</
div
>
)
}
</
Modal
>
</
div
>
);
};
export
default
PatientPrescriptionPage
;
web/src/pages/patient/Profile/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
from
'
react
'
;
import
{
Card
,
Avatar
,
Button
,
Descriptions
,
Tag
,
Typography
,
Row
,
Col
}
from
'
antd
'
;
import
{
UserOutlined
,
PhoneOutlined
,
SafetyCertificateOutlined
,
EditOutlined
,
IdcardOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useRouter
}
from
'
next/navigation
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
const
{
Title
,
Text
}
=
Typography
;
const
PatientProfilePage
:
React
.
FC
=
()
=>
{
const
{
user
}
=
useUserStore
();
const
router
=
useRouter
();
return
(
<
div
className=
"max-w-[1200px] mx-auto space-y-2"
>
{
/* 个人信息卡片 */
}
<
Card
size=
"small"
>
<
Row
gutter=
{
12
}
align=
"middle"
>
<
Col
>
<
Avatar
size=
{
56
}
src=
{
user
?.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
</
Col
>
<
Col
flex=
"auto"
>
<
h4
className=
"text-sm font-bold m-0 mb-0.5"
>
{
user
?.
real_name
||
'
未设置姓名
'
}
</
h4
>
<
span
className=
"text-xs text-gray-400"
><
PhoneOutlined
className=
"mr-1"
/>
{
user
?.
phone
||
'
未绑定手机
'
}
</
span
>
<
div
className=
"mt-1"
>
{
user
?.
is_verified
?
(
<
Tag
icon=
{
<
SafetyCertificateOutlined
/>
}
color=
"success"
>
已实名
</
Tag
>
)
:
(
<
Tag
color=
"warning"
>
未认证
</
Tag
>
)
}
<
Tag
color=
"blue"
>
患者
</
Tag
>
</
div
>
</
Col
>
<
Col
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
EditOutlined
/>
}
>
编辑资料
</
Button
>
</
Col
>
</
Row
>
</
Card
>
{
/* 基本信息 */
}
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
基本信息
</
span
>
}
size=
"small"
>
<
Descriptions
column=
{
2
}
size=
"small"
>
<
Descriptions
.
Item
label=
"用户ID"
>
{
user
?.
id
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"手机"
>
{
user
?.
phone
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"真实姓名"
>
{
user
?.
real_name
||
'
-
'
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"注册时间"
>
{
user
?.
created_at
||
'
-
'
}
</
Descriptions
.
Item
>
</
Descriptions
>
</
Card
>
{
/* 实名认证 */
}
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
实名认证
</
span
>
}
size=
"small"
>
{
user
?.
is_verified
?
(
<
div
className=
"text-center py-4"
>
<
div
className=
"w-10 h-10 rounded-full bg-green-50 flex items-center justify-center mx-auto mb-2"
>
<
SafetyCertificateOutlined
className=
"text-xl text-green-500"
/>
</
div
>
<
Text
strong
className=
"text-xs!"
>
已完成实名认证
</
Text
>
</
div
>
)
:
(
<
div
className=
"text-center py-4"
>
<
div
className=
"w-10 h-10 rounded-full bg-yellow-50 flex items-center justify-center mx-auto mb-2"
>
<
IdcardOutlined
className=
"text-xl text-yellow-500"
/>
</
div
>
<
div
className=
"text-xs text-gray-500 mb-2"
>
完成实名认证后可享受完整的线上问诊服务
</
div
>
<
Button
type=
"primary"
size=
"small"
>
去认证
</
Button
>
</
div
>
)
}
</
Card
>
{
/* 快捷操作 */
}
<
Card
title=
{
<
span
className=
"text-xs font-semibold"
>
更多服务
</
span
>
}
size=
"small"
>
<
Row
gutter=
{
[
8
,
8
]
}
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/consult
'
)
}
>
就诊记录
</
Button
></
Col
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/prescription
'
)
}
>
电子处方
</
Button
></
Col
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/health-records
'
)
}
>
健康档案
</
Button
></
Col
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/chronic
'
)
}
>
慢病管理
</
Button
></
Col
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/pharmacy/order
'
)
}
>
药品配送
</
Button
></
Col
>
<
Col
span=
{
8
}
><
Button
block
size=
"small"
onClick=
{
()
=>
router
.
push
(
'
/patient/pre-consult
'
)
}
>
AI预问诊
</
Button
></
Col
>
</
Row
>
</
Card
>
</
div
>
);
};
export
default
PatientProfilePage
;
web/src/pages/patient/TextConsult/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useState
,
useEffect
,
useRef
}
from
'
react
'
;
import
{
useRouter
,
useParams
}
from
'
next/navigation
'
;
import
{
Card
,
Input
,
Button
,
List
,
Avatar
,
Typography
,
Space
,
Row
,
Col
,
Tag
,
Divider
,
message
}
from
'
antd
'
;
import
{
SendOutlined
,
RobotOutlined
,
UserOutlined
,
PhoneOutlined
,
MedicineBoxOutlined
,
ClockCircleOutlined
,
ArrowLeftOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useQuery
,
useMutation
,
useQueryClient
}
from
'
@tanstack/react-query
'
;
import
{
consultApi
,
type
ConsultMessage
}
from
'
../../../api/consult
'
;
import
{
useUserStore
}
from
'
../../../store/userStore
'
;
import
dayjs
from
'
dayjs
'
;
const
{
Text
,
Title
}
=
Typography
;
const
{
TextArea
}
=
Input
;
const
statusMap
:
Record
<
string
,
{
text
:
string
;
color
:
string
}
>
=
{
pending
:
{
text
:
'
等待接诊
'
,
color
:
'
orange
'
},
waiting
:
{
text
:
'
等待接诊
'
,
color
:
'
orange
'
},
in_progress
:
{
text
:
'
问诊中
'
,
color
:
'
green
'
},
completed
:
{
text
:
'
已完成
'
,
color
:
'
default
'
},
cancelled
:
{
text
:
'
已取消
'
,
color
:
'
red
'
},
};
const
PatientTextConsultPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
router
=
useRouter
();
const
{
user
}
=
useUserStore
();
const
queryClient
=
useQueryClient
();
const
[
inputValue
,
setInputValue
]
=
useState
(
''
);
const
messagesEndRef
=
useRef
<
HTMLDivElement
>
(
null
);
const
{
data
:
consultData
}
=
useQuery
({
queryKey
:
[
'
consult
'
,
id
],
queryFn
:
()
=>
consultApi
.
getConsultDetail
(
id
!
),
enabled
:
!!
id
,
refetchInterval
:
5000
,
});
const
{
data
:
messagesData
}
=
useQuery
({
queryKey
:
[
'
consultMessages
'
,
id
],
queryFn
:
()
=>
consultApi
.
getConsultMessages
(
id
!
),
enabled
:
!!
id
,
refetchInterval
:
3000
,
});
const
sendMutation
=
useMutation
({
mutationFn
:
(
content
:
string
)
=>
consultApi
.
sendMessage
(
id
!
,
content
),
onSuccess
:
()
=>
{
queryClient
.
invalidateQueries
({
queryKey
:
[
'
consultMessages
'
,
id
]
});
setInputValue
(
''
);
},
onError
:
()
=>
{
message
.
error
(
'
发送失败
'
);
},
});
useEffect
(()
=>
{
messagesEndRef
.
current
?.
scrollIntoView
({
behavior
:
'
smooth
'
});
},
[
messagesData
]);
const
handleSend
=
()
=>
{
if
(
!
inputValue
.
trim
())
return
;
sendMutation
.
mutate
(
inputValue
.
trim
());
};
const
consult
=
consultData
?.
data
;
const
canSendMessage
=
consult
?.
status
===
'
in_progress
'
;
const
statusInfo
=
statusMap
[
consult
?.
status
||
'
pending
'
]
||
statusMap
.
pending
;
const
renderMessage
=
(
msg
:
ConsultMessage
)
=>
{
const
isMe
=
msg
.
sender_type
===
'
patient
'
;
const
isAI
=
msg
.
sender_type
===
'
ai
'
;
const
isSystem
=
msg
.
sender_type
===
'
system
'
;
if
(
isSystem
)
{
return
(
<
div
key=
{
msg
.
id
}
style=
{
{
textAlign
:
'
center
'
,
margin
:
'
12px 0
'
}
}
>
<
Tag
color=
"blue"
style=
{
{
fontSize
:
12
}
}
>
{
msg
.
content
}
</
Tag
>
</
div
>
);
}
return
(
<
div
key=
{
msg
.
id
}
style=
{
{
display
:
'
flex
'
,
justifyContent
:
isMe
?
'
flex-end
'
:
'
flex-start
'
,
marginBottom
:
16
,
}
}
>
{
!
isMe
&&
(
<
Avatar
icon=
{
isAI
?
<
RobotOutlined
/>
:
<
UserOutlined
/>
}
src=
{
!
isAI
?
consult
?.
doctor_avatar
:
undefined
}
style=
{
{
backgroundColor
:
isAI
?
'
#722ed1
'
:
'
#1890ff
'
,
marginRight
:
8
,
flexShrink
:
0
}
}
/>
)
}
<
div
style=
{
{
maxWidth
:
'
70%
'
}
}
>
{
!
isMe
&&
(
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
,
marginBottom
:
4
,
display
:
'
block
'
}
}
>
{
isAI
?
'
AI助手
'
:
consult
?.
doctor_name
||
'
医生
'
}
</
Text
>
)
}
<
div
style=
{
{
background
:
isMe
?
'
#1890ff
'
:
isAI
?
'
#f9f0ff
'
:
'
#f5f5f5
'
,
color
:
isMe
?
'
#fff
'
:
'
#000
'
,
padding
:
'
10px 14px
'
,
borderRadius
:
isMe
?
'
12px 12px 4px 12px
'
:
'
12px 12px 12px 4px
'
,
lineHeight
:
1.6
,
}
}
>
{
msg
.
content
}
</
div
>
<
div
style=
{
{
textAlign
:
isMe
?
'
right
'
:
'
left
'
,
marginTop
:
4
}
}
>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
11
}
}
>
{
dayjs
(
msg
.
created_at
).
format
(
'
HH:mm
'
)
}
</
Text
>
</
div
>
</
div
>
{
isMe
&&
(
<
Avatar
style=
{
{
marginLeft
:
8
,
flexShrink
:
0
,
backgroundColor
:
'
#52c41a
'
}
}
src=
{
user
?.
avatar
}
icon=
{
<
UserOutlined
/>
}
/>
)
}
</
div
>
);
};
return
(
<
div
style=
{
{
height
:
'
calc(100vh - 120px)
'
}
}
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
ArrowLeftOutlined
/>
}
onClick=
{
()
=>
router
.
push
(
'
/patient/consult-list
'
)
}
className=
"p-0! mb-1"
>
返回列表
</
Button
>
<
Row
gutter=
{
8
}
style=
{
{
height
:
'
calc(100% - 32px)
'
}
}
>
<
Col
span=
{
6
}
>
<
Card
size=
"small"
style=
{
{
height
:
'
100%
'
}
}
styles=
{
{
body
:
{
padding
:
12
}
}
}
>
<
div
className=
"text-center mb-2"
>
<
Avatar
size=
{
48
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
UserOutlined
/>
}
style=
{
{
backgroundColor
:
'
#1890ff
'
}
}
/>
<
div
className=
"text-sm font-bold mt-1"
>
{
consult
?.
doctor_name
||
'
医生
'
}
</
div
>
<
Tag
color=
{
statusInfo
.
color
}
>
{
statusInfo
.
text
}
</
Tag
>
</
div
>
<
Divider
className=
"my-1!"
/>
<
div
className=
"text-xs space-y-2"
>
<
div
>
<
MedicineBoxOutlined
className=
"text-blue-500 mr-1"
/><
Text
type=
"secondary"
>
类型
</
Text
>
<
div
className=
"ml-4 mt-0.5"
><
Tag
color=
"green"
>
图文
</
Tag
></
div
>
</
div
>
<
div
>
<
ClockCircleOutlined
className=
"text-blue-500 mr-1"
/><
Text
type=
"secondary"
>
发起
</
Text
>
<
div
className=
"ml-4 mt-0.5"
>
{
consult
?.
created_at
?
dayjs
(
consult
.
created_at
).
format
(
'
MM-DD HH:mm
'
)
:
'
-
'
}
</
div
>
</
div
>
{
consult
?.
started_at
&&
(
<
div
>
<
PhoneOutlined
className=
"text-green-500 mr-1"
/><
Text
type=
"secondary"
>
接诊
</
Text
>
<
div
className=
"ml-4 mt-0.5"
>
{
dayjs
(
consult
.
started_at
).
format
(
'
MM-DD HH:mm
'
)
}
</
div
>
</
div
>
)
}
</
div
>
<
Divider
className=
"my-1!"
/>
<
div
className=
"text-[11px] text-gray-400 mb-1"
>
主诉
</
div
>
<
div
className=
"text-xs bg-gray-50 rounded p-2"
>
{
consult
?.
chief_complaint
||
'
未填写
'
}
</
div
>
</
Card
>
</
Col
>
<
Col
span=
{
18
}
>
<
Card
size=
"small"
title=
{
<
span
className=
"text-xs"
>
<
Avatar
size=
{
20
}
src=
{
consult
?.
doctor_avatar
}
icon=
{
<
UserOutlined
/>
}
className=
"mr-1"
/>
{
consult
?.
doctor_name
||
'
医生
'
}
<
Tag
color=
{
statusInfo
.
color
}
className=
"ml-1"
>
{
statusInfo
.
text
}
</
Tag
>
</
span
>
}
style=
{
{
height
:
'
100%
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
}
}
styles=
{
{
body
:
{
flex
:
1
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
,
padding
:
0
,
overflow
:
'
hidden
'
}
}
}
>
<
div
style=
{
{
flex
:
1
,
overflow
:
'
auto
'
,
padding
:
12
}
}
>
{
(
!
messagesData
?.
data
||
messagesData
.
data
.
length
===
0
)
?
(
<
div
className=
"text-center py-10 text-xs text-gray-400"
>
{
consult
?.
status
===
'
pending
'
||
consult
?.
status
===
'
waiting
'
?
'
等待医生接诊中..
'
:
'
暂无消息
'
}
</
div
>
)
:
(
<
List
dataSource=
{
messagesData
.
data
}
renderItem=
{
renderMessage
}
split=
{
false
}
/>
)
}
<
div
ref=
{
messagesEndRef
}
/>
</
div
>
<
div
className=
"border-t border-gray-100 p-2 bg-gray-50"
>
{
canSendMessage
?
(
<
Space
.
Compact
style=
{
{
width
:
'
100%
'
}
}
>
<
TextArea
value=
{
inputValue
}
onChange=
{
(
e
)
=>
setInputValue
(
e
.
target
.
value
)
}
placeholder=
"输入消息..."
autoSize=
{
{
minRows
:
1
,
maxRows
:
3
}
}
size=
"small"
onPressEnter=
{
(
e
)
=>
{
if
(
!
e
.
shiftKey
)
{
e
.
preventDefault
();
handleSend
();
}
}
}
style=
{
{
flex
:
1
}
}
/>
<
Button
type=
"primary"
size=
"small"
icon=
{
<
SendOutlined
/>
}
onClick=
{
handleSend
}
loading=
{
sendMutation
.
isPending
}
>
发送
</
Button
>
</
Space
.
Compact
>
)
:
(
<
div
className=
"text-center text-xs text-gray-400 py-1"
>
{
consult
?.
status
===
'
completed
'
?
'
问诊已结束
'
:
'
等待医生接诊后可发送消息
'
}
</
div
>
)
}
</
div
>
</
Card
>
</
Col
>
</
Row
>
</
div
>
);
};
export
default
PatientTextConsultPage
;
web/src/pages/patient/VideoConsult/index.tsx
0 → 100644
View file @
52e57c72
'
use client
'
;
import
React
,
{
useEffect
,
useRef
}
from
'
react
'
;
import
{
useRouter
,
useParams
}
from
'
next/navigation
'
;
import
{
Card
,
Button
,
Space
,
message
,
Spin
,
Typography
}
from
'
antd
'
;
import
{
AudioMutedOutlined
,
AudioOutlined
,
VideoCameraOutlined
,
VideoCameraAddOutlined
,
PhoneOutlined
,
}
from
'
@ant-design/icons
'
;
import
{
useVideoCall
}
from
'
../../../hooks/useVideoCall
'
;
const
{
Title
}
=
Typography
;
const
PatientVideoConsultPage
:
React
.
FC
=
()
=>
{
const
{
id
}
=
useParams
<
{
id
:
string
}
>
();
const
router
=
useRouter
();
const
localVideoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
const
remoteVideoRef
=
useRef
<
HTMLVideoElement
>
(
null
);
const
{
isConnecting
,
isConnected
,
isMuted
,
isVideoOff
,
localStream
,
joinRoom
,
leaveRoom
,
toggleMute
,
toggleVideo
,
}
=
useVideoCall
({
consultId
:
id
||
''
,
onCallEnded
:
()
=>
{
message
.
info
(
'
通话已结束
'
);
router
.
push
(
'
/patient/consult
'
);
},
});
useEffect
(()
=>
{
if
(
localStream
&&
localVideoRef
.
current
)
{
localVideoRef
.
current
.
srcObject
=
localStream
;
}
},
[
localStream
]);
useEffect
(()
=>
{
joinRoom
().
catch
((
error
)
=>
{
message
.
error
(
'
加入房间失败:
'
+
error
.
message
);
});
return
()
=>
{
leaveRoom
();
};
},
[
joinRoom
,
leaveRoom
]);
const
handleEndCall
=
()
=>
{
leaveRoom
();
router
.
push
(
'
/patient/consult
'
);
};
if
(
isConnecting
)
{
return
(
<
div
style=
{
{
textAlign
:
'
center
'
,
padding
:
100
}
}
>
<
Spin
size=
"large"
/>
<
div
style=
{
{
marginTop
:
16
}
}
>
正在连接...
</
div
>
</
div
>
);
}
return
(
<
div
style=
{
{
height
:
'
calc(100vh - 120px)
'
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
}
}
>
<
Card
size=
"small"
style=
{
{
flex
:
1
,
display
:
'
flex
'
,
flexDirection
:
'
column
'
}
}
>
<
div
className=
"text-sm font-bold mb-2"
>
视频问诊
</
div
>
<
div
style=
{
{
flex
:
1
,
display
:
'
flex
'
,
gap
:
8
,
background
:
'
#000
'
,
borderRadius
:
6
,
padding
:
8
,
minHeight
:
350
,
position
:
'
relative
'
}
}
>
<
div
style=
{
{
flex
:
1
,
position
:
'
relative
'
}
}
>
<
video
ref=
{
remoteVideoRef
}
autoPlay
playsInline
style=
{
{
width
:
'
100%
'
,
height
:
'
100%
'
,
objectFit
:
'
cover
'
,
borderRadius
:
6
}
}
/>
{
!
isConnected
&&
(
<
div
className=
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white text-xs"
>
等待医生接入...
</
div
>
)
}
</
div
>
<
div
style=
{
{
width
:
160
,
height
:
120
,
position
:
'
absolute
'
,
right
:
16
,
bottom
:
60
,
borderRadius
:
6
,
overflow
:
'
hidden
'
,
border
:
'
2px solid #fff
'
}
}
>
<
video
ref=
{
localVideoRef
}
autoPlay
playsInline
muted
style=
{
{
width
:
'
100%
'
,
height
:
'
100%
'
,
objectFit
:
'
cover
'
}
}
/>
</
div
>
</
div
>
<
div
className=
"text-center mt-3"
>
<
Space
size=
"large"
>
<
Button
shape=
"circle"
icon=
{
isMuted
?
<
AudioMutedOutlined
/>
:
<
AudioOutlined
/>
}
onClick=
{
toggleMute
}
type=
{
isMuted
?
'
primary
'
:
'
default
'
}
danger=
{
isMuted
}
/>
<
Button
shape=
"circle"
icon=
{
isVideoOff
?
<
VideoCameraAddOutlined
/>
:
<
VideoCameraOutlined
/>
}
onClick=
{
toggleVideo
}
type=
{
isVideoOff
?
'
primary
'
:
'
default
'
}
danger=
{
isVideoOff
}
/>
<
Button
shape=
"circle"
icon=
{
<
PhoneOutlined
/>
}
onClick=
{
handleEndCall
}
danger
type=
"primary"
/>
</
Space
>
</
div
>
</
Card
>
</
div
>
);
};
export
default
PatientVideoConsultPage
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment