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
d2f9a49c
Commit
d2f9a49c
authored
Feb 28, 2026
by
yuguo
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix
parent
e15d3424
Changes
45
Show whitespace changes
Inline
Side-by-side
Showing
45 changed files
with
10 additions
and
10265 deletions
+10
-10265
web/.claude/settings.local.json
web/.claude/settings.local.json
+10
-0
web/src/pages/admin/AIConfig/index.tsx
web/src/pages/admin/AIConfig/index.tsx
+0
-889
web/src/pages/admin/Admins/index.tsx
web/src/pages/admin/Admins/index.tsx
+0
-326
web/src/pages/admin/Compliance/index.tsx
web/src/pages/admin/Compliance/index.tsx
+0
-241
web/src/pages/admin/Consultations/index.tsx
web/src/pages/admin/Consultations/index.tsx
+0
-202
web/src/pages/admin/Dashboard/index.tsx
web/src/pages/admin/Dashboard/index.tsx
+0
-156
web/src/pages/admin/Departments/index.tsx
web/src/pages/admin/Departments/index.tsx
+0
-162
web/src/pages/admin/DoctorReview/index.tsx
web/src/pages/admin/DoctorReview/index.tsx
+0
-195
web/src/pages/admin/Doctors/index.tsx
web/src/pages/admin/Doctors/index.tsx
+0
-539
web/src/pages/admin/Patients/index.tsx
web/src/pages/admin/Patients/index.tsx
+0
-330
web/src/pages/admin/Pharmacy/index.tsx
web/src/pages/admin/Pharmacy/index.tsx
+0
-277
web/src/pages/admin/Prescription/index.tsx
web/src/pages/admin/Prescription/index.tsx
+0
-245
web/src/pages/admin/Statistics/index.tsx
web/src/pages/admin/Statistics/index.tsx
+0
-77
web/src/pages/admin/Users/index.tsx
web/src/pages/admin/Users/index.tsx
+0
-190
web/src/pages/doctor/AIAssist/index.tsx
web/src/pages/doctor/AIAssist/index.tsx
+0
-165
web/src/pages/doctor/Certification/index.tsx
web/src/pages/doctor/Certification/index.tsx
+0
-186
web/src/pages/doctor/ChronicReview/index.tsx
web/src/pages/doctor/ChronicReview/index.tsx
+0
-243
web/src/pages/doctor/Consult/AIPanel.tsx
web/src/pages/doctor/Consult/AIPanel.tsx
+0
-231
web/src/pages/doctor/Consult/ChatPanel.tsx
web/src/pages/doctor/Consult/ChatPanel.tsx
+0
-228
web/src/pages/doctor/Consult/PatientList.tsx
web/src/pages/doctor/Consult/PatientList.tsx
+0
-172
web/src/pages/doctor/Consult/WaitingQueue.tsx
web/src/pages/doctor/Consult/WaitingQueue.tsx
+0
-198
web/src/pages/doctor/Consult/index.tsx
web/src/pages/doctor/Consult/index.tsx
+0
-235
web/src/pages/doctor/ConsultRoom/index.tsx
web/src/pages/doctor/ConsultRoom/index.tsx
+0
-166
web/src/pages/doctor/History/index.tsx
web/src/pages/doctor/History/index.tsx
+0
-154
web/src/pages/doctor/Income/index.tsx
web/src/pages/doctor/Income/index.tsx
+0
-262
web/src/pages/doctor/PatientRecord/index.tsx
web/src/pages/doctor/PatientRecord/index.tsx
+0
-237
web/src/pages/doctor/Prescription/index.tsx
web/src/pages/doctor/Prescription/index.tsx
+0
-290
web/src/pages/doctor/Profile/index.tsx
web/src/pages/doctor/Profile/index.tsx
+0
-102
web/src/pages/doctor/Queue/index.tsx
web/src/pages/doctor/Queue/index.tsx
+0
-125
web/src/pages/doctor/Schedule/index.tsx
web/src/pages/doctor/Schedule/index.tsx
+0
-159
web/src/pages/doctor/Workbench/index.tsx
web/src/pages/doctor/Workbench/index.tsx
+0
-187
web/src/pages/patient/Chronic/index.tsx
web/src/pages/patient/Chronic/index.tsx
+0
-532
web/src/pages/patient/ConsultCreate/index.tsx
web/src/pages/patient/ConsultCreate/index.tsx
+0
-179
web/src/pages/patient/ConsultList/index.tsx
web/src/pages/patient/ConsultList/index.tsx
+0
-181
web/src/pages/patient/DoctorDetail/index.tsx
web/src/pages/patient/DoctorDetail/index.tsx
+0
-112
web/src/pages/patient/Doctors/index.tsx
web/src/pages/patient/Doctors/index.tsx
+0
-143
web/src/pages/patient/HealthRecords/index.tsx
web/src/pages/patient/HealthRecords/index.tsx
+0
-358
web/src/pages/patient/Home/index.tsx
web/src/pages/patient/Home/index.tsx
+0
-157
web/src/pages/patient/Payment/index.tsx
web/src/pages/patient/Payment/index.tsx
+0
-51
web/src/pages/patient/PharmacyOrder/index.tsx
web/src/pages/patient/PharmacyOrder/index.tsx
+0
-51
web/src/pages/patient/PreConsult/index.tsx
web/src/pages/patient/PreConsult/index.tsx
+0
-736
web/src/pages/patient/Prescription/index.tsx
web/src/pages/patient/Prescription/index.tsx
+0
-190
web/src/pages/patient/Profile/index.tsx
web/src/pages/patient/Profile/index.tsx
+0
-93
web/src/pages/patient/TextConsult/index.tsx
web/src/pages/patient/TextConsult/index.tsx
+0
-212
web/src/pages/patient/VideoConsult/index.tsx
web/src/pages/patient/VideoConsult/index.tsx
+0
-101
No files found.
web/.claude/settings.local.json
0 → 100644
View file @
d2f9a49c
{
"permissions"
:
{
"allow"
:
[
"Bash(xargs grep:*)"
,
"Bash(cat:*)"
,
"Bash(python3:*)"
,
"Bash(python:*)"
]
}
}
web/src/pages/admin/AIConfig/index.tsx
deleted
100644 → 0
View file @
e15d3424
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
;
// 模型提供商选项(DeepSeek 优先?const providerOptions = [
{
label
:
'
DeepSeek
'
,
value
:
'
deepseek
'
},
{
label
:
'
OpenAI
'
,
value
:
'
openai
'
},
{
label
:
'
阿里通义千问
'
,
value
:
'
qwen
'
},
{
label
:
'
百度文心一言
'
,
value
:
'
baidu
'
},
{
label
:
'
本地部署模型
'
,
value
:
'
local
'
},
];
// 各提供商的默?Base URL 和可选模?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
'
},
{
label
:
'
GPT-3.5 Turbo
'
,
value
:
'
gpt-3.5-turbo
'
},
],
},
qwen
:
{
baseURL
:
'
https://dashscope.aliyuncs.com/compatible-mode
'
,
models
:
[
{
label
:
'
Qwen-Max
'
,
value
:
'
qwen-max
'
},
{
label
:
'
Qwen-Plus
'
,
value
:
'
qwen-plus
'
},
{
label
:
'
Qwen-Turbo
'
,
value
:
'
qwen-turbo
'
},
],
},
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
'
},
],
},
};
interface SafeWord {
id: string;
word: string;
category: string;
action:
'
block
'
|
'
warn
'
|
'
replace
'
;
replacement?: string;
}
const mockSafeWords: SafeWord[] = [
{ id:
'
1
'
, word:
'
自杀
'
, category:
'
心理危机
'
, action:
'
block
'
},
{ id:
'
2
'
, word:
'
自残
'
, category:
'
心理危机
'
, action:
'
block
'
},
{ id:
'
3
'
, word:
'
处方药购
,
category
:
'
违规用药
'
,
action
:
'
warn
'
},
{
id
:
'
4
'
,
word
:
'
毒品
'
,
category
:
'
违禁用, action:
'
block
'
},
{ id:
'
5
'
, word:
'
未经验证的偏
,
category
:
'
不实信息
'
,
action
:
'
replace
'
,
replacement
:
'
请咨询专业医生获取科学的治疗建议
'
},
];
const
AdminAIConfigPage
:
React
.
FC
=
()
=>
{
const
[
modelForm
]
=
Form
.
useForm
();
const
[
templateModalVisible
,
setTemplateModalVisible
]
=
useState
(
false
);
const
[
safeWordModalVisible
,
setSafeWordModalVisible
]
=
useState
(
false
);
const
[
templateForm
]
=
Form
.
useForm
();
const
[
safeWordForm
]
=
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
[
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
);
// 加载AI配置
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
:
''
,
// 不回显脱敏的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
(
error
)
{
console
.
error
(
'
加载AI配置失败:
'
,
error
);
//
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
();
},
[]);
// 获取AI日志
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 配置已保存并生效
'
);
// 清空API Key输入框(已保存)
modelForm
.
setFieldValue
(
'
api_key
'
,
''
);
}
catch
(
error
:
any
)
{
if
(
error
?.
errorFields
)
return
;
message
.
error
(
'
保存失败:
'
+
(
error
?.
message
||
'
请稍后重试));
} finally {
setSaving(false);
}
};
// 加载Prompt模板列表
const fetchTemplates = async () => {
setTemplatesLoading(true);
try {
const res = await adminApi.getPromptTemplates();
setTemplates(res.data || []);
} catch (error) {
console.error(
'
加载
Prompt
模板失败
:
'
, error);
} 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 (error) {
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 handleSaveSafeWord = () => {
safeWordForm.validateFields().then(() => {
message.success(
'
安全词已添加
'
);
setSafeWordModalVisible(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) => {
const sceneLabels: Record<string, string> = {
pre_consult_chat:
'
预问诊对
,
pre_consult_analysis
:
'
预问诊报告,
consult_diagnosis:
'
鉴别诊断
'
,
consult_medication:
'
用药建议
'
,
prescription_review:
'
处方审核
'
,
};
return <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
safeWordColumns
=
[
{
title
:
'
关键, dataIndex:
'
word
'
, key:
'
word
'
, render: (v: string) => <Text strong>{v}</Text> },
{ title:
'
分类
'
, dataIndex:
'
category
'
, key:
'
category
'
, width: 120, render: (v: string) => <Tag>{v}</Tag> },
{
title:
'
触发动作
'
, dataIndex:
'
action
'
, key:
'
action
'
, width: 120,
render: (v: string) => {
const map: Record<string, { color: string; text: string }> = {
block: { color:
'
red
'
, text:
'
拦截
'
},
warn: { color:
'
orange
'
, text:
'
预警
'
},
replace: { color:
'
blue
'
, text:
'
替换
'
},
};
const s = map[v] || { color:
'
default
'
, text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title:
'
替换内容
'
, dataIndex:
'
replacement
'
, key:
'
replacement
'
,
render: (v: string) => v ? <Text type="secondary">{v}</Text> :
'
-
'
,
},
{
title:
'
操作
'
, key:
'
action_btn
'
, width: 100,
render: () => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</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 功能(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;如需更新请输入新的 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} placeholder="0.0 - 2.0" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="Max Tokens" name="max_tokens">
<InputNumber style={{ width:
'
100
%
'
}} min={256} max={32768} step={256} placeholder="最大输出 token 数 />
</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>
<Card title="配置说明" style={{ borderRadius: 12, marginTop: 16 }} size="small">
<div style={{ fontSize: 13 }}>
<p><Text strong>DeepSeek</Text>:国产大模型,性价比高,推荐用于医疗场</p>
<p><Text strong>OpenAI</Text>:GPT系列,英文能力强</p>
<p><Text strong>通义千问</Text>:阿里云大模型,中文能力优秀</p>
<p><Text strong>Temperature</Text>:越低输出越稳定,医疗场景建议 0.1-0.3</p>
</div>
</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 模板,控制 AI 输出行为</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: (
<div>
<Alert
message="安全词过滤规则用于防止 AI 输出不安全或不合规的内容"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Card style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display:
'
flex
'
, justifyContent:
'
space
-
between
'
}}>
<Space>
<Input placeholder="搜索关键 prefix={<ExclamationCircleOutlined />} style={{ width: 200 }} allowClear />
<Select placeholder="分类" style={{ width: 120 }} allowClear options={[
{ label:
'
心理危机
'
, value:
'
心理危机
'
},
{ label:
'
违规用药
'
, value:
'
违规用药
'
},
{ label:
'
违禁用
,
value
:
'
违禁用 },
{ label:
'
不实信息
'
, value:
'
不实信息
'
},
]} />
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
safeWordForm.resetFields();
setSafeWordModalVisible(true);
}}>
添加安全 </Button>
</div>
<Table columns={safeWordColumns} dataSource={mockSafeWords} rowKey="id" pagination={false} />
</Card>
</div>
),
},
{
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={[
{ label:
'
预问诊对
,
value
:
'
pre_consult_chat
'
},
{
label
:
'
预问诊报告, value:
'
pre_consult_analysis
'
},
{ label:
'
鉴别诊断
'
, value:
'
consult_diagnosis
'
},
{ label:
'
用药建议
'
, value:
'
consult_medication
'
},
]}
/>
<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) => {
const sceneLabels: Record<string, string> = {
pre_consult_chat:
'
预问诊对
,
pre_consult_analysis
:
'
预问诊报告,
consult_diagnosis:
'
鉴别诊断
'
,
consult_medication:
'
用药建议
'
,
};
return <Tag color="blue">{sceneLabels[v] || v}</Tag>;
},
},
{ title:
'
模型
'
, dataIndex:
'
model
'
, key:
'
model
'
, width: 140 },
{
title:
'
提示
,
dataIndex
:
'
prompt
'
,
key
:
'
prompt
'
,
render
:
(
v
:
string
)
=>
{
//
const
lines
=
v
?.
split
(
'
\n
'
)
||
[];
const
firstLine
=
lines
.
find
(
line
=>
line
.
trim
()
&&
!
line
.
startsWith
(
'
#
'
))
||
lines
[
0
]
||
''
;
return
(
<
Tooltip
title=
{
v
}
>
<
Text
ellipsis
style=
{
{
maxWidth
:
200
,
display
:
'
block
'
}
}
>
{
firstLine
||
'
-
'
}
</
Text
>
</
Tooltip
>
);
},
},
{
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"
style=
{
{
fontSize
:
12
}
}
>
{
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>
),
},
]}
/>
{/* Prompt 模板弹窗 */}
<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="唯一标识,代码中通过此Key取值,? 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=
{
[
{
label
:
'
预问诊对, value:
'
pre_consult_chat
'
},
{ label:
'
预问诊报告
,
value
:
'
pre_consult_analysis
'
},
{
label
:
'
鉴别诊断
'
,
value
:
'
consult_diagnosis
'
},
{
label
:
'
用药建议
'
,
value
:
'
consult_medication
'
},
{
label
:
'
处方审核
'
,
value
:
'
prescription_review
'
},
]
}
/>
</
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
>
<
Alert
message=
'提示:同一场景可创建多个模板,但只有一个“启用”状态的模板会生效。点击“激活”按钮可切换当前场景使用的模板
type="info"
showIcon
style={{ marginBottom: 0 }}
/>
</Form>
</Modal>
{/* 安全词弹窗*/}
<Modal
title="添加安全
open={safeWordModalVisible}
onOk={handleSaveSafeWord}
onCancel={() => setSafeWordModalVisible(false)}
okText="保存"
>
<Form form={safeWordForm} layout="vertical">
<Form.Item label="关键 name="word" rules={[{ required: true }]}>
<Input placeholder="请输入需过滤的关键词" />
</Form.Item>
<Form.Item label="分类" name="category" rules={[{ required: true }]}>
<Select placeholder="选择分类" options={[
{ label: '
心理危机',
value
:
'心理危机'
},
{
label
:
'违规用药',
value
:
'违规用药'
},
{
label
:
'违禁用,
value
:
'违禁用
},
{
label
:
'不实信息',
value
:
'不实信息'
},
]}
/>
<
/
Form
.
Item
>
<
Form
.
Item
label=
"触发动作"
name=
"action"
rules=
{
[{
required
:
true
}]
}
>
<
Select
placeholder=
"选择动作"
options=
{
[
{
label
:
'
拦截(阻止输出)
'
,
value
:
'
block
'
},
{
label
:
'
预警(允许但标记录, value:
'
warn
'
},
{ label:
'
替换(输出替换文本)
'
, value:
'
replace
'
},
]
}
/>
</
Form
.
Item
>
<
Form
.
Item
label=
"替换内容"
name=
"replacement"
>
<
Input
placeholder=
"仅替换动作时需要填 />
</Form.Item>
</Form>
</Modal>
{/* AI日志详情弹窗 */}
<Modal
title="
AI
调用详情"
open=
{
logDetailVisible
}
onCancel=
{
()
=>
setLogDetailVisible
(
false
)
}
footer=
{
[
<
Button
key=
"close"
onClick=
{
()
=>
setLogDetailVisible
(
false
)
}
>
关闭
</
Button
>
]
}
width=
{
800
}
>
{
logDetailItem
&&
(
<
div
>
<
Space
direction=
"vertical"
size=
{
16
}
style=
{
{
width
:
'
100%
'
}
}
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
8
}
>
<
Text
type=
"secondary"
>
场景
</
Text
>
<
Text
strong
>
{
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
>
{
logDetailItem
.
is_mock
&&
<
Tag
color=
"default"
>
模拟
</
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
>
<
Row
gutter=
{
16
}
>
<
Col
span=
{
12
}
>
<
Text
type=
"secondary"
>
响应时间
</
Text
>
<
Text
>
{
logDetailItem
.
response_time_ms
?
(
logDetailItem
.
response_time_ms
/
1000
).
toFixed
(
2
)
+
'
s
'
:
'
-
'
}
</
Text
>
</
Col
>
<
Col
span=
{
12
}
>
<
Text
type=
"secondary"
>
调用时间
</
Text
>
<
Text
>
{
dayjs
(
logDetailItem
.
created_at
).
format
(
'
YYYY-MM-DD HH:mm:ss
'
)
}
</
Text
>
</
Col
>
</
Row
>
<
Divider
style=
{
{
margin
:
'
8px 0
'
}
}
/>
<
div
>
<
Text
type=
"secondary"
>
系统提示词:
</
Text
>
<
Card
size=
"small"
style=
{
{
marginTop
:
8
,
maxHeight
:
250
,
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
:
150
,
overflow
:
'
auto
'
}
}
>
<
pre
style=
{
{
margin
:
0
,
whiteSpace
:
'
pre-wrap
'
,
wordBreak
:
'
break-word
'
}
}
>
{
logDetailItem
.
request_content
||
'
?
}
</
pre
>
</
Card
>
</
div
>
<
div
>
<
Text
type=
"secondary"
>
响应内容
</
Text
>
<
Card
size=
"small"
style=
{
{
marginTop
:
8
,
maxHeight
:
300
,
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
>
</
div
>
)
}
</
Modal
>
</
div
>
);
};
export default AdminAIConfigPage;
web/src/pages/admin/Admins/index.tsx
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
'
,
suffix
:
<
span
className=
"text-[11px] text-gray-400 ml-1"
>
?/span
>
},
{
title
:
'
今日收入
'
,
value
:
stats
.
revenue_today
/
100
,
icon
:
<
DollarOutlined
/>
,
color
:
'
#fa8c16
'
,
bg
:
'
#fff7e6
'
,
suffix
:
<
span
className
=
"
text-[11px] text-gray-400 ml-1
"
>
?
/span>
}
,
].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
deleted
100644 → 0
View file @
e15d3424
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Tag
,
Typography
,
Space
,
Button
,
Modal
,
Form
,
Input
,
InputNumber
,
message
}
from
'
antd
'
;
import
{
PlusOutlined
,
EditOutlined
,
DeleteOutlined
,
ReloadOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
import
type
{
Department
}
from
'
../../../api/doctor
'
;
const
{
Title
,
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
(
error
)
{
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 (error) {
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 (error) {
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="请输入moji图标" />
</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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Input
,
Button
,
Space
,
Tag
,
Avatar
,
Modal
,
message
,
Typography
,
Form
,
Image
,
Descriptions
}
from
'
antd
'
;
import
{
SearchOutlined
,
ReloadOutlined
,
PlusOutlined
,
MedicineBoxOutlined
,
EyeOutlined
,
CheckOutlined
,
CloseOutlined
,
EditOutlined
,
DeleteOutlined
}
from
'
@ant-design/icons
'
;
import
type
{
ColumnsType
}
from
'
antd/es/table
'
;
import
{
adminApi
}
from
'
../../../api/admin
'
;
const
{
Title
,
Text
}
=
Typography
;
interface
DoctorItem
{
user_id
:
string
;
doctor_id
:
string
;
name
:
string
;
phone
:
string
;
gender
?:
string
;
age
?:
number
;
status
:
'
not_submitted
'
|
'
pending
'
|
'
approved
'
|
'
rejected
'
;
is_verified
:
boolean
;
user_status
:
'
active
'
|
'
disabled
'
;
license_no
:
string
;
title
:
string
;
hospital
:
string
;
department_name
:
string
;
rating
:
number
;
consult_count
:
number
;
submitted_at
?:
string
;
review_id
:
string
;
license_image
:
string
;
qualification_image
:
string
;
created_at
:
string
;
}
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
[
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
[
editingDoctor
,
setEditingDoctor
]
=
useState
<
DoctorItem
|
null
>
(
null
);
const
fetchDoctors
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getDoctorManageList
({
keyword
,
page
,
page_size
:
pageSize
});
setDoctors
(
res
.
data
.
list
as
unknown
as
DoctorItem
[]
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
(
error
)
{
message
.
error
(
'
获取医生列表失败
'
);
}
finally
{
setLoading
(
false
);
}
};
useEffect
(()
=>
{
fetchDoctors
();
},
[
page
,
pageSize
]);
const
handleSearch
=
()
=>
{
setPage
(
1
);
fetchDoctors
();
};
const
[
reviewModalVisible
,
setReviewModalVisible
]
=
useState
(
false
);
const
[
currentDoctor
,
setCurrentDoctor
]
=
useState
<
DoctorItem
|
null
>
(
null
);
const
[
rejectReason
,
setRejectReason
]
=
useState
(
''
);
const
handleViewReview
=
(
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
(
error
)
{
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 (error) {
message.error(
'
重置失败
'
);
}
},
});
};
const getStatusTag = (status: string) => {
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 config = statusMap[status] || { color:
'
default
'
, text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const handleEditDoctor = (record: DoctorItem) => {
setEditingDoctor(record);
editForm.setFieldsValue({
real_name: record.name,
phone: record.phone,
title: record.title,
hospital: record.hospital,
});
setEditModalVisible(true);
};
const handleEditSubmit = async (values: any) => {
if (!editingDoctor) return;
setEditLoading(true);
try {
await adminApi.updateDoctor(editingDoctor.user_id, {
real_name: values.real_name,
phone: values.phone,
title: values.title,
hospital: values.hospital,
introduction: values.introduction,
});
message.success(
'
医生信息更新成功
'
);
setEditModalVisible(false);
editForm.resetFields();
fetchDoctors();
} catch (error) {
message.error(
'
更新失败
'
);
} finally {
setEditLoading(false);
}
};
const handleDeleteDoctor = (record: DoctorItem) => {
Modal.confirm({
title:
'
确认删除该医生?
'
,
content: `医生「{record.name}」,删除后不可恢复`,
okType:
'
danger
'
,
onOk: async () => {
try {
await adminApi.deleteDoctor(record.user_id);
message.success(
'
删除成功
'
);
fetchDoctors();
} catch (error) {
message.error(
'
删除失败
'
);
}
},
});
};
const handleAddDoctor = async (values: any) => {
setAddLoading(true);
try {
await adminApi.createDoctor({
phone: values.phone,
real_name: values.real_name,
gender: values.gender,
age: values.age,
password: values.password ||
'
123456
'
,
license_no: values.license_no,
title: values.title,
department_id: values.department_id ||
''
,
hospital: values.hospital,
introduction: values.introduction,
specialties: values.specialties ? values.specialties.split(
'
,
'
) : [],
});
message.success(
'
医生添加成功
'
);
setAddModalVisible(false);
addForm.resetFields();
fetchDoctors();
} catch (error) {
message.error(
'
添加失败
'
);
} finally {
setAddLoading(false);
}
};
const columns: ColumnsType<DoctorItem> = [
{
title:
'
医生
'
,
key:
'
doctor
'
,
width: 200,
render: (_, record) => (
<Space>
<Avatar icon={<MedicineBoxOutlined />} style={{ backgroundColor:
'
#
52
c41a
'
}} />
<div>
<Text strong>{record.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
</div>
</Space>
),
},
{
title:
'
性别
'
,
dataIndex:
'
gender
'
,
key:
'
gender
'
,
width: 80,
render: (gender: string) => gender ||
'
-
'
,
},
{
title:
'
年龄
'
,
dataIndex:
'
age
'
,
key:
'
age
'
,
width: 80,
render: (age: number) => age ||
'
-
'
,
},
{
title:
'
职称
'
,
dataIndex:
'
title
'
,
key:
'
title
'
,
width: 120,
render: (title: string) => title ||
'
-
'
,
},
{
title:
'
科室
'
,
dataIndex:
'
department_name
'
,
key:
'
department_name
'
,
width: 120,
render: (dept: string) => dept ||
'
-
'
,
},
{
title:
'
医院
'
,
dataIndex:
'
hospital
'
,
key:
'
hospital
'
,
ellipsis: true,
render: (hospital: string) => hospital ||
'
-
'
,
},
{
title:
'
认证状态
,
dataIndex
:
'
status
'
,
key
:
'
status
'
,
width
:
120
,
render
:
(
status
:
string
)
=>
getStatusTag
(
status
),
},
{
title
:
'
账号状态,
dataIndex:
'
user_status
'
,
key:
'
user_status
'
,
width: 100,
render: (status: string) => (
<Tag color={status ===
'
active
'
?
'
green
'
:
'
red
'
}>
{status ===
'
active
'
?
'
正常
'
:
'
已禁
}
<
/
Tag
>
),
},
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
300
,
render
:
(
_
,
record
)
=>
(
<
Space
>
{
record
.
status
===
'
pending
'
&&
(
<
Button
type=
"link"
size=
"small"
icon=
{
<
EyeOutlined
/>
}
onClick=
{
()
=>
handleViewReview
(
record
)
}
>
审核
</
Button
>
)
}
{
record
.
status
===
'
approved
'
&&
(
<
Button
type=
"link"
size=
"small"
danger=
{
record
.
user_status
===
'
active
'
}
onClick=
{
()
=>
handleToggleStatus
(
record
)
}
>
{
record
.
user_status
===
'
active
'
?
'
禁用
'
:
'
启用
'
}
</
Button
>
)
}
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEditDoctor
(
record
)
}
>
编辑
</
Button
>
<
Button
type=
"link"
size=
"small"
onClick=
{
()
=>
handleResetPassword
(
record
)
}
>
重置密码
</
Button
>
<
Button
type=
"link"
size=
"small"
danger
icon=
{
<
DeleteOutlined
/>
}
onClick=
{
()
=>
handleDeleteDoctor
(
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
);
fetchDoctors
();
}
}
>
重置
</
Button
>
</
div
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
()
=>
setAddModalVisible
(
true
)
}
>
添加医生
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
doctors
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
:
pageSize
,
total
:
total
,
showTotal
:
(
t
)
=>
`?${t} 条`
,
size
:
'
small
'
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
<
Modal
title=
"添加医生"
open=
{
addModalVisible
}
onCancel=
{
()
=>
setAddModalVisible
(
false
)
}
footer=
{
null
}
width=
{
500
}
>
<
Form
form=
{
addForm
}
layout=
"vertical"
size=
"small"
onFinish=
{
handleAddDoctor
}
>
<
Form
.
Item
name=
"phone"
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
name=
"license_no"
label=
"执业证号"
rules=
{
[{
required
:
true
,
message
:
'
请输入执业证书 }]
}
>
<
Input
placeholder=
"执业证号"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"title"
label=
"职称"
rules=
{
[{
required
:
true
,
message
:
'
请选择职称
'
}]
}
>
<
Input
placeholder=
"如:主治医师"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"hospital"
label=
"所属医院 rules={[{ required: true, message: '请输入医院 }]}>
<Input placeholder="
所属医院
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"introduction"
label=
"简>
<Input.TextArea rows={3} placeholder="
医生简介
/>
</
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=
{
reviewModalVisible
}
onCancel=
{
()
=>
{
setReviewModalVisible
(
false
);
setRejectReason
(
''
);
}
}
footer=
{
null
}
width=
{
700
}
>
{
currentDoctor
&&
(
<
div
>
<
Descriptions
column=
{
2
}
bordered
>
<
Descriptions
.
Item
label=
"姓名"
>
{
currentDoctor
.
name
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"手机>
{
currentDoctor
.
phone
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"执业证号"
>
{
currentDoctor
.
license_no
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"职称"
>
{
currentDoctor
.
title
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"所属医院 span=
{
2
}
>
{
currentDoctor
.
hospital
}
</
Descriptions
.
Item
>
<
Descriptions
.
Item
label=
"科室"
span=
{
2
}
>
{
currentDoctor
.
department_name
}
</
Descriptions
.
Item
>
</
Descriptions
>
{
(
currentDoctor
.
license_image
||
currentDoctor
.
qualification_image
)
&&
(
<
div
style=
{
{
marginTop
:
16
}
}
>
<
Text
strong
>
证照材料
</
Text
>
<
div
style=
{
{
marginTop
:
8
}
}
>
<
Space
>
{
currentDoctor
.
license_image
&&
(
<
Image
width=
{
200
}
src=
{
currentDoctor
.
license_image
}
placeholder
fallback=
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
/>
)
}
{
currentDoctor
.
qualification_image
&&
(
<
Image
width=
{
200
}
src=
{
currentDoctor
.
qualification_image
}
placeholder
fallback=
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
/>
)
}
</
Space
>
</
div
>
</
div
>
)
}
<
div
style=
{
{
marginTop
:
24
}
}
>
<
Space
direction=
"vertical"
style=
{
{
width
:
'
100%
'
}
}
>
<
Input
.
TextArea
rows=
{
3
}
placeholder=
"拒绝原因(仅拒绝时需要填写)"
value=
{
rejectReason
}
onChange=
{
(
e
)
=>
setRejectReason
(
e
.
target
.
value
)
}
/>
<
Space
>
<
Button
type=
"primary"
icon=
{
<
CheckOutlined
/>
}
onClick=
{
handleApprove
}
>
审核通过
</
Button
>
<
Button
danger
icon=
{
<
CloseOutlined
/>
}
onClick=
{
handleReject
}
>
拒绝
</
Button
>
<
Button
onClick=
{
()
=>
{
setReviewModalVisible
(
false
);
setRejectReason
(
''
);
}
}
>
取消
</
Button
>
</
Space
>
</
Space
>
</
div
>
</
div
>
)
}
</
Modal
>
<
Modal
title=
"编辑医生"
open=
{
editModalVisible
}
onCancel=
{
()
=>
setEditModalVisible
(
false
)
}
footer=
{
null
}
width=
{
600
}
>
<
Form
form=
{
editForm
}
layout=
"vertical"
onFinish=
{
handleEditSubmit
}
>
<
Form
.
Item
name=
"real_name"
label=
"姓名"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名 }]
}
>
<
Input
placeholder=
"医生姓名"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"phone"
label=
"手机构 rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="
手机构
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"title"
label=
"职称"
>
<
Input
placeholder=
"如:主治医师"
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"hospital"
label=
"所属医>
<Input placeholder="
所属医院
/>
</
Form
.
Item
>
<
Form
.
Item
name=
"introduction"
label=
"简>
<Input.TextArea rows={3} 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 AdminDoctorsPage;
web/src/pages/admin/Patients/index.tsx
deleted
100644 → 0
View file @
e15d3424
import
React
,
{
useState
,
useEffect
}
from
'
react
'
;
import
{
Card
,
Table
,
Input
,
Select
,
Button
,
Space
,
Tag
,
Avatar
,
Modal
,
message
,
Typography
,
Form
,
InputNumber
,
Row
,
Col
}
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
AdminPatientsPage
:
React
.
FC
=
()
=>
{
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
patients
,
setPatients
]
=
useState
<
UserInfo
[]
>
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
page
,
setPage
]
=
useState
(
1
);
const
[
pageSize
,
setPageSize
]
=
useState
(
10
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
statusFilter
,
setStatusFilter
]
=
useState
<
string
>
(
''
);
// 新增/编辑弹窗
const
[
modalVisible
,
setModalVisible
]
=
useState
(
false
);
const
[
modalType
,
setModalType
]
=
useState
<
'
add
'
|
'
edit
'
>
(
'
add
'
);
const
[
editingRecord
,
setEditingRecord
]
=
useState
<
UserInfo
|
null
>
(
null
);
const
[
modalForm
]
=
Form
.
useForm
();
const
[
modalLoading
,
setModalLoading
]
=
useState
(
false
);
const
fetchPatients
=
async
()
=>
{
setLoading
(
true
);
try
{
const
res
=
await
adminApi
.
getPatientList
({
keyword
,
status
:
statusFilter
,
page
,
page_size
:
pageSize
});
setPatients
(
res
.
data
.
list
||
[]);
setTotal
(
res
.
data
.
total
||
0
);
}
catch
(
error
)
{
message
.
error
(
'
获取患者列表失);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPatients();
}, [page, pageSize]);
const handleSearch = () => {
setPage(1);
fetchPatients();
};
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.updatePatientStatus(record.id, newStatus);
message.success(
'
操作成功
'
);
fetchPatients();
} catch (error) {
message.error(
'
操作失败
'
);
}
},
});
};
const handleResetPassword = (record: UserInfo) => {
Modal.confirm({
title:
'
确认重置密码码
,
content
:
`将重置患者
${
record
.
real_name
||
record
.
phone
}
的密码为默认密码`
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
resetPatientPassword
(
record
.
id
);
message
.
success
(
'
密码已重置为 123456
'
);
fetchPatients
();
}
catch
(
error
)
{
message
.
error
(
'
重置失败
'
);
}
},
});
};
const
handleAdd
=
()
=>
{
setModalType
(
'
add
'
);
setEditingRecord
(
null
);
modalForm
.
resetFields
();
setModalVisible
(
true
);
};
const
handleEdit
=
(
record
:
UserInfo
)
=>
{
setModalType
(
'
edit
'
);
setEditingRecord
(
record
);
modalForm
.
setFieldsValue
({
real_name
:
record
.
real_name
,
phone
:
record
.
phone
,
gender
:
record
.
gender
,
age
:
record
.
age
,
});
setModalVisible
(
true
);
};
const
handleDelete
=
(
record
:
UserInfo
)
=>
{
Modal
.
confirm
({
title
:
'
确认删除该患者?
'
,
content
:
`患者:
${
record
.
real_name
||
record
.
phone
}
,删除后不可恢复`
,
okType
:
'
danger
'
,
onOk
:
async
()
=>
{
try
{
await
adminApi
.
deletePatient
(
record
.
id
);
message
.
success
(
'
删除成功
'
);
fetchPatients
();
}
catch
(
error
)
{
message
.
error
(
'
删除失败
'
);
}
},
});
};
const
handleModalSubmit
=
async
()
=>
{
try
{
const
values
=
await
modalForm
.
validateFields
();
setModalLoading
(
true
);
if
(
modalType
===
'
add
'
)
{
await
adminApi
.
createPatient
({
phone
:
values
.
phone
,
password
:
values
.
password
||
'
123456
'
,
real_name
:
values
.
real_name
,
gender
:
values
.
gender
,
age
:
values
.
age
,
});
message
.
success
(
'
患者添加成);
} else if (editingRecord) {
await adminApi.updatePatient(editingRecord.id, {
real_name: values.real_name,
phone: values.phone,
gender: values.gender,
age: values.age,
});
message.success(
'
患者信息更新成功
);
}
setModalVisible
(
false
);
modalForm
.
resetFields
();
fetchPatients
();
}
catch
(
error
:
any
)
{
if
(
!
error
?.
errorFields
)
{
message
.
error
(
'
操作失败
'
);
}
}
finally
{
setModalLoading
(
false
);
}
};
const
columns
:
ColumnsType
<
UserInfo
>
=
[
{
title
:
'
患者,
key:
'
patient
'
,
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor:
'
#
1890
ff
'
}} />
<div>
<Text strong>{record.real_name ||
'
未填写
}<
/
Text
>
<
br
/>
<
Text
type=
"secondary"
style=
{
{
fontSize
:
12
}
}
>
{
record
.
phone
}
</
Text
>
</
div
>
<
/
Space
>
),
},
{
title
:
'
性别
'
,
dataIndex
:
'
gender
'
,
key
:
'
gender
'
,
width
:
80
,
render
:
(
v
:
string
)
=>
v
||
'
-
'
,
}
,
{
title
:
'
年龄
'
,
dataIndex
:
'
age
'
,
key
:
'
age
'
,
width
:
80
,
render
:
(
v
:
number
)
=>
v
||
'
-
'
,
}
,
{
title
:
'
实名认证
'
,
dataIndex
:
'
is_verified
'
,
key
:
'
is_verified
'
,
render
:
(
verified
:
boolean
)
=>
(
<
Tag
color=
{
verified
?
'
green
'
:
'
default
'
}
>
{
verified
?
'
已认证 :
'
未认证
}
</
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
:
(
time
:
string
)
=>
new
Date
(
time
).
toLocaleString
(
'
zh-CN
'
),
}
,
{
title
:
'
操作
'
,
key
:
'
action
'
,
width
:
260
,
render
:
(
_
,
record
)
=>
(
<
Space
>
<
Button
type=
"link"
size=
"small"
icon=
{
<
EditOutlined
/>
}
onClick=
{
()
=>
handleEdit
(
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=
{
()
=>
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
>
<
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
"
/>
<
Select
placeholder=
"状态
allowClear
size="
small
"
value=
{
statusFilter
||
undefined
}
onChange=
{
(
v
)
=>
setStatusFilter
(
v
||
''
)
}
style=
{
{
width
:
100
}
}
options=
{
[
{
label
:
'
正常
'
,
value
:
'
active
'
},
{
label
:
'
已禁用, value:
'
disabled
'
},
]
}
/>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
SearchOutlined
/>
}
onClick=
{
handleSearch
}
>
搜索
</
Button
>
<
Button
size=
"small"
icon=
{
<
ReloadOutlined
/>
}
onClick=
{
()
=>
{
setKeyword
(
''
);
setStatusFilter
(
''
);
setPage
(
1
);
fetchPatients
();
}
}
>
重置
</
Button
>
</
div
>
<
Button
size=
"small"
type=
"primary"
icon=
{
<
PlusOutlined
/>
}
onClick=
{
handleAdd
}
>
添加患
</
Button
>
</
div
>
</
Card
>
<
Card
size=
"small"
>
<
Table
columns=
{
columns
}
dataSource=
{
patients
}
rowKey=
"id"
loading=
{
loading
}
size=
"small"
pagination=
{
{
current
:
page
,
pageSize
:
pageSize
,
total
:
total
,
showTotal
:
(
t
)
=>
`?${t} 条`
,
size
:
'
small
'
,
onChange
:
(
p
,
ps
)
=>
{
setPage
(
p
);
setPageSize
(
ps
);
},
}
}
/>
</
Card
>
<
Modal
title=
{
modalType
===
'
add
'
?
'
添加患者 :
'
编辑患
}
open=
{
modalVisible
}
onOk=
{
handleModalSubmit
}
onCancel=
{
()
=>
setModalVisible
(
false
)
}
confirmLoading=
{
modalLoading
}
destroyOnClose
width=
{
440
}
>
<
Form
form=
{
modalForm
}
layout=
"vertical"
size=
"small"
>
<
Form
.
Item
name=
"real_name"
label=
"姓名"
rules=
{
[{
required
:
true
,
message
:
'
请输入姓名 }]
}
>
<
Input
placeholder=
"患者姓名 />
</Form.Item>
<Form.Item name="
phone
"
label=
"手机构 rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="
手机构
/>
</
Form
.
Item
>
{
modalType
===
'
add
'
&&
(
<
Form
.
Item
name=
"password"
label=
"初始密码"
>
<
Input
.
Password
placeholder=
"默认 123456"
/>
</
Form
.
Item
>
)
}
<
Row
gutter=
{
12
}
>
<
Col
span=
{
12
}
>
<
Form
.
Item
name=
"gender"
label=
"性别"
>
<
Select
placeholder=
"选择性别"
allowClear
>
<
Select
.
Option
value=
"?>?/Select.Option>
<Select.Option value="
?
>
?/Select.Option
>
</
Select
>
</
Form
.
Item
>
</
Col
>
<
Col
span=
{
12
}
>
<
Form
.
Item
name=
"age"
label=
"年龄"
>
<
InputNumber
style=
{
{
width
:
'
100%
'
}
}
min=
{
1
}
max=
{
150
}
placeholder=
"年龄"
/>
</
Form
.
Item
>
</
Col
>
</
Row
>
</
Form
>
</
Modal
>
</
div
>
);
};
export default AdminPatientsPage;
web/src/pages/admin/Pharmacy/index.tsx
deleted
100644 → 0
View file @
e15d3424
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
{
Title
,
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:
'
3
px
solid
#
1890
ff
'
}}>
<Statistic title="鑽搧鎬绘暟" value={stats.total_count} suffix="种 />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft:
'
3
px
solid
#
52
c41a
'
}}>
<Statistic title="库存充足" value={stats.normal_count} valueStyle={{ color:
'
#
52
c41a
'
}} suffix="种 />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft:
'
3
px
solid
#
fa8c16
'
}}>
<Statistic title="库存预警" value={stats.low_stock_count} valueStyle={{ color:
'
#
fa8c16
'
}} suffix="种 />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft:
'
3
px
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
'
?
'
2
px
solid
#
1890
ff
'
:
'
2
px
solid
#
52
c41a
'
}}>
<Text strong style={{ fontSize: 12, color: msg.role ===
'
user
'
?
'
#
1890
ff
'
:
'
#
52
c41a
'
}}>
{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
deleted
100644 → 0
View file @
e15d3424
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:
'
#
87
d068
'
}} />
<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 ?
'
#
52
c41a
'
: 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
deleted
100644 → 0
View file @
e15d3424
import
React
from
'
react
'
;
import
{
List
,
Typography
,
Tag
,
Space
,
Badge
,
Button
,
Card
}
from
'
antd
'
;
import
{
UserOutlined
,
ClockCircleOutlined
,
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
PatientList
:
React
.
FC
<
PatientListProps
>
=
({
patients
,
onSelectPatient
,
onAccept
,
onReject
,
selectedConsultId
})
=>
{
//
const
statusConfig
:
Record
<
string
,
{
color
:
string
;
bgColor
:
string
;
text
:
string
}
>
=
{
in_progress
:
{
color
:
'
#52c41a
'
,
bgColor
:
'
#f6ffed
'
,
text
:
'
杩涜涓 },
pending: { color:
'
#
1890
ff
'
, bgColor:
'
#
e6f7ff
'
, text:
'
待接诊
},
waiting
:
{
color
:
'
#1890ff
'
,
bgColor
:
'
#e6f7ff
'
,
text
:
'
待接诊 },
completed: { color:
'
#
8
c8c8c
'
, bgColor:
'
#
fafafa
'
, text:
'
已完成
},
};
//
const
formatWaitingTime
=
(
seconds
:
number
)
=>
{
if
(
seconds
<
60
)
return
`
${
seconds
}
秒;
if (seconds < 3600) return `
$
{
Math
.
floor
(
seconds
/
60
)}
分钟
`;
return `
$
{
Math
.
floor
(
seconds
/
3600
)}
小时
`;
};
return (
<Card
title={
<span className="text-xs">
<UserOutlined className="text-[#1890ff] mr-1" />
<Text strong className="text-xs!">患者列表</Text>
<Badge count={patients.length} style={{ backgroundColor: '#52c41a' }} className="ml-1" />
</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) => {
const config = statusConfig[patient.status] || statusConfig.pending;
const isSelected = patient.consult_id === selectedConsultId;
return (
<List.Item
key={patient.consult_id}
onClick={() => {
if (patient.status !== 'pending' && patient.status !== 'waiting') {
onSelectPatient(patient);
}
}}
style={{
cursor: (patient.status === 'pending' || patient.status === 'waiting') ? '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',
}}
onMouseEnter={(e) => {
if (!isSelected && patient.status !== 'pending' && patient.status !== 'waiting') {
e.currentTarget.style.background = '#fafafa';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.background = '#fff';
}
}}
>
<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>
{/* {patient.status === 'pending' || patient.status === 'waiting' ? (
<Space size={4} style={{ fontSize: 11, color: '#ff4d4f' }}>
<ClockCircleOutlined />
<span>等待 {formatWaitingTime(patient.waiting_seconds)}</span>
</Space>
) : null} */}
</div>
{/* 待接诊患者显示接诊/拒绝按钮 */}
{(patient.status === 'pending' || patient.status === 'waiting') && (
<div className="mt-1.5 flex gap-1.5">
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
console.log('接诊鎸夐挳袋偣鍑, patient);
console.log('onAccept 函数:', onAccept);
if (typeof onAccept === 'function') {
onAccept(patient);
} else {
console.error('onAccept 涓嶆槸涓€涓嚱鏁');
}
}}
className="flex-1 text-[11px]!"
>
接诊
</Button>
<Button
danger
size="small"
icon={<CloseOutlined />}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
console.log('拒绝鎸夐挳袋偣鍑, patient);
console.log('onReject 函数:', onReject);
if (typeof onReject === 'function') {
onReject(patient);
} else {
console.error('onReject 涓嶆槸涓€涓嚱鏁');
}
}}
className="flex-1 text-[11px]!"
>
拒绝
</Button>
</div>
)}
</div>
</List.Item>
);
}}
/>
</Card>
);
};
export default PatientList;
web/src/pages/doctor/Consult/WaitingQueue.tsx
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
(
100
vh
-
160
px
)
'
}}>
<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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
import
React
,
{
useState
,
useCallback
}
from
'
react
'
;
import
{
Input
,
Button
,
Table
,
Form
,
Select
,
InputNumber
,
Typography
,
Tag
,
Space
,
Modal
,
message
,
Divider
,
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 className="text-xs"><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
className=
"text-xs!"
>
鑽搧
</
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-sm! 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=
"text-xs font-semibold mb-1"
>
确认使用 CA 数字证书签署此处方?
</
div
>
<
Text
type=
"secondary"
className=
"text-xs!"
>
签署后处方将具有法律效力
</
Text
>
<
div
className=
"mt-3 p-2 bg-green-50 rounded text-xs"
>
<
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
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:
'
40
px
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=
"?个月问诊次数"
value=
{
total
.
consult
}
prefix=
{
<
MedicineBoxOutlined
/>
}
valueStyle=
{
{
color
:
'
#1677ff
'
}
}
/></
Card
></
Col
>
<
Col
span=
{
8
}
><
Card
><
Statistic
title=
"?个月检验报告 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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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:
'
16
px
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 ?
'
#
1890
ff
'
:
'
#
722
ed1
'
,
flexShrink: 0,
}}
/>
<div
style={{
maxWidth:
'
75
%
'
,
padding:
'
12
px
16
px
'
,
borderRadius: isUser ?
'
16
px
16
px
4
px
16
px
'
:
'
16
px
16
px
16
px
4
px
'
,
backgroundColor: isUser ?
'
#
1890
ff
'
:
'
#
f5f5f5
'
,
color: isUser ?
'
#
fff
'
:
'
#
333
'
,
fontSize: 15,
lineHeight: 1.7,
boxShadow:
'
0
1
px
2
px
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
'
:
'
#
722
ed1
'
}} />
</span>
)}
</div>
</div>
);
};
// 分析报告区域
const renderReport = () => {
if (!reportContent && !generating) return null;
return (
<div style={{ margin:
'
24
px
0
'
}}>
<Card
title={
<Space>
<FileTextOutlined style={{ color:
'
#
722
ed1
'
}} />
<span>AI 预问诊分析报</span>
{generating && <LoadingOutlined style={{ color:
'
#
722
ed1
'
}} />}
{reportDone && <Tag color="success">生成完成</Tag>}
</Space>
}
style={{
borderRadius: 12,
borderTop:
'
3
px
solid
#
722
ed1
'
,
}}
>
<div style={{ padding:
'
8
px
0
'
}}>
<MarkdownRenderer content={reportContent} fontSize={15} lineHeight={1.8} />
{generating && <LoadingOutlined style={{ marginLeft: 4, color:
'
#
722
ed1
'
}} />}
</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:
'
#
52
c41a
'
}} />
<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:
'
#
52
c41a
'
, borderColor:
'
#
52
c41a
'
}}
>
发起问诊
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar size={48} src={doctor.avatar} icon={<UserOutlined />} style={{ backgroundColor:
'
#
87
d068
'
}} />
}
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:
'
16
px
20
px
'
,
background:
'
linear
-
gradient
(
135
deg
,
#
722
ed1
0
%
,
#
9254
de
100
%
)
'
,
borderRadius:
'
12
px
12
px
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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
deleted
100644 → 0
View file @
e15d3424
'
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:
'
12
px
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 ?
'
#
722
ed1
'
:
'
#
1890
ff
'
, 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 ?
'
#
1890
ff
'
: isAI ?
'
#
f9f0ff
'
:
'
#
f5f5f5
'
,
color: isMe ?
'
#
fff
'
:
'
#
000
'
,
padding:
'
10
px
14
px
'
,
borderRadius: isMe ?
'
12
px
12
px
4
px
12
px
'
:
'
12
px
12
px
12
px
4
px
'
,
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:
'
#
52
c41a
'
}}
src={user?.avatar}
icon={<UserOutlined />}
/>
)}
</div>
);
};
return (
<div style={{ height:
'
calc
(
100
vh
-
120
px
)
'
}}>
<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
%
-
32
px
)
'
}}>
<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:
'
#
1890
ff
'
}} />
<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
deleted
100644 → 0
View file @
e15d3424
'
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