Commit 52e57c72 authored by yuguo's avatar yuguo

fix

parent d2f9a49c
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Row, Col, Typography, Space, Tabs, Form, Input, Select, Switch,
Button, Table, Tag, Modal, message, Divider, Alert, InputNumber, Spin,
DatePicker, Tooltip,
} from 'antd';
import {
RobotOutlined, SettingOutlined, FileTextOutlined, SafetyOutlined,
PlusOutlined, EditOutlined, DeleteOutlined, ExclamationCircleOutlined,
CheckCircleOutlined, LoadingOutlined, HistoryOutlined, ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { adminApi } from '../../../api/admin';
import type { AIUsageStats, PromptTemplateData, AILogItem } from '../../../api/admin';
const { Title, Text } = Typography;
const { TextArea } = Input;
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek' },
{ label: 'OpenAI', value: 'openai' },
{ label: '阿里通义千问', value: 'qwen' },
{ label: '百度文心一言', value: 'baidu' },
{ label: '本地部署模型', value: 'local' },
];
const providerDefaults: Record<string, { baseURL: string; models: { label: string; value: string }[] }> = {
deepseek: {
baseURL: 'https://api.deepseek.com',
models: [
{ label: 'DeepSeek-V3', value: 'deepseek-chat' },
{ label: 'DeepSeek-R1 (推理)', value: 'deepseek-reasoner' },
],
},
openai: {
baseURL: 'https://api.openai.com',
models: [
{ label: 'GPT-4o', value: 'gpt-4o' },
{ label: 'GPT-4o mini', value: 'gpt-4o-mini' },
{ label: 'GPT-4 Turbo', value: 'gpt-4-turbo' },
],
},
qwen: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode',
models: [
{ label: 'Qwen-Max', value: 'qwen-max' },
{ label: 'Qwen-Plus', value: 'qwen-plus' },
],
},
baidu: {
baseURL: 'https://aip.baidubce.com',
models: [
{ label: 'ERNIE-4.0', value: 'ernie-4.0-8k' },
{ label: 'ERNIE-3.5', value: 'ernie-3.5-8k' },
],
},
local: {
baseURL: 'http://localhost:11434',
models: [{ label: '自定义模型', value: 'custom' }],
},
};
const sceneLabels: Record<string, string> = {
pre_consult_chat: '预问诊对话',
pre_consult_analysis: '预问诊报告',
consult_diagnosis: '鉴别诊断',
consult_medication: '用药建议',
prescription_review: '处方审核',
};
const AdminAIConfigPage: React.FC = () => {
const [modelForm] = Form.useForm();
const [templateForm] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [currentProvider, setCurrentProvider] = useState('deepseek');
const [usageStats, setUsageStats] = useState<AIUsageStats | null>(null);
const [templates, setTemplates] = useState<PromptTemplateData[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [templateModalVisible, setTemplateModalVisible] = useState(false);
const [templateModalType, setTemplateModalType] = useState<'add' | 'edit'>('add');
const [editingTemplate, setEditingTemplate] = useState<PromptTemplateData | null>(null);
const [templateSaving, setTemplateSaving] = useState(false);
const [logData, setLogData] = useState<AILogItem[]>([]);
const [logTotal, setLogTotal] = useState(0);
const [logPage, setLogPage] = useState(1);
const [logPageSize, setLogPageSize] = useState(20);
const [logLoading, setLogLoading] = useState(false);
const [logSceneFilter, setLogSceneFilter] = useState<string | undefined>();
const [logSuccessFilter, setLogSuccessFilter] = useState<string | undefined>();
const [logDateRange, setLogDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>([dayjs(), dayjs()]);
const [logDetailVisible, setLogDetailVisible] = useState(false);
const [logDetailItem, setLogDetailItem] = useState<AILogItem | null>(null);
useEffect(() => {
const fetchConfig = async () => {
setLoading(true);
try {
const [configRes, statsRes] = await Promise.all([
adminApi.getAIConfig(),
adminApi.getAIUsageStats(),
]);
const cfg = configRes.data;
setCurrentProvider(cfg.provider || 'deepseek');
modelForm.setFieldsValue({
provider: cfg.provider || 'deepseek',
model: cfg.model || 'deepseek-chat',
api_key: '',
base_url: cfg.base_url || 'https://api.deepseek.com',
temperature: cfg.temperature || 0.3,
max_tokens: cfg.max_tokens || 2048,
enable_ai: cfg.enable_ai !== false,
});
setUsageStats(statsRes.data);
} catch {
modelForm.setFieldsValue({
provider: 'deepseek',
model: 'deepseek-chat',
base_url: 'https://api.deepseek.com',
temperature: 0.3,
max_tokens: 2048,
enable_ai: true,
});
} finally {
setLoading(false);
}
};
fetchConfig();
}, [modelForm]);
const fetchAILogs = useCallback(async () => {
setLogLoading(true);
try {
const res = await adminApi.getAILogs({
scene: logSceneFilter,
success: logSuccessFilter,
start_date: logDateRange?.[0]?.format('YYYY-MM-DD') || undefined,
end_date: logDateRange?.[1]?.format('YYYY-MM-DD') || undefined,
page: logPage,
page_size: logPageSize,
});
const result = res.data as any;
setLogData(result?.list || []);
setLogTotal(result?.total || 0);
} catch {
message.error('获取AI日志失败');
} finally {
setLogLoading(false);
}
}, [logSceneFilter, logSuccessFilter, logDateRange, logPage, logPageSize]);
useEffect(() => {
fetchAILogs();
}, [fetchAILogs]);
const handleProviderChange = (provider: string) => {
setCurrentProvider(provider);
const defaults = providerDefaults[provider];
if (defaults) {
modelForm.setFieldsValue({
base_url: defaults.baseURL,
model: defaults.models[0]?.value || '',
});
}
};
const handleSaveModel = async () => {
try {
const values = await modelForm.validateFields();
setSaving(true);
await adminApi.saveAIConfig({
provider: values.provider,
model: values.model,
api_key: values.api_key || '',
base_url: values.base_url,
max_tokens: values.max_tokens,
temperature: values.temperature,
enable_ai: values.enable_ai,
});
message.success('AI 配置已保存并生效');
modelForm.setFieldValue('api_key', '');
} catch (error: any) {
if (error?.errorFields) return;
message.error('保存失败: ' + (error?.message || '请稍后重试'));
} finally {
setSaving(false);
}
};
const fetchTemplates = async () => {
setTemplatesLoading(true);
try {
const res = await adminApi.getPromptTemplates();
setTemplates(res.data || []);
} catch {
console.error('加载Prompt模板失败');
} finally {
setTemplatesLoading(false);
}
};
useEffect(() => {
fetchTemplates();
}, []);
const handleAddTemplate = () => {
setTemplateModalType('add');
setEditingTemplate(null);
templateForm.resetFields();
templateForm.setFieldsValue({ status: 'active' });
setTemplateModalVisible(true);
};
const handleEditTemplate = (record: PromptTemplateData) => {
setTemplateModalType('edit');
setEditingTemplate(record);
templateForm.setFieldsValue({
template_key: record.template_key,
name: record.name,
scene: record.scene,
content: record.content,
status: record.status,
});
setTemplateModalVisible(true);
};
const handleDeleteTemplate = (record: PromptTemplateData) => {
Modal.confirm({
title: '确认删除该模板?',
content: `模板「${record.name}」(Key: ${record.template_key})`,
okType: 'danger',
onOk: async () => {
try {
await adminApi.deletePromptTemplate(record.id);
message.success('删除成功');
fetchTemplates();
} catch {
message.error('删除失败');
}
},
});
};
const handleSaveTemplate = async () => {
try {
const values = await templateForm.validateFields();
setTemplateSaving(true);
if (templateModalType === 'add') {
await adminApi.createPromptTemplate({
template_key: values.template_key,
name: values.name,
scene: values.scene,
content: values.content,
status: values.status || 'active',
});
message.success('Prompt 模板已创建');
} else if (editingTemplate) {
await adminApi.updatePromptTemplate(editingTemplate.id, {
template_key: values.template_key,
name: values.name,
scene: values.scene,
content: values.content,
status: values.status,
});
message.success('Prompt 模板已更新');
}
setTemplateModalVisible(false);
fetchTemplates();
} catch (error: any) {
if (!error?.errorFields) {
message.error('保存失败');
}
} finally {
setTemplateSaving(false);
}
};
const currentModelOptions = providerDefaults[currentProvider]?.models || [];
const templateColumns = [
{ title: '模板Key', dataIndex: 'template_key', key: 'template_key', width: 180, render: (v: string) => <Tag color="purple">{v}</Tag> },
{ title: '模板名称', dataIndex: 'name', key: 'name', render: (v: string) => <Text strong>{v}</Text> },
{
title: '应用场景', dataIndex: 'scene', key: 'scene', width: 130,
render: (v: string) => <Tag color="blue">{sceneLabels[v] || v}</Tag>,
},
{
title: 'Prompt 内容', dataIndex: 'content', key: 'content',
render: (v: string) => <Text type="secondary">{v && v.length > 50 ? v.substring(0, 50) + '...' : v}</Text>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{v === 'active' ? '启用' : '停用'}</Tag>,
},
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN') : '-' },
{
title: '操作', key: 'action', width: 180,
render: (_: unknown, record: PromptTemplateData) => (
<Space>
{record.status !== 'active' && (
<Button type="link" size="small" icon={<CheckCircleOutlined />} onClick={async () => {
try {
await adminApi.activatePromptTemplate(record.id);
message.success(`已激活模板「${record.name}」`);
fetchTemplates();
} catch { message.error('激活失败'); }
}}>激活</Button>
)}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditTemplate(record)}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDeleteTemplate(record)}>删除</Button>
</Space>
),
},
];
const formatTokens = (tokens: number) => {
if (tokens >= 1000000) return (tokens / 1000000).toFixed(1) + 'M';
if (tokens >= 1000) return (tokens / 1000).toFixed(1) + 'K';
return String(tokens);
};
return (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">
<RobotOutlined className="mr-1" />AI 配置
</h4>
<Tabs defaultActiveKey="model" size="small" items={[
{
key: 'model',
label: <span><SettingOutlined /> 模型选择</span>,
children: (
<Spin spinning={loading}>
<Row gutter={24}>
<Col span={16}>
<Card title="AI 模型配置" style={{ borderRadius: 12 }}>
<Alert message="此配置将全局生效,影响所有 AI 功能" type="info" showIcon style={{ marginBottom: 20 }} />
<Form form={modelForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="模型提供商" name="provider" rules={[{ required: true }]}>
<Select options={providerOptions} onChange={handleProviderChange} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="模型版本" name="model" rules={[{ required: true }]}>
<Select options={currentModelOptions} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="API Key" name="api_key" help="留空则保留已保存的 Key">
<Input.Password placeholder="请输入 API Key" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="API Base URL" name="base_url" rules={[{ required: true }]}>
<Input placeholder="https://api.deepseek.com" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="Temperature" name="temperature">
<InputNumber style={{ width: '100%' }} min={0} max={2} step={0.1} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="Max Tokens" name="max_tokens">
<InputNumber style={{ width: '100%' }} min={256} max={32768} step={256} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="启用 AI 辅助" name="enable_ai" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
</Col>
</Row>
<Button type="primary" onClick={handleSaveModel} loading={saving}
icon={saving ? <LoadingOutlined /> : <CheckCircleOutlined />} size="large" style={{ minWidth: 140 }}>
{saving ? '保存中...' : '保存配置'}
</Button>
</Form>
</Card>
</Col>
<Col span={8}>
<Card title="使用统计" style={{ borderRadius: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<Text type="secondary">今日调用次数</Text>
<Title level={3} style={{ margin: 0 }}>{usageStats?.today_count ?? 0}</Title>
</div>
<Divider style={{ margin: 0 }} />
<div>
<Text type="secondary">本月 Token 消耗</Text>
<Title level={3} style={{ margin: 0 }}>{formatTokens(usageStats?.month_tokens ?? 0)}</Title>
</div>
<Divider style={{ margin: 0 }} />
<div>
<Text type="secondary">平均响应时间</Text>
<Title level={3} style={{ margin: 0 }}>{usageStats?.avg_response_ms ? (usageStats.avg_response_ms / 1000).toFixed(1) + 's' : '-'}</Title>
</div>
</Space>
</Card>
</Col>
</Row>
</Spin>
),
},
{
key: 'prompts',
label: <span><FileTextOutlined /> Prompt 模板管理</span>,
children: (
<Card style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">管理各场景的 AI Prompt 模板</Text>
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddTemplate}>新建模板</Button>
</div>
<Table columns={templateColumns} dataSource={templates} rowKey="id" loading={templatesLoading} pagination={false} />
</Card>
),
},
{
key: 'safety',
label: <span><SafetyOutlined /> 安全词过滤</span>,
children: (
<Card style={{ borderRadius: 12 }}>
<Alert message="安全词过滤规则用于防止 AI 输出不安全或不合规的内容" type="info" showIcon style={{ marginBottom: 16 }} />
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}>
<ExclamationCircleOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>安全词管理功能开发中...</div>
</div>
</Card>
),
},
{
key: 'logs',
label: <span><HistoryOutlined /> 模型日志</span>,
children: (
<Card style={{ borderRadius: 12 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space wrap>
<Select placeholder="场景筛选" style={{ width: 150 }} allowClear value={logSceneFilter} onChange={setLogSceneFilter}
options={Object.entries(sceneLabels).map(([k, v]) => ({ label: v, value: k }))} />
<Select placeholder="状态筛选" style={{ width: 120 }} allowClear value={logSuccessFilter} onChange={setLogSuccessFilter}
options={[{ label: '成功', value: 'true' }, { label: '失败', value: 'false' }]} />
<DatePicker.RangePicker value={logDateRange} onChange={(dates) => setLogDateRange(dates)} />
<Button icon={<ReloadOutlined />} onClick={fetchAILogs}>刷新</Button>
</Space>
</div>
<Table
columns={[
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '场景', dataIndex: 'scene', key: 'scene', width: 130, render: (v: string) => <Tag color="blue">{sceneLabels[v] || v}</Tag> },
{ title: '模型', dataIndex: 'model', key: 'model', width: 140 },
{
title: '请求摘要', dataIndex: 'request_content', key: 'request_content', width: 180,
render: (v: string) => <Tooltip title={v}><Text ellipsis style={{ maxWidth: 180, display: 'block' }}>{v || '-'}</Text></Tooltip>,
},
{ title: 'Tokens', key: 'tokens', width: 100, render: (_: any, record: AILogItem) => <Text type="secondary">{record.total_tokens || 0}</Text> },
{ title: '耗时', dataIndex: 'response_time_ms', key: 'response_time_ms', width: 80, render: (v: number) => <Text type="secondary">{v ? (v / 1000).toFixed(2) + 's' : '-'}</Text> },
{
title: '状态', dataIndex: 'success', key: 'success', width: 80,
render: (v: boolean, record: AILogItem) => (
<Space direction="vertical" size={0}>
<Tag color={v ? 'success' : 'error'}>{v ? '成功' : '失败'}</Tag>
{record.is_mock && <Tag color="default">模拟</Tag>}
</Space>
),
},
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160, render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-' },
{
title: '操作', key: 'action', width: 80,
render: (_: any, record: AILogItem) => (
<Button type="link" size="small" onClick={() => { setLogDetailItem(record); setLogDetailVisible(true); }}>详情</Button>
),
},
]}
dataSource={logData}
rowKey="id"
loading={logLoading}
pagination={{
current: logPage,
pageSize: logPageSize,
total: logTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => { setLogPage(p); setLogPageSize(ps); },
}}
/>
</Card>
),
},
]} />
<Modal title={templateModalType === 'add' ? '新建 Prompt 模板' : '编辑 Prompt 模板'}
open={templateModalVisible} onOk={handleSaveTemplate} onCancel={() => setTemplateModalVisible(false)}
okText="保存" confirmLoading={templateSaving} width={640}>
<Form form={templateForm} layout="vertical">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="模板Key" name="template_key" rules={[{ required: true, message: '请输入模板Key' }]}
help="唯一标识,如 pre_consult_chat">
<Input placeholder="如 pre_consult_chat" disabled={templateModalType === 'edit'} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="模板名称" name="name" rules={[{ required: true, message: '请输入模板名称' }]}>
<Input placeholder="请输入模板名称" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="应用场景" name="scene" rules={[{ required: true, message: '请选择场景' }]}>
<Select placeholder="选择场景" options={Object.entries(sceneLabels).map(([k, v]) => ({ label: v, value: k }))} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" name="status" rules={[{ required: true }]}>
<Select options={[{ label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]} />
</Form.Item>
</Col>
</Row>
<Form.Item label="Prompt 内容" name="content" rules={[{ required: true, message: '请输入Prompt内容' }]}>
<TextArea rows={8} placeholder="请输入 Prompt 模板内容..." />
</Form.Item>
</Form>
</Modal>
<Modal title="AI 调用详情" open={logDetailVisible} onCancel={() => setLogDetailVisible(false)}
footer={[<Button key="close" onClick={() => setLogDetailVisible(false)}>关闭</Button>]} width={800}>
{logDetailItem && (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Row gutter={16}>
<Col span={8}><Text type="secondary">场景:</Text><Text strong>{sceneLabels[logDetailItem.scene] || logDetailItem.scene}</Text></Col>
<Col span={8}><Text type="secondary">模型:</Text><Text strong>{logDetailItem.model || '-'}</Text></Col>
<Col span={8}><Text type="secondary">状态:</Text><Tag color={logDetailItem.success ? 'success' : 'error'}>{logDetailItem.success ? '成功' : '失败'}</Tag></Col>
</Row>
<Row gutter={16}>
<Col span={8}><Text type="secondary">Prompt Tokens:</Text><Text>{logDetailItem.prompt_tokens || 0}</Text></Col>
<Col span={8}><Text type="secondary">Completion Tokens:</Text><Text>{logDetailItem.completion_tokens || 0}</Text></Col>
<Col span={8}><Text type="secondary">Total Tokens:</Text><Text strong>{logDetailItem.total_tokens || 0}</Text></Col>
</Row>
<Divider style={{ margin: '8px 0' }} />
<div>
<Text type="secondary">系统提示词:</Text>
<Card size="small" style={{ marginTop: 8, maxHeight: 200, overflow: 'auto', backgroundColor: '#f5f5f5' }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logDetailItem.prompt || '无'}</pre>
</Card>
</div>
<div>
<Text type="secondary">响应内容:</Text>
<Card size="small" style={{ marginTop: 8, maxHeight: 250, overflow: 'auto' }}>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{logDetailItem.response_content || '无'}</pre>
</Card>
</div>
{logDetailItem.error_message && (
<div>
<Text type="secondary">错误信息:</Text>
<Alert message={logDetailItem.error_message} type="error" style={{ marginTop: 8 }} />
</div>
)}
</Space>
)}
</Modal>
</div>
);
};
export default AdminAIConfigPage;
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;
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;
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' },
{ title: '今日收入', value: stats.revenue_today / 100, icon: <DollarOutlined />, color: '#fa8c16', bg: '#fff7e6', prefix: '¥' },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div className="bg-white rounded-lg p-3 border border-[#e6f0fa] hover:shadow-md transition-shadow">
<div className="flex items-center gap-2 mb-2">
<div className="w-7 h-7 rounded-md flex items-center justify-center text-sm" style={{ background: item.bg, color: item.color }}>{item.icon}</div>
<span className="text-xs text-gray-500">{item.title}</span>
</div>
<div className="text-xl font-bold" style={{ color: item.color }}>
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
{item.suffix}
</div>
</div>
</Col>
))}
</Row>
{/* 平台概况 + 待审核*/}
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Card title={<span className="text-xs font-semibold">平台概况</span>} size="small" className="h-full!">
<div className="space-y-3">
<div>
<div className="flex justify-between mb-1">
<Text className="text-xs!">医生在线</Text>
<Text strong className="text-xs!">{stats.total_doctors > 0 ? ((stats.online_doctors / stats.total_doctors) * 100).toFixed(0) : 0}%</Text>
</div>
<Progress percent={stats.total_doctors > 0 ? Math.round((stats.online_doctors / stats.total_doctors) * 100) : 0} strokeColor="#52c41a" showInfo={false} size="small" />
</div>
<div>
<div className="flex justify-between mb-1">
<Text className="text-xs!">今日完成</Text>
<Text strong className="text-xs!">{stats.today_consultations > 0 ? Math.round((stats.today_consultations / (stats.today_consultations + stats.pending_doctor_reviews)) * 100) : 0}%</Text>
</div>
<Progress percent={stats.today_consultations > 0 ? Math.round((stats.today_consultations / (stats.today_consultations + stats.pending_doctor_reviews)) * 100) : 0} strokeColor="#1890ff" showInfo={false} size="small" />
</div>
<div>
<div className="flex justify-between mb-1">
<Text className="text-xs!">患者满意度</Text>
<Text strong className="text-xs!">96%</Text>
</div>
<Progress percent={96} strokeColor="#722ed1" showInfo={false} size="small" />
</div>
</div>
</Card>
</Col>
<Col xs={24} sm={12}>
<Card
title={<span className="text-xs font-semibold">待审核医生 <Tag color="orange" className="ml-1">{stats.pending_doctor_reviews}</Tag></span>}
size="small"
className="h-full!"
>
<List
size="small"
dataSource={recentReviews}
renderItem={(item) => (
<List.Item className="py-2!">
<List.Item.Meta
avatar={<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />}
title={<span className="text-xs"><Text strong>{item.name}</Text> <Tag className="ml-1">{item.title}</Tag></span>}
description={<span className="text-[11px]">{item.department_name} · {item.submitted_at}</span>}
/>
<Tag color="orange" className="text-[10px]!">待审</Tag>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
{/* 底部统计 */}
<Row gutter={[8, 8]}>
{[
{ icon: <CheckCircleOutlined />, color: '#52c41a', bg: '#f6ffed', label: '累计问诊', value: stats.total_consultations.toLocaleString() },
{ icon: <RiseOutlined />, color: '#fa8c16', bg: '#fff7e6', label: '本月收入', value: `¥${(stats.revenue_month / 100).toLocaleString()}` },
{ icon: <ClockCircleOutlined />, color: '#1890ff', bg: '#e6f7ff', label: '平均响应', value: '3.2分钟' },
{ icon: <AuditOutlined />, color: '#722ed1', bg: '#f9f0ff', label: '待审核', value: stats.pending_doctor_reviews },
].map((item, i) => (
<Col xs={12} sm={6} key={i}>
<div className="bg-white rounded-lg p-3 border border-[#e6f0fa] text-center">
<div className="w-8 h-8 rounded-lg flex items-center justify-center mx-auto mb-1 text-base" style={{ background: item.bg, color: item.color }}>{item.icon}</div>
<div className="text-[11px] text-gray-400">{item.label}</div>
<div className="text-lg font-bold" style={{ color: item.color }}>{item.value}</div>
</div>
</Col>
))}
</Row>
</div>
);
};
export default AdminDashboardPage;
'use client';
import React, { useState, useEffect } from 'react';
import {
Card, Table, Typography, Space, Button, Modal, Form, Input, InputNumber, message
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { adminApi } from '../../../api/admin';
import type { Department } from '../../../api/doctor';
const { Text } = Typography;
const AdminDepartmentsPage: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [departments, setDepartments] = useState<Department[]>([]);
const [saveLoading, setSaveLoading] = useState(false);
const fetchDepartments = async () => {
setLoading(true);
try {
const res = await adminApi.getDepartmentList();
setDepartments(res.data || []);
} catch {
message.error('获取科室列表失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDepartments();
}, []);
const handleAdd = () => {
setEditingDept(null);
form.resetFields();
setIsModalOpen(true);
};
const handleEdit = (record: Department) => {
setEditingDept(record);
form.setFieldsValue(record);
setIsModalOpen(true);
};
const handleDelete = (record: Department) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除科室「${record.name}」吗?该操作不可恢复。`,
onOk: async () => {
try {
await adminApi.deleteDepartment(record.id);
message.success('已删除');
fetchDepartments();
} catch {
message.error('删除失败');
}
},
});
};
const handleSave = async (values: any) => {
setSaveLoading(true);
try {
if (editingDept) {
await adminApi.updateDepartment(editingDept.id, values);
message.success('科室更新成功');
} else {
await adminApi.createDepartment(values);
message.success('科室创建成功');
}
setIsModalOpen(false);
fetchDepartments();
} catch {
message.error('操作失败');
} finally {
setSaveLoading(false);
}
};
const columns: ColumnsType<Department> = [
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 60,
render: (icon: string) => <span style={{ fontSize: 24 }}>{icon || '🏥'}</span>,
},
{
title: '科室名称',
dataIndex: 'name',
key: 'name',
render: (name: string) => <Text strong>{name}</Text>,
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>删除</Button>
</Space>
),
},
];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-gray-800 m-0">科室管理</h4>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加科室</Button>
</div>
<Card size="small">
<Table columns={columns} dataSource={departments} rowKey="id" loading={loading} size="small" pagination={false} />
</Card>
<Modal title={editingDept ? '编辑科室' : '添加科室'} open={isModalOpen} onCancel={() => setIsModalOpen(false)}
onOk={() => form.submit()} confirmLoading={saveLoading} width={400}>
<Form form={form} onFinish={handleSave} layout="vertical" size="small">
<Form.Item name="name" label="科室名称" rules={[{ required: true, message: '请输入科室名称' }]}>
<Input placeholder="请输入科室名称" />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="请输入Emoji图标" />
</Form.Item>
<Form.Item name="sort_order" label="排序号" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminDepartmentsPage;
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;
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Tag, Typography, Space, Button, Avatar, Modal, message,
Input, Select, Form, Descriptions, Row, Col,
} from 'antd';
import {
UserOutlined, SearchOutlined, CheckCircleOutlined, CloseCircleOutlined,
EyeOutlined, EditOutlined, ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { adminApi } from '../../../api/admin';
import type { DoctorItem } from '../../../api/admin';
const { Text } = Typography;
const statusMap: Record<string, { color: string; text: string }> = {
not_submitted: { color: 'default', text: '未提交认证' },
pending: { color: 'orange', text: '待审核' },
approved: { color: 'green', text: '已认证' },
rejected: { color: 'red', text: '已拒绝' },
};
const AdminDoctorsPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [doctors, setDoctors] = useState<DoctorItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const [statusFilter, setStatusFilter] = useState<string | undefined>();
const [detailVisible, setDetailVisible] = useState(false);
const [currentDoctor, setCurrentDoctor] = useState<DoctorItem | null>(null);
const [reviewModalVisible, setReviewModalVisible] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingDoctor, setEditingDoctor] = useState<DoctorItem | null>(null);
const [editForm] = Form.useForm();
const [editLoading, setEditLoading] = useState(false);
const fetchDoctors = useCallback(async () => {
setLoading(true);
try {
const res = await adminApi.getDoctorList({
keyword: keyword || undefined,
status: statusFilter,
page,
page_size: pageSize,
});
const data = res.data as any;
setDoctors(data?.list || []);
setTotal(data?.total || 0);
} catch {
message.error('获取医生列表失败');
} finally {
setLoading(false);
}
}, [keyword, statusFilter, page, pageSize]);
useEffect(() => {
fetchDoctors();
}, [fetchDoctors]);
const handleSearch = () => {
setPage(1);
fetchDoctors();
};
const handleViewDetail = (record: DoctorItem) => {
setCurrentDoctor(record);
setDetailVisible(true);
};
const handleReview = (record: DoctorItem) => {
setCurrentDoctor(record);
setReviewModalVisible(true);
};
const handleApprove = async () => {
if (!currentDoctor?.review_id) return;
try {
await adminApi.approveDoctorReview(currentDoctor.review_id);
message.success('审核通过');
setReviewModalVisible(false);
fetchDoctors();
} catch (error: any) {
message.error(error.response?.data?.message || '审核失败');
}
};
const handleReject = async () => {
if (!currentDoctor?.review_id || !rejectReason) {
message.error('请输入拒绝原因');
return;
}
try {
await adminApi.rejectDoctorReview(currentDoctor.review_id, rejectReason);
message.success('已拒绝');
setReviewModalVisible(false);
setRejectReason('');
fetchDoctors();
} catch (error: any) {
message.error(error.response?.data?.message || '操作失败');
}
};
const handleToggleStatus = (record: DoctorItem) => {
const newStatus = record.user_status === 'active' ? 'disabled' : 'active';
Modal.confirm({
title: `确认${newStatus === 'disabled' ? '禁用' : '启用'}该医生?`,
content: `医生「${record.name}」`,
onOk: async () => {
try {
await adminApi.updateUserStatus(record.user_id, newStatus);
message.success('操作成功');
fetchDoctors();
} catch {
message.error('操作失败');
}
},
});
};
const handleResetPassword = (record: DoctorItem) => {
Modal.confirm({
title: '确认重置密码?',
content: `将重置医生 ${record.name} 的密码为默认密码 123456`,
onOk: async () => {
try {
await adminApi.resetUserPassword(record.user_id);
message.success('密码已重置为 123456');
} catch {
message.error('重置失败');
}
},
});
};
const handleEditDoctor = (record: DoctorItem) => {
setEditingDoctor(record);
editForm.setFieldsValue({
real_name: record.name,
phone: record.phone,
title: record.title,
department_id: record.department_id,
hospital: record.hospital,
});
setEditModalVisible(true);
};
const handleSaveEdit = async () => {
try {
const values = await editForm.validateFields();
setEditLoading(true);
await adminApi.updateDoctor(editingDoctor!.id, values);
message.success('医生信息已更新');
setEditModalVisible(false);
fetchDoctors();
} catch (error: any) {
if (!error?.errorFields) {
message.error('更新失败');
}
} finally {
setEditLoading(false);
}
};
const getStatusTag = (status: string) => {
const config = statusMap[status] || { color: 'default', text: status };
return <Tag color={config.color}>{config.text}</Tag>;
};
const columns: ColumnsType<DoctorItem> = [
{
title: '医生',
key: 'doctor',
render: (_, record) => (
<Space>
<Avatar icon={<UserOutlined />} src={record.avatar} style={{ backgroundColor: '#87d068' }} />
<div>
<Text strong>{record.name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{record.phone}</Text>
</div>
</Space>
),
},
{ title: '职称', dataIndex: 'title', key: 'title', width: 100, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : '-' },
{ title: '科室', dataIndex: 'department_name', key: 'department_name', width: 100 },
{ title: '医院', dataIndex: 'hospital', key: 'hospital', width: 150, ellipsis: true },
{ title: '认证状态', dataIndex: 'review_status', key: 'review_status', width: 100, render: (v: string) => getStatusTag(v) },
{
title: '账号状态', dataIndex: 'user_status', key: 'user_status', width: 90,
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'red'}>{v === 'active' ? '正常' : '已禁用'}</Tag>,
},
{
title: '操作', key: 'action', width: 220,
render: (_, record) => (
<Space size={0}>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>详情</Button>
{record.review_status === 'pending' && (
<Button type="link" size="small" icon={<CheckCircleOutlined />} onClick={() => handleReview(record)}>审核</Button>
)}
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditDoctor(record)}>编辑</Button>
<Button type="link" size="small" danger={record.user_status === 'active'} onClick={() => handleToggleStatus(record)}>
{record.user_status === 'active' ? '禁用' : '启用'}
</Button>
</Space>
),
},
];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold text-gray-800 m-0">医生管理</h4>
<span className="text-xs text-gray-400">共 {total} 位医生</span>
</div>
<Card size="small">
<div className="flex flex-wrap items-center gap-2">
<Input placeholder="搜索姓名/手机号" prefix={<SearchOutlined />} style={{ width: 160 }} size="small"
value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={handleSearch} />
<Select placeholder="认证状态" style={{ width: 120 }} size="small" allowClear value={statusFilter} onChange={setStatusFilter}
options={Object.entries(statusMap).map(([k, v]) => ({ label: v.text, value: k }))} />
<Button size="small" type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
<Button size="small" icon={<ReloadOutlined />} onClick={() => { setKeyword(''); setStatusFilter(undefined); setPage(1); }}>重置</Button>
</div>
</Card>
<Card size="small">
<Table columns={columns} dataSource={doctors} rowKey="id" loading={loading} size="small"
pagination={{
current: page, pageSize, total, showSizeChanger: true, size: 'small',
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}} />
</Card>
<Modal title="医生详情" open={detailVisible} onCancel={() => setDetailVisible(false)} footer={null} width={600}>
{currentDoctor && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="姓名">{currentDoctor.name}</Descriptions.Item>
<Descriptions.Item label="手机号">{currentDoctor.phone}</Descriptions.Item>
<Descriptions.Item label="职称">{currentDoctor.title || '-'}</Descriptions.Item>
<Descriptions.Item label="科室">{currentDoctor.department_name || '-'}</Descriptions.Item>
<Descriptions.Item label="医院" span={2}>{currentDoctor.hospital || '-'}</Descriptions.Item>
<Descriptions.Item label="执业证号">{currentDoctor.license_no || '-'}</Descriptions.Item>
<Descriptions.Item label="认证状态">{getStatusTag(currentDoctor.review_status)}</Descriptions.Item>
<Descriptions.Item label="账号状态">
<Tag color={currentDoctor.user_status === 'active' ? 'green' : 'red'}>
{currentDoctor.user_status === 'active' ? '正常' : '已禁用'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="简介" span={2}>{currentDoctor.introduction || '-'}</Descriptions.Item>
</Descriptions>
)}
</Modal>
<Modal title="审核医生认证" open={reviewModalVisible} onCancel={() => { setReviewModalVisible(false); setRejectReason(''); }}
footer={[
<Button key="reject" danger onClick={handleReject} disabled={!rejectReason}>拒绝</Button>,
<Button key="approve" type="primary" onClick={handleApprove}>通过</Button>,
]} width={500}>
{currentDoctor && (
<div className="space-y-4">
<Descriptions column={1} size="small">
<Descriptions.Item label="姓名">{currentDoctor.name}</Descriptions.Item>
<Descriptions.Item label="职称">{currentDoctor.title}</Descriptions.Item>
<Descriptions.Item label="执业证号">{currentDoctor.license_no}</Descriptions.Item>
<Descriptions.Item label="医院">{currentDoctor.hospital}</Descriptions.Item>
</Descriptions>
<div>
<Text type="secondary" className="text-xs">拒绝原因(拒绝时必填):</Text>
<Input.TextArea rows={3} placeholder="请输入拒绝原因..." value={rejectReason} onChange={(e) => setRejectReason(e.target.value)} />
</div>
</div>
)}
</Modal>
<Modal title="编辑医生信息" open={editModalVisible} onCancel={() => setEditModalVisible(false)}
onOk={handleSaveEdit} okText="保存" confirmLoading={editLoading} width={500}>
<Form form={editForm} layout="vertical" size="small">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="real_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="手机号" name="phone">
<Input placeholder="请输入手机号" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="职称" name="title">
<Select placeholder="选择职称" options={[
{ label: '主任医师', value: '主任医师' },
{ label: '副主任医师', value: '副主任医师' },
{ label: '主治医师', value: '主治医师' },
{ label: '住院医师', value: '住院医师' },
]} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="科室" name="department_id">
<Input placeholder="科室ID" />
</Form.Item>
</Col>
</Row>
<Form.Item label="医院" name="hospital">
<Input placeholder="请输入医院名称" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default AdminDoctorsPage;
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Row, Col, Table, Tag, Typography, Space, Tabs, Statistic,
Button, Input, Modal, Form, InputNumber, Select, message, Badge, Alert, Popconfirm,
} from 'antd';
import {
MedicineBoxOutlined,
PlusOutlined,
SearchOutlined,
WarningOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import { medicineApi } from '../../../api/prescription';
import type { Medicine, MedicineStats } from '../../../api/prescription';
const { Text } = Typography;
const categoryOptions = [
{ label: '抗生素', value: '抗生素' },
{ label: '解热镇痛', value: '解热镇痛' },
{ label: '降糖药', value: '降糖药' },
{ label: '降压药', value: '降压药' },
{ label: '消化系统', value: '消化系统' },
{ label: '心血管', value: '心血管' },
{ label: '呼吸系统', value: '呼吸系统' },
{ label: '维生素', value: '维生素' },
{ label: '中成药', value: '中成药' },
{ label: '其他', value: '其他' },
];
const AdminPharmacyPage: React.FC = () => {
const [drugModalVisible, setDrugModalVisible] = useState(false);
const [editingDrug, setEditingDrug] = useState<Medicine | null>(null);
const [form] = Form.useForm();
const [drugs, setDrugs] = useState<Medicine[]>([]);
const [stats, setStats] = useState<MedicineStats>({ total_count: 0, normal_count: 0, low_stock_count: 0, out_of_stock_count: 0 });
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const [filterCategory, setFilterCategory] = useState<string>('');
const [filterStatus, setFilterStatus] = useState<string>('');
const fetchStats = useCallback(async () => {
try {
const res = await medicineApi.getStats();
setStats(res.data);
} catch { /* ignore */ }
}, []);
const fetchList = useCallback(async (statusOverride?: string) => {
setLoading(true);
try {
const res = await medicineApi.getList({
keyword, category: filterCategory,
status: statusOverride !== undefined ? statusOverride : filterStatus,
page, page_size: 10,
});
const data = res.data as any;
setDrugs(data?.list || []);
setTotal(data?.total || 0);
} catch { /* ignore */ }
setLoading(false);
}, [keyword, filterCategory, filterStatus, page]);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => { fetchList(); }, [fetchList]);
const handleAddDrug = () => {
setEditingDrug(null);
form.resetFields();
setDrugModalVisible(true);
};
const handleEditDrug = (drug: Medicine) => {
setEditingDrug(drug);
form.setFieldsValue({
name: drug.name, generic_name: drug.generic_name,
specification: drug.specification, category: drug.category,
manufacturer: drug.manufacturer, unit: drug.unit,
price: drug.price / 100, stock: drug.stock, stock_warning: drug.stock_warning,
});
setDrugModalVisible(true);
};
const handleDeleteDrug = async (id: string) => {
try {
await medicineApi.delete(id);
message.success('药品已删除');
fetchList(); fetchStats();
} catch (err: any) { message.error(err?.message || '删除失败'); }
};
const handleSaveDrug = async () => {
try {
const values = await form.validateFields();
const priceInCents = Math.round((values.price || 0) * 100);
if (editingDrug) {
await medicineApi.update(editingDrug.id, { ...values, price: priceInCents });
message.success('药品已更新');
} else {
await medicineApi.create({ ...values, price: priceInCents });
message.success('药品已添加');
}
setDrugModalVisible(false);
fetchList(); fetchStats();
} catch (err: any) {
if (err?.message) message.error(err.message);
}
};
const getStockTag = (status: string) => {
switch (status) {
case 'normal': return <Tag color="green">库存充足</Tag>;
case 'low_stock': return <Tag color="orange" icon={<WarningOutlined />}>库存预警</Tag>;
case 'out_of_stock': return <Tag color="red">缺货</Tag>;
default: return <Tag>{status}</Tag>;
}
};
const drugColumns = [
{ title: '药品名称', dataIndex: 'name', key: 'name', width: 150, render: (v: string) => <Text strong>{v}</Text> },
{ title: '通用名', dataIndex: 'generic_name', key: 'generic_name', width: 120 },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 140 },
{ title: '类别', dataIndex: 'category', key: 'category', width: 80, render: (v: string) => <Tag>{v}</Tag> },
{ title: '厂商', dataIndex: 'manufacturer', key: 'manufacturer', width: 100 },
{ title: '价格', dataIndex: 'price', key: 'price', width: 80, render: (v: number) => `¥${(v / 100).toFixed(2)}` },
{ title: '库存', dataIndex: 'stock', key: 'stock', width: 80 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, render: (v: string) => getStockTag(v) },
{
title: '操作', key: 'action', width: 140,
render: (_: any, record: Medicine) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEditDrug(record)}>编辑</Button>
<Popconfirm title="确定删除该药品?" onConfirm={() => handleDeleteDrug(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">
<MedicineBoxOutlined className="mr-1" />药品库管理
</h4>
<Row gutter={[8, 8]}>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #1890ff' }}>
<Statistic title="药品总数" value={stats.total_count} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #52c41a' }}>
<Statistic title="库存充足" value={stats.normal_count} valueStyle={{ color: '#52c41a' }} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #fa8c16' }}>
<Statistic title="库存预警" value={stats.low_stock_count} valueStyle={{ color: '#fa8c16' }} suffix="种" />
</Card>
</Col>
<Col span={6}>
<Card size="small" style={{ borderLeft: '3px solid #f5222d' }}>
<Statistic title="缺货药品" value={stats.out_of_stock_count} valueStyle={{ color: '#f5222d' }} suffix="种" />
</Card>
</Col>
</Row>
{(stats.low_stock_count > 0 || stats.out_of_stock_count > 0) && (
<Alert message={`当前有 ${stats.low_stock_count} 种药品库存不足,${stats.out_of_stock_count} 种药品缺货,请及时补货`}
type="warning" showIcon className="text-xs!" />
)}
<Tabs defaultActiveKey="drugs" size="small" items={[
{
key: 'drugs',
label: <span><MedicineBoxOutlined /> 药品信息</span>,
children: (
<Card size="small">
<div className="flex flex-wrap items-center justify-between gap-2 mb-2">
<div className="flex flex-wrap items-center gap-2">
<Input prefix={<SearchOutlined />} placeholder="搜索药品" style={{ width: 180 }} size="small" allowClear
value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={() => { setPage(1); fetchList(); }} />
<Select placeholder="类别" style={{ width: 100 }} size="small" allowClear options={categoryOptions}
value={filterCategory || undefined} onChange={(v) => { setFilterCategory(v || ''); setPage(1); }} />
<Select placeholder="库存" style={{ width: 100 }} size="small" allowClear
value={filterStatus || undefined} onChange={(v) => { setFilterStatus(v || ''); setPage(1); }}
options={[{ label: '充足', value: 'normal' }, { label: '预警', value: 'low_stock' }, { label: '缺货', value: 'out_of_stock' }]} />
</div>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={handleAddDrug}>添加药品</Button>
</div>
<Table columns={drugColumns} dataSource={drugs} rowKey="id" loading={loading} size="small"
pagination={{ current: page, total, pageSize: 10, size: 'small', onChange: setPage, showTotal: (t) => `共 ${t} 种` }} />
</Card>
),
},
{
key: 'stock',
label: <Badge count={stats.low_stock_count + stats.out_of_stock_count} offset={[10, 0]}>库存预警</Badge>,
children: (
<Card size="small">
<Table columns={drugColumns} size="small"
dataSource={drugs.filter((d) => d.status !== 'normal')}
rowKey="id" pagination={false} loading={loading} />
</Card>
),
},
]} />
<Modal title={editingDrug ? '编辑药品' : '添加药品'} open={drugModalVisible}
onOk={handleSaveDrug} onCancel={() => setDrugModalVisible(false)} okText="保存" width={500}>
<Form form={form} layout="vertical" size="small">
<Row gutter={16}>
<Col span={12}>
<Form.Item label="药品名称" name="name" rules={[{ required: true, message: '请输入药品名称' }]}>
<Input placeholder="请输入药品名称" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="通用名" name="generic_name">
<Input placeholder="请输入通用名" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="规格" name="specification" rules={[{ required: true, message: '请输入规格' }]}>
<Input placeholder="如 0.5g×24粒/盒" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="类别" name="category" rules={[{ required: true, message: '请选择类别' }]}>
<Select placeholder="选择类别" options={categoryOptions} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item label="生产厂商" name="manufacturer">
<Input placeholder="请输入厂商" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="单位" name="unit">
<Select placeholder="选择单位" options={[
{ label: '盒', value: '盒' },
{ label: '瓶', value: '瓶' },
{ label: '袋', value: '袋' },
{ label: '支', value: '支' },
]} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="价格(元)" name="price" rules={[{ required: true, message: '请输入价格' }]}>
<InputNumber style={{ width: '100%' }} min={0} precision={2} placeholder="0.00" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="库存数量" name="stock">
<InputNumber style={{ width: '100%' }} min={0} placeholder="0" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="库存预警线" name="stock_warning">
<InputNumber style={{ width: '100%' }} min={0} placeholder="低于此数量预警" />
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
</div>
);
};
export default AdminPharmacyPage;
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;
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;
import React from 'react';
import { List, Typography, Tag, Button, Card } from 'antd';
import { UserOutlined, MessageOutlined, VideoCameraOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons';
import type { PatientListItem } from '../../../api/consult';
const { Text } = Typography;
interface PatientListProps {
patients: PatientListItem[];
onSelectPatient: (patient: PatientListItem) => void;
onAccept: (patient: PatientListItem) => void;
onReject: (patient: PatientListItem) => void;
selectedConsultId?: string;
}
const statusConfig: Record<string, { color: string; bgColor: string; text: string }> = {
in_progress: { color: '#52c41a', bgColor: '#f6ffed', text: '进行中' },
pending: { color: '#1890ff', bgColor: '#e6f7ff', text: '待接诊' },
waiting: { color: '#1890ff', bgColor: '#e6f7ff', text: '待接诊' },
completed: { color: '#8c8c8c', bgColor: '#fafafa', text: '已完成' },
};
const PatientList: React.FC<PatientListProps> = ({ patients, onSelectPatient, onAccept, onReject, selectedConsultId }) => {
const isWaiting = (status: string) => status === 'pending' || status === 'waiting';
return (
<Card
title={
<span className="text-xs">
<UserOutlined className="text-[#1890ff] mr-1" />
<Text strong className="text-xs!">患者列表</Text>
<Tag className="ml-2 text-[10px]!">{patients.length}</Tag>
</span>
}
style={{ height: '100%' }}
styles={{ body: { padding: 0, height: 'calc(100% - 40px)', overflow: 'auto' } }}
size="small"
>
<List
dataSource={patients}
locale={{
emptyText: (
<div className="text-center py-8 text-gray-400">
<UserOutlined className="text-3xl text-gray-300 mb-2 block" />
<div className="text-xs">暂无患者</div>
</div>
),
}}
renderItem={(patient: PatientListItem) => {
const config = statusConfig[patient.status] || statusConfig.pending;
const isSelected = patient.consult_id === selectedConsultId;
return (
<List.Item
key={patient.consult_id}
onClick={() => {
if (!isWaiting(patient.status)) onSelectPatient(patient);
}}
style={{
cursor: isWaiting(patient.status) ? 'default' : 'pointer',
padding: '8px 12px',
background: isSelected ? '#e6f7ff' : '#fff',
borderLeft: isSelected ? '3px solid #1890ff' : '3px solid transparent',
borderBottom: '1px solid #f0f0f0',
transition: 'all 0.2s',
}}
>
<div className="w-full">
<div className="flex justify-between items-center mb-1">
<span className="text-xs">
<UserOutlined className="text-[#1890ff] mr-1" />
<Text strong className="text-xs!">{patient.patient_name}</Text>
<Tag color={config.color} className="ml-1 text-[10px]! leading-4! px-1!">{config.text}</Tag>
</span>
{patient.type === 'video' ? (
<VideoCameraOutlined className="text-purple-500 text-xs" />
) : (
<MessageOutlined className="text-green-500 text-xs" />
)}
</div>
<div className="text-[11px] text-gray-500 mb-1">
<Text ellipsis className="text-[11px]! text-gray-500!">
{'主诉:'}{patient.chief_complaint || '暂无'}
</Text>
</div>
<div className="flex justify-between items-center">
<span className="text-[10px] text-gray-400">
{patient.patient_gender}{' · '}{patient.patient_age}{'岁'}
</span>
</div>
{isWaiting(patient.status) && (
<div className="mt-1.5 flex gap-1.5">
<Button
type="primary"
size="small"
icon={<CheckOutlined />}
onClick={(e) => { e.stopPropagation(); onAccept(patient); }}
className="flex-1 text-[11px]!"
>
接诊
</Button>
<Button
danger
size="small"
icon={<CloseOutlined />}
onClick={(e) => { e.stopPropagation(); onReject(patient); }}
className="flex-1 text-[11px]!"
>
拒绝
</Button>
</div>
)}
</div>
</List.Item>
);
}}
/>
</Card>
);
};
export default PatientList;
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;
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Row, Col, Typography, message, Modal } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { consultApi, type PatientListItem, type ConsultMessage } from '../../../api/consult';
import { preConsultApi } from '../../../api/preConsult';
import type { PreConsultResponse } from '../../../api/preConsult';
import PatientList from './PatientList';
import ChatPanel from './ChatPanel';
import AIPanel from './AIPanel';
const { Title } = Typography;
interface ActiveConsult {
consult_id: string;
patient_id?: string;
patient_name: string;
patient_gender?: string;
patient_age?: number;
chief_complaint?: string;
type: 'text' | 'video';
status?: string;
}
const ConsultPage: React.FC = () => {
const [patientList, setPatientList] = useState<PatientListItem[]>([]);
const [activeConsult, setActiveConsult] = useState<ActiveConsult | null>(null);
const [messages, setMessages] = useState<ConsultMessage[]>([]);
const [aiPanelVisible, setAiPanelVisible] = useState(true);
const [preConsultReport, setPreConsultReport] = useState<PreConsultResponse | null>(null);
const [preConsultLoading, setPreConsultLoading] = useState(false);
const [sending, setSending] = useState(false);
const fetchPatientList = useCallback(async () => {
try {
const res = await consultApi.getPatientList();
setPatientList(res.data || []);
} catch {
// 静默失败
}
}, []);
// 获取消息列表
const fetchMessages = useCallback(async (consultId: string) => {
try {
const res = await consultApi.getConsultMessages(consultId);
setMessages(res.data || []);
} catch {
// 静默失败
}
}, []);
useEffect(() => {
fetchPatientList();
const timer = setInterval(() => {
fetchPatientList();
}, 5000);
return () => clearInterval(timer);
}, [fetchPatientList]);
// 定时刷新消息
useEffect(() => {
if (!activeConsult) return;
fetchMessages(activeConsult.consult_id);
const timer = setInterval(() => fetchMessages(activeConsult.consult_id), 3000);
return () => clearInterval(timer);
}, [activeConsult, fetchMessages]);
// 加载预问诊报告(先通过问诊ID查,查不到再通过患者ID查)
const fetchPreConsultReport = async (consultId: string, patientId?: string) => {
setPreConsultLoading(true);
try {
const res = await preConsultApi.getByConsultationId(consultId);
setPreConsultReport(res.data);
} catch {
// 问诊没有关联预问诊,尝试通过患者ID获取最新预问诊
if (patientId) {
try {
const res = await preConsultApi.getByPatientId(patientId);
setPreConsultReport(res.data);
} catch {
setPreConsultReport(null);
}
} else {
setPreConsultReport(null);
}
} finally {
setPreConsultLoading(false);
}
};
// 接诊
const handleAccept = async (patient: PatientListItem) => {
console.log('handleAccept 被调用', patient);
try {
console.log('调用 acceptConsult API', patient.consult_id);
await consultApi.acceptConsult(patient.consult_id);
console.log('acceptConsult API 调用成功');
setActiveConsult({
consult_id: patient.consult_id,
patient_id: patient.patient_id,
patient_name: patient.patient_name || '患者',
patient_gender: patient.patient_gender,
patient_age: patient.patient_age,
chief_complaint: patient.chief_complaint,
type: patient.type,
status: 'in_progress',
});
setPreConsultReport(null);
message.success(`已接诊患者 ${patient.patient_name || '未知'}`);
fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchPatientList();
fetchMessages(patient.consult_id);
} catch (err: any) {
console.error('接诊失败', err);
message.error(err?.message || '接诊失败');
}
};
// 选择患者(仅用于进行中和已完成的问诊)
const handleSelectPatient = (patient: PatientListItem) => {
setActiveConsult({
consult_id: patient.consult_id,
patient_id: patient.patient_id,
patient_name: patient.patient_name || '患者',
patient_gender: patient.patient_gender,
patient_age: patient.patient_age,
chief_complaint: patient.chief_complaint,
type: patient.type,
status: patient.status,
});
setPreConsultReport(null);
fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchMessages(patient.consult_id);
};
// 拒诊
const handleReject = (patient: PatientListItem) => {
console.log('handleReject 被调用', patient);
Modal.confirm({
title: '确认拒诊',
content: `确定拒绝接诊患者 ${patient.patient_name || '未知'}?`,
onOk: async () => {
try {
console.log('调用 rejectConsult API', patient.consult_id);
await consultApi.rejectConsult(patient.consult_id);
console.log('rejectConsult API 调用成功');
message.info('已拒诊');
fetchPatientList();
} catch (err: any) {
console.error('拒诊失败', err);
message.error('拒诊失败');
}
},
});
};
const handleSendMessage = async (content: string) => {
if (!activeConsult) return;
setSending(true);
try {
await consultApi.sendMessage(activeConsult.consult_id, content);
fetchMessages(activeConsult.consult_id);
} catch {
message.error('发送失败');
} finally {
setSending(false);
}
};
// 结束问诊
const handleEndConsult = async () => {
if (!activeConsult) return;
try {
await consultApi.endConsult(activeConsult.consult_id);
message.success('问诊已结束');
setActiveConsult(null);
setMessages([]);
setPreConsultReport(null);
fetchPatientList();
} catch (err: any) {
message.error('结束问诊失败: ' + (err?.message || '未知错误'));
}
};
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<MessageOutlined style={{ marginRight: 8 }} />
问诊大厅
</Title>
<Row gutter={16} style={{ height: 'calc(100vh - 160px)' }}>
<Col span={6}>
<PatientList
patients={patientList}
onSelectPatient={handleSelectPatient}
onAccept={handleAccept}
onReject={handleReject}
selectedConsultId={activeConsult?.consult_id}
/>
</Col>
<Col span={aiPanelVisible ? 12 : 18}>
<ChatPanel
activeConsult={activeConsult}
messages={messages}
onSend={handleSendMessage}
onEndConsult={handleEndConsult}
onToggleAI={() => setAiPanelVisible(!aiPanelVisible)}
aiPanelVisible={aiPanelVisible}
sending={sending}
/>
</Col>
{aiPanelVisible && (
<Col span={6}>
<AIPanel
hasActiveConsult={!!activeConsult}
activeConsultId={activeConsult?.consult_id}
preConsultReport={preConsultReport}
preConsultLoading={preConsultLoading}
/>
</Col>
)}
</Row>
</div>
);
};
export default ConsultPage;
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-25T13:01:00Z',
},
{
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(100vh - 120px)' }}>
<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: '#87d068' }} 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 ? '#52c41a' : '#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: '#87d068' }} />
<div className="mt-1">
<div className="text-sm font-bold">{patientInfo.name}</div>
<Text type="secondary" className="text-xs!">{patientInfo.age}?· {patientInfo.gender}</Text>
</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;
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-23T09:00:00Z',
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;
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: '#52c41a' }}>¥{(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;
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;
import React, { useState, useCallback } from 'react';
import {
Input, Button, Table, Form, Select, InputNumber,
Typography, Tag, Space, Modal, message, AutoComplete, Empty,
} from 'antd';
import {
DeleteOutlined,
SearchOutlined,
SafetyCertificateOutlined,
PrinterOutlined,
MedicineBoxOutlined,
} from '@ant-design/icons';
import { medicineApi, prescriptionDoctorApi } from '../../../api/prescription';
import type { Medicine } from '../../../api/prescription';
const { Text } = Typography;
const { TextArea } = Input;
interface DrugItem {
key: string;
medicine_id: string;
name: string;
specification: string;
usage: string;
dosage: string;
frequency: string;
days: number;
quantity: number;
unit: string;
price: number;
note: string;
}
interface PrescriptionModalProps {
open: boolean;
onClose: () => void;
consultId?: string;
patientId?: string;
patientName?: string;
patientGender?: string;
patientAge?: number;
}
const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
open, onClose, consultId, patientId, patientName, patientGender, patientAge,
}) => {
const [drugs, setDrugs] = useState<DrugItem[]>([]);
const [searchValue, setSearchValue] = useState('');
const [searchResults, setSearchResults] = useState<Medicine[]>([]);
const [signModalVisible, setSignModalVisible] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [remarkText, setRemarkText] = useState('');
const [form] = Form.useForm();
React.useEffect(() => {
if (open) {
form.setFieldsValue({
patient_name: patientName || '',
patient_gender: patientGender || undefined,
patient_age: patientAge || undefined,
});
}
}, [open, patientName, patientGender, patientAge, form]);
const handleClose = () => {
setDrugs([]);
setSearchValue('');
setSearchResults([]);
setRemarkText('');
form.resetFields();
onClose();
};
const handleSearch = useCallback(async (value: string) => {
setSearchValue(value);
if (value.length < 1) { setSearchResults([]); return; }
try {
const res = await medicineApi.search(value);
setSearchResults(res.data || []);
} catch { setSearchResults([]); }
}, []);
const handleAddDrug = (medicineId: string) => {
const med = searchResults.find((m) => m.id === medicineId);
if (!med) return;
if (drugs.find((d) => d.medicine_id === med.id)) {
message.warning('该药品已添加');
return;
}
setDrugs([...drugs, {
key: med.id,
medicine_id: med.id,
name: med.name,
specification: med.specification,
usage: '口服',
dosage: '',
frequency: '每日3次',
days: 3,
quantity: 1,
unit: med.unit || '盒',
price: med.price,
note: '',
}]);
setSearchValue('');
setSearchResults([]);
};
const handleRemoveDrug = (key: string) => setDrugs(drugs.filter((d) => d.key !== key));
const handleDrugChange = (key: string, field: string, value: any) =>
setDrugs(drugs.map((d) => (d.key === key ? { ...d, [field]: value } : d)));
const totalAmount = drugs.reduce((sum, d) => sum + d.price * d.quantity, 0);
const handleSign = async () => {
if (drugs.length === 0) {
message.warning('请至少添加一种药品');
return;
}
try { await form.validateFields(); } catch { return; }
setSignModalVisible(true);
};
const handleConfirmSign = async () => {
setSubmitting(true);
try {
const values = form.getFieldsValue();
await prescriptionDoctorApi.create({
consult_id: consultId || '',
patient_id: patientId || '',
patient_name: values.patient_name,
patient_gender: values.patient_gender,
patient_age: values.patient_age,
diagnosis: values.diagnosis,
allergy_history: values.allergy,
remark: remarkText,
items: drugs.map((d) => ({
medicine_id: d.medicine_id,
medicine_name: d.name,
specification: d.specification,
usage: d.usage,
dosage: d.dosage,
frequency: d.frequency,
days: d.days,
quantity: d.quantity,
unit: d.unit,
price: d.price,
note: d.note,
})),
});
message.success('处方已签名并提交');
setSignModalVisible(false);
handleClose();
} catch (err: any) {
message.error(err?.message || '提交处方失败');
} finally {
setSubmitting(false);
}
};
const columns = [
{ title: '药品名称', dataIndex: 'name', key: 'name', width: 140, render: (t: string) => <Text strong>{t}</Text> },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 90 },
{
title: '用法', dataIndex: 'usage', key: 'usage', width: 100,
render: (_: string, r: DrugItem) => (
<Select value={r.usage} size="small" style={{ width: 90 }}
onChange={(v) => handleDrugChange(r.key, 'usage', v)}
options={[
{ label: '口服', value: '口服' },
{ label: '外用', value: '外用' },
{ label: '静脉注射', value: '静脉注射' },
{ label: '肌肉注射', value: '肌肉注射' },
{ label: '雾化吸入', value: '雾化吸入' },
]}
/>
),
},
{
title: '单次剂量', dataIndex: 'dosage', key: 'dosage', width: 110,
render: (_: string, r: DrugItem) => (
<Input size="small" placeholder="如 1粒" value={r.dosage}
onChange={(e) => handleDrugChange(r.key, 'dosage', e.target.value)} />
),
},
{
title: '频次', dataIndex: 'frequency', key: 'frequency', width: 110,
render: (_: string, r: DrugItem) => (
<Select value={r.frequency} size="small" style={{ width: 100 }}
onChange={(v) => handleDrugChange(r.key, 'frequency', v)}
options={[
{ label: '每日1次', value: '每日1次' },
{ label: '每日2次', value: '每日2次' },
{ label: '每日3次', value: '每日3次' },
{ label: '必要时', value: '必要时' },
{ label: '睡前', value: '睡前' },
]}
/>
),
},
{
title: '天数', dataIndex: 'days', key: 'days', width: 70,
render: (_: number, r: DrugItem) => (
<InputNumber size="small" min={1} max={30} value={r.days}
onChange={(v) => handleDrugChange(r.key, 'days', v)} style={{ width: 60 }} />
),
},
{
title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100,
render: (_: number, r: DrugItem) => (
<Space size={4}>
<InputNumber size="small" min={1} value={r.quantity}
onChange={(v) => handleDrugChange(r.key, 'quantity', v)} style={{ width: 55 }} />
<Text type="secondary">{r.unit}</Text>
</Space>
),
},
{
title: '单价', dataIndex: 'price', key: 'price', width: 70,
render: (v: number) => `¥${(v / 100).toFixed(2)}`,
},
{
title: '操作', key: 'action', width: 50,
render: (_: any, r: DrugItem) => (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleRemoveDrug(r.key)} />
),
},
];
return (
<>
<Modal
title={<span><MedicineBoxOutlined className="text-green-500 mr-1" />开具处方</span>}
open={open} onCancel={handleClose} width={960} footer={null} destroyOnClose
>
<Form layout="inline" form={form} size="small" className="mb-2 p-2 bg-gray-50 rounded">
<Form.Item label="姓名" name="patient_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" style={{ width: 100 }} />
</Form.Item>
<Form.Item label="性别" name="patient_gender">
<Select placeholder="性别" style={{ width: 70 }}
options={[{ label: '男', value: '男' }, { label: '女', value: '女' }]} allowClear />
</Form.Item>
<Form.Item label="年龄" name="patient_age">
<InputNumber placeholder="岁" style={{ width: 60 }} min={0} max={150} />
</Form.Item>
<Form.Item label="诊断" name="diagnosis" rules={[{ required: true, message: '请输入诊断' }]}>
<Input placeholder="诊断" style={{ width: 160 }} />
</Form.Item>
<Form.Item label="过敏" name="allergy">
<Input placeholder="过敏史" style={{ width: 110 }} />
</Form.Item>
</Form>
<div className="flex items-center gap-2 mb-2">
<Space size={4}>
<MedicineBoxOutlined />
<Text strong>药品</Text>
<Tag color="blue">{drugs.length} 种</Tag>
</Space>
<AutoComplete
value={searchValue}
options={searchResults.map((m) => ({
value: m.id,
label: `${m.name} (${m.specification}) 库存:${m.stock}`,
}))}
onSelect={handleAddDrug}
onSearch={handleSearch}
onChange={setSearchValue}
style={{ width: 260 }}
>
<Input prefix={<SearchOutlined />} placeholder="搜索药品..." allowClear size="small" />
</AutoComplete>
</div>
{drugs.length > 0 ? (
<Table columns={columns} dataSource={drugs} pagination={false} size="small"
rowKey="key" scroll={{ x: 800 }} className="mb-2" />
) : (
<Empty description="请搜索并添加药品" className="my-3" />
)}
{drugs.length > 0 && (
<div className="text-right mb-1">
<Text strong className="text-red-500">合计:¥{(totalAmount / 100).toFixed(2)}</Text>
</div>
)}
<TextArea rows={2} placeholder="医嘱备注..." size="small"
value={remarkText} onChange={(e) => setRemarkText(e.target.value)} className="mb-2" />
<div className="flex justify-end gap-1">
<Button size="small" icon={<PrinterOutlined />} onClick={() => message.info('打印功能开发中')}>打印</Button>
<Button size="small" onClick={handleClose}>取消</Button>
<Button type="primary" size="small" icon={<SafetyCertificateOutlined />}
style={{ background: '#52c41a', borderColor: '#52c41a' }} onClick={handleSign}>签名提交</Button>
</div>
</Modal>
<Modal
title="CA 签名确认"
open={signModalVisible}
onOk={handleConfirmSign}
onCancel={() => setSignModalVisible(false)}
okText="确认签名"
cancelText="取消"
confirmLoading={submitting}
width={380}
>
<div className="text-center py-3">
<SafetyCertificateOutlined className="text-3xl text-green-500 mb-2" />
<div className="font-semibold mb-1">确认使用 CA 数字证书签署此处方?</div>
<Text type="secondary">签署后处方将具有法律效力</Text>
<div className="mt-3 p-2 bg-green-50 rounded">
<div>药品:<Text strong>{drugs.length}</Text> 种</div>
<div>金额:<Text strong className="text-red-500">¥{(totalAmount / 100).toFixed(2)}</Text></div>
</div>
</div>
</Modal>
</>
);
};
export default PrescriptionModal;
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;
'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-25T13:00:00Z',
},
{
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-25T13:10:00Z',
},
{
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;
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;
'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;
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: '#722ed1' }} /><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="苯磺酸氨氯地平片&#10;厄贝沙坦类 />
</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: '#1890ff' }}>
{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: '#1890ff' }} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#f6ffed' }}>
<Statistic title="记录次数" value={list.length} suffix="? valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ borderRadius: 8, background: '#fff7e6' }}>
<Statistic title="最近记录 value={dayjs(latest.recorded_at).format('MM-DD')}
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;
'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;
'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;
'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;
'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;
'use client';
import React, { useState } from 'react';
import {
Card, Tabs, Form, Input, Select, DatePicker, Button, Table, Tag,
message, Spin, Modal, Popconfirm, Empty, Row, Col, Statistic, Typography
} from 'antd';
import {
PlusOutlined, DeleteOutlined, RobotOutlined, FileTextOutlined, TeamOutlined,
BarChartOutlined, MedicineBoxOutlined, EditOutlined
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { healthApi } from '../../../api/health';
import type { UpdateProfileParams, ConsultHistoryItem, FamilyMemberParams, CreateReportParams, LabReport, FamilyMember } from '../../../api/health';
const { Text } = Typography;
// ==================== 基本健康信息 ====================
const HealthProfileTab: React.FC = () => {
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['health-profile'],
queryFn: () => healthApi.getProfile(),
select: (res) => res.data,
});
const mutation = useMutation({
mutationFn: (values: UpdateProfileParams) => healthApi.updateProfile(values),
onSuccess: () => {
message.success('健康档案已更新');
queryClient.invalidateQueries({ queryKey: ['health-profile'] });
},
onError: () => message.error('更新失败,请重试'),
});
React.useEffect(() => {
if (data) form.setFieldsValue({ ...data, birth_date: data.birth_date ? dayjs(data.birth_date) : null });
}, [data, form]);
if (isLoading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
return (
<Form form={form} layout="vertical" onFinish={(v) => mutation.mutate({ ...v, birth_date: v.birth_date ? v.birth_date.toISOString() : null })} style={{ maxWidth: 600 }}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="性别" name="gender">
<Select placeholder="请选择" allowClear>
<Select.Option value="male">男</Select.Option>
<Select.Option value="female">女</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="出生日期" name="birth_date">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="既往病史" name="medical_history">
<Input.TextArea rows={3} placeholder="如高血压、糖尿病史" />
</Form.Item>
<Form.Item label="过敏史" name="allergy_history">
<Input.TextArea rows={2} placeholder="过敏药物或食物" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="紧急联系人电话" name="emergency_contact">
<Input placeholder="紧急联系人手机号" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="医保类型" name="insurance_type">
<Select placeholder="请选择" allowClear>
{['城镇职工医保', '城乡居民医保', '商业保险', '自费'].map(v => <Select.Option key={v} value={v}>{v}</Select.Option>)}
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="医保卡号" name="insurance_no">
<Input placeholder="医保卡号" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={mutation.isPending}>保存</Button>
</Form.Item>
</Form>
);
};
// ==================== 检验报告 ====================
const LabReportsTab: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [interpretId, setInterpretId] = useState<string | null>(null);
const [interpretText, setInterpretText] = useState('');
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: reports, isLoading } = useQuery({
queryKey: ['lab-reports'],
queryFn: () => healthApi.listReports(),
select: (res) => res.data,
});
const createMutation = useMutation({
mutationFn: (v: CreateReportParams) => healthApi.createReport(v),
onSuccess: () => {
message.success('报告已添加');
setModalOpen(false);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['lab-reports'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => healthApi.deleteReport(id),
onSuccess: () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['lab-reports'] });
},
});
const interpretMutation = useMutation({
mutationFn: (id: string) => healthApi.aiInterpret(id),
onSuccess: (res) => {
setInterpretText(res.data.interpret);
queryClient.invalidateQueries({ queryKey: ['lab-reports'] });
},
onError: () => message.error('AI解读失败'),
});
const columns = [
{ title: '报告名称', dataIndex: 'title', key: 'title' },
{ title: '类别', dataIndex: 'category', key: 'category', render: (v: string) => v ? <Tag color="blue">{v}</Tag> : '-' },
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
{ title: 'AI解读', dataIndex: 'ai_interpret', key: 'ai_interpret', render: (v: string) => v ? <Text type="success" style={{ fontSize: 12 }}>已解读</Text> : <Text type="secondary" style={{ fontSize: 12 }}>未解读</Text> },
{
title: '操作', key: 'action', render: (_: any, r: LabReport) => (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<RobotOutlined />} loading={interpretMutation.isPending && interpretId === r.id}
onClick={() => { setInterpretId(r.id); interpretMutation.mutate(r.id, { onSuccess: () => setInterpretId(null) }); }}>
AI解读
</Button>
{r.ai_interpret && <Button size="small" onClick={() => { setInterpretText(r.ai_interpret); setInterpretId(r.id); }}>查看解读</Button>}
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
),
},
];
return (
<>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>添加报告</Button>
</div>
<Table columns={columns} dataSource={reports || []} loading={isLoading} rowKey="id" pagination={{ pageSize: 10 }} />
<Modal title="添加检验报告" open={modalOpen} onCancel={() => setModalOpen(false)} onOk={() => form.submit()} confirmLoading={createMutation.isPending}>
<Form form={form} layout="vertical" onFinish={(v) => createMutation.mutate({ ...v, report_date: v.report_date ? v.report_date.toISOString() : null })}>
<Form.Item label="报告名称" name="title" rules={[{ required: true }]}>
<Input placeholder="如:血常规检验报告" />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="类别" name="category">
<Select placeholder="请选择" allowClear>
{['血常规', '尿常规', '生化', '影像', '其他'].map(v => <Select.Option key={v} value={v}>{v}</Select.Option>)}
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="报告日期" name="report_date">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="文件链接" name="file_url">
<Input placeholder="报告文件URL(可选)" />
</Form.Item>
</Form>
</Modal>
<Modal title="AI解读结果" open={!!interpretText} onCancel={() => setInterpretText('')} footer={<Button onClick={() => setInterpretText('')}>关闭</Button>} width={600}>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>{interpretText}</div>
</Modal>
</>
);
};
// ==================== 就诊历史 ====================
const statusMap: Record<string, { color: string; text: string }> = {
pending: { color: 'orange', text: '待接诊' },
waiting: { color: 'blue', text: '等待中' },
in_progress: { color: 'processing', text: '进行中' },
completed: { color: 'success', text: '已完成' },
cancelled: { color: 'default', text: '已取消' },
};
const ConsultHistoryTab: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['consult-history'],
queryFn: () => healthApi.getConsultHistory(),
select: (res) => res.data,
});
const columns = [
{ title: '就诊时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', render: (v: string) => v || '-' },
{ title: '科室', dataIndex: 'department_name', key: 'department_name', render: (v: string) => v || '-' },
{ title: '主诉', dataIndex: 'chief_complaint', key: 'chief_complaint', ellipsis: true },
{ title: '类型', dataIndex: 'type', key: 'type', render: (v: string) => v === 'video' ? '视频问诊' : '图文问诊' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => { const s = statusMap[v] || { color: 'default', text: v }; return <Tag color={s.color}>{s.text}</Tag>; } },
];
return <Table<ConsultHistoryItem> columns={columns} dataSource={data || []} loading={isLoading} rowKey="id" pagination={{ pageSize: 10 }} />;
};
// ==================== 健康趋势 ====================
const HealthTrendTab: React.FC = () => {
const { data, isLoading } = useQuery({
queryKey: ['health-trend'],
queryFn: () => healthApi.getHealthTrend(),
select: (res) => res.data,
});
if (isLoading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
if (!data?.length) return <Empty description="暂无数据" />;
const total = data.reduce((acc, d) => ({ consult: acc.consult + d.consult_count, report: acc.report + d.report_count }), { consult: 0, report: 0 });
return (
<>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}><Card><Statistic title="近6个月问诊次数" value={total.consult} prefix={<MedicineBoxOutlined />} valueStyle={{ color: '#1677ff' }} /></Card></Col>
<Col span={8}><Card><Statistic title="近6个月检验报告" value={total.report} prefix={<FileTextOutlined />} valueStyle={{ color: '#52c41a' }} /></Card></Col>
<Col span={8}><Card><Statistic title="健康记录月份" value={data.filter(d => d.consult_count + d.report_count > 0).length} suffix="/ 6" valueStyle={{ color: '#722ed1' }} /></Card></Col>
</Row>
<Table
dataSource={data}
rowKey="month"
pagination={false}
columns={[
{ title: '月份', dataIndex: 'month', key: 'month' },
{ title: '问诊次数', dataIndex: 'consult_count', key: 'consult_count', render: (v: number) => <Tag color={v > 0 ? 'blue' : 'default'}>{v} 次</Tag> },
{ title: '检验报告', dataIndex: 'report_count', key: 'report_count', render: (v: number) => <Tag color={v > 0 ? 'green' : 'default'}>{v} 份</Tag> },
{ title: '活跃度', key: 'active', render: (_: any, r: any) => { const total = r.consult_count + r.report_count; return total > 0 ? <Tag color="success">有记录</Tag> : <Tag>无记录</Tag>; } },
]}
/>
</>
);
};
// ==================== 家庭成员 ====================
const FamilyTab: React.FC = () => {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FamilyMember | null>(null);
const [form] = Form.useForm();
const queryClient = useQueryClient();
const { data: members, isLoading } = useQuery({
queryKey: ['family-members'],
queryFn: () => healthApi.listFamily(),
select: (res) => res.data,
});
const saveMutation = useMutation({
mutationFn: (v: FamilyMemberParams) => editing ? healthApi.updateFamily(editing.id, v) : healthApi.createFamily(v),
onSuccess: () => {
message.success(editing ? '已更新' : '已添加');
setModalOpen(false);
setEditing(null);
form.resetFields();
queryClient.invalidateQueries({ queryKey: ['family-members'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => healthApi.deleteFamily(id),
onSuccess: () => {
message.success('已删除');
queryClient.invalidateQueries({ queryKey: ['family-members'] });
},
});
const openEdit = (m: FamilyMember) => {
setEditing(m);
form.setFieldsValue({ ...m, birth_date: m.birth_date ? dayjs(m.birth_date) : null });
setModalOpen(true);
};
const openAdd = () => { setEditing(null); form.resetFields(); setModalOpen(true); };
const columns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '关系', dataIndex: 'relation', key: 'relation', render: (v: string) => <Tag color="purple">{v}</Tag> },
{ title: '性别', dataIndex: 'gender', key: 'gender', render: (v: string) => v === 'male' ? '男' : v === 'female' ? '女' : '-' },
{ title: '出生日期', dataIndex: 'birth_date', key: 'birth_date', render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-' },
{ title: '联系电话', dataIndex: 'phone', key: 'phone', render: (v: string) => v || '-' },
{
title: '操作', key: 'action', render: (_: any, r: FamilyMember) => (
<div style={{ display: 'flex', gap: 8 }}>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确认删除?" onConfirm={() => deleteMutation.mutate(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
),
},
];
return (
<>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>添加成员</Button>
</div>
<Table columns={columns} dataSource={members || []} loading={isLoading} rowKey="id" pagination={false} />
<Modal title={editing ? '编辑家庭成员' : '添加家庭成员'} open={modalOpen} onCancel={() => { setModalOpen(false); setEditing(null); }} onOk={() => form.submit()} confirmLoading={saveMutation.isPending}>
<Form form={form} layout="vertical" onFinish={(v) => saveMutation.mutate({ ...v, birth_date: v.birth_date ? v.birth_date.toISOString() : null })}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="姓名" name="name" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="关系" name="relation" rules={[{ required: true }]}>
<Select placeholder="请选择">
{['配偶', '父亲', '母亲', '子女', '兄弟姐妹', '其他'].map(v => <Select.Option key={v} value={v}>{v}</Select.Option>)}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="性别" name="gender">
<Select placeholder="请选择" allowClear>
<Select.Option value="male">男</Select.Option>
<Select.Option value="female">女</Select.Option>
</Select>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="出生日期" name="birth_date">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item label="联系电话" name="phone">
<Input />
</Form.Item>
<Form.Item label="既往病史" name="medical_history">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="过敏史" name="allergy_history">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
);
};
// ==================== 主页面 ====================
const PatientHealthRecordsPage: React.FC = () => (
<div className="space-y-2">
<h4 className="text-sm font-bold text-gray-800 m-0">健康档案</h4>
<Card size="small">
<Tabs defaultActiveKey="profile" size="small" items={[
{ key: 'profile', label: <span><MedicineBoxOutlined /> 健康信息</span>, children: <HealthProfileTab /> },
{ key: 'reports', label: <span><FileTextOutlined /> 检验报告</span>, children: <LabReportsTab /> },
{ key: 'history', label: <span><MedicineBoxOutlined /> 就诊历史</span>, children: <ConsultHistoryTab /> },
{ key: 'trend', label: <span><BarChartOutlined /> 健康趋势</span>, children: <HealthTrendTab /> },
{ key: 'family', label: <span><TeamOutlined /> 家庭成员</span>, children: <FamilyTab /> },
]} />
</Card>
</div>
);
export default PatientHealthRecordsPage;
'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')}>
全部科室 &gt;
</Button>
</div>
</Card>
</div>
);
};
export default PatientHomePage;
'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;
'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;
'use client';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
Input, Button, Typography, Space, Avatar, Tag, Card, List, Divider,
Alert, message, Spin, Drawer, Empty,
} from 'antd';
import {
RobotOutlined,
UserOutlined,
SendOutlined,
MedicineBoxOutlined,
ArrowRightOutlined,
FileTextOutlined,
LoadingOutlined,
InfoCircleOutlined,
HistoryOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useRouter } from 'next/navigation';
import { preConsultApi } from '../../../api/preConsult';
import type { DoctorBrief, ChatDoneInfo, ChatMessage, PreConsultResponse } from '../../../api/preConsult';
import { useUserStore } from '../../../store/userStore';
import ConsultCreateModal from '../../../components/ConsultCreateModal';
import MarkdownRenderer from '../../../components/MarkdownRenderer';
const { Text } = Typography;
const { TextArea } = Input;
// 显示用的消息类型(扩展了loading等状态)
interface DisplayMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
loading?: boolean;
timestamp: number;
}
const PatientPreConsultPage: React.FC = () => {
const router = useRouter();
const { user } = useUserStore();
const [messages, setMessages] = useState<DisplayMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [sessionStatus, setSessionStatus] = useState<string>('new'); // new, in_progress, completed
const [turnCount, setTurnCount] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const [reportContent, setReportContent] = useState('');
const [generating, setGenerating] = useState(false);
const [reportDone, setReportDone] = useState(false);
const [doneInfo, setDoneInfo] = useState<ChatDoneInfo | null>(null);
const [consultModalOpen, setConsultModalOpen] = useState(false);
const [selectedDoctor, setSelectedDoctor] = useState<DoctorBrief | null>(null);
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
const [historyList, setHistoryList] = useState<PreConsultResponse[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
// 患者信息(从用户信息自动获取)
const patientInfo = {
name: user?.real_name || '患者',
gender: user?.gender || '未知',
age: user?.age || 0,
};
// refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<any>(null);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, reportContent, scrollToBottom]);
// 将ChatMessage[] 转换为DisplayMessage[]
const chatMessagesToDisplay = (chatMsgs: ChatMessage[]): DisplayMessage[] => {
return chatMsgs.map((msg, idx) => ({
id: `history-${idx}`,
role: msg.role,
content: msg.content,
timestamp: Date.now() - (chatMsgs.length - idx) * 1000,
}));
};
// 加载历史对话列表
const loadHistoryList = async () => {
setHistoryLoading(true);
try {
const res = await preConsultApi.getList();
setHistoryList(res.data || []);
} catch (error) {
message.error('加载历史对话失败');
} finally {
setHistoryLoading(false);
}
};
// 恢复某个历史会话
const restoreSession = (session: PreConsultResponse) => {
setSessionId(session.id);
setSessionStatus(session.status);
// 恢复对话历史
const chatMsgs = session.chat_messages || [];
if (chatMsgs.length > 0) {
setMessages(chatMessagesToDisplay(chatMsgs));
setTurnCount(Math.floor(chatMsgs.length / 2));
} else {
setMessages([]);
setTurnCount(0);
}
// 如果已完成,恢复报告
if (session.status === 'completed' && session.ai_analysis) {
setReportContent(session.ai_analysis);
setReportDone(true);
if (session.recommended_doctors && session.recommended_doctors.length > 0) {
setDoneInfo({
session_id: session.id,
status: 'completed',
ai_department: session.ai_department,
ai_severity: session.ai_severity,
recommended_doctors: session.recommended_doctors,
});
}
} else {
setReportContent('');
setReportDone(false);
setDoneInfo(null);
}
setHistoryDrawerOpen(false);
setTimeout(() => inputRef.current?.focus(), 300);
};
// 初始化:优先恢复最新的会话(包括已完成的)
useEffect(() => {
const initSession = async () => {
try {
const latestRes = await preConsultApi.getLatest();
if (latestRes.data) {
restoreSession(latestRes.data);
return;
}
const listRes = await preConsultApi.getList();
if (listRes.data && listRes.data.length > 0) {
const latestCompleted = listRes.data.find(s => s.status === 'completed');
if (latestCompleted) {
restoreSession(latestCompleted);
return;
}
}
} catch {
// 没有历史对话,正常初始化
}
// 无历史对话,显示欢迎消息
const welcomeMsg: DisplayMessage = {
id: 'welcome',
role: 'assistant',
content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`,
timestamp: Date.now(),
};
setMessages([welcomeMsg]);
setTimeout(() => inputRef.current?.focus(), 300);
};
setInitialLoading(true);
initSession().finally(() => setInitialLoading(false));
}, []);
const handleSend = () => {
const text = inputValue.trim();
if (!text || sending || generating) return;
// 添加用户消息
const userMsg: DisplayMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: text,
timestamp: Date.now(),
};
// 添加AI占位消息
const aiMsgId = `ai-${Date.now()}`;
const aiMsg: DisplayMessage = {
id: aiMsgId,
role: 'assistant',
content: '',
loading: true,
timestamp: Date.now(),
};
setMessages(prev => [...prev, userMsg, aiMsg]);
setInputValue('');
setSending(true);
// 构建请求参数
const params: any = {
message: text,
patient_name: patientInfo.name,
patient_gender: patientInfo.gender,
patient_age: patientInfo.age,
};
if (sessionId) {
params.session_id = sessionId;
}
// SSE流式对话
abortRef.current = preConsultApi.chat(params, {
onSession: (info) => {
setSessionId(info.session_id);
setSessionStatus('in_progress');
},
onChunk: (content) => {
setMessages(prev => prev.map(m =>
m.id === aiMsgId
? { ...m, content: m.content + content, loading: true }
: m
));
},
onDone: (info) => {
setMessages(prev => prev.map(m =>
m.id === aiMsgId
? { ...m, loading: false }
: m
));
setSending(false);
setTurnCount(info.turn_count || turnCount + 1);
if (!sessionId && info.session_id) {
setSessionId(info.session_id);
}
setTimeout(() => inputRef.current?.focus(), 100);
},
onError: (error) => {
setMessages(prev => prev.map(m =>
m.id === aiMsgId
? { ...m, content: `抱歉,出现了错误:${error}`, loading: false }
: m
));
setSending(false);
message.error(error);
},
});
};
const handleFinishChat = () => {
if (!sessionId || generating) return;
setGenerating(true);
setReportContent('');
setReportDone(false);
// 添加系统消息
const sysMsg: DisplayMessage = {
id: `sys-report-${Date.now()}`,
role: 'system',
content: '正在根据对话内容生成综合分析报告...',
timestamp: Date.now(),
};
setMessages(prev => [...prev, sysMsg]);
abortRef.current = preConsultApi.finishChat(sessionId, {
onChunk: (content) => {
setReportContent(prev => prev + content);
},
onDone: (info) => {
setGenerating(false);
setReportDone(true);
setSessionStatus('completed');
setDoneInfo(info);
},
onError: (error) => {
setGenerating(false);
message.error('报告生成失败: ' + error);
},
});
};
// 选择医生发起问诊
const handleSelectDoctor = (doctor: DoctorBrief) => {
setSelectedDoctor(doctor);
setConsultModalOpen(true);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 清理
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
// =============== 渲染 ===============
// 渲染单条消息
const renderMessage = (msg: DisplayMessage) => {
const isUser = msg.role === 'user';
const isSystem = msg.role === 'system';
if (isSystem) {
return (
<div key={msg.id} style={{ textAlign: 'center', margin: '16px 0' }}>
<Tag icon={<InfoCircleOutlined />} color="processing">{msg.content}</Tag>
</div>
);
}
return (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: isUser ? 'row-reverse' : 'row',
alignItems: 'flex-start',
marginBottom: 20,
gap: 12,
}}
>
<Avatar
size={40}
icon={isUser ? <UserOutlined /> : <RobotOutlined />}
style={{
backgroundColor: isUser ? '#1890ff' : '#722ed1',
flexShrink: 0,
}}
/>
<div
style={{
maxWidth: '75%',
padding: '12px 16px',
borderRadius: isUser ? '16px 16px 4px 16px' : '16px 16px 16px 4px',
backgroundColor: isUser ? '#1890ff' : '#f5f5f5',
color: isUser ? '#fff' : '#333',
fontSize: 15,
lineHeight: 1.7,
boxShadow: '0 1px 2px rgba(0,0,0,0.06)',
position: 'relative',
}}
>
{msg.content && (
isUser ? (
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{msg.content}
</span>
) : (
<MarkdownRenderer
content={msg.content}
fontSize={15}
lineHeight={1.7}
textColor="#333"
/>
)
)}
{msg.loading && (
<span style={{ display: 'inline-block', marginLeft: 4 }}>
<LoadingOutlined style={{ color: isUser ? '#fff' : '#722ed1' }} />
</span>
)}
</div>
</div>
);
};
// 分析报告区域
const renderReport = () => {
if (!reportContent && !generating) return null;
return (
<div style={{ margin: '24px 0' }}>
<Card
title={
<Space>
<FileTextOutlined style={{ color: '#722ed1' }} />
<span>AI 预问诊分析报告</span>
{generating && <LoadingOutlined style={{ color: '#722ed1' }} />}
{reportDone && <Tag color="success">生成完成</Tag>}
</Space>
}
style={{
borderRadius: 12,
borderTop: '3px solid #722ed1',
}}
>
<div style={{ padding: '8px 0' }}>
<MarkdownRenderer content={reportContent} fontSize={15} lineHeight={1.8} />
{generating && <LoadingOutlined style={{ marginLeft: 4, color: '#722ed1' }} />}
</div>
</Card>
</div>
);
};
// 推荐医生列表
const renderDoctors = () => {
const doctors = doneInfo?.recommended_doctors;
if (!doctors || doctors.length === 0) return null;
return (
<Card
style={{ borderRadius: 12, marginTop: 16 }}
title={
<Space>
<MedicineBoxOutlined style={{ color: '#52c41a' }} />
<span>AI 推荐医生</span>
{doneInfo?.ai_department && <Tag color="green">{doneInfo.ai_department}</Tag>}
</Space>
}
>
<List
dataSource={doctors}
renderItem={(doctor) => (
<List.Item
actions={[
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={() => handleSelectDoctor(doctor)}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
发起问诊
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar size={48} src={doctor.avatar} icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} />
}
title={
<Space>
<Text strong>{doctor.name}</Text>
<Tag color="blue">{doctor.title}</Tag>
<Tag>{doctor.department_name}</Tag>
</Space>
}
description={
<Space split={<Divider type="vertical" />}>
<span>评分 {doctor.rating}</span>
<span>问诊 {doctor.consult_count} 次</span>
<span style={{ color: '#fa8c16' }}>¥{(doctor.price / 100).toFixed(0)}/次</span>
</Space>
}
/>
</List.Item>
)}
/>
</Card>
);
};
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', maxWidth: 800, margin: '0 auto' }}>
{/* 头部 */}
<div
style={{
padding: '16px 20px',
background: 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)',
borderRadius: '12px 12px 0 0',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space>
<RobotOutlined style={{ fontSize: 24 }} />
<div>
<div style={{ fontSize: 16, fontWeight: 600 }}>AI 智能预问诊</div>
<div style={{ fontSize: 12, opacity: 0.85 }}>
{sessionStatus === 'completed' ? '问诊已完成' :
sessionStatus === 'in_progress' ? `对话中 · 第 ${turnCount} 轮` :
'请先填写基本信息'}
</div>
</div>
</Space>
<Space>
<Button
ghost
icon={<HistoryOutlined />}
onClick={() => {
setHistoryDrawerOpen(true);
loadHistoryList();
}}
style={{ borderColor: '#fff', color: '#fff' }}
>
历史对话
</Button>
{sessionStatus === 'in_progress' && turnCount >= 2 && !generating && !reportDone && (
<Button
type="primary"
ghost
onClick={handleFinishChat}
icon={<FileTextOutlined />}
style={{ borderColor: '#fff', color: '#fff' }}
>
结束对话 · 生成报告
</Button>
)}
{reportDone && (
<Button
ghost
onClick={() => router.push('/patient/doctors')}
style={{ borderColor: '#fff', color: '#fff' }}
>
浏览全部医生
</Button>
)}
</Space>
</div>
{/* 消息区域 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '20px 20px 0 20px',
background: '#fff',
minHeight: 400,
maxHeight: 'calc(100vh - 320px)',
}}
>
{initialLoading ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<Spin tip="正在加载对话记录..." />
</div>
) : messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: '#bfbfbf' }}>
<RobotOutlined style={{ fontSize: 48, marginBottom: 16 }} />
<div>请在下方输入您的症状,开始智能预问诊</div>
</div>
)}
{messages.map(renderMessage)}
{/* 分析报告 */}
{renderReport()}
{/* 推荐医生 */}
{reportDone && renderDoctors()}
{/* 底部操作区(对话完成后) */}
{reportDone && (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Space size={16}>
<Button
size="large"
onClick={() => {
setSessionId(null);
setSessionStatus('new');
setTurnCount(0);
setReportContent('');
setReportDone(false);
setDoneInfo(null);
// 显示欢迎消息
const welcomeMsg: DisplayMessage = {
id: 'welcome',
role: 'assistant',
content: `您好,${patientInfo.name}!我是AI预问诊助手。\n\n请告诉我您目前的主要症状或不适,我会根据您的描述进一步了解情况,帮助您更好地就诊。\n\n比如您可以说:“我头痛三天了,还有点恶心”`,
timestamp: Date.now(),
};
setMessages([welcomeMsg]);
setTimeout(() => inputRef.current?.focus(), 300);
}}
style={{ borderColor: '#722ed1', color: '#722ed1' }}
>
重新预问诊
</Button>
</Space>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
{sessionStatus !== 'completed' && (
<div
style={{
padding: '16px 20px',
background: '#fff',
borderTop: '1px solid #f0f0f0',
borderRadius: '0 0 12px 12px',
}}
>
{turnCount >= 2 && !generating && !reportDone && (
<Alert
message="对话信息已足够,您可以点击右上角「结束对话 · 生成报告」获取分析结果,或继续补充信息。"
type="info"
showIcon
style={{ marginBottom: 12, borderRadius: 8 }}
closable
/>
)}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
<TextArea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={sending ? 'AI 正在回复...' : '请描述您的症状或回答AI的问题... (Enter发送,Shift+Enter换行)'}
autoSize={{ minRows: 1, maxRows: 4 }}
disabled={sending || generating}
style={{
flex: 1,
borderRadius: 12,
fontSize: 15,
padding: '10px 16px',
resize: 'none',
}}
/>
<Button
type="primary"
icon={sending ? <LoadingOutlined /> : <SendOutlined />}
onClick={handleSend}
disabled={!inputValue.trim() || sending || generating}
style={{
height: 44,
width: 44,
borderRadius: 12,
background: '#722ed1',
borderColor: '#722ed1',
}}
/>
</div>
</div>
)}
{/* 创建问诊弹窗 */}
{selectedDoctor && sessionId && (
<ConsultCreateModal
open={consultModalOpen}
onCancel={() => setConsultModalOpen(false)}
doctorId={selectedDoctor.id}
consultType="text"
preConsultId={sessionId}
chiefComplaint={messages.find(m => m.role === 'user')?.content || ''}
/>
)}
{/* 历史对话抽屉 */}
<Drawer
title={
<Space>
<HistoryOutlined />
<span>历史对话记录</span>
</Space>
}
placement="right"
width={400}
open={historyDrawerOpen}
onClose={() => setHistoryDrawerOpen(false)}
>
{historyLoading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin tip="加载中.." />
</div>
) : historyList.length === 0 ? (
<Empty description="暂无历史对话" />
) : (
<List
dataSource={historyList}
renderItem={(item) => (
<List.Item
style={{ cursor: 'pointer', borderRadius: 8, marginBottom: 8, padding: 12, background: item.id === sessionId ? '#f0f5ff' : '#fafafa' }}
onClick={() => restoreSession(item)}
>
<List.Item.Meta
avatar={
<Avatar
icon={item.status === 'completed' ? <FileTextOutlined /> : <ClockCircleOutlined />}
style={{ backgroundColor: item.status === 'completed' ? '#52c41a' : '#faad14' }}
/>
}
title={
<Space>
<Text strong ellipsis style={{ maxWidth: 200 }}>
{item.chief_complaint || '预问诊对话'}
</Text>
{item.id === sessionId && <Tag color="blue">当前</Tag>}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>
{item.status === 'completed' ? (
<>
<Tag color="green" style={{ marginRight: 4 }}>已完成</Tag>
{item.ai_department && <Tag>{item.ai_department}</Tag>}
</>
) : (
<Tag color="orange">进行中</Tag>
)}
</div>
<div style={{ fontSize: 12, color: '#999' }}>
{item.chat_messages ? `${Math.floor(item.chat_messages.length / 2)}轮对话` : '无对话'} · {new Date(item.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
</div>
);
};
export default PatientPreConsultPage;
'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;
'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;
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Card, Input, Button, List, Avatar, Typography, Space, Row, Col, Tag, Divider, message } from 'antd';
import {
SendOutlined, RobotOutlined, UserOutlined, PhoneOutlined,
MedicineBoxOutlined, ClockCircleOutlined, ArrowLeftOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { consultApi, type ConsultMessage } from '../../../api/consult';
import { useUserStore } from '../../../store/userStore';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const { TextArea } = Input;
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '等待接诊', color: 'orange' },
waiting: { text: '等待接诊', color: 'orange' },
in_progress: { text: '问诊中', color: 'green' },
completed: { text: '已完成', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
};
const PatientTextConsultPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { user } = useUserStore();
const queryClient = useQueryClient();
const [inputValue, setInputValue] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: consultData } = useQuery({
queryKey: ['consult', id],
queryFn: () => consultApi.getConsultDetail(id!),
enabled: !!id,
refetchInterval: 5000,
});
const { data: messagesData } = useQuery({
queryKey: ['consultMessages', id],
queryFn: () => consultApi.getConsultMessages(id!),
enabled: !!id,
refetchInterval: 3000,
});
const sendMutation = useMutation({
mutationFn: (content: string) => consultApi.sendMessage(id!, content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['consultMessages', id] });
setInputValue('');
},
onError: () => {
message.error('发送失败');
},
});
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messagesData]);
const handleSend = () => {
if (!inputValue.trim()) return;
sendMutation.mutate(inputValue.trim());
};
const consult = consultData?.data;
const canSendMessage = consult?.status === 'in_progress';
const statusInfo = statusMap[consult?.status || 'pending'] || statusMap.pending;
const renderMessage = (msg: ConsultMessage) => {
const isMe = msg.sender_type === 'patient';
const isAI = msg.sender_type === 'ai';
const isSystem = msg.sender_type === 'system';
if (isSystem) {
return (
<div key={msg.id} style={{ textAlign: 'center', margin: '12px 0' }}>
<Tag color="blue" style={{ fontSize: 12 }}>{msg.content}</Tag>
</div>
);
}
return (
<div
key={msg.id}
style={{
display: 'flex',
justifyContent: isMe ? 'flex-end' : 'flex-start',
marginBottom: 16,
}}
>
{!isMe && (
<Avatar
icon={isAI ? <RobotOutlined /> : <UserOutlined />}
src={!isAI ? consult?.doctor_avatar : undefined}
style={{ backgroundColor: isAI ? '#722ed1' : '#1890ff', marginRight: 8, flexShrink: 0 }}
/>
)}
<div style={{ maxWidth: '70%' }}>
{!isMe && (
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4, display: 'block' }}>
{isAI ? 'AI助手' : consult?.doctor_name || '医生'}
</Text>
)}
<div
style={{
background: isMe ? '#1890ff' : isAI ? '#f9f0ff' : '#f5f5f5',
color: isMe ? '#fff' : '#000',
padding: '10px 14px',
borderRadius: isMe ? '12px 12px 4px 12px' : '12px 12px 12px 4px',
lineHeight: 1.6,
}}
>
{msg.content}
</div>
<div style={{ textAlign: isMe ? 'right' : 'left', marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(msg.created_at).format('HH:mm')}
</Text>
</div>
</div>
{isMe && (
<Avatar
style={{ marginLeft: 8, flexShrink: 0, backgroundColor: '#52c41a' }}
src={user?.avatar}
icon={<UserOutlined />}
/>
)}
</div>
);
};
return (
<div style={{ height: 'calc(100vh - 120px)' }}>
<Button type="link" size="small" icon={<ArrowLeftOutlined />}
onClick={() => router.push('/patient/consult-list')} className="p-0! mb-1">返回列表</Button>
<Row gutter={8} style={{ height: 'calc(100% - 32px)' }}>
<Col span={6}>
<Card size="small" style={{ height: '100%' }} styles={{ body: { padding: 12 } }}>
<div className="text-center mb-2">
<Avatar size={48} src={consult?.doctor_avatar} icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<div className="text-sm font-bold mt-1">{consult?.doctor_name || '医生'}</div>
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
</div>
<Divider className="my-1!" />
<div className="text-xs space-y-2">
<div>
<MedicineBoxOutlined className="text-blue-500 mr-1" /><Text type="secondary">类型</Text>
<div className="ml-4 mt-0.5"><Tag color="green">图文</Tag></div>
</div>
<div>
<ClockCircleOutlined className="text-blue-500 mr-1" /><Text type="secondary">发起</Text>
<div className="ml-4 mt-0.5">{consult?.created_at ? dayjs(consult.created_at).format('MM-DD HH:mm') : '-'}</div>
</div>
{consult?.started_at && (
<div>
<PhoneOutlined className="text-green-500 mr-1" /><Text type="secondary">接诊</Text>
<div className="ml-4 mt-0.5">{dayjs(consult.started_at).format('MM-DD HH:mm')}</div>
</div>
)}
</div>
<Divider className="my-1!" />
<div className="text-[11px] text-gray-400 mb-1">主诉</div>
<div className="text-xs bg-gray-50 rounded p-2">{consult?.chief_complaint || '未填写'}</div>
</Card>
</Col>
<Col span={18}>
<Card size="small"
title={<span className="text-xs">
<Avatar size={20} src={consult?.doctor_avatar} icon={<UserOutlined />} className="mr-1" />
{consult?.doctor_name || '医生'} <Tag color={statusInfo.color} className="ml-1">{statusInfo.text}</Tag>
</span>}
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0, overflow: 'hidden' } }}>
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
{(!messagesData?.data || messagesData.data.length === 0) ? (
<div className="text-center py-10 text-xs text-gray-400">
{consult?.status === 'pending' || consult?.status === 'waiting' ? '等待医生接诊中..' : '暂无消息'}
</div>
) : (
<List dataSource={messagesData.data} renderItem={renderMessage} split={false} />
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-gray-100 p-2 bg-gray-50">
{canSendMessage ? (
<Space.Compact style={{ width: '100%' }}>
<TextArea value={inputValue} onChange={(e) => setInputValue(e.target.value)}
placeholder="输入消息..." autoSize={{ minRows: 1, maxRows: 3 }} size="small"
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend(); } }} style={{ flex: 1 }} />
<Button type="primary" size="small" icon={<SendOutlined />} onClick={handleSend}
loading={sendMutation.isPending}>发送</Button>
</Space.Compact>
) : (
<div className="text-center text-xs text-gray-400 py-1">
{consult?.status === 'completed' ? '问诊已结束' : '等待医生接诊后可发送消息'}
</div>
)}
</div>
</Card>
</Col>
</Row>
</div>
);
};
export default PatientTextConsultPage;
'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;
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