Commit 9033450a authored by yuguo's avatar yuguo

fix

parent f5410548
...@@ -23,7 +23,8 @@ ...@@ -23,7 +23,8 @@
"Bash(npm:*)", "Bash(npm:*)",
"Bash(./api.exe:*)", "Bash(./api.exe:*)",
"Bash(PGPASSWORD=123456 psql:*)", "Bash(PGPASSWORD=123456 psql:*)",
"Bash(go mod:*)" "Bash(go mod:*)",
"Bash(go vet:*)"
] ]
} }
} }
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, Table, Tag, Button, Modal, Input, message, Space, Collapse, Timeline, Typography } from 'antd'; import { Card, Table, Tag, Button, Modal, Input, message, Space, Collapse, Timeline, Typography, Tabs, DatePicker, Select, Badge, Tooltip } from 'antd';
import { RobotOutlined, PlayCircleOutlined, ToolOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { RobotOutlined, PlayCircleOutlined, ToolOutlined, CheckCircleOutlined, CloseCircleOutlined, HistoryOutlined, ThunderboltOutlined } from '@ant-design/icons';
import { agentApi } from '@/api/agent'; import { agentApi } from '@/api/agent';
import type { ToolCall } from '@/api/agent'; import type { ToolCall, AgentExecutionLog } from '@/api/agent';
const { Text } = Typography; const { Text } = Typography;
const { RangePicker } = DatePicker;
const BUILTIN_AGENTS = [ const BUILTIN_AGENTS = [
{ id: 'pre_consult_agent', name: '预问诊智能助手', description: '通过多轮对话收集患者症状,生成预问诊报告', category: 'pre_consult', tools: ['query_symptom_knowledge', 'recommend_department'], max_iterations: 5 }, { id: 'pre_consult_agent', name: '预问诊智能助手', description: '通过多轮对话收集患者症状,生成预问诊报告', category: 'pre_consult', tools: ['query_symptom_knowledge', 'recommend_department'], max_iterations: 5 },
...@@ -16,17 +17,10 @@ const BUILTIN_AGENTS = [ ...@@ -16,17 +17,10 @@ const BUILTIN_AGENTS = [
]; ];
const categoryColor: Record<string, string> = { const categoryColor: Record<string, string> = {
pre_consult: 'blue', pre_consult: 'blue', diagnosis: 'purple', prescription: 'orange', follow_up: 'green',
diagnosis: 'purple',
prescription: 'orange',
follow_up: 'green',
}; };
const categoryLabel: Record<string, string> = { const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', pre_consult: '预问诊', diagnosis: '诊断辅助', prescription: '处方审核', follow_up: '随访管理',
diagnosis: '诊断辅助',
prescription: '处方审核',
follow_up: '随访管理',
}; };
interface AgentResponse { interface AgentResponse {
...@@ -34,7 +28,6 @@ interface AgentResponse { ...@@ -34,7 +28,6 @@ interface AgentResponse {
tool_calls?: ToolCall[]; tool_calls?: ToolCall[];
iterations?: number; iterations?: number;
total_tokens?: number; total_tokens?: number;
finish_reason?: string;
} }
export default function AgentsPage() { export default function AgentsPage() {
...@@ -44,6 +37,24 @@ export default function AgentsPage() { ...@@ -44,6 +37,24 @@ export default function AgentsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [sessionId, setSessionId] = useState(''); const [sessionId, setSessionId] = useState('');
// 执行日志
const [logs, setLogs] = useState<AgentExecutionLog[]>([]);
const [logTotal, setLogTotal] = useState(0);
const [logLoading, setLogLoading] = useState(false);
const [logFilter, setLogFilter] = useState<{ agent_id?: string; page: number; page_size: number }>({ page: 1, page_size: 10 });
const [expandedLog, setExpandedLog] = useState<AgentExecutionLog | null>(null);
const fetchLogs = async (filter = logFilter) => {
setLogLoading(true);
try {
const res = await agentApi.getExecutionLogs(filter);
setLogs(res.data?.list || []);
setLogTotal(res.data?.total || 0);
} catch {} finally { setLogLoading(false); }
};
useEffect(() => { fetchLogs(); }, []);
const openTest = (agentId: string, agentName: string) => { const openTest = (agentId: string, agentName: string) => {
setTestModal({ open: true, agentId, agentName }); setTestModal({ open: true, agentId, agentName });
setTestMessages([]); setTestMessages([]);
...@@ -59,10 +70,9 @@ export default function AgentsPage() { ...@@ -59,10 +70,9 @@ export default function AgentsPage() {
try { try {
const res = await agentApi.chat(testModal.agentId, { session_id: sessionId, message: userMsg }); const res = await agentApi.chat(testModal.agentId, { session_id: sessionId, message: userMsg });
const agentData = res.data as AgentResponse; const agentData = res.data as AgentResponse;
const reply = agentData?.response || '无响应';
setTestMessages(prev => [...prev, { setTestMessages(prev => [...prev, {
role: 'assistant', role: 'assistant',
content: reply, content: agentData?.response || '无响应',
toolCalls: agentData?.tool_calls, toolCalls: agentData?.tool_calls,
meta: { iterations: agentData?.iterations, tokens: agentData?.total_tokens }, meta: { iterations: agentData?.iterations, tokens: agentData?.total_tokens },
}]); }]);
...@@ -89,8 +99,7 @@ export default function AgentsPage() { ...@@ -89,8 +99,7 @@ export default function AgentsPage() {
<div style={{ color: '#8c8c8c' }}>参数: {tc.arguments}</div> <div style={{ color: '#8c8c8c' }}>参数: {tc.arguments}</div>
{tc.result && ( {tc.result && (
<div style={{ color: tc.success ? '#52c41a' : '#ff4d4f' }}> <div style={{ color: tc.success ? '#52c41a' : '#ff4d4f' }}>
结果: {tc.success ? JSON.stringify(tc.result.data).slice(0, 100) : tc.result.error} {tc.success ? JSON.stringify(tc.result.data).slice(0, 100) + (JSON.stringify(tc.result.data).length > 100 ? '...' : '') : tc.result.error}
{JSON.stringify(tc.result.data).length > 100 && '...'}
</div> </div>
)} )}
</div> </div>
...@@ -101,7 +110,7 @@ export default function AgentsPage() { ...@@ -101,7 +110,7 @@ export default function AgentsPage() {
); );
}; };
const columns = [ const agentColumns = [
{ {
title: '智能体名称', key: 'name', title: '智能体名称', key: 'name',
render: (_: unknown, r: typeof BUILTIN_AGENTS[0]) => ( render: (_: unknown, r: typeof BUILTIN_AGENTS[0]) => (
...@@ -122,9 +131,7 @@ export default function AgentsPage() { ...@@ -122,9 +131,7 @@ export default function AgentsPage() {
}, },
{ {
title: '工具', dataIndex: 'tools', key: 'tools', title: '工具', dataIndex: 'tools', key: 'tools',
render: (v: string[]) => ( render: (v: string[]) => <Space size={4} wrap>{v.map(t => <Tag key={t} style={{ fontSize: 11, margin: 0 }}>{t}</Tag>)}</Space>,
<Space size={4} wrap>{v.map(t => <Tag key={t} style={{ fontSize: 11, margin: 0 }}>{t}</Tag>)}</Space>
),
}, },
{ title: '最大迭代', dataIndex: 'max_iterations', key: 'max_iterations', width: 90, render: (v: number) => <Tag>{v}</Tag> }, { title: '最大迭代', dataIndex: 'max_iterations', key: 'max_iterations', width: 90, render: (v: number) => <Tag>{v}</Tag> },
{ {
...@@ -135,17 +142,105 @@ export default function AgentsPage() { ...@@ -135,17 +142,105 @@ export default function AgentsPage() {
}, },
]; ];
const logColumns = [
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160,
render: (v: string) => <Text style={{ fontSize: 12 }}>{new Date(v).toLocaleString('zh-CN')}</Text>,
},
{
title: '智能体', dataIndex: 'agent_id', key: 'agent_id', width: 160,
render: (v: string) => <Tag color={categoryColor[v?.replace('_agent', '')] || 'default'}>{v}</Tag>,
},
{ title: '用户ID', dataIndex: 'user_id', key: 'user_id', width: 130, ellipsis: true, render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text> },
{
title: '输入摘要', dataIndex: 'input', key: 'input', ellipsis: true,
render: (v: string) => {
try { const obj = JSON.parse(v); return <Tooltip title={obj.message}><Text style={{ fontSize: 12 }}>{(obj.message || '').slice(0, 30)}...</Text></Tooltip>; } catch { return v; }
},
},
{ title: '迭代', dataIndex: 'iterations', key: 'iterations', width: 70, render: (v: number) => <Badge count={v} style={{ backgroundColor: '#722ed1' }} /> },
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 80, render: (v: number) => <Text style={{ fontSize: 12 }}>{v}</Text> },
{ title: '耗时(ms)', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: (v: number) => <Text style={{ fontSize: 12 }}>{v}</Text> },
{
title: '状态', dataIndex: 'success', key: 'success', width: 80,
render: (v: boolean) => v ? <Badge status="success" text="成功" /> : <Badge status="error" text="失败" />,
},
{
title: '操作', key: 'action', width: 80,
render: (_: unknown, record: AgentExecutionLog) => (
<Button type="link" size="small" icon={<HistoryOutlined />} onClick={() => setExpandedLog(record)}>详情</Button>
),
},
];
return ( return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div> <div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>智能体管理</h2> <h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>智能体管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台内置 AI 智能体,支持对话测试与工具调用查看</div> <div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理平台内置 AI 智能体,支持对话测试与执行日志查看</div>
</div> </div>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}> <Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table dataSource={BUILTIN_AGENTS} columns={columns} rowKey="id" pagination={false} size="small" /> <Tabs
items={[
{
key: 'agents',
label: <Space><RobotOutlined />智能体列表</Space>,
children: <Table dataSource={BUILTIN_AGENTS} columns={agentColumns} rowKey="id" pagination={false} size="small" />,
},
{
key: 'logs',
label: <Space><HistoryOutlined />执行日志</Space>,
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<Select
placeholder="筛选智能体"
allowClear
style={{ width: 200 }}
options={BUILTIN_AGENTS.map(a => ({ value: a.id, label: a.name }))}
onChange={v => {
const newFilter = { ...logFilter, agent_id: v, page: 1 };
setLogFilter(newFilter);
fetchLogs(newFilter);
}}
/>
<RangePicker
onChange={(_, strs) => {
const newFilter = { ...logFilter, ...(strs[0] ? { start: strs[0] } : {}), ...(strs[1] ? { end: strs[1] } : {}), page: 1 };
setLogFilter(newFilter);
fetchLogs(newFilter);
}}
/>
<Button icon={<ThunderboltOutlined />} onClick={() => fetchLogs()}>刷新</Button>
</div>
<Table
dataSource={logs}
columns={logColumns}
rowKey="id"
loading={logLoading}
size="small"
pagination={{
current: logFilter.page,
pageSize: logFilter.page_size,
total: logTotal,
size: 'small',
showTotal: (t) => `共 ${t} 条`,
onChange: (page, pageSize) => {
const newFilter = { ...logFilter, page, page_size: pageSize };
setLogFilter(newFilter);
fetchLogs(newFilter);
},
}}
/>
</div>
),
},
]}
/>
</Card> </Card>
{/* 测试对话 Modal */}
<Modal <Modal
title={`测试 · ${testModal.agentName}`} title={`测试 · ${testModal.agentName}`}
open={testModal.open} open={testModal.open}
...@@ -179,6 +274,43 @@ export default function AgentsPage() { ...@@ -179,6 +274,43 @@ export default function AgentsPage() {
<Button type="primary" onClick={sendMessage} loading={loading}>发送</Button> <Button type="primary" onClick={sendMessage} loading={loading}>发送</Button>
</Space.Compact> </Space.Compact>
</Modal> </Modal>
{/* 执行日志详情 Modal */}
<Modal
title={<Space><HistoryOutlined />执行日志详情</Space>}
open={!!expandedLog}
onCancel={() => setExpandedLog(null)}
footer={null}
width={700}
>
{expandedLog && (() => {
let toolCalls: ToolCall[] = [];
try { toolCalls = JSON.parse(expandedLog.tool_calls || '[]'); } catch {}
let inputObj: Record<string, unknown> = {};
try { inputObj = JSON.parse(expandedLog.input || '{}'); } catch {}
let outputObj: Record<string, unknown> = {};
try { outputObj = JSON.parse(expandedLog.output || '{}'); } catch {}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, fontSize: 13 }}>
<div><Text type="secondary">智能体:</Text><Tag>{expandedLog.agent_id}</Tag></div>
<div><Text type="secondary">状态:</Text><Badge status={expandedLog.success ? 'success' : 'error'} text={expandedLog.success ? '成功' : '失败'} /></div>
<div><Text type="secondary">迭代次数:</Text>{expandedLog.iterations}</div>
<div><Text type="secondary">耗时:</Text>{expandedLog.duration_ms}ms</div>
<div><Text type="secondary">Tokens:</Text>{expandedLog.total_tokens}</div>
<div><Text type="secondary">完成原因:</Text>{expandedLog.finish_reason || '-'}</div>
</div>
<Card size="small" title="用户输入">
<Text style={{ fontSize: 12 }}>{(inputObj.message as string) || expandedLog.input}</Text>
</Card>
<Card size="small" title="AI 回复">
<Text style={{ fontSize: 12 }}>{(outputObj.response as string) || expandedLog.output}</Text>
</Card>
{renderToolCalls(toolCalls)}
</div>
);
})()}
</Modal>
</div> </div>
); );
} }
...@@ -5,7 +5,6 @@ import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, B ...@@ -5,7 +5,6 @@ import { Card, Table, Tag, Button, Modal, Form, Input, Select, message, Space, B
import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined } from '@ant-design/icons'; import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined } from '@ant-design/icons';
import { workflowApi } from '@/api/agent'; import { workflowApi } from '@/api/agent';
import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor'; import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor';
import type { Node, Edge } from '@xyflow/react';
interface Workflow { interface Workflow {
id: number; id: number;
...@@ -60,7 +59,13 @@ export default function WorkflowsPage() { ...@@ -60,7 +59,13 @@ export default function WorkflowsPage() {
}, },
edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }], edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }],
}; };
await workflowApi.create({ ...values, definition: JSON.stringify(definition) }); await workflowApi.create({
workflow_id: values.workflow_id,
name: values.name,
description: values.description,
category: values.category,
definition: JSON.stringify(definition),
});
message.success('创建成功'); message.success('创建成功');
setCreateModal(false); setCreateModal(false);
form.resetFields(); form.resetFields();
...@@ -72,7 +77,8 @@ export default function WorkflowsPage() { ...@@ -72,7 +77,8 @@ export default function WorkflowsPage() {
} }
}; };
const handleSaveWorkflow = useCallback(async (nodes: Node[], edges: Edge[]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSaveWorkflow = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return; if (!editingWorkflow) return;
try { try {
await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) }); await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) });
...@@ -81,7 +87,8 @@ export default function WorkflowsPage() { ...@@ -81,7 +87,8 @@ export default function WorkflowsPage() {
} catch { message.error('保存失败'); } } catch { message.error('保存失败'); }
}, [editingWorkflow]); }, [editingWorkflow]);
const handleExecuteFromEditor = useCallback(async (nodes: Node[], edges: Edge[]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleExecuteFromEditor = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return; if (!editingWorkflow) return;
try { try {
const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } }); const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } });
...@@ -96,7 +103,7 @@ export default function WorkflowsPage() { ...@@ -96,7 +103,7 @@ export default function WorkflowsPage() {
} catch { message.error('执行失败'); } } catch { message.error('执行失败'); }
}; };
const getEditorInitialData = (): { nodes?: Node[]; edges?: Edge[] } | undefined => { const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | undefined => {
if (!editingWorkflow?.definition) return undefined; if (!editingWorkflow?.definition) return undefined;
try { return JSON.parse(editingWorkflow.definition); } catch { return undefined; } try { return JSON.parse(editingWorkflow.definition); } catch { return undefined; }
}; };
...@@ -193,10 +200,14 @@ export default function WorkflowsPage() { ...@@ -193,10 +200,14 @@ export default function WorkflowsPage() {
<div style={{ height: 650 }}> <div style={{ height: 650 }}>
<VisualWorkflowEditor <VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'} workflowName={editingWorkflow?.name || '编辑工作流'}
initialNodes={getEditorInitialData()?.nodes} // eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEdges={getEditorInitialData()?.edges} initialNodes={getEditorInitialData()?.nodes as any}
onSave={handleSaveWorkflow} // eslint-disable-next-line @typescript-eslint/no-explicit-any
onExecute={handleExecuteFromEditor} initialEdges={getEditorInitialData()?.edges as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSave={handleSaveWorkflow as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onExecute={handleExecuteFromEditor as any}
/> />
</div> </div>
</Modal> </Modal>
......
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
HeartOutlined, HeartOutlined,
MessageOutlined, MessageOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { usePathname } from 'next/navigation';
import { useUserStore } from '../../store/userStore'; import { useUserStore } from '../../store/userStore';
import { useAIAssistStore } from '../../store/aiAssistStore'; import { useAIAssistStore } from '../../store/aiAssistStore';
import ChatPanel from './ChatPanel'; import ChatPanel from './ChatPanel';
...@@ -15,6 +16,7 @@ import ChatPanel from './ChatPanel'; ...@@ -15,6 +16,7 @@ import ChatPanel from './ChatPanel';
const FloatContainer: React.FC = () => { const FloatContainer: React.FC = () => {
const { user } = useUserStore(); const { user } = useUserStore();
const { isOpen, patientContext, openDrawer, closeDrawer } = useAIAssistStore(); const { isOpen, patientContext, openDrawer, closeDrawer } = useAIAssistStore();
const pathname = usePathname();
if (!user) return null; if (!user) return null;
...@@ -22,6 +24,9 @@ const FloatContainer: React.FC = () => { ...@@ -22,6 +24,9 @@ const FloatContainer: React.FC = () => {
const isPatient = user.role === 'patient'; const isPatient = user.role === 'patient';
if (!isDoctor && !isPatient) return null; if (!isDoctor && !isPatient) return null;
// Hide on doctor consult page — it has its own integrated AI panel
if (isDoctor && pathname?.includes('/doctor/consult')) return null;
const primaryColor = isPatient const primaryColor = isPatient
? 'linear-gradient(135deg, #22c55e, #06b6d4)' ? 'linear-gradient(135deg, #22c55e, #06b6d4)'
: 'linear-gradient(135deg, #3b82f6, #8b5cf6)'; : 'linear-gradient(135deg, #3b82f6, #8b5cf6)';
......
...@@ -40,6 +40,7 @@ interface NodeData { ...@@ -40,6 +40,7 @@ interface NodeData {
label: string; label: string;
nodeType: NodeType; nodeType: NodeType;
config?: Record<string, unknown>; config?: Record<string, unknown>;
[key: string]: unknown;
} }
const NODE_CONFIGS: { type: NodeType; label: string; icon: React.ReactNode; color: string; bgColor: string }[] = [ const NODE_CONFIGS: { type: NodeType; label: string; icon: React.ReactNode; color: string; bgColor: string }[] = [
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Card, Tabs, Typography, Space, Empty, Tag, Divider, Alert, Spin, Badge, Button, Tabs, Typography, Space, Empty, Tag, Alert, Spin, Badge, Button,
Collapse, Timeline, Collapse, Timeline, Divider,
} from 'antd'; } from 'antd';
import { import {
RobotOutlined, FileTextOutlined, UserOutlined, RobotOutlined, FileTextOutlined, UserOutlined,
...@@ -20,6 +20,8 @@ interface AIPanelProps { ...@@ -20,6 +20,8 @@ interface AIPanelProps {
activeConsultId?: string; activeConsultId?: string;
preConsultReport: PreConsultResponse | null; preConsultReport: PreConsultResponse | null;
preConsultLoading: boolean; preConsultLoading: boolean;
onDiagnosisChange?: (text: string) => void;
onMedicationChange?: (text: string) => void;
} }
const AIPanel: React.FC<AIPanelProps> = ({ const AIPanel: React.FC<AIPanelProps> = ({
...@@ -27,6 +29,8 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -27,6 +29,8 @@ const AIPanel: React.FC<AIPanelProps> = ({
activeConsultId, activeConsultId,
preConsultReport, preConsultReport,
preConsultLoading, preConsultLoading,
onDiagnosisChange,
onMedicationChange,
}) => { }) => {
const [diagnosisContent, setDiagnosisContent] = useState(''); const [diagnosisContent, setDiagnosisContent] = useState('');
const [diagnosisLoading, setDiagnosisLoading] = useState(false); const [diagnosisLoading, setDiagnosisLoading] = useState(false);
...@@ -38,16 +42,22 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -38,16 +42,22 @@ const AIPanel: React.FC<AIPanelProps> = ({
const handleAIAssist = async (scene: 'consult_diagnosis' | 'consult_medication') => { const handleAIAssist = async (scene: 'consult_diagnosis' | 'consult_medication') => {
if (!activeConsultId) return; if (!activeConsultId) return;
const setLoading = scene === 'consult_diagnosis' ? setDiagnosisLoading : setMedicationLoading; const isDiag = scene === 'consult_diagnosis';
const setContent = scene === 'consult_diagnosis' ? setDiagnosisContent : setMedicationContent; const setLoading = isDiag ? setDiagnosisLoading : setMedicationLoading;
const setToolCalls = scene === 'consult_diagnosis' ? setDiagnosisToolCalls : setMedicationToolCalls; const setContent = isDiag ? setDiagnosisContent : setMedicationContent;
const setToolCalls = isDiag ? setDiagnosisToolCalls : setMedicationToolCalls;
const onChange = isDiag ? onDiagnosisChange : onMedicationChange;
setLoading(true); setLoading(true);
try { try {
const res = await consultApi.aiAssist(activeConsultId, scene); const res = await consultApi.aiAssist(activeConsultId, scene);
setContent(res.data?.response || '暂无分析结果'); const text = res.data?.response || '暂无分析结果';
setContent(text);
setToolCalls(res.data?.tool_calls || []); setToolCalls(res.data?.tool_calls || []);
} catch (err: any) { onChange?.(text);
setContent('AI分析失败: ' + (err?.message || '请稍后重试')); } catch (err: unknown) {
const text = 'AI分析失败: ' + ((err as Error)?.message || '请稍后重试');
setContent(text);
setToolCalls([]); setToolCalls([]);
} finally { } finally {
setLoading(false); setLoading(false);
...@@ -59,7 +69,7 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -59,7 +69,7 @@ const AIPanel: React.FC<AIPanelProps> = ({
return ( return (
<Collapse <Collapse
size="small" size="small"
style={{ marginTop: 8, background: '#f9fafb', borderRadius: 8 }} style={{ marginTop: 8, borderRadius: 6 }}
items={[{ items={[{
key: 'tools', key: 'tools',
label: ( label: (
...@@ -79,7 +89,10 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -79,7 +89,10 @@ const AIPanel: React.FC<AIPanelProps> = ({
children: ( children: (
<div key={idx} style={{ fontSize: 12 }}> <div key={idx} style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500, color: '#374151' }}>{tc.tool_name}</div> <div style={{ fontWeight: 500, color: '#374151' }}>{tc.tool_name}</div>
<div style={{ color: '#9ca3af', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 260 }}> <div style={{
color: '#9ca3af',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
参数: {tc.arguments} 参数: {tc.arguments}
</div> </div>
{tc.result && ( {tc.result && (
...@@ -97,6 +110,80 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -97,6 +110,80 @@ const AIPanel: React.FC<AIPanelProps> = ({
); );
}; };
const renderAIAssistContent = (
loading: boolean,
content: string,
toolCalls: ToolCall[],
scene: 'consult_diagnosis' | 'consult_medication',
) => {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '32px 16px' }}>
<Spin tip="AI分析中,请稍候..." />
</div>
);
}
if (content) {
return (
<div>
<div style={{ maxHeight: 360, overflow: 'auto', padding: '4px 0' }}>
<MarkdownRenderer content={content} fontSize={12} lineHeight={1.6} />
</div>
{renderToolCalls(toolCalls)}
{toolCalls.length > 0 && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 6, display: 'flex', alignItems: 'center', gap: 3 }}>
<ThunderboltOutlined />
通过 {toolCalls.length} 次工具调用生成
</div>
)}
<Divider style={{ margin: '10px 0' }} />
<Space size={6}>
<Button
size="small"
icon={<ThunderboltOutlined />}
onClick={() => handleAIAssist(scene)}
>
重新分析
</Button>
{scene === 'consult_medication' && (
<Button
size="small"
type="primary"
ghost
icon={<MedicineBoxOutlined />}
onClick={() => {
// Medication content is already synced to parent via onMedicationChange
// The prescription modal reads it from aiMedication prop
}}
>
已同步至处方
</Button>
)}
</Space>
</div>
);
}
return (
<div style={{ textAlign: 'center', padding: '28px 16px' }}>
<RobotOutlined style={{ fontSize: 36, color: '#d9d9d9', display: 'block', marginBottom: 12 }} />
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 14, lineHeight: 1.6 }}>
{scene === 'consult_diagnosis'
? '基于本次问诊对话内容,AI将辅助生成鉴别诊断建议'
: '基于问诊对话和患者信息,AI将辅助生成安全用药建议'}
</Text>
<Button
type="primary"
size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
style={scene === 'consult_medication' ? { background: '#722ed1', borderColor: '#722ed1' } : {}}
>
{scene === 'consult_diagnosis' ? '生成鉴别诊断' : '生成用药建议'}
</Button>
</div>
);
};
const hasPreConsultData = preConsultReport && ( const hasPreConsultData = preConsultReport && (
preConsultReport.ai_analysis || (preConsultReport.chat_messages && preConsultReport.chat_messages.length > 0) preConsultReport.ai_analysis || (preConsultReport.chat_messages && preConsultReport.chat_messages.length > 0)
); );
...@@ -106,17 +193,23 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -106,17 +193,23 @@ const AIPanel: React.FC<AIPanelProps> = ({
return <Alert message="暂无对话记录" type="info" showIcon style={{ fontSize: 12 }} />; return <Alert message="暂无对话记录" type="info" showIcon style={{ fontSize: 12 }} />;
} }
return ( return (
<div style={{ maxHeight: 300, overflow: 'auto', padding: 8, background: '#f9f9f9', borderRadius: 6 }}> <div style={{ maxHeight: 320, overflow: 'auto', padding: 8, background: '#f9f9f9', borderRadius: 6 }}>
<Text strong style={{ fontSize: 12, color: '#666', marginBottom: 8, display: 'block' }}> <Text style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 8, display: 'block' }}>
<MessageOutlined style={{ marginRight: 4 }} /> <MessageOutlined style={{ marginRight: 4 }} />
对话记录(共{Math.floor(chatMsgs.length / 2)}轮) 对话记录(共 {Math.floor(chatMsgs.length / 2)} 轮)
</Text> </Text>
{chatMsgs.map((msg, i) => ( {chatMsgs.map((msg, i) => (
<div key={i} style={{ fontSize: 12, marginTop: 6, paddingLeft: 8, borderLeft: msg.role === 'user' ? '2px solid #1890ff' : '2px solid #52c41a' }}> <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' }}> <Text strong style={{ fontSize: 12, color: msg.role === 'user' ? '#1890ff' : '#52c41a' }}>
{msg.role === 'user' ? '患者' : 'AI'} {msg.role === 'user' ? '患者' : 'AI助手'}
</Text> </Text>
<div style={{ marginTop: 2, color: '#333' }}>{msg.content}</div> <div style={{ marginTop: 2, color: '#333', lineHeight: 1.5 }}>{msg.content}</div>
</div> </div>
))} ))}
</div> </div>
...@@ -125,38 +218,47 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -125,38 +218,47 @@ const AIPanel: React.FC<AIPanelProps> = ({
const renderPreConsultContent = () => { const renderPreConsultContent = () => {
if (!preConsultReport) return null; if (!preConsultReport) return null;
const patientInfo = ( return (
<div style={{ padding: 8, background: '#f9f0ff', borderRadius: 6, marginBottom: 8 }}> <div>
{/* 患者信息 */}
<div style={{ padding: '8px 10px', background: '#f9f0ff', borderRadius: 6, marginBottom: 8 }}>
<Text strong style={{ fontSize: 13, color: '#722ed1' }}> <Text strong style={{ fontSize: 13, color: '#722ed1' }}>
<UserOutlined style={{ marginRight: 4 }} /> <UserOutlined style={{ marginRight: 4 }} />
{preConsultReport.patient_name || '患者'} {preConsultReport.patient_name || '患者'}
</Text> </Text>
{(preConsultReport.patient_gender || preConsultReport.patient_age) && ( {(preConsultReport.patient_gender || preConsultReport.patient_age) && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}> <Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
{preConsultReport.patient_gender} {preConsultReport.patient_age ? `· ${preConsultReport.patient_age}岁` : ''} {preConsultReport.patient_gender}
{preConsultReport.patient_age ? ` · ${preConsultReport.patient_age}岁` : ''}
</Text> </Text>
)} )}
</div> </div>
);
const tags = (preConsultReport.ai_severity || preConsultReport.ai_department) && ( {/* 严重程度 + 推荐科室 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8, flexWrap: 'wrap' }}> {(preConsultReport.ai_severity || preConsultReport.ai_department) && (
<Space size={4} style={{ marginBottom: 8, flexWrap: 'wrap' }}>
{preConsultReport.ai_severity && ( {preConsultReport.ai_severity && (
<Tag color={preConsultReport.ai_severity === 'severe' ? 'red' : preConsultReport.ai_severity === 'moderate' ? 'orange' : 'green'}> <Tag color={
{preConsultReport.ai_severity === 'severe' ? '重度' : preConsultReport.ai_severity === 'moderate' ? '中度' : '轻度'} preConsultReport.ai_severity === 'severe' ? 'red'
: preConsultReport.ai_severity === 'moderate' ? 'orange' : 'green'
}>
{preConsultReport.ai_severity === 'severe' ? '重度'
: preConsultReport.ai_severity === 'moderate' ? '中度' : '轻度'}
</Tag> </Tag>
)} )}
{preConsultReport.ai_department && <Tag color="blue">{preConsultReport.ai_department}</Tag>} {preConsultReport.ai_department && <Tag color="blue">{preConsultReport.ai_department}</Tag>}
</div> </Space>
); )}
return (
<div> {/* 主诉 */}
{patientInfo}
{tags}
{preConsultReport.chief_complaint && ( {preConsultReport.chief_complaint && (
<div style={{ fontSize: 12, marginBottom: 8 }}> <div style={{ fontSize: 12, marginBottom: 10 }}>
<Text strong>主诉</Text>{preConsultReport.chief_complaint} <Text type="secondary">主诉:</Text>
{preConsultReport.chief_complaint}
</div> </div>
)} )}
{/* 子标签:对话 vs 报告 */}
<Tabs <Tabs
size="small" size="small"
activeKey={preConsultSubTab} activeKey={preConsultSubTab}
...@@ -171,7 +273,7 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -171,7 +273,7 @@ const AIPanel: React.FC<AIPanelProps> = ({
key: 'analysis', key: 'analysis',
label: <span><FileTextOutlined /> AI分析报告</span>, label: <span><FileTextOutlined /> AI分析报告</span>,
children: preConsultReport.ai_analysis ? ( children: preConsultReport.ai_analysis ? (
<div style={{ maxHeight: 300, overflow: 'auto', padding: 8, background: '#f6ffed', borderRadius: 6 }}> <div style={{ maxHeight: 320, overflow: 'auto', padding: 8, background: '#f6ffed', borderRadius: 6 }}>
<MarkdownRenderer content={preConsultReport.ai_analysis} fontSize={12} lineHeight={1.6} /> <MarkdownRenderer content={preConsultReport.ai_analysis} fontSize={12} lineHeight={1.6} />
</div> </div>
) : ( ) : (
...@@ -184,81 +286,51 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -184,81 +286,51 @@ const AIPanel: React.FC<AIPanelProps> = ({
); );
}; };
const renderAIAssistContent = (
loading: boolean,
content: string,
toolCalls: ToolCall[],
scene: 'consult_diagnosis' | 'consult_medication',
) => {
if (loading) return <div style={{ textAlign: 'center', padding: 20 }}><Spin tip="AI分析.." /></div>;
if (content) {
return ( return (
<div> <div style={{
<div style={{ maxHeight: 400, overflow: 'auto' }}> height: '100%',
<MarkdownRenderer content={content} fontSize={12} lineHeight={1.6} /> borderRadius: 12,
</div> border: '1px solid #edf2fc',
{renderToolCalls(toolCalls)} background: '#fff',
{toolCalls.length > 0 && ( display: 'flex',
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}> flexDirection: 'column',
<ThunderboltOutlined style={{ marginRight: 2 }} /> overflow: 'hidden',
通过 {toolCalls.length} 次工具调用生成 }}>
</div> {/* 标题 */}
<div style={{
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
}}>
<RobotOutlined style={{ color: '#52c41a', fontSize: 16 }} />
<span style={{ fontWeight: 600, fontSize: 14 }}>AI 辅助</span>
{hasActiveConsult && (
<Badge status="processing" style={{ marginLeft: 2 }} />
)} )}
<Divider style={{ margin: '8px 0' }} />
<Button
size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
>
重新分析
</Button>
</div> </div>
);
}
return (
<div style={{ textAlign: 'center', padding: 16 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
{scene === 'consult_diagnosis'
? '基于问诊对话内容,AI将辅助生成鉴别诊断'
: '基于问诊对话内容,AI将辅助生成用药建议'}
</Text>
<Button type="primary" size="small"
icon={scene === 'consult_diagnosis' ? <ThunderboltOutlined /> : <MedicineBoxOutlined />}
onClick={() => handleAIAssist(scene)}
>
{scene === 'consult_diagnosis' ? '生成鉴别诊断' : '生成用药建议'}
</Button>
</div>
);
};
return ( {/* 内容区 */}
<Card <div style={{ flex: 1, overflow: 'auto', padding: '0 4px' }}>
title={
<Space>
<RobotOutlined style={{ color: '#52c41a' }} />
<span>AI 辅助</span>
</Space>
}
style={{ borderRadius: 12, height: '100%' }}
styles={{ body: { padding: 12 } }}
size="small"
>
{hasActiveConsult ? ( {hasActiveConsult ? (
<Tabs <Tabs
size="small" size="small"
defaultActiveKey={hasPreConsultData ? 'preConsult' : 'diagnosis'} defaultActiveKey={hasPreConsultData ? 'preConsult' : 'diagnosis'}
style={{ padding: '0 8px' }}
items={[ items={[
{ {
key: 'preConsult', key: 'preConsult',
label: ( label: (
<span> <span>
<FileTextOutlined /> <FileTextOutlined />
{' 预问诊'}{hasPreConsultData && <Badge dot offset={[4, -2]} />} {' 预问诊'}
{hasPreConsultData && <Badge dot offset={[4, -2]} />}
</span> </span>
), ),
children: preConsultLoading ? ( children: preConsultLoading ? (
<div style={{ textAlign: 'center', padding: 20 }}><Spin tip="加载中.." /></div> <div style={{ textAlign: 'center', padding: 24 }}><Spin tip="加载中..." /></div>
) : hasPreConsultData ? ( ) : hasPreConsultData ? (
renderPreConsultContent() renderPreConsultContent()
) : ( ) : (
...@@ -268,19 +340,32 @@ const AIPanel: React.FC<AIPanelProps> = ({ ...@@ -268,19 +340,32 @@ const AIPanel: React.FC<AIPanelProps> = ({
{ {
key: 'diagnosis', key: 'diagnosis',
label: '鉴别诊断', label: '鉴别诊断',
children: renderAIAssistContent(diagnosisLoading, diagnosisContent, diagnosisToolCalls, 'consult_diagnosis'), children: renderAIAssistContent(
diagnosisLoading, diagnosisContent, diagnosisToolCalls, 'consult_diagnosis'
),
}, },
{ {
key: 'drugs', key: 'drugs',
label: '用药建议', label: (
children: renderAIAssistContent(medicationLoading, medicationContent, medicationToolCalls, 'consult_medication'), <span>
用药建议
{medicationContent && <Badge dot offset={[3, -2]} style={{ backgroundColor: '#722ed1' }} />}
</span>
),
children: renderAIAssistContent(
medicationLoading, medicationContent, medicationToolCalls, 'consult_medication'
),
}, },
]} ]}
/> />
) : ( ) : (
<Empty description="接诊后自动开始AI分析" style={{ marginTop: 40 }} /> <div style={{ textAlign: 'center', paddingTop: 60 }}>
<RobotOutlined style={{ fontSize: 40, color: '#d9d9d9', display: 'block', marginBottom: 14 }} />
<Text type="secondary" style={{ fontSize: 13 }}>接诊后自动开始AI辅助</Text>
</div>
)} )}
</Card> </div>
</div>
); );
}; };
......
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { import {
Card, Avatar, Tag, Button, Typography, Space, Empty, Input, Modal, Avatar, Tag, Button, Typography, Space, Empty, Input, Modal, Tooltip, Popover,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined, UserOutlined, VideoCameraOutlined, SendOutlined, StopOutlined,
FileTextOutlined, RobotOutlined, FileTextOutlined, ThunderboltOutlined, SmileOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ConsultMessage } from '../../../api/consult'; import type { ConsultMessage } from '../../../api/consult';
import PrescriptionModal from '../Prescription'; import PrescriptionModal from '../Prescription';
...@@ -12,38 +12,53 @@ import PrescriptionModal from '../Prescription'; ...@@ -12,38 +12,53 @@ import PrescriptionModal from '../Prescription';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
const QUICK_REPLIES = [
'请问您现在的主要症状是什么?',
'症状持续多久了?',
'有没有发烧、咳嗽等其他伴随症状?',
'既往有什么基础疾病吗?',
'目前正在服用什么药物?',
'您好,请详细描述一下您的不适情况。',
'根据您的描述,初步考虑以下方向...',
'建议您先做以下检查,结果出来后我再为您进一步诊疗。',
'您的情况不严重,注意休息、多喝水,一般3-5天会好转。',
'如果症状明显加重,请及时前往线下医院就诊。',
'我这边为您开具处方,请到附近药房取药并按时服用。',
];
interface ActiveConsult { interface ActiveConsult {
consult_id: string; consult_id: string;
patient_id?: string; patient_id?: string;
patient_name: string; patient_name: string;
patient_gender?: string; patient_gender?: string;
patient_age?: number; patient_age?: number;
chief_complaint?: string;
type: 'text' | 'video'; type: 'text' | 'video';
status?: string;
} }
interface ChatPanelProps { interface ChatPanelProps {
activeConsult: ActiveConsult | null; activeConsult: ActiveConsult | null;
consultStatus?: string;
messages: ConsultMessage[]; messages: ConsultMessage[];
onSend: (content: string) => void; onSend: (content: string) => void;
onEndConsult: () => void; onEndConsult: () => void;
onToggleAI: () => void;
aiPanelVisible: boolean;
sending: boolean; sending: boolean;
aiDiagnosis?: string;
aiMedication?: string;
} }
const ChatPanel: React.FC<ChatPanelProps> = ({ const ChatPanel: React.FC<ChatPanelProps> = ({
activeConsult, activeConsult,
consultStatus,
messages, messages,
onSend, onSend,
onEndConsult, onEndConsult,
onToggleAI,
aiPanelVisible,
sending, sending,
aiDiagnosis,
aiMedication,
}) => { }) => {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [prescriptionOpen, setPrescriptionOpen] = useState(false); const [prescriptionOpen, setPrescriptionOpen] = useState(false);
const [quickReplyOpen, setQuickReplyOpen] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
...@@ -56,6 +71,11 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -56,6 +71,11 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
setInputValue(''); setInputValue('');
}; };
const handleQuickReply = (text: string) => {
setInputValue(prev => prev ? `${prev}\n${text}` : text);
setQuickReplyOpen(false);
};
const handleEndConsult = () => { const handleEndConsult = () => {
Modal.confirm({ Modal.confirm({
title: '确认结束问诊', title: '确认结束问诊',
...@@ -75,88 +95,172 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -75,88 +95,172 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
} }
}; };
const isCompleted = consultStatus === 'completed'; const isCompleted = activeConsult?.status === 'completed';
const quickReplyContent = (
<div style={{ width: 290 }}>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 8, fontWeight: 500 }}>快捷回复模板</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{QUICK_REPLIES.map((text, i) => (
<div
key={i}
onClick={() => handleQuickReply(text)}
style={{
padding: '7px 10px',
borderRadius: 6,
fontSize: 12,
cursor: 'pointer',
color: '#374151',
background: '#f9fafb',
border: '1px solid #f0f0f0',
lineHeight: 1.5,
transition: 'background 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = '#e6f7ff'; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = '#f9fafb'; }}
>
{text}
</div>
))}
</div>
</div>
);
return ( return (
<> <>
<Card <div style={{
title={ height: '100%',
activeConsult ? ( display: 'flex',
<Space> flexDirection: 'column',
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#87d068' }} /> borderRadius: 12,
<Text strong>{activeConsult.patient_name}</Text> border: '1px solid #edf2fc',
<Tag color={activeConsult.type === 'video' ? 'blue' : 'green'}> background: '#fff',
overflow: 'hidden',
}}>
{/* 顶部患者信息栏 */}
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 10,
flexShrink: 0,
background: '#fff',
}}>
{activeConsult ? (
<>
<Avatar
size={38}
icon={<UserOutlined />}
style={{ backgroundColor: '#87d068', flexShrink: 0 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Text strong style={{ fontSize: 15 }}>{activeConsult.patient_name}</Text>
<Tag
color={activeConsult.type === 'video' ? 'purple' : 'green'}
style={{ fontSize: 11, margin: 0, lineHeight: '18px' }}
>
{activeConsult.type === 'video' ? '视频问诊' : '图文问诊'} {activeConsult.type === 'video' ? '视频问诊' : '图文问诊'}
</Tag> </Tag>
{isCompleted && <Tag color="default">已结束</Tag>} {isCompleted
</Space> ? <Tag color="default" style={{ fontSize: 11, margin: 0, lineHeight: '18px' }}>已结束</Tag>
) : '问诊对话' : <Tag color="success" style={{ fontSize: 11, margin: 0, lineHeight: '18px' }}>进行中</Tag>
} }
style={{ borderRadius: 12, height: '100%', display: 'flex', flexDirection: 'column' }} </div>
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0 } }} <div style={{ fontSize: 12, color: '#8c8c8c' }}>
extra={ {activeConsult.patient_gender && `${activeConsult.patient_gender} · `}
activeConsult && ( {activeConsult.patient_age && `${activeConsult.patient_age}岁 · `}
<Space> {activeConsult.chief_complaint
<Button ? `主诉:${activeConsult.chief_complaint.slice(0, 28)}${activeConsult.chief_complaint.length > 28 ? '...' : ''}`
type="text" : '暂无主诉'
icon={<RobotOutlined />} }
onClick={onToggleAI} </div>
style={{ color: aiPanelVisible ? '#52c41a' : undefined }} </div>
> <Space size={6}>
AI面板
</Button>
{activeConsult.type === 'video' && !isCompleted && ( {activeConsult.type === 'video' && !isCompleted && (
<Button type="primary" icon={<VideoCameraOutlined />}> <Button size="small" type="primary" ghost icon={<VideoCameraOutlined />}>
发起视频 发起视频
</Button> </Button>
)} )}
<Button <Button
size="small"
icon={<FileTextOutlined />} icon={<FileTextOutlined />}
onClick={() => setPrescriptionOpen(true)} onClick={() => setPrescriptionOpen(true)}
> >
开处方 开处方
</Button> </Button>
{!isCompleted && ( {!isCompleted && (
<Button danger icon={<StopOutlined />} onClick={handleEndConsult}> <Button size="small" danger icon={<StopOutlined />} onClick={handleEndConsult}>
结束问诊 结束问诊
</Button> </Button>
)} )}
</Space> </Space>
) </>
} ) : (
> <Text style={{ color: '#8c8c8c', fontSize: 14 }}>
<FileTextOutlined style={{ marginRight: 6 }} />
问诊对话
</Text>
)}
</div>
{/* 消息区域 */}
<div style={{ flex: 1, overflow: 'auto', padding: 16, background: '#f5f7fb' }}>
{activeConsult ? ( {activeConsult ? (
<> <>
<div style={{ flex: 1, overflow: 'auto', padding: 16, background: '#f9f9f9' }}> {messages.length === 0 && (
{messages.map((msg) => ( <div style={{ textAlign: 'center', paddingTop: 60, color: '#bbb', fontSize: 13 }}>
等待患者发送消息...
</div>
)}
{messages.map(msg => (
<div <div
key={msg.id} key={msg.id}
style={{ style={{
display: 'flex', display: 'flex',
justifyContent: msg.sender_type === 'doctor' ? 'flex-end' : msg.sender_type === 'system' ? 'center' : 'flex-start', justifyContent: msg.sender_type === 'doctor'
marginBottom: 12, ? 'flex-end'
: msg.sender_type === 'system'
? 'center'
: 'flex-start',
marginBottom: 16,
}} }}
> >
{msg.sender_type === 'system' ? ( {msg.sender_type === 'system' ? (
<Tag color="default" style={{ fontSize: 12 }}>{msg.content}</Tag> <div style={{
background: 'rgba(0,0,0,0.04)',
borderRadius: 12,
padding: '3px 14px',
fontSize: 11,
color: '#8c8c8c',
}}>
{msg.content}
</div>
) : ( ) : (
<div style={{ maxWidth: '70%' }}>
<div style={{ <div style={{
textAlign: msg.sender_type === 'doctor' ? 'right' : 'left', maxWidth: '72%',
marginBottom: 4, display: 'flex',
flexDirection: 'column',
alignItems: msg.sender_type === 'doctor' ? 'flex-end' : 'flex-start',
}}> }}>
<Text type="secondary" style={{ fontSize: 12 }}> <div style={{ fontSize: 11, color: '#aaa', marginBottom: 5 }}>
{msg.sender_type === 'doctor' ? '' : activeConsult.patient_name} {formatTime(msg.created_at)} {msg.sender_type === 'doctor' ? '' : activeConsult.patient_name}
</Text> {' · '}
{formatTime(msg.created_at)}
</div> </div>
<div style={{ <div style={{
padding: '8px 12px', padding: '10px 14px',
borderRadius: 8, borderRadius: msg.sender_type === 'doctor'
? '12px 4px 12px 12px'
: '4px 12px 12px 12px',
background: msg.sender_type === 'doctor' ? '#52c41a' : '#fff', background: msg.sender_type === 'doctor' ? '#52c41a' : '#fff',
color: msg.sender_type === 'doctor' ? '#fff' : '#333', color: msg.sender_type === 'doctor' ? '#fff' : '#1d2129',
boxShadow: '0 1px 2px rgba(0,0,0,0.1)', boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
fontSize: 14,
lineHeight: 1.6,
}}> }}>
{msg.content} {msg.content}
</div> </div>
...@@ -165,53 +269,95 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -165,53 +269,95 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
</div> </div>
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</>
) : (
<Empty
description={<span style={{ color: '#8c8c8c', fontSize: 14 }}>请从左侧列表中选择患者接诊</span>}
style={{ marginTop: 80 }}
/>
)}
</div> </div>
{/* 已结束时显示提示,进行中时显示输入框 */} {/* 输入区域 */}
{activeConsult && (
<div style={{ borderTop: '1px solid #f0f0f0', background: '#fff', flexShrink: 0 }}>
{isCompleted ? ( {isCompleted ? (
<div style={{ padding: '12px 16px', textAlign: 'center', color: '#8c8c8c', fontSize: 13 }}>
本次问诊已结束
</div>
) : (
<>
{/* 工具栏 */}
<div style={{ <div style={{
padding: '12px 16px', padding: '6px 12px 0',
borderTop: '1px solid #f0f0f0', display: 'flex',
textAlign: 'center', gap: 2,
background: '#fafafa', borderBottom: '1px solid #f5f5f5',
}}> }}>
<Text type="secondary" style={{ fontSize: 13 }}>本次问诊已结束</Text> <Popover
content={quickReplyContent}
trigger="click"
open={quickReplyOpen}
onOpenChange={setQuickReplyOpen}
placement="topLeft"
>
<Tooltip title="快捷回复">
<Button
type="text"
size="small"
icon={<ThunderboltOutlined />}
style={{ color: '#fa8c16', fontSize: 13 }}
/>
</Tooltip>
</Popover>
<Tooltip title="表情(开发中)">
<Button
type="text"
size="small"
icon={<SmileOutlined />}
style={{ color: '#8c8c8c' }}
/>
</Tooltip>
</div> </div>
) : (
<div style={{ padding: 12, borderTop: '1px solid #f0f0f0' }}> {/* 输入框 + 发送 */}
<Space.Compact style={{ width: '100%' }}> <div style={{ padding: '8px 12px 10px', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<TextArea <TextArea
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={e => setInputValue(e.target.value)}
placeholder="输入消息..." placeholder="输入消息,Enter 发送,Shift+Enter 换行..."
autoSize={{ minRows: 1, maxRows: 3 }} autoSize={{ minRows: 2, maxRows: 5 }}
onPressEnter={(e) => { onPressEnter={e => {
if (!e.shiftKey) { if (!e.shiftKey) {
e.preventDefault(); e.preventDefault();
handleSend(); handleSend();
} }
}} }}
style={{ borderRadius: '8px 0 0 8px' }} style={{ flex: 1, borderRadius: 8, resize: 'none' }}
/> />
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined />}
onClick={handleSend} onClick={handleSend}
loading={sending} loading={sending}
style={{ height: 'auto', borderRadius: '0 8px 8px 0', background: '#52c41a', borderColor: '#52c41a' }} style={{
background: '#52c41a',
borderColor: '#52c41a',
borderRadius: 8,
height: 'auto',
alignSelf: 'flex-end',
paddingBottom: 8,
paddingTop: 8,
}}
> >
发送 发送
</Button> </Button>
</Space.Compact>
</div> </div>
)}
</> </>
) : ( )}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Empty description="请从左侧列表中选择患者接诊" />
</div> </div>
)} )}
</Card> </div>
<PrescriptionModal <PrescriptionModal
open={prescriptionOpen} open={prescriptionOpen}
...@@ -221,6 +367,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({ ...@@ -221,6 +367,8 @@ const ChatPanel: React.FC<ChatPanelProps> = ({
patientName={activeConsult?.patient_name} patientName={activeConsult?.patient_name}
patientGender={activeConsult?.patient_gender} patientGender={activeConsult?.patient_gender}
patientAge={activeConsult?.patient_age} patientAge={activeConsult?.patient_age}
diagnosis={aiDiagnosis}
medicationSuggestion={aiMedication}
/> />
</> </>
); );
......
import React from 'react'; import React from 'react';
import { List, Typography, Tag, Button, Card } from 'antd'; import { Typography, Tag, Button, Badge } from 'antd';
import { UserOutlined, MessageOutlined, VideoCameraOutlined, CheckOutlined, CloseOutlined } from '@ant-design/icons'; import {
UserOutlined, MessageOutlined, VideoCameraOutlined,
CheckOutlined, CloseOutlined, ClockCircleOutlined,
} from '@ant-design/icons';
import type { PatientListItem } from '../../../api/consult'; import type { PatientListItem } from '../../../api/consult';
const { Text } = Typography; const { Text } = Typography;
const formatWaitTime = (seconds: number): string => {
if (!seconds || seconds <= 0) return '';
const m = Math.floor(seconds / 60);
if (m >= 60) return `${Math.floor(m / 60)}小时${m % 60}分`;
if (m > 0) return `${m}${seconds % 60}秒`;
return `${seconds}秒`;
};
interface PatientListProps { interface PatientListProps {
patients: PatientListItem[]; patients: PatientListItem[];
onSelectPatient: (patient: PatientListItem) => void; onSelectPatient: (patient: PatientListItem) => void;
...@@ -13,89 +24,91 @@ interface PatientListProps { ...@@ -13,89 +24,91 @@ interface PatientListProps {
selectedConsultId?: string; selectedConsultId?: string;
} }
const statusConfig: Record<string, { color: string; bgColor: string; text: string }> = { const PatientCard: React.FC<{
in_progress: { color: '#52c41a', bgColor: '#f6ffed', text: '进行中' }, patient: PatientListItem;
pending: { color: '#1890ff', bgColor: '#e6f7ff', text: '待接诊' }, isSelected: boolean;
waiting: { color: '#1890ff', bgColor: '#e6f7ff', text: '待接诊' }, onSelect: () => void;
completed: { color: '#8c8c8c', bgColor: '#fafafa', text: '已完成' }, onAccept: () => void;
}; onReject: () => void;
}> = ({ patient, isSelected, onSelect, onAccept, onReject }) => {
const PatientList: React.FC<PatientListProps> = ({ patients, onSelectPatient, onAccept, onReject, selectedConsultId }) => { const isWaiting = patient.status === 'pending' || patient.status === 'waiting';
const isWaiting = (status: string) => status === 'pending' || status === 'waiting'; const isInProgress = patient.status === 'in_progress';
const isCompleted = patient.status === 'completed';
return ( const avatarColor = isInProgress ? '#52c41a' : isWaiting ? '#fa8c16' : '#d9d9d9';
<Card const avatarBg = isInProgress ? '#f6ffed' : isWaiting ? '#fff7e6' : '#fafafa';
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 ( return (
<List.Item <div
key={patient.consult_id} onClick={() => !isWaiting && onSelect()}
onClick={() => {
if (!isWaiting(patient.status)) onSelectPatient(patient);
}}
style={{ style={{
cursor: isWaiting(patient.status) ? 'default' : 'pointer', padding: '10px 12px',
padding: '8px 12px',
background: isSelected ? '#e6f7ff' : '#fff', background: isSelected ? '#e6f7ff' : '#fff',
borderLeft: isSelected ? '3px solid #1890ff' : '3px solid transparent', borderLeft: `3px solid ${isSelected ? '#1890ff' : 'transparent'}`,
borderBottom: '1px solid #f0f0f0', borderBottom: '1px solid #f5f5f5',
transition: 'all 0.2s', cursor: isWaiting ? 'default' : 'pointer',
transition: 'background 0.15s',
}}
onMouseEnter={e => {
if (!isSelected && !isWaiting) (e.currentTarget as HTMLDivElement).style.background = '#fafafa';
}}
onMouseLeave={e => {
if (!isSelected) (e.currentTarget as HTMLDivElement).style.background = '#fff';
}} }}
> >
<div className="w-full"> <div style={{ display: 'flex', gap: 9, alignItems: 'flex-start' }}>
<div className="flex justify-between items-center mb-1"> {/* Avatar */}
<span className="text-xs"> <div style={{
<UserOutlined className="text-[#1890ff] mr-1" /> width: 34, height: 34, borderRadius: '50%', flexShrink: 0,
<Text strong className="text-xs!">{patient.patient_name}</Text> background: avatarBg,
<Tag color={config.color} className="ml-1 text-[10px]! leading-4! px-1!">{config.text}</Tag> border: `2px solid ${avatarColor}`,
</span> display: 'flex', alignItems: 'center', justifyContent: 'center',
{patient.type === 'video' ? ( }}>
<VideoCameraOutlined className="text-purple-500 text-xs" /> <UserOutlined style={{ fontSize: 15, color: avatarColor }} />
) : ( </div>
<MessageOutlined className="text-green-500 text-xs" />
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 3 }}>
<Text strong style={{ fontSize: 13 }}>{patient.patient_name || '患者'}</Text>
{patient.type === 'video'
? <VideoCameraOutlined style={{ fontSize: 12, color: '#722ed1' }} />
: <MessageOutlined style={{ fontSize: 12, color: '#52c41a' }} />
}
</div>
<div style={{ fontSize: 11, color: '#8c8c8c', marginBottom: 3 }}>
{patient.patient_gender && `${patient.patient_gender} · `}
{patient.patient_age ? `${patient.patient_age}岁` : ''}
</div>
<div style={{
fontSize: 11, color: '#666',
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
}}>
{patient.chief_complaint || '暂无主诉'}
</div>
{isWaiting && patient.waiting_seconds > 0 && (
<div style={{ fontSize: 11, color: '#fa8c16', marginTop: 4, display: 'flex', alignItems: 'center', gap: 3 }}>
<ClockCircleOutlined />
等待 {formatWaitTime(patient.waiting_seconds)}
</div>
)} )}
{isCompleted && (
<div style={{ fontSize: 11, color: '#8c8c8c', marginTop: 4 }}>
已完成
</div> </div>
<div className="text-[11px] text-gray-500 mb-1"> )}
<Text ellipsis className="text-[11px]! text-gray-500!">
{'主诉:'}{patient.chief_complaint || '暂无'}
</Text>
</div> </div>
<div className="flex justify-between items-center">
<span className="text-[10px] text-gray-400">
{patient.patient_gender}{' · '}{patient.patient_age}{''}
</span>
</div> </div>
{isWaiting(patient.status) && (
<div className="mt-1.5 flex gap-1.5"> {/* 接诊/拒诊按钮 */}
{isWaiting && (
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<CheckOutlined />} icon={<CheckOutlined />}
onClick={(e) => { e.stopPropagation(); onAccept(patient); }} style={{ flex: 1, fontSize: 11, height: 26 }}
className="flex-1 text-[11px]!" onClick={e => { e.stopPropagation(); onAccept(); }}
> >
接诊 接诊
</Button> </Button>
...@@ -103,19 +116,121 @@ const PatientList: React.FC<PatientListProps> = ({ patients, onSelectPatient, on ...@@ -103,19 +116,121 @@ const PatientList: React.FC<PatientListProps> = ({ patients, onSelectPatient, on
danger danger
size="small" size="small"
icon={<CloseOutlined />} icon={<CloseOutlined />}
onClick={(e) => { e.stopPropagation(); onReject(patient); }} style={{ flex: 1, fontSize: 11, height: 26 }}
className="flex-1 text-[11px]!" onClick={e => { e.stopPropagation(); onReject(); }}
> >
拒绝 拒诊
</Button> </Button>
</div> </div>
)} )}
</div> </div>
</List.Item>
); );
}} };
interface SectionProps { label: string; count: number; color: string }
const SectionHeader: React.FC<SectionProps> = ({ label, count, color }) => (
<div style={{
padding: '5px 12px',
background: '#fafafa',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 6,
}}>
<Badge count={count} style={{ backgroundColor: color }} showZero />
<Text style={{ fontSize: 11, color: '#8c8c8c', fontWeight: 500 }}>{label}</Text>
</div>
);
const PatientList: React.FC<PatientListProps> = ({
patients, onSelectPatient, onAccept, onReject, selectedConsultId,
}) => {
const waiting = patients.filter(p => p.status === 'pending' || p.status === 'waiting');
const inProgress = patients.filter(p => p.status === 'in_progress');
const completed = patients.filter(p => p.status === 'completed');
return (
<div style={{
height: '100%',
borderRadius: 12,
border: '1px solid #edf2fc',
background: '#fff',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* 标题栏 */}
<div style={{
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 8,
flexShrink: 0,
}}>
<UserOutlined style={{ color: '#1890ff' }} />
<Text strong style={{ fontSize: 13 }}>患者列表</Text>
<Tag style={{ marginLeft: 'auto', fontSize: 11, margin: 0 }}>{patients.length}</Tag>
</div>
{/* 列表区域 */}
<div style={{ flex: 1, overflow: 'auto' }}>
{patients.length === 0 ? (
<div style={{ textAlign: 'center', padding: '48px 0', color: '#bbb' }}>
<UserOutlined style={{ fontSize: 30, display: 'block', marginBottom: 10 }} />
<div style={{ fontSize: 12 }}>暂无患者</div>
</div>
) : (
<>
{waiting.length > 0 && (
<>
<SectionHeader label="候诊中" count={waiting.length} color="#fa8c16" />
{waiting.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
/> />
</Card> ))}
</>
)}
{inProgress.length > 0 && (
<>
<SectionHeader label="接诊中" count={inProgress.length} color="#52c41a" />
{inProgress.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
/>
))}
</>
)}
{completed.length > 0 && (
<>
<SectionHeader label="已完成" count={completed.length} color="#8c8c8c" />
{completed.map(p => (
<PatientCard
key={p.consult_id}
patient={p}
isSelected={p.consult_id === selectedConsultId}
onSelect={() => onSelectPatient(p)}
onAccept={() => onAccept(p)}
onReject={() => onReject(p)}
/>
))}
</>
)}
</>
)}
</div>
</div>
); );
}; };
......
'use client'; 'use client';
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Row, Col, Typography, message, Modal } from 'antd'; import { message, Modal } from 'antd';
import { MessageOutlined } from '@ant-design/icons'; import { ClockCircleOutlined, MessageOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { consultApi, type PatientListItem, type ConsultMessage } from '../../../api/consult'; import { consultApi, type PatientListItem, type ConsultMessage } from '../../../api/consult';
import { preConsultApi } from '../../../api/preConsult'; import { preConsultApi } from '../../../api/preConsult';
import type { PreConsultResponse } from '../../../api/preConsult'; import type { PreConsultResponse } from '../../../api/preConsult';
...@@ -10,8 +10,6 @@ import PatientList from './PatientList'; ...@@ -10,8 +10,6 @@ import PatientList from './PatientList';
import ChatPanel from './ChatPanel'; import ChatPanel from './ChatPanel';
import AIPanel from './AIPanel'; import AIPanel from './AIPanel';
const { Title } = Typography;
interface ActiveConsult { interface ActiveConsult {
consult_id: string; consult_id: string;
patient_id?: string; patient_id?: string;
...@@ -27,11 +25,18 @@ const ConsultPage: React.FC = () => { ...@@ -27,11 +25,18 @@ const ConsultPage: React.FC = () => {
const [patientList, setPatientList] = useState<PatientListItem[]>([]); const [patientList, setPatientList] = useState<PatientListItem[]>([]);
const [activeConsult, setActiveConsult] = useState<ActiveConsult | null>(null); const [activeConsult, setActiveConsult] = useState<ActiveConsult | null>(null);
const [messages, setMessages] = useState<ConsultMessage[]>([]); const [messages, setMessages] = useState<ConsultMessage[]>([]);
const [aiPanelVisible, setAiPanelVisible] = useState(true);
const [preConsultReport, setPreConsultReport] = useState<PreConsultResponse | null>(null); const [preConsultReport, setPreConsultReport] = useState<PreConsultResponse | null>(null);
const [preConsultLoading, setPreConsultLoading] = useState(false); const [preConsultLoading, setPreConsultLoading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
// AI state lifted up for prescription data integration
const [aiDiagnosis, setAiDiagnosis] = useState('');
const [aiMedication, setAiMedication] = useState('');
const waitingCount = patientList.filter(p => p.status === 'pending' || p.status === 'waiting').length;
const inProgressCount = patientList.filter(p => p.status === 'in_progress').length;
const completedCount = patientList.filter(p => p.status === 'completed').length;
const fetchPatientList = useCallback(async () => { const fetchPatientList = useCallback(async () => {
try { try {
const res = await consultApi.getPatientList(); const res = await consultApi.getPatientList();
...@@ -41,7 +46,6 @@ const ConsultPage: React.FC = () => { ...@@ -41,7 +46,6 @@ const ConsultPage: React.FC = () => {
} }
}, []); }, []);
// 获取消息列表
const fetchMessages = useCallback(async (consultId: string) => { const fetchMessages = useCallback(async (consultId: string) => {
try { try {
const res = await consultApi.getConsultMessages(consultId); const res = await consultApi.getConsultMessages(consultId);
...@@ -53,13 +57,10 @@ const ConsultPage: React.FC = () => { ...@@ -53,13 +57,10 @@ const ConsultPage: React.FC = () => {
useEffect(() => { useEffect(() => {
fetchPatientList(); fetchPatientList();
const timer = setInterval(() => { const timer = setInterval(() => fetchPatientList(), 5000);
fetchPatientList();
}, 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [fetchPatientList]); }, [fetchPatientList]);
// 定时刷新消息
useEffect(() => { useEffect(() => {
if (!activeConsult) return; if (!activeConsult) return;
fetchMessages(activeConsult.consult_id); fetchMessages(activeConsult.consult_id);
...@@ -67,14 +68,12 @@ const ConsultPage: React.FC = () => { ...@@ -67,14 +68,12 @@ const ConsultPage: React.FC = () => {
return () => clearInterval(timer); return () => clearInterval(timer);
}, [activeConsult, fetchMessages]); }, [activeConsult, fetchMessages]);
// 加载预问诊报告(先通过问诊ID查,查不到再通过患者ID查)
const fetchPreConsultReport = async (consultId: string, patientId?: string) => { const fetchPreConsultReport = async (consultId: string, patientId?: string) => {
setPreConsultLoading(true); setPreConsultLoading(true);
try { try {
const res = await preConsultApi.getByConsultationId(consultId); const res = await preConsultApi.getByConsultationId(consultId);
setPreConsultReport(res.data); setPreConsultReport(res.data);
} catch { } catch {
// 问诊没有关联预问诊,尝试通过患者ID获取最新预问诊
if (patientId) { if (patientId) {
try { try {
const res = await preConsultApi.getByPatientId(patientId); const res = await preConsultApi.getByPatientId(patientId);
...@@ -90,13 +89,17 @@ const ConsultPage: React.FC = () => { ...@@ -90,13 +89,17 @@ const ConsultPage: React.FC = () => {
} }
}; };
// 接诊 const resetConsultState = () => {
setAiDiagnosis('');
setAiMedication('');
setPreConsultReport(null);
setMessages([]);
};
const handleAccept = async (patient: PatientListItem) => { const handleAccept = async (patient: PatientListItem) => {
console.log('handleAccept 被调用', patient);
try { try {
console.log('调用 acceptConsult API', patient.consult_id);
await consultApi.acceptConsult(patient.consult_id); await consultApi.acceptConsult(patient.consult_id);
console.log('acceptConsult API 调用成功'); resetConsultState();
setActiveConsult({ setActiveConsult({
consult_id: patient.consult_id, consult_id: patient.consult_id,
patient_id: patient.patient_id, patient_id: patient.patient_id,
...@@ -107,19 +110,17 @@ const ConsultPage: React.FC = () => { ...@@ -107,19 +110,17 @@ const ConsultPage: React.FC = () => {
type: patient.type, type: patient.type,
status: 'in_progress', status: 'in_progress',
}); });
setPreConsultReport(null);
message.success(`已接诊患者 ${patient.patient_name || '未知'}`); message.success(`已接诊患者 ${patient.patient_name || '未知'}`);
fetchPreConsultReport(patient.consult_id, patient.patient_id); fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchPatientList(); fetchPatientList();
fetchMessages(patient.consult_id); fetchMessages(patient.consult_id);
} catch (err: any) { } catch (err: unknown) {
console.error('接诊失败', err); message.error((err as Error)?.message || '接诊失败');
message.error(err?.message || '接诊失败');
} }
}; };
// 选择患者(仅用于进行中和已完成的问诊)
const handleSelectPatient = (patient: PatientListItem) => { const handleSelectPatient = (patient: PatientListItem) => {
resetConsultState();
setActiveConsult({ setActiveConsult({
consult_id: patient.consult_id, consult_id: patient.consult_id,
patient_id: patient.patient_id, patient_id: patient.patient_id,
...@@ -130,33 +131,26 @@ const ConsultPage: React.FC = () => { ...@@ -130,33 +131,26 @@ const ConsultPage: React.FC = () => {
type: patient.type, type: patient.type,
status: patient.status, status: patient.status,
}); });
setPreConsultReport(null);
fetchPreConsultReport(patient.consult_id, patient.patient_id); fetchPreConsultReport(patient.consult_id, patient.patient_id);
fetchMessages(patient.consult_id); fetchMessages(patient.consult_id);
}; };
// 拒诊
const handleReject = (patient: PatientListItem) => { const handleReject = (patient: PatientListItem) => {
console.log('handleReject 被调用', patient);
Modal.confirm({ Modal.confirm({
title: '确认拒诊', title: '确认拒诊',
content: `确定拒绝接诊患者 ${patient.patient_name || '未知'}?`, content: `确定拒绝接诊患者 ${patient.patient_name || '未知'}?`,
onOk: async () => { onOk: async () => {
try { try {
console.log('调用 rejectConsult API', patient.consult_id);
await consultApi.rejectConsult(patient.consult_id); await consultApi.rejectConsult(patient.consult_id);
console.log('rejectConsult API 调用成功');
message.info('已拒诊'); message.info('已拒诊');
fetchPatientList(); fetchPatientList();
} catch (err: any) { } catch {
console.error('拒诊失败', err);
message.error('拒诊失败'); message.error('拒诊失败');
} }
}, },
}); });
}; };
const handleSendMessage = async (content: string) => { const handleSendMessage = async (content: string) => {
if (!activeConsult) return; if (!activeConsult) return;
setSending(true); setSending(true);
...@@ -170,31 +164,66 @@ const ConsultPage: React.FC = () => { ...@@ -170,31 +164,66 @@ const ConsultPage: React.FC = () => {
} }
}; };
// 结束问诊
const handleEndConsult = async () => { const handleEndConsult = async () => {
if (!activeConsult) return; if (!activeConsult) return;
try { try {
await consultApi.endConsult(activeConsult.consult_id); await consultApi.endConsult(activeConsult.consult_id);
message.success('问诊已结束'); message.success('问诊已结束');
setActiveConsult(null); setActiveConsult(null);
setMessages([]); resetConsultState();
setPreConsultReport(null);
fetchPatientList(); fetchPatientList();
} catch (err: any) { } catch (err: unknown) {
message.error('结束问诊失败: ' + (err?.message || '未知错误')); message.error('结束问诊失败: ' + ((err as Error)?.message || '未知错误'));
} }
}; };
const statItems = [
{ label: '候诊中', value: waitingCount, color: '#fa8c16', icon: <ClockCircleOutlined /> },
{ label: '接诊中', value: inProgressCount, color: '#52c41a', icon: <MessageOutlined /> },
{ label: '今日完成', value: completedCount, color: '#1890ff', icon: <CheckCircleOutlined /> },
];
return ( return (
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 顶部标题 + 统计卡片 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexShrink: 0 }}>
<div> <div>
<Title level={4} style={{ marginBottom: 16 }}> <div style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageOutlined style={{ marginRight: 8 }} /> <MessageOutlined style={{ color: '#1890ff' }} />
问诊大厅 问诊大厅
</Title> </div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>实时接诊管理,AI辅助诊断</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 10 }}>
{statItems.map(({ label, value, color, icon }) => (
<div
key={label}
style={{
background: '#fff',
borderRadius: 10,
padding: '8px 18px',
border: '1px solid #edf2fc',
minWidth: 110,
boxShadow: '0 1px 4px rgba(0,0,0,0.04)',
}}
>
<div style={{ fontSize: 11, color, display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }}>
{icon}
{label}
</div>
<div style={{ fontSize: 24, fontWeight: 700, color, lineHeight: 1.2 }}>
{value}
<span style={{ fontSize: 12, fontWeight: 400, marginLeft: 2 }}></span>
</div>
</div>
))}
</div>
</div>
<Row gutter={16} style={{ height: 'calc(100vh - 160px)' }}> {/* 主体三栏 */}
<Col span={6}> <div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{/* 左:患者列表 */}
<div style={{ width: 272, flexShrink: 0 }}>
<PatientList <PatientList
patients={patientList} patients={patientList}
onSelectPatient={handleSelectPatient} onSelectPatient={handleSelectPatient}
...@@ -202,31 +231,33 @@ const ConsultPage: React.FC = () => { ...@@ -202,31 +231,33 @@ const ConsultPage: React.FC = () => {
onReject={handleReject} onReject={handleReject}
selectedConsultId={activeConsult?.consult_id} selectedConsultId={activeConsult?.consult_id}
/> />
</Col> </div>
<Col span={aiPanelVisible ? 12 : 18}> {/* 中:聊天区域 */}
<div style={{ flex: 1, minWidth: 0 }}>
<ChatPanel <ChatPanel
activeConsult={activeConsult} activeConsult={activeConsult}
messages={messages} messages={messages}
onSend={handleSendMessage} onSend={handleSendMessage}
onEndConsult={handleEndConsult} onEndConsult={handleEndConsult}
onToggleAI={() => setAiPanelVisible(!aiPanelVisible)}
aiPanelVisible={aiPanelVisible}
sending={sending} sending={sending}
aiDiagnosis={aiDiagnosis}
aiMedication={aiMedication}
/> />
</Col> </div>
{aiPanelVisible && ( {/* 右:AI辅助面板 */}
<Col span={6}> <div style={{ width: 340, flexShrink: 0 }}>
<AIPanel <AIPanel
hasActiveConsult={!!activeConsult} hasActiveConsult={!!activeConsult}
activeConsultId={activeConsult?.consult_id} activeConsultId={activeConsult?.consult_id}
preConsultReport={preConsultReport} preConsultReport={preConsultReport}
preConsultLoading={preConsultLoading} preConsultLoading={preConsultLoading}
onDiagnosisChange={setAiDiagnosis}
onMedicationChange={setAiMedication}
/> />
</Col> </div>
)} </div>
</Row>
</div> </div>
); );
}; };
......
...@@ -4,16 +4,17 @@ import React, { useState, useCallback } from 'react'; ...@@ -4,16 +4,17 @@ import React, { useState, useCallback } from 'react';
import { import {
Input, Button, Table, Form, Select, InputNumber, Input, Button, Table, Form, Select, InputNumber,
Typography, Tag, Space, Modal, message, AutoComplete, Empty, Typography, Tag, Space, Modal, message, AutoComplete, Empty,
Alert, Collapse, Timeline,
} from 'antd'; } from 'antd';
import { import {
DeleteOutlined, DeleteOutlined, SearchOutlined, SafetyCertificateOutlined,
SearchOutlined, PrinterOutlined, MedicineBoxOutlined, RobotOutlined,
SafetyCertificateOutlined, CheckCircleOutlined, CloseCircleOutlined, WarningOutlined, ThunderboltOutlined,
PrinterOutlined,
MedicineBoxOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { medicineApi, prescriptionDoctorApi } from '../../../api/prescription'; import { medicineApi, prescriptionDoctorApi } from '../../../api/prescription';
import type { Medicine } from '../../../api/prescription'; import type { Medicine } from '../../../api/prescription';
import { doctorPortalApi } from '../../../api/doctorPortal';
import type { ToolCall } from '../../../api/agent';
const { Text } = Typography; const { Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
...@@ -33,6 +34,13 @@ interface DrugItem { ...@@ -33,6 +34,13 @@ interface DrugItem {
note: string; note: string;
} }
interface SafetyResult {
report: string;
tool_calls?: ToolCall[];
has_warning: boolean;
has_contraindication: boolean;
}
interface PrescriptionModalProps { interface PrescriptionModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
...@@ -41,10 +49,15 @@ interface PrescriptionModalProps { ...@@ -41,10 +49,15 @@ interface PrescriptionModalProps {
patientName?: string; patientName?: string;
patientGender?: string; patientGender?: string;
patientAge?: number; patientAge?: number;
/** AI鉴别诊断内容 — 用于预填诊断字段 */
diagnosis?: string;
/** AI用药建议内容 — 作为开方参考 */
medicationSuggestion?: string;
} }
const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
open, onClose, consultId, patientId, patientName, patientGender, patientAge, open, onClose, consultId, patientId, patientName, patientGender, patientAge,
diagnosis, medicationSuggestion,
}) => { }) => {
const [drugs, setDrugs] = useState<DrugItem[]>([]); const [drugs, setDrugs] = useState<DrugItem[]>([]);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
...@@ -52,23 +65,40 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -52,23 +65,40 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
const [signModalVisible, setSignModalVisible] = useState(false); const [signModalVisible, setSignModalVisible] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [remarkText, setRemarkText] = useState(''); const [remarkText, setRemarkText] = useState('');
const [safetyResult, setSafetyResult] = useState<SafetyResult | null>(null);
const [safetyLoading, setSafetyLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
// Extract a brief diagnosis summary from AI text (first line or first 60 chars)
const extractDiagnosisSummary = (aiText: string): string => {
if (!aiText) return '';
const lines = aiText.split('\n').filter(l => l.trim());
const firstMeaningful = lines.find(l => !l.startsWith('#') && l.trim().length > 0);
if (firstMeaningful) {
const clean = firstMeaningful.replace(/[#*`]/g, '').trim();
return clean.length > 60 ? clean.slice(0, 60) + '...' : clean;
}
return aiText.slice(0, 60) + (aiText.length > 60 ? '...' : '');
};
React.useEffect(() => { React.useEffect(() => {
if (open) { if (open) {
form.setFieldsValue({ form.setFieldsValue({
patient_name: patientName || '', patient_name: patientName || '',
patient_gender: patientGender || undefined, patient_gender: patientGender || undefined,
patient_age: patientAge || undefined, patient_age: patientAge || undefined,
diagnosis: diagnosis ? extractDiagnosisSummary(diagnosis) : '',
}); });
setSafetyResult(null);
} }
}, [open, patientName, patientGender, patientAge, form]); }, [open, patientName, patientGender, patientAge, diagnosis, form]);
const handleClose = () => { const handleClose = () => {
setDrugs([]); setDrugs([]);
setSearchValue(''); setSearchValue('');
setSearchResults([]); setSearchResults([]);
setRemarkText(''); setRemarkText('');
setSafetyResult(null);
form.resetFields(); form.resetFields();
onClose(); onClose();
}; };
...@@ -83,9 +113,9 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -83,9 +113,9 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
}, []); }, []);
const handleAddDrug = (medicineId: string) => { const handleAddDrug = (medicineId: string) => {
const med = searchResults.find((m) => m.id === medicineId); const med = searchResults.find(m => m.id === medicineId);
if (!med) return; if (!med) return;
if (drugs.find((d) => d.medicine_id === med.id)) { if (drugs.find(d => d.medicine_id === med.id)) {
message.warning('该药品已添加'); message.warning('该药品已添加');
return; return;
} }
...@@ -103,22 +133,48 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -103,22 +133,48 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
price: med.price, price: med.price,
note: '', note: '',
}]); }]);
setSafetyResult(null); // Reset safety check when drugs change
setSearchValue(''); setSearchValue('');
setSearchResults([]); setSearchResults([]);
}; };
const handleRemoveDrug = (key: string) => setDrugs(drugs.filter((d) => d.key !== key)); const handleRemoveDrug = (key: string) => {
setDrugs(drugs.filter(d => d.key !== key));
setSafetyResult(null);
};
const handleDrugChange = (key: string, field: string, value: any) => const handleDrugChange = (key: string, field: string, value: unknown) =>
setDrugs(drugs.map((d) => (d.key === key ? { ...d, [field]: value } : d))); setDrugs(drugs.map(d => d.key === key ? { ...d, [field]: value } : d));
const totalAmount = drugs.reduce((sum, d) => sum + d.price * d.quantity, 0); const totalAmount = drugs.reduce((sum, d) => sum + d.price * d.quantity, 0);
const handleSign = async () => { // AI safety check
if (drugs.length === 0) { const handleSafetyCheck = async () => {
message.warning('请至少添加一种药品'); if (!patientId) { message.warning('无法获取患者信息,请确认已接诊'); return; }
return; if (drugs.length === 0) { message.warning('请先添加药品'); return; }
setSafetyLoading(true);
try {
const res = await doctorPortalApi.checkPrescriptionSafety({
patient_id: patientId,
drugs: drugs.map(d => d.name),
});
setSafetyResult(res.data);
if (res.data?.has_contraindication) {
message.error('检测到禁忌症,请重新审核处方');
} else if (res.data?.has_warning) {
message.warning('存在药物相互作用风险,请注意');
} else {
message.success('处方安全审核通过');
} }
} catch (err: unknown) {
message.error('安全审核失败: ' + ((err as Error)?.message || '请稍后重试'));
} finally {
setSafetyLoading(false);
}
};
const handleSign = async () => {
if (drugs.length === 0) { message.warning('请至少添加一种药品'); return; }
try { await form.validateFields(); } catch { return; } try { await form.validateFields(); } catch { return; }
setSignModalVisible(true); setSignModalVisible(true);
}; };
...@@ -136,7 +192,7 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -136,7 +192,7 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
diagnosis: values.diagnosis, diagnosis: values.diagnosis,
allergy_history: values.allergy, allergy_history: values.allergy,
remark: remarkText, remark: remarkText,
items: drugs.map((d) => ({ items: drugs.map(d => ({
medicine_id: d.medicine_id, medicine_id: d.medicine_id,
medicine_name: d.name, medicine_name: d.name,
specification: d.specification, specification: d.specification,
...@@ -153,26 +209,97 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -153,26 +209,97 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
message.success('处方已签名并提交'); message.success('处方已签名并提交');
setSignModalVisible(false); setSignModalVisible(false);
handleClose(); handleClose();
} catch (err: any) { } catch (err: unknown) {
message.error(err?.message || '提交处方失败'); message.error((err as Error)?.message || '提交处方失败');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const renderSafetyResult = () => {
if (!safetyResult) return null;
return (
<div style={{ marginBottom: 12 }}>
{safetyResult.has_contraindication ? (
<Alert
type="error"
showIcon
message="存在禁忌症"
description="该患者对处方中的某些药物存在禁忌,请重新审查处方。"
style={{ marginBottom: 6 }}
/>
) : safetyResult.has_warning ? (
<Alert
type="warning"
showIcon
message="存在药物相互作用风险"
description="处方中存在可能的药物相互作用,建议调整用药方案或注意监测。"
style={{ marginBottom: 6 }}
/>
) : (
<Alert
type="success"
showIcon
message="处方安全审核通过"
description="未检测到明显的禁忌症或药物相互作用风险。"
style={{ marginBottom: 6 }}
/>
)}
{safetyResult.report && (
<Collapse
size="small"
items={[{
key: 'report',
label: <span style={{ fontSize: 12 }}><RobotOutlined style={{ marginRight: 4 }} />AI审核报告</span>,
children: <div style={{ fontSize: 12, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{safetyResult.report}</div>,
}]}
/>
)}
{safetyResult.tool_calls && safetyResult.tool_calls.length > 0 && (
<Collapse
size="small"
style={{ marginTop: 4 }}
items={[{
key: 'tools',
label: <span style={{ fontSize: 12 }}>工具调用记录 ({safetyResult.tool_calls.length})</span>,
children: (
<Timeline
items={safetyResult.tool_calls.map((tc, i) => ({
key: i,
color: tc.success ? 'green' : 'red',
dot: tc.success
? <CheckCircleOutlined style={{ fontSize: 11 }} />
: <CloseCircleOutlined style={{ fontSize: 11 }} />,
children: (
<div style={{ fontSize: 11 }}>
<strong>{tc.tool_name}</strong>
<div style={{ color: '#8c8c8c' }}>{tc.arguments}</div>
</div>
),
}))}
/>
),
}]}
/>
)}
</div>
);
};
const columns = [ const columns = [
{ title: '药品名称', dataIndex: 'name', key: 'name', width: 140, render: (t: string) => <Text strong>{t}</Text> }, { title: '药品名称', dataIndex: 'name', key: 'name', width: 130, render: (t: string) => <Text strong style={{ fontSize: 13 }}>{t}</Text> },
{ title: '规格', dataIndex: 'specification', key: 'specification', width: 90 }, { title: '规格', dataIndex: 'specification', key: 'specification', width: 90, render: (t: string) => <Text style={{ fontSize: 12 }}>{t}</Text> },
{ {
title: '用法', dataIndex: 'usage', key: 'usage', width: 100, title: '用法', dataIndex: 'usage', key: 'usage', width: 100,
render: (_: string, r: DrugItem) => ( render: (_: string, r: DrugItem) => (
<Select value={r.usage} size="small" style={{ width: 90 }} <Select
onChange={(v) => handleDrugChange(r.key, 'usage', v)} value={r.usage}
size="small"
style={{ width: 90 }}
onChange={v => handleDrugChange(r.key, 'usage', v)}
options={[ options={[
{ label: '口服', value: '口服' }, { label: '口服', value: '口服' }, { label: '外用', value: '外用' },
{ label: '外用', value: '外用' }, { label: '静脉注射', value: '静脉注射' }, { label: '肌肉注射', value: '肌肉注射' },
{ label: '静脉注射', value: '静脉注射' },
{ label: '肌肉注射', value: '肌肉注射' },
{ label: '雾化吸入', value: '雾化吸入' }, { label: '雾化吸入', value: '雾化吸入' },
]} ]}
/> />
...@@ -182,19 +309,20 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -182,19 +309,20 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
title: '单次剂量', dataIndex: 'dosage', key: 'dosage', width: 110, title: '单次剂量', dataIndex: 'dosage', key: 'dosage', width: 110,
render: (_: string, r: DrugItem) => ( render: (_: string, r: DrugItem) => (
<Input size="small" placeholder="如 1粒" value={r.dosage} <Input size="small" placeholder="如 1粒" value={r.dosage}
onChange={(e) => handleDrugChange(r.key, 'dosage', e.target.value)} /> onChange={e => handleDrugChange(r.key, 'dosage', e.target.value)} />
), ),
}, },
{ {
title: '频次', dataIndex: 'frequency', key: 'frequency', width: 110, title: '频次', dataIndex: 'frequency', key: 'frequency', width: 110,
render: (_: string, r: DrugItem) => ( render: (_: string, r: DrugItem) => (
<Select value={r.frequency} size="small" style={{ width: 100 }} <Select
onChange={(v) => handleDrugChange(r.key, 'frequency', v)} value={r.frequency}
size="small"
style={{ width: 100 }}
onChange={v => handleDrugChange(r.key, 'frequency', v)}
options={[ options={[
{ label: '每日1次', value: '每日1次' }, { label: '每日1次', value: '每日1次' }, { label: '每日2次', value: '每日2次' },
{ label: '每日2次', value: '每日2次' }, { label: '每日3次', value: '每日3次' }, { label: '必要时', value: '必要时' },
{ label: '每日3次', value: '每日3次' },
{ label: '必要时', value: '必要时' },
{ label: '睡前', value: '睡前' }, { label: '睡前', value: '睡前' },
]} ]}
/> />
...@@ -203,28 +331,29 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -203,28 +331,29 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
{ {
title: '天数', dataIndex: 'days', key: 'days', width: 70, title: '天数', dataIndex: 'days', key: 'days', width: 70,
render: (_: number, r: DrugItem) => ( render: (_: number, r: DrugItem) => (
<InputNumber size="small" min={1} max={30} value={r.days} <InputNumber size="small" min={1} max={90} value={r.days}
onChange={(v) => handleDrugChange(r.key, 'days', v)} style={{ width: 60 }} /> onChange={v => handleDrugChange(r.key, 'days', v)} style={{ width: 58 }} />
), ),
}, },
{ {
title: '数量', dataIndex: 'quantity', key: 'quantity', width: 100, title: '数量', dataIndex: 'quantity', key: 'quantity', width: 90,
render: (_: number, r: DrugItem) => ( render: (_: number, r: DrugItem) => (
<Space size={4}> <Space size={4}>
<InputNumber size="small" min={1} value={r.quantity} <InputNumber size="small" min={1} value={r.quantity}
onChange={(v) => handleDrugChange(r.key, 'quantity', v)} style={{ width: 55 }} /> onChange={v => handleDrugChange(r.key, 'quantity', v)} style={{ width: 50 }} />
<Text type="secondary">{r.unit}</Text> <Text type="secondary" style={{ fontSize: 12 }}>{r.unit}</Text>
</Space> </Space>
), ),
}, },
{ {
title: '单价', dataIndex: 'price', key: 'price', width: 70, title: '单价', dataIndex: 'price', key: 'price', width: 70,
render: (v: number) => ${(v / 100).toFixed(2)}`, render: (v: number) => <Text style={{ fontSize: 12 }}>¥{(v / 100).toFixed(2)}</Text>,
}, },
{ {
title: '操作', key: 'action', width: 50, title: '操作', key: 'action', width: 48,
render: (_: any, r: DrugItem) => ( render: (_: unknown, r: DrugItem) => (
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => handleRemoveDrug(r.key)} /> <Button type="text" danger size="small" icon={<DeleteOutlined />}
onClick={() => handleRemoveDrug(r.key)} />
), ),
}, },
]; ];
...@@ -232,73 +361,161 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -232,73 +361,161 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
return ( return (
<> <>
<Modal <Modal
title={<span><MedicineBoxOutlined className="text-green-500 mr-1" />开具处方</span>} title={<span><MedicineBoxOutlined style={{ color: '#52c41a', marginRight: 6 }} />开具处方</span>}
open={open} onCancel={handleClose} width={960} footer={null} destroyOnClose open={open}
onCancel={handleClose}
width={980}
footer={null}
destroyOnClose
> >
<Form layout="inline" form={form} size="small" className="mb-2 p-2 bg-gray-50 rounded"> {/* AI用药建议参考 */}
{medicationSuggestion && (
<Collapse
size="small"
defaultActiveKey={['suggestion']}
style={{ marginBottom: 12, borderRadius: 8 }}
items={[{
key: 'suggestion',
label: (
<span style={{ fontSize: 12, color: '#722ed1' }}>
<RobotOutlined style={{ marginRight: 4 }} />
AI用药建议参考(点击展开/折叠)
</span>
),
children: (
<div style={{
maxHeight: 140, overflow: 'auto',
fontSize: 12, lineHeight: 1.6, color: '#333',
whiteSpace: 'pre-wrap',
background: '#faf5ff', padding: 8, borderRadius: 6,
}}>
{medicationSuggestion}
</div>
),
}]}
/>
)}
{/* 患者基本信息 */}
<Form layout="inline" form={form} size="small" style={{ marginBottom: 10, padding: '8px 12px', background: '#f9fafb', borderRadius: 8 }}>
<Form.Item label="姓名" name="patient_name" rules={[{ required: true, message: '请输入姓名' }]}> <Form.Item label="姓名" name="patient_name" rules={[{ required: true, message: '请输入姓名' }]}>
<Input placeholder="请输入姓名" style={{ width: 100 }} /> <Input placeholder="请输入姓名" style={{ width: 96 }} />
</Form.Item> </Form.Item>
<Form.Item label="性别" name="patient_gender"> <Form.Item label="性别" name="patient_gender">
<Select placeholder="性别" style={{ width: 70 }} <Select placeholder="性别" style={{ width: 68 }}
options={[{ label: '', value: '' }, { label: '', value: '' }]} allowClear /> options={[{ label: '', value: '' }, { label: '', value: '' }]} allowClear />
</Form.Item> </Form.Item>
<Form.Item label="年龄" name="patient_age"> <Form.Item label="年龄" name="patient_age">
<InputNumber placeholder="岁" style={{ width: 60 }} min={0} max={150} /> <InputNumber placeholder="岁" style={{ width: 60 }} min={0} max={150} />
</Form.Item> </Form.Item>
<Form.Item label="诊断" name="diagnosis" rules={[{ required: true, message: '请输入诊断' }]}> <Form.Item
<Input placeholder="诊断" style={{ width: 160 }} /> label="诊断"
name="diagnosis"
rules={[{ required: true, message: '请输入诊断' }]}
tooltip={diagnosis ? 'AI已根据鉴别诊断结果预填,可修改' : undefined}
>
<Input placeholder="诊断(必填)" style={{ width: 200 }}
suffix={diagnosis ? <RobotOutlined style={{ color: '#722ed1', fontSize: 11 }} /> : undefined}
/>
</Form.Item> </Form.Item>
<Form.Item label="过敏" name="allergy"> <Form.Item label="过敏" name="allergy">
<Input placeholder="过敏史" style={{ width: 110 }} /> <Input placeholder="过敏史" style={{ width: 120 }} />
</Form.Item> </Form.Item>
</Form> </Form>
<div className="flex items-center gap-2 mb-2"> {/* 药品列表 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Space size={4}> <Space size={4}>
<MedicineBoxOutlined /> <MedicineBoxOutlined />
<Text strong>药品</Text> <Text strong>药品清单</Text>
<Tag color="blue">{drugs.length}</Tag> <Tag color="blue">{drugs.length}</Tag>
</Space> </Space>
<AutoComplete <AutoComplete
value={searchValue} value={searchValue}
options={searchResults.map((m) => ({ options={searchResults.map(m => ({
value: m.id, value: m.id,
label: `${m.name} (${m.specification}) 库存:${m.stock}`, label: `${m.name} (${m.specification}) 库存:${m.stock}`,
}))} }))}
onSelect={handleAddDrug} onSelect={handleAddDrug}
onSearch={handleSearch} onSearch={handleSearch}
onChange={setSearchValue} onChange={setSearchValue}
style={{ width: 260 }} style={{ width: 280, marginLeft: 8 }}
> >
<Input prefix={<SearchOutlined />} placeholder="搜索药品..." allowClear size="small" /> <Input prefix={<SearchOutlined />} placeholder="搜索药品名称..." allowClear size="small" />
</AutoComplete> </AutoComplete>
{drugs.length > 0 && (
<Button
size="small"
icon={<ThunderboltOutlined style={{ color: '#52c41a' }} />}
loading={safetyLoading}
onClick={handleSafetyCheck}
style={{ marginLeft: 'auto' }}
>
AI安全审核
</Button>
)}
</div> </div>
{/* 安全审核结果 */}
{renderSafetyResult()}
{drugs.length > 0 ? ( {drugs.length > 0 ? (
<Table columns={columns} dataSource={drugs} pagination={false} size="small" <Table
rowKey="key" scroll={{ x: 800 }} className="mb-2" /> columns={columns}
dataSource={drugs}
pagination={false}
size="small"
rowKey="key"
scroll={{ x: 800 }}
style={{ marginBottom: 8 }}
rowClassName={safetyResult?.has_contraindication ? () => 'prescription-warning-row' : undefined}
/>
) : ( ) : (
<Empty description="请搜索并添加药品" className="my-3" /> <Empty description="请搜索并添加药品" style={{ margin: '12px 0' }} />
)} )}
{/* 合计 */}
{drugs.length > 0 && ( {drugs.length > 0 && (
<div className="text-right mb-1"> <div style={{ textAlign: 'right', marginBottom: 8 }}>
<Text strong className="text-red-500">合计:¥{(totalAmount / 100).toFixed(2)}</Text> <Text strong style={{ color: '#ff4d4f', fontSize: 14 }}>
合计:¥{(totalAmount / 100).toFixed(2)}
</Text>
</div> </div>
)} )}
<TextArea rows={2} placeholder="医嘱备注..." size="small" {/* 医嘱 */}
value={remarkText} onChange={(e) => setRemarkText(e.target.value)} className="mb-2" /> <TextArea
rows={2}
placeholder="医嘱备注..."
size="small"
value={remarkText}
onChange={e => setRemarkText(e.target.value)}
style={{ marginBottom: 10 }}
/>
<div className="flex justify-end gap-1"> {/* 底部操作 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button size="small" icon={<PrinterOutlined />} onClick={() => message.info('打印功能开发中')}>打印</Button> <Button size="small" icon={<PrinterOutlined />} onClick={() => message.info('打印功能开发中')}>打印</Button>
<Button size="small" onClick={handleClose}>取消</Button> <Button size="small" onClick={handleClose}>取消</Button>
<Button type="primary" size="small" icon={<SafetyCertificateOutlined />} {safetyResult?.has_contraindication && (
style={{ background: '#52c41a', borderColor: '#52c41a' }} onClick={handleSign}>签名提交</Button> <Tag color="error" icon={<WarningOutlined />} style={{ display: 'flex', alignItems: 'center' }}>
存在禁忌症
</Tag>
)}
<Button
type="primary"
size="small"
icon={<SafetyCertificateOutlined />}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
onClick={handleSign}
disabled={safetyResult?.has_contraindication === true}
>
签名提交
</Button>
</div> </div>
</Modal> </Modal>
{/* CA签名确认 */}
<Modal <Modal
title="CA 签名确认" title="CA 签名确认"
open={signModalVisible} open={signModalVisible}
...@@ -309,13 +526,19 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({ ...@@ -309,13 +526,19 @@ const PrescriptionModal: React.FC<PrescriptionModalProps> = ({
confirmLoading={submitting} confirmLoading={submitting}
width={380} width={380}
> >
<div className="text-center py-3"> <div style={{ textAlign: 'center', padding: '16px 0' }}>
<SafetyCertificateOutlined className="text-3xl text-green-500 mb-2" /> <SafetyCertificateOutlined style={{ fontSize: 36, color: '#52c41a', marginBottom: 12, display: 'block' }} />
<div className="font-semibold mb-1">确认使用 CA 数字证书签署此处方?</div> <div style={{ fontWeight: 600, marginBottom: 6 }}>确认使用 CA 数字证书签署此处方?</div>
<Text type="secondary">签署后处方将具有法律效力</Text> <Text type="secondary" style={{ fontSize: 13 }}>签署后处方将具有法律效力</Text>
<div className="mt-3 p-2 bg-green-50 rounded"> <div style={{ marginTop: 14, padding: '10px 16px', background: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
<div>药品:<Text strong>{drugs.length}</Text></div> <div>药品:<Text strong>{drugs.length}</Text></div>
<div>金额:<Text strong className="text-red-500">¥{(totalAmount / 100).toFixed(2)}</Text></div> <div style={{ marginTop: 4 }}>金额:<Text strong style={{ color: '#ff4d4f' }}>¥{(totalAmount / 100).toFixed(2)}</Text></div>
{safetyResult && !safetyResult.has_contraindication && !safetyResult.has_warning && (
<div style={{ marginTop: 4, color: '#52c41a', fontSize: 12 }}>
<CheckCircleOutlined style={{ marginRight: 4 }} />
已通过AI安全审核
</div>
)}
</div> </div>
</div> </div>
</Modal> </Modal>
......
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