Commit d2f9a49c authored by yuguo's avatar yuguo

fix

parent e15d3424
{
"permissions": {
"allow": [
"Bash(xargs grep:*)",
"Bash(cat:*)",
"Bash(python3:*)",
"Bash(python:*)"
]
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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;
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;
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;
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;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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;
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;
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;
'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;
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/85mmHg',
},
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;
import React, { useState } from 'react';
import {
Card, Tabs, Typography, Space, Empty, Tag, Divider, Alert, Spin, Badge, Button,
} from 'antd';
import {
RobotOutlined, FileTextOutlined, UserOutlined,
MessageOutlined, MedicineBoxOutlined, ThunderboltOutlined,
} from '@ant-design/icons';
import type { PreConsultResponse, ChatMessage } from '../../../api/preConsult';
import { consultApi } from '../../../api/consult';
import MarkdownRenderer from '../../../components/MarkdownRenderer';
const { Text } = Typography;
interface AIPanelProps {
hasActiveConsult: boolean;
activeConsultId?: string;
preConsultReport: PreConsultResponse | null;
preConsultLoading: boolean;
}
const AIPanel: React.FC<AIPanelProps> = ({
hasActiveConsult,
activeConsultId,
preConsultReport,
preConsultLoading,
}) => {
const [diagnosisContent, setDiagnosisContent] = useState('');
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [medicationContent, setMedicationContent] = useState('');
const [medicationLoading, setMedicationLoading] = useState(false);
const [preConsultSubTab, setPreConsultSubTab] = useState('chat');
const handleAIAssist = async (scene: 'consult_diagnosis' | 'consult_medication') => {
if (!activeConsultId) return;
const setLoading = scene === 'consult_diagnosis' ? setDiagnosisLoading : setMedicationLoading;
const setContent = scene === 'consult_diagnosis' ? setDiagnosisContent : setMedicationContent;
setLoading(true);
try {
const res = await consultApi.aiAssist(activeConsultId, scene);
setContent(res.data?.content || '暂无分析结果');
} catch (err: any) {
setContent('AI分析失败: ' + (err?.message || '请稍后重试));
} finally {
setLoading(false);
}
};
const hasPreConsultData = preConsultReport && (
preConsultReport.ai_analysis || (preConsultReport.chat_messages && preConsultReport.chat_messages.length > 0)
);
// 渲染完整对话记录
const renderFullChatHistory = (chatMsgs: ChatMessage[]) => {
if (!chatMsgs || chatMsgs.length === 0) {
return <Alert message="暂无对话记录" type="info" showIcon style={{ fontSize: 12 }} />;
}
return (
<div style={{ maxHeight: 300, overflow: 'auto', padding: 8, background: '#f9f9f9', borderRadius: 6 }}>
<Text strong style={{ fontSize: 12, color: '#666', marginBottom: 8, display: 'block' }}>
<MessageOutlined style={{ marginRight: 4 }} />
对话记录(共{Math.floor(chatMsgs.length / 2)}轮)
</Text>
{chatMsgs.map((msg, i) => (
<div key={i} style={{ fontSize: 12, marginTop: 6, paddingLeft: 8, borderLeft: msg.role === 'user' ? '2px solid #1890ff' : '2px solid #52c41a' }}>
<Text strong style={{ fontSize: 12, color: msg.role === 'user' ? '#1890ff' : '#52c41a' }}>
{msg.role === 'user' ? '患者 : 'AI'}? </Text>
<div style={{ marginTop: 2, color: '#333' }}>{msg.content}</div>
</div>
))}
</div>
);
};
//
const renderPreConsultContent = () => {
if (!preConsultReport) return null;
const patientInfo = (
<div style={{ padding: 8, background: '#f9f0ff', borderRadius: 6, marginBottom: 8 }}>
<Text strong style={{ fontSize: 13, color: '#722ed1' }}>
<UserOutlined style={{ marginRight: 4 }} />
{preConsultReport.patient_name || '}
</Text>
{(preConsultReport.patient_gender || preConsultReport.patient_age) && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{preConsultReport.patient_gender} {preConsultReport.patient_age ? `· ${preConsultReport.patient_age}岁` : ''}
</Text>
)}
</div>
);
const tags = (preConsultReport.ai_severity || preConsultReport.ai_department) && (
<div style={{ display: 'flex', gap: 4, marginBottom: 8, flexWrap: 'wrap' }}>
{preConsultReport.ai_severity && (
<Tag color={preConsultReport.ai_severity === 'severe' ? 'red' : preConsultReport.ai_severity === 'moderate' ? 'orange' : 'green'}>
{preConsultReport.ai_severity === 'severe' ? '重度' : preConsultReport.ai_severity === 'moderate' ? '中度' : '轻度'}
</Tag>
)}
{preConsultReport.ai_department && <Tag color="blue">{preConsultReport.ai_department}</Tag>}
</div>
);
return (
<div>
{patientInfo}
{tags}
{preConsultReport.chief_complaint && (
<div style={{ fontSize: 12, marginBottom: 8 }}>
<Text strong>主诉</Text>{preConsultReport.chief_complaint}
</div>
)}
<Tabs
size="small"
activeKey={preConsultSubTab}
onChange={setPreConsultSubTab}
items={[
{
key: 'chat',
label: <span><MessageOutlined /> 对话记录</span>,
children: renderFullChatHistory(preConsultReport.chat_messages || []),
},
{
key: 'analysis',
label: <span><FileTextOutlined /> AI分析报告</span>,
children: preConsultReport.ai_analysis ? (
<div style={{ maxHeight: 300, overflow: 'auto', padding: 8, background: '#f6ffed', borderRadius: 6 }}>
<MarkdownRenderer content={preConsultReport.ai_analysis} fontSize={12} lineHeight={1.6} />
</div>
) : (
<Alert message="暂无AI分析报告" type="info" showIcon style={{ fontSize: 12 }} />
),
},
]}
/>
</div>
);
};
return (
<Card
title={
<Space>
<RobotOutlined style={{ color: '#52c41a' }} />
<span>AI 辅助</span>
</Space>
}
style={{ borderRadius: 12, height: '100%' }}
styles={{ body: { padding: 12 } }}
size="small"
>
{hasActiveConsult ? (
<Tabs
size="small"
defaultActiveKey={hasPreConsultData ? 'preConsult' : 'diagnosis'}
items={[
{
key: 'preConsult',
label: (
<span>
<FileTextOutlined />
{' '}预问诊 {hasPreConsultData && <Badge dot offset={[4, -2]} />}
</span>
),
children: preConsultLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="加载中.." /></div>
) : hasPreConsultData ? (
renderPreConsultContent()
) : (
<Alert message="该患者未进行AI预问诊 type="info" showIcon style={{ fontSize: 12 }} />
),
},
{
key: 'diagnosis',
label: '鉴别诊断',
children: diagnosisLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>
) : diagnosisContent ? (
<div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<MarkdownRenderer content={diagnosisContent} fontSize={12} lineHeight={1.6} />
</div>
<Divider style={{ margin: '8px 0' }} />
<Button size="small" icon={<ThunderboltOutlined />} onClick={() => handleAIAssist('consult_diagnosis')}>
重新分析
</Button>
</div>
) : (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
基于问诊对话内容,AI将辅助生成鉴别诊量 </Text>
<Button type="primary" size="small" icon={<ThunderboltOutlined />} onClick={() => handleAIAssist('consult_diagnosis')}>
生成鉴别诊断
</Button>
</div>
),
},
{
key: 'drugs',
label: '用药建议',
children: medicationLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>
) : medicationContent ? (
<div>
<div style={{ maxHeight: 400, overflow: 'auto' }}>
<MarkdownRenderer content={medicationContent} fontSize={12} lineHeight={1.6} />
</div>
<Divider style={{ margin: '8px 0' }} />
<Button size="small" icon={<MedicineBoxOutlined />} onClick={() => handleAIAssist('consult_medication')}>
重新生成
</Button>
</div>
) : (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
基于问诊对话内容,AI将辅助生成用药建 </Text>
<Button type="primary" size="small" icon={<MedicineBoxOutlined />} onClick={() => handleAIAssist('consult_medication')}>
生成用药建议
</Button>
</div>
),
},
]}
/>
) : (
<Empty description="接诊后自动开始AI分析" style={{ marginTop: 40 }} />
)}
</Card>
);
};
export default AIPanel;
import React, { useState, useEffect, useRef } from 'react';
import {
Card, Avatar, Tag, Button, Typography, Space, Empty, Input, Modal,
} from 'antd';
import {
UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined,
FileTextOutlined, RobotOutlined,
} from '@ant-design/icons';
import type { ConsultMessage } from '../../../api/consult';
import PrescriptionModal from '../Prescription';
const { Text } = Typography;
const { TextArea } = Input;
interface ActiveConsult {
consult_id: string;
patient_id?: string;
patient_name: string;
patient_gender?: string;
patient_age?: number;
type: 'text' | 'video';
}
interface ChatPanelProps {
activeConsult: ActiveConsult | null;
consultStatus?: string; //
messages: ConsultMessage[];
onSend: (content: string) => void;
onEndConsult: () => void;
onToggleAI: () => void;
aiPanelVisible: boolean;
sending: boolean;
}
const ChatPanel: React.FC<ChatPanelProps> = ({
activeConsult,
consultStatus,
messages,
onSend,
onEndConsult,
onToggleAI,
aiPanelVisible,
sending,
}) => {
const [inputValue, setInputValue] = useState('');
const [prescriptionOpen, setPrescriptionOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
if (!inputValue.trim()) return;
onSend(inputValue.trim());
setInputValue('');
};
const handleEndConsult = () => {
Modal.confirm({
title: '确认结束问诊',
content: '结束后患者将无法继续发送消息,确认结束本次问诊量,
okText: '确认结束',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: onEndConsult,
});
};
const formatTime = (timeStr: string) => {
try {
return new Date(timeStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
} catch {
return '';
}
};
const isCompleted = consultStatus === 'completed';
return (
<>
<Card
title={
activeConsult ? (
<Space>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
<Text strong>{activeConsult.patient_name}</Text>
<Tag color={activeConsult.type === 'video' ? 'blue' : 'green'}>
{activeConsult.type === 'video' ? '视频问诊' : '图文问诊'}
</Tag>
{isCompleted && <Tag color="default">已结束</Tag>}
</Space>
) : '问诊对话'
}
style={{ borderRadius: 12, height: '100%', display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }}
extra={
activeConsult && (
<Space>
<Button
type="text"
icon={<RobotOutlined />}
onClick={onToggleAI}
style={{ color: aiPanelVisible ? '#52c41a' : undefined }}
>
AI面板
</Button>
{activeConsult.type === 'video' && !isCompleted && (
<Button type="primary" icon={<VideoCameraOutlined />}>
发起视频
</Button>
)}
<Button
icon={<FileTextOutlined />}
onClick={() => setPrescriptionOpen(true)}
>
开处方
</Button>
{!isCompleted && (
<Button danger icon={<StopOutlined />} onClick={handleEndConsult}>
结束问诊
</Button>
)}
</Space>
)
}
>
{activeConsult ? (
<>
<div style={{ flex: 1, overflow: 'auto', padding: 16, background: '#f9f9f9' }}>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent: msg.sender_type === 'doctor' ? 'flex-end' : msg.sender_type === 'system' ? 'center' : 'flex-start',
marginBottom: 12,
}}
>
{msg.sender_type === 'system' ? (
<Tag color="default" style={{ fontSize: 12 }}>{msg.content}</Tag>
) : (
<div style={{ maxWidth: '70%' }}>
<div style={{
textAlign: msg.sender_type === 'doctor' ? 'right' : 'left',
marginBottom: 4,
}}>
<Text type="secondary" style={{ fontSize: 12 }}>
{msg.sender_type === 'doctor' ? '? : activeConsult.patient_name} {formatTime(msg.created_at)}
</Text>
</div>
<div style={{
padding: '8px 12px',
borderRadius: 8,
background: msg.sender_type === 'doctor' ? '#52c41a' : '#fff',
color: msg.sender_type === 'doctor' ? '#fff' : '#333',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{msg.content}
</div>
</div>
)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* 已结束时显示提示,进行中时显示输入框 */}
{isCompleted ? (
<div style={{
padding: '12px 16px',
borderTop: '1px solid #f0f0f0',
textAlign: 'center',
background: '#fafafa',
}}>
<Text type="secondary" style={{ fontSize: 13 }}>本次问诊已结束</Text>
</div>
) : (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}>
<Space.Compact style={{ width: '100%' }}>
<TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入消息..."
autoSize={{ minRows: 1, maxRows: 3 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
style={{ borderRadius: '8px 0 0 8px' }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
style={{ height: 'auto', borderRadius: '0 8px 8px 0', background: '#52c41a', borderColor: '#52c41a' }}
>
发送 </Button>
</Space.Compact>
</div>
)}
</>
) : (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请从左侧列表中选择患者接诊 />
</div>
)}
</Card>
<PrescriptionModal
open={prescriptionOpen}
onClose={() => setPrescriptionOpen(false)}
consultId={activeConsult?.consult_id}
patientId={activeConsult?.patient_id}
patientName={activeConsult?.patient_name}
patientGender={activeConsult?.patient_gender}
patientAge={activeConsult?.patient_age}
/>
</>
);
};
export default ChatPanel;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment