Commit 04584395 authored by yuguo's avatar yuguo

fix

parent 671cf8df
......@@ -47,6 +47,7 @@ export interface DoctorItem {
license_no: string;
introduction: string;
specialties: string[];
price: number;
review_status: string;
review_id: string;
user_status: string;
......@@ -290,6 +291,7 @@ export const adminApi = {
hospital: string;
introduction?: string;
specialties?: string[];
price?: number;
}) => post<null>('/admin/doctors/create', data),
updateDoctor: (doctorId: string, data: UpdateDoctorData) =>
......
'use client';
import React, { useState } from 'react';
import {
Card, Row, Col, Tag, Switch, Badge, Empty, Tooltip, Typography, message,
} from 'antd';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, httpToolApi } from '@/api/agent';
import type { CATEGORY_CONFIG_TYPE, SOURCE_CONFIG_TYPE } from './toolsConfig';
const { Text, Paragraph } = Typography;
type ToolSource = 'builtin' | 'http';
interface UnifiedTool {
id: string;
name: string;
description: string;
category: string;
source: ToolSource;
is_enabled: boolean;
cache_ttl?: number;
timeout?: number;
rawId?: number;
}
interface Props {
search: string;
categoryFilter: string;
CATEGORY_CONFIG: CATEGORY_CONFIG_TYPE;
SOURCE_CONFIG: SOURCE_CONFIG_TYPE;
}
export default function AllToolsTab({ search, categoryFilter, CATEGORY_CONFIG, SOURCE_CONFIG }: Props) {
const qc = useQueryClient();
const [togglingName, setTogglingName] = useState('');
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as {
id: string; name: string; description: string; category: string;
parameters: Record<string, unknown>; is_enabled: boolean; created_at: string;
}[],
});
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const allTools: UnifiedTool[] = [
...(builtinData ?? []).map(t => ({
id: t.name, name: t.name, description: t.description, category: t.category,
source: 'builtin' as ToolSource, is_enabled: t.is_enabled,
})),
...(httpData ?? []).map(t => ({
id: `http:${t.id}`, name: t.name, description: t.description || t.display_name,
category: t.category || 'http', source: 'http' as ToolSource,
is_enabled: t.status === 'active', cache_ttl: t.cache_ttl, timeout: t.timeout, rawId: t.id,
})),
];
const toggleMut = useMutation({
mutationFn: async ({ tool, checked }: { tool: UnifiedTool; checked: boolean }) => {
if (tool.source === 'builtin') {
return agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
} else {
return httpToolApi.update(tool.rawId!, { status: checked ? 'active' : 'disabled' });
}
},
onSuccess: (_, { tool, checked }) => {
message.success(`${tool.name}${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
qc.invalidateQueries({ queryKey: ['http-tools'] });
setTogglingName('');
},
onError: () => { message.error('操作失败'); setTogglingName(''); },
});
const filtered = allTools.filter(t => {
const matchSearch = !search || t.name.includes(search) || t.description.includes(search);
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchCategory;
});
const grouped: Record<string, UnifiedTool[]> = {};
for (const t of filtered) {
if (!grouped[t.category]) grouped[t.category] = [];
grouped[t.category].push(t);
}
if (Object.keys(grouped).length === 0) {
return <Empty description="暂无匹配工具" style={{ marginTop: 60 }} />;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
{Object.entries(grouped).map(([category, tools]) => {
const cfg = CATEGORY_CONFIG[category] || CATEGORY_CONFIG.other;
return (
<div key={category}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Tag color={cfg.color} icon={cfg.icon} style={{ fontSize: 13, padding: '2px 10px' }}>
{cfg.label}
</Tag>
<Text type="secondary" style={{ fontSize: 12 }}>{tools.length} 个工具</Text>
</div>
<Row gutter={[16, 16]}>
{tools.map(tool => (
<Col key={tool.id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{
borderRadius: 12,
border: `1px solid ${tool.is_enabled ? '#d9f7be' : '#f0f0f0'}`,
background: tool.is_enabled ? '#f6ffed' : '#fafafa',
transition: 'all 0.2s',
}}
styles={{ body: { padding: 14 } }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<Badge status={tool.is_enabled ? 'success' : 'default'} />
<Text code style={{ fontSize: 12 }}>{tool.name}</Text>
</div>
<div style={{ marginTop: 4 }}>
<Tag
style={{
fontSize: 11, lineHeight: '16px', padding: '0 4px', margin: 0,
background: SOURCE_CONFIG[tool.source].color + '15',
color: SOURCE_CONFIG[tool.source].color,
border: `1px solid ${SOURCE_CONFIG[tool.source].color}40`,
}}
>
{SOURCE_CONFIG[tool.source].label}
</Tag>
</div>
</div>
<Tooltip title={tool.is_enabled ? '禁用工具' : '启用工具'}>
<Switch
size="small"
checked={tool.is_enabled}
loading={togglingName === tool.id}
onChange={checked => { setTogglingName(tool.id); toggleMut.mutate({ tool, checked }); }}
/>
</Tooltip>
</div>
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 12, color: '#595959', margin: 0 }}>
{tool.description}
</Paragraph>
{(tool.cache_ttl !== undefined || tool.timeout !== undefined) && (
<div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
{tool.timeout && <Text type="secondary" style={{ fontSize: 11 }}>超时 {tool.timeout}s</Text>}
{tool.cache_ttl !== undefined && tool.cache_ttl > 0 && <Text type="secondary" style={{ fontSize: 11 }}>缓存 {tool.cache_ttl}s</Text>}
</div>
)}
</Card>
</Col>
))}
</Row>
</div>
);
})}
</div>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Modal, Descriptions, Space, Badge, Typography, Switch, message,
} from 'antd';
import { InfoCircleOutlined, CodeOutlined, ReloadOutlined } from '@ant-design/icons';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { agentApi } from '@/api/agent';
import type { CATEGORY_CONFIG_TYPE } from './toolsConfig';
const { Text } = Typography;
interface AgentTool {
id: string; name: string; display_name: string; description: string; category: string;
parameters: Record<string, unknown>; status: string; is_enabled: boolean;
cache_ttl: number; timeout: number; max_retries: number; created_at: string;
}
interface Props {
search: string;
categoryFilter: string;
CATEGORY_CONFIG: CATEGORY_CONFIG_TYPE;
}
export default function BuiltinToolsTab({ search, categoryFilter, CATEGORY_CONFIG }: Props) {
const qc = useQueryClient();
const [togglingId, setTogglingId] = useState('');
const [detailModal, setDetailModal] = useState<{ open: boolean; tool: AgentTool | null }>({ open: false, tool: null });
const { data: tools = [], isLoading } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as AgentTool[],
});
const handleToggle = async (tool: AgentTool, checked: boolean) => {
setTogglingId(tool.name);
try {
await agentApi.updateToolStatus(tool.name, checked ? 'active' : 'disabled');
message.success(`工具 ${tool.name}${checked ? '启用' : '禁用'}`);
qc.invalidateQueries({ queryKey: ['agent-tools'] });
if (detailModal.tool?.name === tool.name) {
setDetailModal(prev => ({ ...prev, tool: prev.tool ? { ...prev.tool, is_enabled: checked } : null }));
}
} catch {
message.error('操作失败');
} finally {
setTogglingId('');
}
};
const filtered = tools.filter(t => {
const matchSearch = !search || t.name.toLowerCase().includes(search.toLowerCase()) || t.description.toLowerCase().includes(search.toLowerCase());
const matchCategory = categoryFilter === 'all' || t.category === categoryFilter;
return matchSearch && matchCategory;
});
const columns = [
{
title: '工具名称', dataIndex: 'name', key: 'name',
render: (v: string) => <Space><CodeOutlined style={{ color: '#0D9488' }} /><Text code style={{ fontSize: 13 }}>{v}</Text></Space>,
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 280 },
{
title: '分类', dataIndex: 'category', key: 'category', width: 110,
render: (v: string) => {
const cfg = CATEGORY_CONFIG[v] || CATEGORY_CONFIG.other;
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: '参数数量', dataIndex: 'parameters', key: 'parameters', width: 90,
render: (v: Record<string, unknown>) => <Text type="secondary">{v ? Object.keys(v).length : 0}</Text>,
},
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: AgentTool) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 12 }}>超时 {r.timeout || 30}s</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{r.cache_ttl > 0 ? `缓存 ${r.cache_ttl}s` : '不缓存'}</Text>
</Space>
),
},
{
title: '启用状态', dataIndex: 'is_enabled', key: 'is_enabled', width: 110,
render: (v: boolean, record: AgentTool) => (
<Switch checked={v} loading={togglingId === record.name} checkedChildren="启用" unCheckedChildren="禁用"
onChange={checked => handleToggle(record, checked)} />
),
},
{
title: '操作', key: 'action', width: 80,
render: (_: unknown, record: AgentTool) => (
<Button type="link" size="small" icon={<InfoCircleOutlined />} onClick={() => setDetailModal({ open: true, tool: record })}>详情</Button>
),
},
];
return (
<>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Table dataSource={filtered} columns={columns} rowKey="name" loading={isLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: t => `共 ${t} 个工具` }} />
</Card>
<Modal
title={<Space><CodeOutlined style={{ color: '#0D9488' }} /><span>工具详情</span></Space>}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, tool: null })}
footer={detailModal.tool && (
<Space>
<Switch checked={detailModal.tool.is_enabled} loading={togglingId === detailModal.tool.name}
checkedChildren="启用" unCheckedChildren="禁用"
onChange={checked => handleToggle(detailModal.tool!, checked)} />
<Text type="secondary" style={{ fontSize: 12 }}>
{detailModal.tool.is_enabled ? '工具已启用,Agent 可正常调用' : '工具已禁用'}
</Text>
</Space>
)}
width={600}
>
{detailModal.tool && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工具名称"><Text code style={{ color: '#0D9488' }}>{detailModal.tool.name}</Text></Descriptions.Item>
<Descriptions.Item label="描述">{detailModal.tool.description}</Descriptions.Item>
<Descriptions.Item label="分类"><Tag color={(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).color}>{(CATEGORY_CONFIG[detailModal.tool.category] || CATEGORY_CONFIG.other).label}</Tag></Descriptions.Item>
<Descriptions.Item label="状态"><Badge status={detailModal.tool.is_enabled ? 'success' : 'default'} text={detailModal.tool.is_enabled ? '已启用' : '已禁用'} /></Descriptions.Item>
<Descriptions.Item label="执行超时">{detailModal.tool.timeout || 30}</Descriptions.Item>
<Descriptions.Item label="结果缓存">{detailModal.tool.cache_ttl > 0 ? `${detailModal.tool.cache_ttl} 秒` : '不缓存'}</Descriptions.Item>
<Descriptions.Item label="失败重试">{detailModal.tool.max_retries || 0}</Descriptions.Item>
</Descriptions>
<Card size="small" title="参数定义" style={{ background: '#fafafa', borderRadius: 8 }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: '#4ade80', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{JSON.stringify(detailModal.tool.parameters, null, 2)}
</pre>
</Card>
</div>
)}
</Modal>
</>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Modal, Form, Input, Select, InputNumber,
Space, Popconfirm, message, Typography, Badge, Tooltip,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined,
ThunderboltOutlined, ReloadOutlined, PlayCircleOutlined, CodeOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { httpToolApi, type HTTPToolDefinition } from '@/api/agent';
const { Text } = Typography;
const { TextArea } = Input;
const METHOD_COLORS: Record<string, string> = {
GET: 'green', POST: 'blue', PUT: 'orange', DELETE: 'red', PATCH: 'purple',
};
const AUTH_LABELS: Record<string, string> = {
none: '无认证', bearer: 'Bearer Token', basic: 'Basic Auth', apikey: 'API Key',
};
interface Props { search: string }
export default function HTTPToolsTab({ search }: Props) {
const qc = useQueryClient();
const [form] = Form.useForm();
const [testForm] = Form.useForm();
const [modalOpen, setModalOpen] = useState(false);
const [testModal, setTestModal] = useState<{ open: boolean; tool: HTTPToolDefinition | null }>({ open: false, tool: null });
const [editingId, setEditingId] = useState<number | null>(null);
const [authType, setAuthType] = useState('none');
const { data, isLoading } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const tools = (data ?? []).filter(t =>
!search || t.name.includes(search) || (t.description || '').includes(search)
);
const saveMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
editingId ? httpToolApi.update(editingId, values) : httpToolApi.create(values),
onSuccess: () => {
message.success(editingId ? '更新成功' : '创建成功');
qc.invalidateQueries({ queryKey: ['http-tools'] });
setModalOpen(false); form.resetFields(); setEditingId(null);
},
onError: () => message.error('操作失败'),
});
const deleteMut = useMutation({
mutationFn: (id: number) => httpToolApi.delete(id),
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['http-tools'] }); },
});
const reloadMut = useMutation({
mutationFn: () => httpToolApi.reload(),
onSuccess: () => message.success('HTTP 工具已热重载'),
});
const testMut = useMutation({
mutationFn: ({ id, params }: { id: number; params: Record<string, unknown> }) => httpToolApi.test(id, params),
});
const openCreate = () => { setEditingId(null); form.resetFields(); setAuthType('none'); setModalOpen(true); };
const openEdit = (tool: HTTPToolDefinition) => {
setEditingId(tool.id); setAuthType(tool.auth_type || 'none');
let authConfig: Record<string, string> = {};
try { authConfig = JSON.parse(tool.auth_config || '{}'); } catch { /* ignore */ }
form.setFieldsValue({
name: tool.name, display_name: tool.display_name, description: tool.description,
category: tool.category, method: tool.method || 'GET', url: tool.url,
headers: tool.headers, body_template: tool.body_template,
auth_type: tool.auth_type || 'none', timeout: tool.timeout || 10,
cache_ttl: tool.cache_ttl || 0, status: tool.status, ...authConfig,
});
setModalOpen(true);
};
const handleSave = () => {
form.validateFields().then(values => {
const authConfig: Record<string, string> = {};
if (values.auth_type === 'bearer') authConfig.token = values.token || '';
else if (values.auth_type === 'basic') { authConfig.username = values.username || ''; authConfig.password = values.password || ''; }
else if (values.auth_type === 'apikey') { authConfig.key = values.key || ''; authConfig.value = values.value || ''; authConfig.in = values.in || 'header'; }
saveMut.mutate({
name: values.name, display_name: values.display_name || values.name,
description: values.description || '', category: values.category || 'http',
method: values.method || 'GET', url: values.url, headers: values.headers || '{}',
body_template: values.body_template || '', auth_type: values.auth_type || 'none',
auth_config: JSON.stringify(authConfig), parameters: '[]',
timeout: values.timeout || 10, cache_ttl: values.cache_ttl || 0, status: values.status || 'active',
});
});
};
const handleTest = () => {
if (!testModal.tool) return;
testForm.validateFields().then(values => {
let params: Record<string, unknown> = {};
try { params = JSON.parse(values.params || '{}'); } catch { message.error('参数格式错误'); return; }
testMut.mutate({ id: testModal.tool!.id, params });
});
};
const columns = [
{
title: '名称', dataIndex: 'name', key: 'name',
render: (v: string, r: HTTPToolDefinition) => (
<div>
<Text code style={{ fontSize: 13 }}>{v}</Text>
{r.display_name && r.display_name !== v && <div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.display_name}</div>}
</div>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true, width: 200 },
{ title: '方法', dataIndex: 'method', key: 'method', width: 80, render: (v: string) => <Tag color={METHOD_COLORS[v] || 'default'}>{v}</Tag> },
{ title: 'URL', dataIndex: 'url', key: 'url', ellipsis: true, width: 200, render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v}</Text> },
{ title: '认证', dataIndex: 'auth_type', key: 'auth_type', width: 100, render: (v: string) => <Tag>{AUTH_LABELS[v] || v}</Tag> },
{
title: '超时/缓存', key: 'timing', width: 110,
render: (_: unknown, r: HTTPToolDefinition) => (
<Space direction="vertical" size={0}>
<Text type="secondary" style={{ fontSize: 11 }}>超时: {r.timeout}s</Text>
<Text type="secondary" style={{ fontSize: 11 }}>缓存: {r.cache_ttl > 0 ? `${r.cache_ttl}s` : '不缓存'}</Text>
</Space>
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Badge status={v === 'active' ? 'success' : 'default'} text={v === 'active' ? '启用' : '禁用'} />,
},
{
title: '操作', key: 'action', width: 160,
render: (_: unknown, record: HTTPToolDefinition) => (
<Space>
<Tooltip title="测试"><Button type="link" size="small" icon={<PlayCircleOutlined />}
onClick={() => { setTestModal({ open: true, tool: record }); testForm.resetFields(); testMut.reset(); }} /></Tooltip>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
<Popconfirm title="确定删除?" onConfirm={() => deleteMut.mutate(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => reloadMut.mutate()} loading={reloadMut.isPending}>热重载</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建 HTTP 工具</Button>
</Space>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Table dataSource={tools} columns={columns} rowKey="id" loading={isLoading} size="small"
pagination={{ pageSize: 10, showTotal: t => `共 ${t} 个工具` }} />
</Card>
{/* Create/Edit Modal */}
<Modal title={<Space><ApiOutlined style={{ color: '#0D9488' }} />{editingId ? '编辑 HTTP 工具' : '新建 HTTP 工具'}</Space>}
open={modalOpen} onCancel={() => { setModalOpen(false); form.resetFields(); }}
onOk={handleSave} confirmLoading={saveMut.isPending} width={680} destroyOnHidden>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="name" label="工具名称(英文)" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="my_http_tool" disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称"><Input placeholder="我的 HTTP 工具" /></Form.Item>
</div>
<Form.Item name="description" label="描述"><TextArea rows={2} placeholder="工具功能说明" /></Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '0 12px' }}>
<Form.Item name="method" label="请求方法" initialValue="GET">
<Select options={['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].map(m => ({ value: m, label: m }))} />
</Form.Item>
<Form.Item name="url" label="URL" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="https://api.example.com/endpoint?q={{keyword}}" />
</Form.Item>
</div>
<Form.Item name="headers" label="请求头 (JSON)" initialValue="{}"><TextArea rows={2} /></Form.Item>
<Form.Item name="body_template" label="请求体模板(支持 {{variable}})"><TextArea rows={3} /></Form.Item>
<Form.Item name="auth_type" label="认证方式" initialValue="none">
<Select options={Object.entries(AUTH_LABELS).map(([v, l]) => ({ value: v, label: l }))} onChange={v => setAuthType(v)} />
</Form.Item>
{authType === 'bearer' && <Form.Item name="token" label="Bearer Token" rules={[{ required: true }]}><Input.Password /></Form.Item>}
{authType === 'basic' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="username" label="用户名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}><Input.Password /></Form.Item>
</div>
)}
{authType === 'apikey' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 120px', gap: '0 12px' }}>
<Form.Item name="key" label="参数名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="value" label="参数值" rules={[{ required: true }]}><Input.Password /></Form.Item>
<Form.Item name="in" label="位置" initialValue="header">
<Select options={[{ value: 'header', label: 'Header' }, { value: 'query', label: 'Query' }]} />
</Form.Item>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0 16px' }}>
<Form.Item name="timeout" label="超时(秒)" initialValue={10}><InputNumber min={1} max={120} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="cache_ttl" label="缓存TTL(秒)" initialValue={0}><InputNumber min={0} max={86400} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="status" label="状态" initialValue="active">
<Select options={[{ value: 'active', label: '启用' }, { value: 'disabled', label: '禁用' }]} />
</Form.Item>
</div>
</Form>
</Modal>
{/* Test Modal */}
<Modal title={<Space><ThunderboltOutlined style={{ color: '#fa8c16' }} />测试工具: <Text code>{testModal.tool?.name}</Text></Space>}
open={testModal.open} onCancel={() => setTestModal({ open: false, tool: null })}
onOk={handleTest} confirmLoading={testMut.isPending} width={600}>
<Form form={testForm} layout="vertical">
<Form.Item name="params" label="输入参数 (JSON)"><TextArea rows={4} placeholder='{"keyword": "高血压"}' /></Form.Item>
</Form>
{testMut.data && (
<Card size="small" title="调用结果" style={{ background: '#fafafa' }}>
<pre style={{ fontSize: 12, background: '#1f2937', color: testMut.data.data?.success ? '#4ade80' : '#f87171', padding: 12, borderRadius: 6, overflow: 'auto', margin: 0 }}>
{JSON.stringify(testMut.data.data, null, 2)}
</pre>
</Card>
)}
</Modal>
</>
);
}
'use client';
import React, { useState } from 'react';
import {
Card, Row, Col, Tag, Button, Modal, Form, Input, Select, Empty, Space,
Popconfirm, message, Typography, Badge,
} from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined,
BookOutlined, HeartOutlined, SafetyOutlined, CalendarOutlined,
MedicineBoxOutlined, SettingOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { skillApi, agentApi, type AgentSkill } from '@/api/agent';
const { Text, Paragraph } = Typography;
const { TextArea } = Input;
const SKILL_ICONS: Record<string, React.ReactNode> = {
heart: <HeartOutlined />, book: <BookOutlined />, safety: <SafetyOutlined />,
calendar: <CalendarOutlined />, 'medicine-box': <MedicineBoxOutlined />,
'file-text': <BookOutlined />, setting: <SettingOutlined />,
};
const CATEGORY_COLORS: Record<string, string> = {
patient: 'green', doctor: 'blue', admin: 'purple', general: 'cyan',
};
const CATEGORY_LABELS: Record<string, string> = {
patient: '患者', doctor: '医生', admin: '管理', general: '通用',
};
interface Props { search: string }
export default function SkillsTab({ search }: Props) {
const qc = useQueryClient();
const [form] = Form.useForm();
const [modalOpen, setModalOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<AgentSkill | null>(null);
const { data: skills = [] } = useQuery({
queryKey: ['agent-skills'],
queryFn: () => skillApi.list(),
select: r => r.data ?? [],
});
const { data: toolList = [] } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []).map((t: { name: string; description: string }) => ({ value: t.name, label: `${t.name} - ${t.description}` })),
});
const saveMut = useMutation({
mutationFn: (values: Record<string, unknown>) =>
editingSkill
? skillApi.update(editingSkill.skill_id, values as Partial<AgentSkill>)
: skillApi.create(values as Partial<AgentSkill>),
onSuccess: () => {
message.success(editingSkill ? '更新成功' : '创建成功');
qc.invalidateQueries({ queryKey: ['agent-skills'] });
setModalOpen(false); form.resetFields(); setEditingSkill(null);
},
onError: () => message.error('操作失败'),
});
const deleteMut = useMutation({
mutationFn: (skillId: string) => skillApi.delete(skillId),
onSuccess: () => { message.success('已删除'); qc.invalidateQueries({ queryKey: ['agent-skills'] }); },
});
const openCreate = () => { setEditingSkill(null); form.resetFields(); setModalOpen(true); };
const openEdit = (skill: AgentSkill) => {
setEditingSkill(skill);
let parsedTools: string[] = [];
let parsedQR: string[] = [];
try { parsedTools = JSON.parse(skill.tools || '[]'); } catch { /* */ }
try { parsedQR = JSON.parse(skill.quick_replies || '[]'); } catch { /* */ }
form.setFieldsValue({
skill_id: skill.skill_id, name: skill.name, description: skill.description,
category: skill.category, tools: parsedTools, system_prompt_addon: skill.system_prompt_addon,
quick_replies: parsedQR.join('\n'), icon: skill.icon,
});
setModalOpen(true);
};
const handleSave = () => {
form.validateFields().then(values => {
const quickReplies = (values.quick_replies || '').split('\n').filter((s: string) => s.trim());
saveMut.mutate({
skill_id: values.skill_id,
name: values.name,
description: values.description || '',
category: values.category || 'general',
tools: values.tools || [],
system_prompt_addon: values.system_prompt_addon || '',
quick_replies: quickReplies,
icon: values.icon || 'setting',
});
});
};
const filtered = skills.filter(s =>
!search || s.name.includes(search) || s.skill_id.includes(search) || (s.description || '').includes(search)
);
return (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新建技能包</Button>
</div>
{filtered.length === 0 ? (
<Empty description="暂无技能包" style={{ marginTop: 40 }} />
) : (
<Row gutter={[16, 16]}>
{filtered.map(skill => {
let toolCount = 0;
try { toolCount = JSON.parse(skill.tools || '[]').length; } catch { /* */ }
const icon = SKILL_ICONS[skill.icon] || <ThunderboltOutlined />;
return (
<Col key={skill.skill_id} xs={24} sm={12} md={8} lg={6}>
<Card
size="small"
style={{ borderRadius: 12, border: '1px solid #E0F2F1', transition: 'all 0.2s' }}
styles={{ body: { padding: 16 } }}
hoverable
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 10 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'linear-gradient(135deg, #667eea, #764ba2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 18, flexShrink: 0,
}}>
{icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: '#1d2129' }}>{skill.name}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
<Tag color={CATEGORY_COLORS[skill.category] || 'default'} style={{ fontSize: 11, margin: 0 }}>
{CATEGORY_LABELS[skill.category] || skill.category}
</Tag>
<Tag style={{ fontSize: 11, margin: 0 }}>{toolCount} 个工具</Tag>
</div>
</div>
</div>
<Paragraph ellipsis={{ rows: 2 }} style={{ fontSize: 12, color: '#595959', margin: '0 0 10px' }}>
{skill.description || '暂无描述'}
</Paragraph>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 4 }}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => openEdit(skill)} />
<Popconfirm title="确定删除该技能包?" onConfirm={() => deleteMut.mutate(skill.skill_id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</Card>
</Col>
);
})}
</Row>
)}
{/* Create/Edit Modal */}
<Modal
title={editingSkill ? '编辑技能包' : '新建技能包'}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields(); setEditingSkill(null); }}
onOk={handleSave}
confirmLoading={saveMut.isPending}
width={640}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="skill_id" label="技能ID(英文)" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="symptom_analysis" disabled={!!editingSkill} />
</Form.Item>
<Form.Item name="name" label="技能名称" rules={[{ required: true, message: '必填' }]}>
<Input placeholder="症状分析" />
</Form.Item>
</div>
<Form.Item name="description" label="描述">
<TextArea rows={2} placeholder="技能功能说明" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="category" label="分类" initialValue="general">
<Select options={Object.entries(CATEGORY_LABELS).map(([v, l]) => ({ value: v, label: l }))} />
</Form.Item>
<Form.Item name="icon" label="图标" initialValue="setting">
<Select options={Object.keys(SKILL_ICONS).map(k => ({ value: k, label: k }))} />
</Form.Item>
</div>
<Form.Item name="tools" label="关联工具">
<Select mode="multiple" placeholder="选择工具" options={toolList} allowClear
filterOption={(input, option) => (option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())} />
</Form.Item>
<Form.Item name="system_prompt_addon" label="系统提示词追加">
<TextArea rows={3} placeholder="加载此技能时追加到Agent系统提示的内容" />
</Form.Item>
<Form.Item name="quick_replies" label="快捷回复(每行一个)">
<TextArea rows={3} placeholder={"头痛\n发热\n咳嗽"} />
</Form.Item>
</Form>
</Modal>
</>
);
}
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import {
Card, Table, Tag, Button, Drawer, Input, message, Space, Collapse, Timeline,
Typography, Tabs, DatePicker, Select, Badge, Tooltip, Form, InputNumber, Switch,
Card, Table, Tag, Button, Modal, Input, message, Space, Collapse, Timeline,
Typography, Tabs, Select, Badge, Tooltip, Form, InputNumber, Switch,
Segmented,
} from 'antd';
import { DrawerForm } from '@ant-design/pro-components';
import {
RobotOutlined, PlayCircleOutlined, ToolOutlined, CheckCircleOutlined,
CloseCircleOutlined, HistoryOutlined, ThunderboltOutlined, EditOutlined,
ReloadOutlined, PlusOutlined,
CloseCircleOutlined, ThunderboltOutlined, EditOutlined,
ReloadOutlined, PlusOutlined, SearchOutlined, AppstoreOutlined,
CloudOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { agentApi, skillApi } from '@/api/agent';
import type { ToolCall, AgentExecutionLog, AgentDefinition } from '@/api/agent';
import { agentApi, skillApi, httpToolApi } from '@/api/agent';
import type { ToolCall, AgentDefinition } from '@/api/agent';
import AllToolsTab from './AllToolsTab';
import BuiltinToolsTab from './BuiltinToolsTab';
import HTTPToolsTab from './HTTPToolsTab';
import SkillsTab from './SkillsTab';
import { CATEGORY_CONFIG, SOURCE_CONFIG } from './toolsConfig';
const { Text } = Typography;
const { RangePicker } = DatePicker;
const categoryColor: Record<string, string> = {
patient: 'green', doctor: 'blue', pharmacy: 'orange', admin: 'purple', general: 'cyan',
......@@ -59,57 +64,33 @@ interface AgentResponse {
total_tokens?: number;
}
export default function AgentsPage() {
/* ============ 智能体 Tab ============ */
function AgentsTab() {
const queryClient = useQueryClient();
const [form] = Form.useForm();
// 测试对话
const [testModal, setTestModal] = useState<{ open: boolean; agentId: string; agentName: string }>({ open: false, agentId: '', agentName: '' });
const [testMessages, setTestMessages] = useState<{ role: string; content: string; toolCalls?: ToolCall[]; meta?: { iterations?: number; tokens?: number } }[]>([]);
const [inputMsg, setInputMsg] = useState('');
const [chatLoading, setChatLoading] = useState(false);
const [sessionId, setSessionId] = useState('');
// 编辑 Agent
const [editModal, setEditModal] = useState<{ open: boolean; agent: AgentDefinition | null; isNew: boolean }>({ open: false, agent: null, isNew: false });
// 可用工具列表
const [availableTools, setAvailableTools] = useState<{ name: string; description: string; category: string }[]>([]);
// 执行日志
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);
// 加载 Agent 列表
const { data: agentsData, isLoading: agentsLoading } = useQuery({
queryKey: ['agent-definitions'],
queryFn: () => agentApi.listDefinitions(),
});
const agents: AgentDefinition[] = agentsData?.data || [];
// 加载工具列表
useEffect(() => {
agentApi.listTools().then(res => {
if (res.data?.length > 0) {
setAvailableTools(res.data.map(t => ({ name: t.name, description: t.description, category: t.category })));
}
}).catch(() => {});
fetchLogs();
}, []);
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); }
};
// 保存 Agent(创建或更新)
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const toolsArr = values.tools_array as string[] || [];
......@@ -120,7 +101,7 @@ export default function AgentsPage() {
description: values.description as string,
category: values.category as string,
system_prompt: values.system_prompt as string,
tools: toolsArr,
tools: JSON.stringify(toolsArr),
skills: skillsArr,
max_iterations: values.max_iterations as number,
status: values.status as string,
......@@ -140,7 +121,6 @@ export default function AgentsPage() {
onError: () => message.error('操作失败'),
});
// 热重载
const reloadMutation = useMutation({
mutationFn: (agentId: string) => agentApi.reloadAgent(agentId),
onSuccess: () => {
......@@ -163,15 +143,10 @@ export default function AgentsPage() {
try { toolsArr = JSON.parse(agent.tools || '[]'); } catch {}
try { skillsArr = JSON.parse(agent.skills || '[]'); } catch {}
form.setFieldsValue({
agent_id: agent.agent_id,
name: agent.name,
description: agent.description,
category: agent.category,
system_prompt: agent.system_prompt,
tools_array: toolsArr,
skills_array: skillsArr,
max_iterations: agent.max_iterations,
status: agent.status === 'active',
agent_id: agent.agent_id, name: agent.name, description: agent.description,
category: agent.category, system_prompt: agent.system_prompt,
tools_array: toolsArr, skills_array: skillsArr,
max_iterations: agent.max_iterations, status: agent.status === 'active',
});
setEditModal({ open: true, agent, isNew: false });
} else {
......@@ -230,12 +205,30 @@ export default function AgentsPage() {
);
};
// 按分类分组工具选项
const toolCategoryLabels: Record<string, string> = {
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理',
notification: '消息通知', agent: 'Agent调用', workflow: '工作流',
expression: '表达式', other: '其他',
};
const toolsByCategory = availableTools.reduce((acc, t) => {
const cat = t.category || 'other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(t);
return acc;
}, {} as Record<string, typeof availableTools>);
const toolOptions = Object.entries(toolsByCategory).map(([cat, tools]) => ({
label: toolCategoryLabels[cat] || cat,
options: tools.map(t => ({ value: t.name, label: t.name, title: t.description })),
}));
const agentColumns = [
{
title: '智能体', key: 'name',
render: (_: unknown, r: AgentDefinition) => (
<Space>
<RobotOutlined style={{ color: '#1890ff' }} />
<RobotOutlined style={{ color: '#0D9488' }} />
<div>
<Text strong>{r.name}</Text>
<br />
......@@ -278,195 +271,84 @@ 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>
),
},
];
// 按分类分组工具选项
const toolCategoryLabels: Record<string, string> = {
knowledge: '知识库', recommendation: '智能推荐', medical: '病历管理',
pharmacy: '药品管理', safety: '安全检查', follow_up: '随访管理',
notification: '消息通知', agent: 'Agent调用', workflow: '工作流',
expression: '表达式', other: '其他',
};
const toolsByCategory = availableTools.reduce((acc, t) => {
const cat = t.category || 'other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(t);
return acc;
}, {} as Record<string, typeof availableTools>);
const toolOptions = Object.entries(toolsByCategory).map(([cat, tools]) => ({
label: toolCategoryLabels[cat] || cat,
options: tools.map(t => ({ value: t.name, label: t.name, title: t.description })),
}));
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>智能体管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>从数据库加载 Agent 配置,支持在线编辑、热重载与对话测试</div>
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>新增智能体</Button>
</div>
<Table
dataSource={agents}
columns={agentColumns}
rowKey="agent_id"
loading={agentsLoading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
/>
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Tabs
tabBarExtraContent={
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>新增 Agent</Button>
}
items={[
{
key: 'agents',
label: <Space><RobotOutlined />智能体列表</Space>,
children: (
<Table
dataSource={agents}
columns={agentColumns}
rowKey="agent_id"
loading={agentsLoading}
pagination={false}
size="small"
scroll={{ x: 1100 }}
/>
),
},
{
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={agents.map(a => ({ value: a.agent_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>
{/* 新增/编辑 Agent DrawerForm */}
<DrawerForm
title={editModal.isNew ? '新增 Agent' : `编辑 · ${editModal.agent?.name}`}
{/* 新增/编辑 Agent Modal */}
<Modal
title={editModal.isNew ? '新增智能体' : `编辑 · ${editModal.agent?.name}`}
open={editModal.open}
onOpenChange={(open) => { if (!open) { setEditModal({ open: false, agent: null, isNew: false }); form.resetFields(); } }}
onFinish={async (values) => { saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' }); return true; }}
drawerProps={{ placement: 'right', destroyOnClose: true }}
width={600}
loading={saveMutation.isPending}
form={form}
onCancel={() => { setEditModal({ open: false, agent: null, isNew: false }); form.resetFields(); }}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
width={700}
>
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: doctor_universal_agent" disabled={!editModal.isNew} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="简短描述 Agent 的功能" />
</Form.Item>
<Form.Item name="category" label="类别">
<Select options={CATEGORY_OPTIONS} placeholder="选择类别" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<Input.TextArea rows={5} placeholder="输入 Agent 的系统提示词(留空则使用数据库中关联的提示词模板)" />
</Form.Item>
<Form.Item name="tools_array" label="关联工具">
<Select
mode="multiple"
options={toolOptions}
placeholder="选择 Agent 可使用的工具"
allowClear
/>
</Form.Item>
<Form.Item name="skills_array" label="技能包">
<SkillSelect />
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</DrawerForm>
<Form
form={form}
layout="vertical"
onFinish={(values) => saveMutation.mutate({ ...values, status: values.status ? 'active' : 'disabled' })}
>
<Form.Item name="agent_id" label="Agent ID" rules={[{ required: true, message: '请输入 Agent ID' }]}>
<Input placeholder="如: doctor_universal_agent" disabled={!editModal.isNew} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如: 诊断辅助 Agent" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input placeholder="简短描述 Agent 的功能" />
</Form.Item>
<Form.Item name="category" label="类别">
<Select options={CATEGORY_OPTIONS} placeholder="选择类别" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<Input.TextArea rows={5} placeholder="输入 Agent 的系统提示词(留空则使用数据库中关联的提示词模板)" />
</Form.Item>
<Form.Item name="tools_array" label="关联工具">
<Select
mode="multiple"
options={toolOptions}
placeholder="选择 Agent 可使用的工具"
allowClear
/>
</Form.Item>
<Form.Item name="skills_array" label="技能包">
<SkillSelect />
</Form.Item>
<Form.Item name="max_iterations" label="最大迭代次数">
<InputNumber min={1} max={50} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="status" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
</Form>
</Modal>
{/* 测试对话 Drawer */}
<Drawer
{/* 测试对话 Modal */}
<Modal
title={`测试 · ${testModal.agentName}`}
open={testModal.open}
onClose={() => setTestModal({ open: false, agentId: '', agentName: '' })}
placement="right"
destroyOnClose
width={600}
onCancel={() => setTestModal({ open: false, agentId: '', agentName: '' })}
footer={null}
width={700}
>
<div style={{ height: 400, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 12, marginBottom: 12 }}>
{testMessages.map((m, i) => (
<div key={i} style={{ marginBottom: 12, textAlign: m.role === 'user' ? 'right' : 'left' }}>
<div style={{
display: 'inline-block', padding: '8px 14px', borderRadius: 8, maxWidth: '85%',
background: m.role === 'user' ? '#1890ff' : '#f5f5f5',
background: m.role === 'user' ? '#0D9488' : '#f5f5f5',
color: m.role === 'user' ? '#fff' : '#333',
textAlign: 'left',
}}>
......@@ -486,45 +368,131 @@ export default function AgentsPage() {
<Input value={inputMsg} onChange={e => setInputMsg(e.target.value)} onPressEnter={sendMessage} placeholder="输入消息..." />
<Button type="primary" onClick={sendMessage} loading={chatLoading}>发送</Button>
</Space.Compact>
</Drawer>
</Modal>
</>
);
}
{/* 执行日志详情 Drawer */}
<Drawer
title={<Space><HistoryOutlined />执行日志详情</Space>}
open={!!expandedLog}
onClose={() => setExpandedLog(null)}
placement="right"
destroyOnClose
width={600}
>
{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>
);
})()}
</Drawer>
/* ============ 工具 Tab (内嵌子Tab) ============ */
function ToolsTab() {
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [activeSubTab, setActiveSubTab] = useState('all');
const { data: builtinData } = useQuery({
queryKey: ['agent-tools'],
queryFn: () => agentApi.listTools(),
select: r => (r.data ?? []) as { name: string; is_enabled: boolean; category: string }[],
});
const { data: httpData } = useQuery({
queryKey: ['http-tools'],
queryFn: () => httpToolApi.list(),
select: r => r.data ?? [],
});
const totalCount = (builtinData?.length || 0) + (httpData?.length || 0);
const enabledCount = (builtinData?.filter(t => t.is_enabled).length || 0) +
(httpData?.filter(t => t.status === 'active').length || 0);
const allCategories = useMemo(() => {
const cats = new Set<string>();
builtinData?.forEach(t => cats.add(t.category));
httpData?.forEach(t => cats.add(t.category || 'http'));
return [...cats];
}, [builtinData, httpData]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* 统计 + 筛选 */}
<div style={{
display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
}}>
<Input
placeholder="搜索工具名称或描述..."
prefix={<SearchOutlined />}
value={search}
onChange={e => setSearch(e.target.value)}
style={{ width: 240, borderRadius: 8 }}
allowClear
/>
{(activeSubTab === 'all' || activeSubTab === 'builtin') && (
<Segmented
value={categoryFilter}
onChange={v => setCategoryFilter(v as string)}
options={[
{ value: 'all', label: '全部分类' },
...allCategories.map(c => ({ value: c, label: CATEGORY_CONFIG[c]?.label || c })),
]}
/>
)}
<div style={{ marginLeft: 'auto', fontSize: 13, color: '#8c8c8c' }}>
<Text strong>{totalCount}</Text> 个工具,已启用 <Text strong style={{ color: '#52c41a' }}>{enabledCount}</Text>
</div>
</div>
<Tabs
activeKey={activeSubTab}
onChange={setActiveSubTab}
size="small"
items={[
{
key: 'all',
label: <span><AppstoreOutlined /> 全部</span>,
children: <AllToolsTab search={search} categoryFilter={categoryFilter} CATEGORY_CONFIG={CATEGORY_CONFIG} SOURCE_CONFIG={SOURCE_CONFIG} />,
},
{
key: 'builtin',
label: <span><ToolOutlined /> 内置工具</span>,
children: <BuiltinToolsTab search={search} categoryFilter={categoryFilter} CATEGORY_CONFIG={CATEGORY_CONFIG} />,
},
{
key: 'http',
label: <span><CloudOutlined /> HTTP 工具</span>,
children: <HTTPToolsTab search={search} />,
},
]}
/>
</div>
);
}
/* ============ 主页面 ============ */
export default function AgentManagementPage() {
const [activeTab, setActiveTab] = useState('agents');
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<RobotOutlined style={{ marginRight: 8, color: '#0D9488' }} />
智能体管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理智能体、工具与技能,支持在线编辑、热重载与对话测试</div>
</div>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'agents',
label: <Space><RobotOutlined />智能体</Space>,
children: <AgentsTab />,
},
{
key: 'tools',
label: <Space><ToolOutlined />Tools 工具</Space>,
children: <ToolsTab />,
},
{
key: 'skills',
label: <Space><ThunderboltOutlined />Skill 技能</Space>,
children: <SkillsTab search="" />,
},
]}
/>
</Card>
</div>
);
}
import React from 'react';
import {
ToolOutlined, BookOutlined, ThunderboltOutlined, HeartOutlined,
SafetyOutlined, BellOutlined, RobotOutlined, DeploymentUnitOutlined,
CodeOutlined, ApiOutlined,
} from '@ant-design/icons';
export const CATEGORY_CONFIG: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
knowledge: { color: 'blue', label: '知识库', icon: React.createElement(BookOutlined) },
recommendation: { color: 'cyan', label: '智能推荐', icon: React.createElement(ThunderboltOutlined) },
medical: { color: 'purple', label: '病历管理', icon: React.createElement(HeartOutlined) },
pharmacy: { color: 'green', label: '药品管理', icon: React.createElement(ToolOutlined) },
safety: { color: 'red', label: '安全检查', icon: React.createElement(SafetyOutlined) },
follow_up: { color: 'orange', label: '随访管理', icon: React.createElement(HeartOutlined) },
notification: { color: 'gold', label: '消息通知', icon: React.createElement(BellOutlined) },
agent: { color: 'geekblue', label: 'Agent调用', icon: React.createElement(RobotOutlined) },
workflow: { color: 'volcano', label: '工作流', icon: React.createElement(DeploymentUnitOutlined) },
expression: { color: 'lime', label: '表达式', icon: React.createElement(CodeOutlined) },
http: { color: 'magenta', label: 'HTTP服务', icon: React.createElement(ApiOutlined) },
other: { color: 'default', label: '其他', icon: React.createElement(ToolOutlined) },
};
export type CATEGORY_CONFIG_TYPE = typeof CATEGORY_CONFIG;
export const SOURCE_CONFIG = {
builtin: { label: '内置', color: '#0D9488' },
http: { label: 'HTTP', color: '#fa8c16' },
} as const;
export type SOURCE_CONFIG_TYPE = typeof SOURCE_CONFIG;
'use client';
import React, { useState } from 'react';
import {
Card, Table, Tag, Button, Space, Modal, Typography, Row, Col,
Statistic, Tabs, Input, Select, Timeline, Spin, Empty, DatePicker,
Badge, Collapse,
} from 'antd';
import {
FileTextOutlined, SearchOutlined, ToolOutlined, RobotOutlined,
ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined,
HistoryOutlined, FundOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { agentApi } from '@/api/agent';
import type { AgentExecutionLog, ToolCall } from '@/api/agent';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
const { Text } = Typography;
const { RangePicker } = DatePicker;
/* ---- AI 调用日志类型 ---- */
interface AIUsageLog {
id: number;
scene: string;
user_id: string;
provider: string;
model: string;
total_tokens: number;
response_time_ms: number;
success: boolean;
is_mock: boolean;
trace_id: string;
agent_id: string;
session_id: string;
iteration: number;
created_at: string;
}
interface TraceDetail {
trace_id: string;
execution_logs: unknown[];
llm_calls: AIUsageLog[];
tool_calls: { tool_name: string; iteration: number; success: boolean; duration_ms: number }[];
}
const token = () => localStorage.getItem('access_token') || '';
async function fetchAIStats(page: number, agentID: string, startDate?: string, endDate?: string) {
const params = new URLSearchParams({ page: String(page), page_size: '20' });
if (agentID) params.append('agent_id', agentID);
if (startDate) params.append('start_date', startDate);
if (endDate) params.append('end_date', endDate);
const res = await fetch(`/api/v1/admin/ai-center/stats?${params}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data ?? {};
}
async function fetchTrace(traceID: string) {
const res = await fetch(`/api/v1/admin/ai-center/trace?trace_id=${traceID}`, {
headers: { Authorization: `Bearer ${token()}` },
});
const data = await res.json();
return data.data as TraceDetail;
}
// AI日志页面
export default function AILogsPage() {
const [activeTab, setActiveTab] = useState('execution');
// 执行日志状态
const [execLogs, setExecLogs] = useState<AgentExecutionLog[]>([]);
const [execTotal, setExecTotal] = useState(0);
const [execLoading, setExecLoading] = useState(false);
const [execFilter, setExecFilter] = useState<{
agent_id?: string; page: number; page_size: number;
start?: string; end?: string;
}>({
page: 1, page_size: 20,
start: dayjs().format('YYYY-MM-DD'),
end: dayjs().format('YYYY-MM-DD'),
});
const [expandedLog, setExpandedLog] = useState<AgentExecutionLog | null>(null);
// AI 调用日志状态
const [aiPage, setAiPage] = useState(1);
const [aiAgentFilter, setAiAgentFilter] = useState('');
const [aiDateRange, setAiDateRange] = useState<[string, string]>([
dayjs().format('YYYY-MM-DD'),
dayjs().format('YYYY-MM-DD'),
]);
const [traceSearch, setTraceSearch] = useState('');
const [traceModalOpen, setTraceModalOpen] = useState(false);
const [selectedTraceID, setSelectedTraceID] = useState('');
// 获取 Agent 列表
const { data: agentsData } = useQuery({
queryKey: ['agent-definitions'],
queryFn: () => agentApi.listDefinitions(),
});
const agents = agentsData?.data || [];
// 获取 AI 调用统计
const { data: aiStats, isLoading: aiLoading } = useQuery({
queryKey: ['ai-logs-stats', aiPage, aiAgentFilter, aiDateRange],
queryFn: () => fetchAIStats(aiPage, aiAgentFilter, aiDateRange[0], aiDateRange[1]),
refetchInterval: 60000,
});
// 链路追踪详情
const { data: traceDetail, isFetching: traceFetching } = useQuery({
queryKey: ['trace-detail', selectedTraceID],
queryFn: () => fetchTrace(selectedTraceID),
enabled: !!selectedTraceID && traceModalOpen,
});
// 获取执行日志
const fetchExecLogs = async (filter = execFilter) => {
setExecLoading(true);
try {
const res = await agentApi.getExecutionLogs(filter);
setExecLogs(res.data?.list || []);
setExecTotal(res.data?.total || 0);
} catch {} finally { setExecLoading(false); }
};
// 初始加载执行日志
React.useEffect(() => {
fetchExecLogs();
}, []);
const openTrace = (traceID: string) => {
setSelectedTraceID(traceID);
setTraceModalOpen(true);
};
const renderToolCalls = (toolCalls?: ToolCall[]) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<Collapse size="small" style={{ marginTop: 8 }} items={[{
key: 'tools',
label: <span style={{ fontSize: 12 }}><ToolOutlined style={{ marginRight: 4 }} />Tool调用记录 ({toolCalls.length})</span>,
children: (
<Timeline items={toolCalls.map((tc, idx) => ({
color: tc.success ? 'green' : 'red',
dot: tc.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />,
children: (
<div key={idx} style={{ fontSize: 12 }}>
<div style={{ fontWeight: 500 }}>{tc.tool_name}</div>
<div style={{ color: '#8c8c8c' }}>参数: {tc.arguments}</div>
{tc.result && (
<div style={{ color: tc.success ? '#52c41a' : '#ff4d4f' }}>
{tc.success ? JSON.stringify(tc.result.data).slice(0, 100) + (JSON.stringify(tc.result.data).length > 100 ? '...' : '') : tc.result.error}
</div>
)}
</div>
),
}))} />
),
}]} />
);
};
/* ---- 执行日志列定义 ---- */
const execColumns: ColumnsType<AgentExecutionLog> = [
{
title: '时间', dataIndex: 'created_at', key: 'created_at', width: 160,
render: (v: string) => <Text style={{ fontSize: 12 }}>{dayjs(v).format('MM-DD HH:mm:ss')}</Text>,
},
{
title: '智能体', dataIndex: 'agent_id', key: 'agent_id', width: 160,
render: (v: string) => {
const agent = agents.find(a => a.agent_id === v);
return <Tag color="blue">{agent?.name || 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);
const msg = (obj.message || '') as string;
return <Text style={{ fontSize: 12 }}>{msg.slice(0, 40)}{msg.length > 40 ? '...' : ''}</Text>;
} catch { return <Text style={{ fontSize: 12 }}>{v}</Text>; }
},
},
{ title: '迭代', dataIndex: 'iterations', key: 'iterations', width: 70, render: (v: number) => <Badge count={v} style={{ backgroundColor: '#0891B2' }} /> },
{ title: 'Tokens', dataIndex: 'total_tokens', key: 'total_tokens', width: 80, render: (v: number) => <Text style={{ fontSize: 12 }}>{v?.toLocaleString()}</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>
),
},
];
/* ---- AI 调用日志列定义 ---- */
const aiLogColumns: ColumnsType<AIUsageLog> = [
{
title: 'TraceID', dataIndex: 'trace_id', width: 140,
render: (v) => v ? (
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => openTrace(v)}>
{v.slice(0, 12)}...
</Button>
) : '-',
},
{ title: '场景', dataIndex: 'scene', width: 140, render: (v) => <Tag>{v}</Tag> },
{ title: 'Agent', dataIndex: 'agent_id', width: 140, render: (v) => v ? <Tag color="blue">{v}</Tag> : '-' },
{ title: '迭代', dataIndex: 'iteration', width: 60, render: (v) => v > 0 ? <Tag color="purple">#{v}</Tag> : '-' },
{ title: 'Tokens', dataIndex: 'total_tokens', width: 80, render: (v) => <Text>{v?.toLocaleString()}</Text> },
{
title: '耗时(ms)', dataIndex: 'response_time_ms', width: 90,
render: (v) => {
const color = v > 5000 ? '#ff4d4f' : v > 2000 ? '#fa8c16' : '#52c41a';
return <Text style={{ color }}>{v}</Text>;
},
},
{
title: '状态', dataIndex: 'success', width: 70,
render: (v, r) => (
<Tag color={!v ? 'error' : r.is_mock ? 'warning' : 'success'}>
{!v ? '失败' : r.is_mock ? '模拟' : '成功'}
</Tag>
),
},
{ title: '时间', dataIndex: 'created_at', width: 140, render: (v) => dayjs(v).format('MM-DD HH:mm:ss') },
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>
<FileTextOutlined style={{ marginRight: 8, color: '#0D9488' }} />
AI 日志
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>智能体执行日志与 AI 调用追踪,默认查询当天数据</div>
</div>
{/* 统计卡片 */}
<Row gutter={[16, 16]}>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="总调用" value={aiStats?.total_calls ?? 0} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="成功率" value={aiStats?.total_calls ? Math.round((aiStats.success_calls / aiStats.total_calls) * 100) : 0} suffix="%" valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="总 Tokens" value={aiStats?.total_tokens ?? 0} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="Agent 执行" value={aiStats?.agent_execs ?? 0} prefix={<RobotOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="工具调用" value={aiStats?.tool_calls ?? 0} prefix={<ToolOutlined />} />
</Card>
</Col>
<Col span={4}>
<Card loading={aiLoading} size="small" style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Statistic title="模拟调用" value={aiStats?.mock_calls ?? 0} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
</Row>
<Card style={{ borderRadius: 12, border: '1px solid #E0F2F1' }}>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={[
{
key: 'execution',
label: <Space><RobotOutlined />智能体执行日志</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={agents.map(a => ({ value: a.agent_id, label: a.name }))}
onChange={v => {
const f = { ...execFilter, agent_id: v, page: 1 };
setExecFilter(f);
fetchExecLogs(f);
}}
/>
<RangePicker
defaultValue={[dayjs(), dayjs()]}
onChange={(dates) => {
const start = dates?.[0]?.format('YYYY-MM-DD') || '';
const end = dates?.[1]?.format('YYYY-MM-DD') || '';
const f = { ...execFilter, start, end, page: 1 };
setExecFilter(f);
fetchExecLogs(f);
}}
/>
<Button icon={<ThunderboltOutlined />} onClick={() => fetchExecLogs()}>刷新</Button>
</div>
<Table
dataSource={execLogs}
columns={execColumns}
rowKey="id"
loading={execLoading}
size="small"
pagination={{
current: execFilter.page,
pageSize: execFilter.page_size,
total: execTotal,
size: 'small',
showTotal: (t) => `共 ${t} 条`,
onChange: (page, pageSize) => {
const f = { ...execFilter, page, page_size: pageSize };
setExecFilter(f);
fetchExecLogs(f);
},
}}
/>
</div>
),
},
{
key: 'ai-calls',
label: <Space><FundOutlined />AI 调用日志</Space>,
children: (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Space style={{ flexWrap: 'wrap' }}>
<Select
placeholder="按 Agent 过滤"
allowClear
style={{ width: 200 }}
value={aiAgentFilter || undefined}
onChange={(v) => { setAiAgentFilter(v ?? ''); setAiPage(1); }}
options={agents.map(a => ({ value: a.agent_id, label: a.name }))}
/>
<RangePicker
defaultValue={[dayjs(), dayjs()]}
onChange={(dates) => {
const start = dates?.[0]?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD');
const end = dates?.[1]?.format('YYYY-MM-DD') || dayjs().format('YYYY-MM-DD');
setAiDateRange([start, end]);
setAiPage(1);
}}
/>
<Input.Search
placeholder="按 TraceID 追踪"
value={traceSearch}
onChange={(e) => setTraceSearch(e.target.value)}
onSearch={(v) => v && openTrace(v)}
style={{ width: 280 }}
prefix={<SearchOutlined />}
/>
</Space>
<Table
columns={aiLogColumns}
dataSource={aiStats?.recent_logs ?? []}
rowKey="id"
loading={aiLoading}
size="small"
pagination={{
current: aiPage,
total: aiStats?.logs_total ?? 0,
pageSize: 20,
onChange: setAiPage,
showTotal: (t) => `共 ${t} 条`,
}}
/>
</div>
),
},
{
key: 'agent-stats',
label: <Space><FundOutlined />统计分析</Space>,
children: (
<Row gutter={16}>
<Col span={12}>
<Card title="各 Agent 调用量 TOP 10" size="small">
{(aiStats?.agent_counts ?? []).map((item: { agent_id: string; count: number }, idx: number) => (
<div key={item.agent_id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text>{idx + 1}. {item.agent_id}</Text>
<Tag color="blue">{item.count}</Tag>
</div>
))}
{(aiStats?.agent_counts ?? []).length === 0 && <Empty description="暂无数据" />}
</Card>
</Col>
<Col span={12}>
<Card title="各场景调用量 TOP 10" size="small">
{(aiStats?.scene_counts ?? []).map((item: { scene: string; count: number }, idx: number) => (
<div key={item.scene} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px solid #f0f0f0' }}>
<Text>{idx + 1}. {item.scene}</Text>
<Tag color="green">{item.count}</Tag>
</div>
))}
{(aiStats?.scene_counts ?? []).length === 0 && <Empty description="暂无数据" />}
</Card>
</Col>
</Row>
),
},
]}
/>
</Card>
{/* 执行日志详情 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>
{/* 链路追踪详情弹窗 */}
<Modal
title={`链路追踪: ${selectedTraceID?.slice(0, 20)}...`}
open={traceModalOpen}
onCancel={() => { setTraceModalOpen(false); setSelectedTraceID(''); }}
footer={<Button onClick={() => setTraceModalOpen(false)}>关闭</Button>}
width={700}
>
{traceFetching ? (
<div style={{ textAlign: 'center', padding: 40 }}><Spin /></div>
) : traceDetail ? (
<div>
<Card size="small" title="LLM 调用序列" style={{ marginBottom: 16 }}>
<Timeline
items={(traceDetail.llm_calls ?? []).map((log: AIUsageLog) => ({
color: log.success ? 'green' : 'red',
children: (
<div>
<Space>
<Tag color="purple">迭代 #{log.iteration}</Tag>
<Tag>{log.scene}</Tag>
<Text type="secondary">{log.total_tokens} tokens</Text>
<Text type="secondary">{log.response_time_ms}ms</Text>
</Space>
</div>
),
}))}
/>
{(traceDetail.llm_calls ?? []).length === 0 && <Empty description="无 LLM 调用记录" />}
</Card>
<Card size="small" title="工具调用序列">
<Timeline
items={(traceDetail.tool_calls ?? []).map((tc) => ({
color: tc.success ? 'blue' : 'red',
children: (
<div>
<Space>
<Tag color="blue">迭代 #{tc.iteration}</Tag>
<Tag color="cyan">{tc.tool_name}</Tag>
<Tag color={tc.success ? 'green' : 'red'}>{tc.success ? '成功' : '失败'}</Tag>
<Text type="secondary">{tc.duration_ms}ms</Text>
</Space>
</div>
),
}))}
/>
{(traceDetail.tool_calls ?? []).length === 0 && <Empty description="无工具调用记录" />}
</Card>
</div>
) : (
<Empty description="未找到追踪数据" />
)}
</Modal>
</div>
);
}
'use client';
import React, { Suspense, useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag, Spin, Popover, List } from 'antd';
import React, { useState, useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { Layout, Menu, Avatar, Dropdown, Badge, Space, Typography, Tag } from 'antd';
import {
DashboardOutlined, UserOutlined, TeamOutlined, ApartmentOutlined,
SettingOutlined, LogoutOutlined, BellOutlined, MedicineBoxOutlined,
FileSearchOutlined, FileTextOutlined, RobotOutlined, SafetyCertificateOutlined,
ApiOutlined, DeploymentUnitOutlined, BookOutlined, CheckCircleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined, ShopOutlined,
MenuFoldOutlined, MenuUnfoldOutlined, CompassOutlined, ToolOutlined,
AuditOutlined, ScheduleOutlined,
SafetyOutlined, FundOutlined, AppstoreOutlined,
MenuFoldOutlined, MenuUnfoldOutlined,
} from '@ant-design/icons';
import { useUserStore } from '@/store/userStore';
import type { Menu as MenuType } from '@/api/rbac';
import { myMenuApi } from '@/api/rbac';
import { notificationApi, type Notification } from '@/api/notification';
const { Sider, Content } = Layout;
const { Text } = Typography;
// 图标名称 → React 图标组件映射
const ICON_MAP: Record<string, React.ReactNode> = {
DashboardOutlined: <DashboardOutlined />,
UserOutlined: <UserOutlined />,
TeamOutlined: <TeamOutlined />,
ApartmentOutlined: <ApartmentOutlined />,
SettingOutlined: <SettingOutlined />,
MedicineBoxOutlined: <MedicineBoxOutlined />,
FileSearchOutlined: <FileSearchOutlined />,
FileTextOutlined: <FileTextOutlined />,
RobotOutlined: <RobotOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
ApiOutlined: <ApiOutlined />,
DeploymentUnitOutlined: <DeploymentUnitOutlined />,
BookOutlined: <BookOutlined />,
CheckCircleOutlined: <CheckCircleOutlined />,
SafetyOutlined: <SafetyOutlined />,
FundOutlined: <FundOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
ShopOutlined: <ShopOutlined />,
CompassOutlined: <CompassOutlined />,
ToolOutlined: <ToolOutlined />,
AuditOutlined: <AuditOutlined />,
ScheduleOutlined: <ScheduleOutlined />,
BellOutlined: <BellOutlined />,
};
// 将数据库菜单树转换为 Ant Design Menu items
function convertMenuTree(menus: MenuType[]): any[] {
return menus.map(m => {
const item: any = {
key: m.path || `menu-${m.id}`,
label: m.name,
icon: ICON_MAP[m.icon] || null,
};
if (m.children && m.children.length > 0) {
item.children = convertMenuTree(m.children);
}
return item;
});
}
// 从菜单树中收集所有叶子节点路径
function collectLeafPaths(menus: MenuType[]): string[] {
const paths: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
paths.push(...collectLeafPaths(m.children));
} else if (m.path) {
paths.push(m.path);
}
}
return paths;
}
// 从菜单树中查找包含某路径的父节点key
function findOpenKeys(menus: MenuType[], targetPath: string): string[] {
const keys: string[] = [];
for (const m of menus) {
if (m.children && m.children.length > 0) {
const childPaths = collectLeafPaths(m.children);
if (childPaths.some(p => targetPath.startsWith(p))) {
keys.push(m.path || `menu-${m.id}`);
}
keys.push(...findOpenKeys(m.children, targetPath));
}
}
return keys;
}
const menuItems = [
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营大盘' },
{
key: 'user-mgmt', icon: <TeamOutlined />, label: '用户管理',
children: [
{ key: '/admin/patients', icon: <UserOutlined />, label: '患者管理' },
{ key: '/admin/doctors', icon: <MedicineBoxOutlined />, label: '医生管理' },
{ key: '/admin/admins', icon: <SettingOutlined />, label: '管理员管理' },
],
},
{ key: '/admin/departments', icon: <ApartmentOutlined />, label: '科室管理' },
{ key: '/admin/consultations', icon: <FileSearchOutlined />, label: '问诊管理' },
{ key: '/admin/prescription', icon: <FileTextOutlined />, label: '处方监管' },
{ key: '/admin/pharmacy', icon: <MedicineBoxOutlined />, label: '药品库' },
{ key: '/admin/ai-config', icon: <RobotOutlined />, label: 'AI配置' },
{ key: '/admin/compliance', icon: <SafetyCertificateOutlined />, label: '合规报表' },
{
key: 'ai-platform', icon: <ApiOutlined />, label: '智能体平台',
children: [
{ key: '/admin/agents', icon: <RobotOutlined />, label: '智能体管理' },
{ key: '/admin/ai-logs', icon: <FundOutlined />, label: 'AI日志' },
{ key: '/admin/workflows', icon: <DeploymentUnitOutlined />, label: '工作流' },
{ key: '/admin/tasks', icon: <CheckCircleOutlined />, label: '人工审核' },
{ key: '/admin/knowledge', icon: <BookOutlined />, label: '知识库' },
{ key: '/admin/safety', icon: <SafetyOutlined />, label: '内容安全' },
],
},
{
key: 'system-mgmt', icon: <SafetyCertificateOutlined />, label: '系统管理',
children: [
{ key: '/admin/roles', icon: <SafetyOutlined />, label: '角色管理' },
{ key: '/admin/menus', icon: <AppstoreOutlined />, label: '菜单管理' },
],
},
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense fallback={<div style={{ minHeight: '100vh', background: '#f5f6fa' }} />}>
<AdminLayoutInner>{children}</AdminLayoutInner>
</Suspense>
);
}
function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const isEmbed = searchParams?.get('embed') === '1';
const { user, logout, menus, setMenus } = useUserStore();
const { user, logout } = useUserStore();
const [collapsed, setCollapsed] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const currentPath = pathname || '';
// 菜单为空时主动拉取
// 监听 AI 助手导航事件
useEffect(() => {
if (!user || menus.length > 0) return;
myMenuApi.getMenus().then(res => {
if (res.data && res.data.length > 0) setMenus(res.data);
}).catch(() => {});
}, [user, menus.length, setMenus]);
useEffect(() => {
if (!user) return;
const fetchUnread = () => {
notificationApi.getUnreadCount().then(res => setUnreadCount(res.data?.count || 0)).catch(() => {});
};
fetchUnread();
const timer = setInterval(fetchUnread, 60000);
return () => clearInterval(timer);
}, [user]);
const handleBellClick = () => {
notificationApi.list({ page: 1, page_size: 5 }).then(res => setNotifications(res.data?.list || [])).catch(() => {});
};
const handleMarkAllRead = () => {
notificationApi.markAllRead().then(() => { setUnreadCount(0); setNotifications(n => n.map(i => ({ ...i, is_read: true }))); }).catch(() => {});
};
// 转换为 Ant Design menu items(直接使用store中的菜单数据)
const menuItems = useMemo(() => convertMenuTree(menus), [menus]);
// 监听 AI 助手导航事件(embed 模式下跳过,避免 iframe 内拦截)
useEffect(() => {
if (isEmbed) return;
const handleAIAction = (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.action === 'navigate' && typeof detail.page === 'string') {
......@@ -151,7 +71,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
};
window.addEventListener('ai-action', handleAIAction);
return () => window.removeEventListener('ai-action', handleAIAction);
}, [router, isEmbed]);
}, [router]);
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
......@@ -168,23 +88,35 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
const handleUserMenuClick = ({ key }: { key: string }) => {
if (key === 'logout') { logout(); router.push('/login'); }
else if (key === 'profile' || key === 'settings') { router.push('/admin/dashboard'); }
};
const getSelectedKeys = useCallback(() => {
const allPaths = collectLeafPaths(menus);
const match = allPaths.find(k => currentPath.startsWith(k));
const getSelectedKeys = () => {
const allKeys = ['/admin/dashboard', '/admin/patients', '/admin/doctors', '/admin/admins',
'/admin/departments', '/admin/consultations', '/admin/prescription', '/admin/pharmacy',
'/admin/ai-config', '/admin/compliance', '/admin/agents', '/admin/ai-logs',
'/admin/workflows',
'/admin/tasks', '/admin/knowledge', '/admin/safety',
'/admin/roles', '/admin/menus'];
const match = allKeys.find(k => currentPath.startsWith(k));
return match ? [match] : [];
}, [menus, currentPath]);
};
const getOpenKeys = useCallback(() => {
const getOpenKeys = () => {
if (collapsed) return [];
return findOpenKeys(menus, currentPath);
}, [menus, currentPath, collapsed]);
if (isEmbed) {
return <div style={{ minHeight: '100vh', background: '#f5f6fa' }}>{children}</div>;
}
const keys: string[] = [];
if (['/admin/patients', '/admin/doctors', '/admin/admins'].some(k => currentPath.startsWith(k))) {
keys.push('user-mgmt');
}
if (['/admin/agents', '/admin/ai-logs',
'/admin/workflows', '/admin/tasks', '/admin/knowledge', '/admin/safety']
.some(k => currentPath.startsWith(k))) {
keys.push('ai-platform');
}
if (['/admin/roles', '/admin/menus'].some(k => currentPath.startsWith(k))) {
keys.push('system-mgmt');
}
return keys;
};
return (
<Layout style={{ minHeight: '100vh' }}>
......@@ -213,7 +145,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
>
<div style={{
width: 32, height: 32, borderRadius: 8, flexShrink: 0,
background: 'linear-gradient(135deg, #722ed1 0%, #531dab 100%)',
background: 'linear-gradient(135deg, #0D9488 0%, #0F766E 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<MedicineBoxOutlined style={{ fontSize: 16, color: '#fff' }} />
......@@ -221,7 +153,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
{!collapsed && (
<>
<span style={{ fontSize: 15, fontWeight: 700, color: '#1d2129', marginLeft: 8 }}>互联网医院</span>
<Tag color="purple" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理</Tag>
<Tag color="cyan" style={{ marginLeft: 6, fontSize: 10, lineHeight: '18px', padding: '0 5px' }}>管理</Tag>
</>
)}
</div>
......@@ -254,37 +186,13 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<Popover
trigger="click"
placement="bottomRight"
onOpenChange={(open) => { if (open) handleBellClick(); }}
content={
<div style={{ width: 300 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>通知</span>
{unreadCount > 0 && <a onClick={handleMarkAllRead} style={{ fontSize: 12 }}>全部已读</a>}
</div>
<List
size="small"
dataSource={notifications}
locale={{ emptyText: '暂无通知' }}
renderItem={(item) => (
<List.Item style={{ opacity: item.is_read ? 0.6 : 1 }}>
<List.Item.Meta title={<span style={{ fontSize: 13 }}>{item.title}</span>} description={<span style={{ fontSize: 12 }}>{item.content}</span>} />
</List.Item>
)}
/>
</div>
}
>
<Badge count={unreadCount} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
</Popover>
<Badge count={2} size="small">
<BellOutlined style={{ fontSize: 16, color: '#595959', cursor: 'pointer' }} />
</Badge>
{user ? (
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }}>
<Space style={{ cursor: 'pointer' }}>
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#722ed1' }} />
<Avatar size="small" icon={<UserOutlined />} style={{ backgroundColor: '#0D9488' }} />
<Text style={{ color: '#1d2129', fontSize: 13 }}>{user.real_name || '管理员'}</Text>
</Space>
</Dropdown>
......@@ -292,7 +200,7 @@ function AdminLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</header>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#f5f6fa' }}>
<Content style={{ minHeight: 'calc(100vh - 56px)', background: '#F8FAFB' }}>
{children}
</Content>
</Layout>
......
......@@ -3,11 +3,18 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
Card, Button, Modal, Tree, Tag, Space, Spin, App,
Dropdown,
} from 'antd';
import {
AppstoreOutlined, ReloadOutlined,
SaveOutlined, CheckSquareOutlined, MinusSquareOutlined,
PlusOutlined, EditOutlined, DeleteOutlined,
EyeOutlined, EyeInvisibleOutlined, MoreOutlined,
} from '@ant-design/icons';
import {
DrawerForm, ProFormText, ProFormDigit, ProFormSelect, ProFormSwitch,
ProFormTreeSelect,
} from '@ant-design/pro-components';
import { menuApi, roleApi } from '@/api/rbac';
import type { Menu, Role } from '@/api/rbac';
......@@ -17,22 +24,66 @@ type TreeDataNode = {
children?: TreeDataNode[];
};
// 将菜单树转为 Tree 组件的 treeData
function menusToTreeData(menus: Menu[]): TreeDataNode[] {
// 菜单类型选项
const menuTypeOptions = [
{ label: '目录', value: 'directory' },
{ label: '菜单', value: 'menu' },
{ label: '按钮', value: 'button' },
];
// 将菜单树转为 Tree 组件的 treeData(带操作按钮)
function menusToTreeData(
menus: Menu[],
onEdit: (m: Menu) => void,
onDelete: (m: Menu) => void,
onAddChild: (m: Menu) => void,
): TreeDataNode[] {
return menus.map((m) => ({
key: m.id,
title: (
<span>
{m.name}
{m.path && <span style={{ color: '#999', fontSize: 12, marginLeft: 8 }}>{m.path}</span>}
{!m.visible && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}>隐藏</Tag>}
</span>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%', paddingRight: 8 }}>
<span style={{ flex: 1 }}>
{m.name}
{m.path && <span style={{ color: '#999', fontSize: 12, marginLeft: 8 }}>{m.path}</span>}
{m.type && <Tag color={m.type === 'directory' ? 'blue' : m.type === 'button' ? 'orange' : 'green'} style={{ marginLeft: 6, fontSize: 11 }}>{m.type === 'directory' ? '目录' : m.type === 'button' ? '按钮' : '菜单'}</Tag>}
{!m.visible && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}><EyeInvisibleOutlined /> 隐藏</Tag>}
</span>
<Space size={4} style={{ flexShrink: 0 }} onClick={(e) => e.stopPropagation()}>
<Dropdown
menu={{
items: [
{ key: 'addChild', icon: <PlusOutlined />, label: '添加子菜单' },
{ key: 'edit', icon: <EditOutlined />, label: '编辑' },
{ type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除', danger: true },
],
onClick: ({ key }) => {
if (key === 'edit') onEdit(m);
else if (key === 'delete') onDelete(m);
else if (key === 'addChild') onAddChild(m);
},
}}
trigger={['click']}
>
<Button type="text" size="small" icon={<MoreOutlined />} style={{ opacity: 0.6 }} />
</Dropdown>
</Space>
</div>
),
children: m.children?.length ? menusToTreeData(m.children) : undefined,
children: m.children?.length ? menusToTreeData(m.children, onEdit, onDelete, onAddChild) : undefined,
}));
}
// 收集树中所有叶节点 key(用于全选逻辑中正确处理 checkStrictly=false 模式)
// 将菜单树转为 TreeSelect 的 treeData
function menusToSelectData(menus: Menu[]): any[] {
return menus.map((m) => ({
value: m.id,
title: m.name,
children: m.children?.length ? menusToSelectData(m.children) : undefined,
}));
}
// 收集树中所有 key
function collectAllKeys(menus: Menu[]): number[] {
const keys: number[] = [];
for (const m of menus) {
......@@ -44,6 +95,18 @@ function collectAllKeys(menus: Menu[]): number[] {
return keys;
}
// 在菜单树中查找指定 id 的菜单
function findMenuById(menus: Menu[], id: number): Menu | undefined {
for (const m of menus) {
if (m.id === id) return m;
if (m.children?.length) {
const found = findMenuById(m.children, id);
if (found) return found;
}
}
return undefined;
}
export default function MenusPage() {
const { message } = App.useApp();
const [roles, setRoles] = useState<Role[]>([]);
......@@ -54,8 +117,57 @@ export default function MenusPage() {
const [menuLoading, setMenuLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Menu CRUD state
const [addVisible, setAddVisible] = useState(false);
const [editVisible, setEditVisible] = useState(false);
const [editingMenu, setEditingMenu] = useState<Menu | null>(null);
const [defaultParentId, setDefaultParentId] = useState<number | undefined>(undefined);
const allMenuKeys = useMemo(() => collectAllKeys(menus), [menus]);
const treeData = useMemo(() => menusToTreeData(menus), [menus]);
const parentTreeData = useMemo(() => menusToSelectData(menus), [menus]);
const handleEdit = useCallback((m: Menu) => {
setEditingMenu(m);
setEditVisible(true);
}, []);
const handleDelete = useCallback((m: Menu) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除菜单「${m.name}」吗?子菜单将一并删除,该操作不可恢复。`,
okType: 'danger',
onOk: async () => {
try {
await menuApi.delete(m.id);
message.success('已删除');
fetchMenus();
} catch {
message.error('删除失败');
}
},
});
}, []);
const handleAddChild = useCallback((m: Menu) => {
setDefaultParentId(m.id);
setAddVisible(true);
}, []);
const treeData = useMemo(
() => menusToTreeData(menus, handleEdit, handleDelete, handleAddChild),
[menus, handleEdit, handleDelete, handleAddChild],
);
const fetchMenus = useCallback(async () => {
setMenuLoading(true);
try {
const res = await menuApi.listTree();
setMenus(res.data || []);
} catch {
message.error('加载菜单失败');
}
setMenuLoading(false);
}, []);
// 加载角色列表和菜单树
const fetchInitial = useCallback(async () => {
......@@ -68,7 +180,6 @@ export default function MenusPage() {
const roleList = rolesRes.data || [];
setRoles(roleList);
setMenus(menusRes.data || []);
// 默认选中第一个角色
if (roleList.length > 0 && !selectedRoleId) {
setSelectedRoleId(roleList[0].id);
}
......@@ -120,9 +231,69 @@ export default function MenusPage() {
setCheckedKeys([]);
};
const selectedRole = roles.find((r) => r.id === selectedRoleId);
// 表单字段
const menuFormFields = (
<>
<ProFormText
name="name"
label="菜单名称"
rules={[{ required: true, message: '请输入菜单名称' }]}
placeholder="请输入菜单名称"
/>
<ProFormSelect
name="type"
label="菜单类型"
options={menuTypeOptions}
rules={[{ required: true, message: '请选择菜单类型' }]}
placeholder="请选择菜单类型"
/>
<ProFormTreeSelect
name="parent_id"
label="上级菜单"
placeholder="留空表示顶级菜单"
allowClear
fieldProps={{
treeData: parentTreeData,
treeDefaultExpandAll: true,
}}
/>
<ProFormText
name="path"
label="路由路径"
placeholder="如 /admin/menus"
/>
<ProFormText
name="icon"
label="图标"
placeholder="Ant Design 图标名称"
/>
<ProFormText
name="component"
label="组件路径"
placeholder="前端组件路径(选填)"
/>
<ProFormText
name="permission"
label="权限标识"
placeholder="如 admin:menu:list(选填)"
/>
<ProFormDigit
name="sort"
label="排序号"
placeholder="数字越小越靠前"
min={0}
fieldProps={{ style: { width: '100%' } }}
/>
<ProFormSwitch
name="visible"
label="是否显示"
fieldProps={{ checkedChildren: '显示', unCheckedChildren: '隐藏' }}
/>
</>
);
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Header */}
......@@ -132,11 +303,18 @@ export default function MenusPage() {
<AppstoreOutlined style={{ marginRight: 8, color: '#1890ff' }} />菜单管理
</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>
选择角色,勾选分配菜单权限
管理系统菜单结构,为角色分配菜单权限
</div>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchInitial}>刷新</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setDefaultParentId(undefined); setAddVisible(true); }}
>
添加菜单
</Button>
</Space>
</div>
......@@ -225,6 +403,75 @@ export default function MenusPage() {
</Card>
</div>
</Spin>
{/* Add Menu Drawer */}
<DrawerForm
title="添加菜单"
open={addVisible}
onOpenChange={(open) => {
setAddVisible(open);
if (!open) setDefaultParentId(undefined);
}}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
initialValues={{ sort: 0, visible: true, type: 'menu', parent_id: defaultParentId }}
onFinish={async (values) => {
try {
await menuApi.create({
...values,
parent_id: values.parent_id || 0,
});
message.success('菜单创建成功');
fetchMenus();
return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
{menuFormFields}
</DrawerForm>
{/* Edit Menu Drawer */}
<DrawerForm
title="编辑菜单"
open={editVisible}
onOpenChange={(open) => {
setEditVisible(open);
if (!open) setEditingMenu(null);
}}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
initialValues={editingMenu ? {
name: editingMenu.name,
type: editingMenu.type || 'menu',
parent_id: editingMenu.parent_id || undefined,
path: editingMenu.path,
icon: editingMenu.icon,
component: editingMenu.component,
permission: editingMenu.permission,
sort: editingMenu.sort,
visible: editingMenu.visible,
} : undefined}
onFinish={async (values) => {
if (!editingMenu) return false;
try {
await menuApi.update(editingMenu.id, {
...values,
parent_id: values.parent_id || 0,
});
message.success('菜单更新成功');
fetchMenus();
return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
{menuFormFields}
</DrawerForm>
</div>
);
}
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Card, Table, Tag, Button, Drawer, Form, Input, Select, message, Space, Badge, Popconfirm } from 'antd';
import { DrawerForm, ProFormText, ProFormTextArea, ProFormSelect } from '@ant-design/pro-components';
import { DeploymentUnitOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { workflowApi } from '@/api/agent';
import VisualWorkflowEditor from '@/components/workflow/VisualWorkflowEditor';
interface Workflow {
id: number;
workflow_id: string;
name: string;
description: string;
category: string;
status: string;
version: number;
definition?: string;
}
const statusColor: Record<string, 'success' | 'warning' | 'default'> = {
active: 'success', draft: 'warning', archived: 'default',
};
const statusLabel: Record<string, string> = {
active: '已启用', draft: '草稿', archived: '已归档',
};
const categoryLabel: Record<string, string> = {
pre_consult: '预问诊', diagnosis: '诊断', prescription: '处方审核', follow_up: '随访',
};
export default function WorkflowsPage() {
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [createDrawer, setCreateDrawer] = useState(false);
const [editorDrawer, setEditorDrawer] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [tableLoading, setTableLoading] = useState(false);
const fetchWorkflows = async () => {
setTableLoading(true);
try {
const res = await workflowApi.list();
setWorkflows((res.data as Workflow[]) || []);
} catch {} finally {
setTableLoading(false);
}
};
useEffect(() => { fetchWorkflows(); }, []);
const handleCreate = async (values: Record<string, string>) => {
setLoading(true);
try {
const definition = {
id: values.workflow_id, name: values.name,
nodes: {
start: { id: 'start', type: 'start', name: '开始', config: {}, next_nodes: ['end'] },
end: { id: 'end', type: 'end', name: '结束', config: {}, next_nodes: [] },
},
edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }],
};
await workflowApi.create({
workflow_id: values.workflow_id,
name: values.name,
description: values.description,
category: values.category,
definition: JSON.stringify(definition),
});
message.success('创建成功');
setCreateDrawer(false);
form.resetFields();
fetchWorkflows();
} catch {
message.error('创建失败');
} finally {
setLoading(false);
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleSaveWorkflow = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return;
try {
await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) });
message.success('工作流已保存');
fetchWorkflows();
} catch { message.error('保存失败'); }
}, [editingWorkflow]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleExecuteFromEditor = useCallback(async (nodes: any[], edges: any[]) => {
if (!editingWorkflow) return;
try {
const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } });
message.success(`执行已启动: ${result.data?.execution_id}`);
} catch { message.error('执行失败'); }
}, [editingWorkflow]);
const handleExecute = async (workflowId: string) => {
try {
const result = await workflowApi.execute(workflowId);
message.success(`执行已启动: ${result.data?.execution_id}`);
} catch { message.error('执行失败'); }
};
const getEditorInitialData = (): { nodes?: unknown[]; edges?: unknown[] } | undefined => {
if (!editingWorkflow?.definition) return undefined;
try { return JSON.parse(editingWorkflow.definition); } catch { return undefined; }
};
const columns = [
{
title: '工作流', key: 'info',
render: (_: unknown, r: Workflow) => (
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.workflow_id}</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{
title: '类别', dataIndex: 'category', key: 'category', width: 100,
render: (v: string) => <Tag color="blue">{categoryLabel[v] || v}</Tag>,
},
{ title: '版本', dataIndex: 'version', key: 'version', width: 70, render: (v: number) => `v${v}` },
{
title: '状态', dataIndex: 'status', key: 'status', width: 90,
render: (v: string) => <Badge status={statusColor[v] || 'default'} text={statusLabel[v] || v} />,
},
{
title: '操作', key: 'action', width: 260,
render: (_: unknown, r: Workflow) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditingWorkflow(r); setEditorDrawer(true); }}>编辑</Button>
<Button type="link" size="small" icon={<PlayCircleOutlined />} disabled={r.status !== 'active'} onClick={() => handleExecute(r.workflow_id)}>执行</Button>
{r.status === 'draft' && (
<Button type="link" size="small" onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
fetchWorkflows();
}}>激活</Button>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
fetchWorkflows();
}}>
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>工作流管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>设计和管理 AI 工作流,实现复杂业务流程自动化</div>
</div>
{/* 操作栏 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<DeploymentUnitOutlined style={{ color: '#8c8c8c' }} />
<span style={{ fontSize: 13, color: '#8c8c8c' }}>共 {workflows.length} 个工作流</span>
<Button type="primary" icon={<PlusOutlined />} style={{ marginLeft: 'auto' }} onClick={() => setCreateDrawer(true)}>
新建工作流
</Button>
</div>
</Card>
{/* 工作流列表 */}
<Card style={{ borderRadius: 12, border: '1px solid #edf2fc' }}>
<Table dataSource={workflows} columns={columns} rowKey="id" loading={tableLoading} size="small"
pagination={{ pageSize: 10, showSizeChanger: true, size: 'small', showTotal: (t) => ` ${t} ` }}
/>
</Card>
{/* 新建工作流 DrawerForm */}
<DrawerForm
title="新建工作流"
open={createDrawer}
onOpenChange={(open) => { setCreateDrawer(open); if (!open) form.resetFields(); }}
onFinish={async (values) => { await handleCreate(values); return true; }}
drawerProps={{ placement: 'right', destroyOnClose: true }}
width={480}
loading={loading}
form={form}
>
<ProFormText name="workflow_id" label="工作流 ID" placeholder="如: smart_pre_consult"
rules={[{ required: true, message: '请输入工作流ID' }]} />
<ProFormText name="name" label="名称" placeholder="请输入工作流名称"
rules={[{ required: true, message: '请输入名称' }]} />
<ProFormTextArea name="description" label="描述" placeholder="请输入描述(选填)"
fieldProps={{ rows: 2 }} />
<ProFormSelect name="category" label="类别" placeholder="选择类别"
options={[
{ value: 'pre_consult', label: '预问诊' },
{ value: 'diagnosis', label: '诊断' },
{ value: 'prescription', label: '处方审核' },
{ value: 'follow_up', label: '随访' },
]} />
</DrawerForm>
{/* 可视化编辑器 Drawer */}
<Drawer
title={`编辑工作流 · ${editingWorkflow?.name}`}
open={editorDrawer}
onClose={() => { setEditorDrawer(false); setEditingWorkflow(null); }}
placement="right"
destroyOnClose
width={960}
>
<div style={{ height: 650 }}>
<VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialNodes={getEditorInitialData()?.nodes as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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>
</Drawer>
</div>
);
}
import PageComponent from '@/pages/admin/Workflows';
export default function Page() { return <PageComponent />; }
......@@ -95,6 +95,30 @@ export const ROUTE_REGISTRY: RouteDefinition[] = [
permissions: ['admin:pharmacy:list'],
operations: { list: '/admin/pharmacy' },
},
{
code: 'admin_users',
path: '/admin/users',
name: '用户管理',
role: 'admin',
permissions: ['admin:users:list'],
operations: { list: '/admin/users' },
},
{
code: 'admin_doctor_review',
path: '/admin/doctor-review',
name: '医生审核',
role: 'admin',
permissions: ['admin:doctor-review:list'],
operations: { list: '/admin/doctor-review' },
},
{
code: 'admin_statistics',
path: '/admin/statistics',
name: '数据统计',
role: 'admin',
permissions: ['admin:statistics:view'],
operations: { list: '/admin/statistics' },
},
// AI 管理
{
code: 'admin_ai_config',
......@@ -104,29 +128,21 @@ export const ROUTE_REGISTRY: RouteDefinition[] = [
permissions: ['admin:ai-config:view'],
operations: { list: '/admin/ai-config' },
},
{
code: 'admin_ai_center',
path: '/admin/ai-center',
name: 'AI中心',
role: 'admin',
permissions: ['admin:ai-center:view'],
operations: { list: '/admin/ai-center' },
},
{
code: 'admin_agents',
path: '/admin/agents',
name: 'Agent管理',
name: '智能体管理',
role: 'admin',
permissions: ['admin:agents:list'],
operations: { list: '/admin/agents' },
},
{
code: 'admin_tools',
path: '/admin/tools',
name: '工具管理',
code: 'admin_ai_logs',
path: '/admin/ai-logs',
name: 'AI日志',
role: 'admin',
permissions: ['admin:tools:list'],
operations: { list: '/admin/tools' },
permissions: ['admin:ai-logs:view'],
operations: { list: '/admin/ai-logs' },
},
{
code: 'admin_workflows',
......
......@@ -2,28 +2,42 @@
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { Typography, Space, Button, Modal, App } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { ProTable, DrawerForm, ProFormText, ProFormDigit } from '@ant-design/pro-components';
import { Typography, Space, Button, Modal, Tag, App } from 'antd';
import {
PlusOutlined, EditOutlined, DeleteOutlined, MedicineBoxOutlined,
} from '@ant-design/icons';
import {
ProTable, DrawerForm, ProFormText, ProFormDigit,
} from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { adminApi } from '../../../api/admin';
import type { Department } from '../../../api/doctor';
const { Text } = Typography;
const AdminDepartmentsPage: React.FC = () => {
const { message } = App.useApp();
const searchParams = useSearchParams();
const actionRef = useRef<ActionType>();
const [modalVisible, setModalVisible] = useState(false);
const actionRef = useRef<ActionType>(null);
const [addModalVisible, setAddModalVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editingDept, setEditingDept] = useState<Department | null>(null);
useEffect(() => {
if (searchParams.get('action') === 'add') setModalVisible(true);
if (searchParams.get('action') === 'add') setAddModalVisible(true);
}, [searchParams]);
const handleEdit = (record: Department) => {
setEditingDept(record);
setEditModalVisible(true);
};
const handleDelete = (record: Department) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除科室「${record.name}」吗?该操作不可恢复。`,
okType: 'danger',
onOk: async () => {
try {
await adminApi.deleteDepartment(record.id);
......@@ -38,74 +52,192 @@ const AdminDepartmentsPage: React.FC = () => {
const columns: ProColumns<Department>[] = [
{
title: '图标',
dataIndex: 'icon',
width: 60,
render: (_, record) => <span style={{ fontSize: 24 }}>{record.icon || '🏥'}</span>,
title: '关键词',
dataIndex: 'keyword',
hideInTable: true,
fieldProps: { placeholder: '搜索科室名称' },
},
{
title: '科室名称',
title: '科室',
dataIndex: 'name',
render: (_, record) => <Typography.Text strong>{record.name}</Typography.Text>,
search: false,
render: (_, record) => (
<Space>
<div style={{
width: 40,
height: 40,
borderRadius: 8,
background: 'linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 20,
}}>
{record.icon || <MedicineBoxOutlined style={{ color: '#1890ff' }} />}
</div>
<div>
<Text strong>{record.name}</Text>
{record.parent_id && (
<>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>子科室</Text>
</>
)}
</div>
</Space>
),
},
{
title: '排序',
dataIndex: 'sort_order',
search: false,
width: 80,
render: (v) => <Tag>{v as number}</Tag>,
},
{
title: '子科室',
dataIndex: 'children',
width: 100,
search: false,
render: (_, record) => {
const count = record.children?.length || 0;
return count > 0
? <Tag color="blue">{count}</Tag>
: <Text type="secondary">-</Text>;
},
},
{
title: '操作',
valueType: 'option',
width: 160,
render: (_, record) => (
<Space>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { setEditingDept(record); setModalVisible(true); }}>编辑</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>删除</Button>
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
编辑
</Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={() => handleDelete(record)}>
删除
</Button>
</Space>
),
},
];
return (
const formContent = (
<>
<ProFormText
name="name"
label="科室名称"
rules={[{ required: true, message: '请输入科室名称' }]}
placeholder="请输入科室名称"
/>
<ProFormText
name="icon"
label="图标"
placeholder="请输入Emoji图标,如 🏥 🫀 🧠"
extra="支持 Emoji 表情,留空将显示默认图标"
/>
<ProFormDigit
name="sort_order"
label="排序号"
placeholder="数字越小越靠前"
min={1}
fieldProps={{ style: { width: '100%' } }}
extra="排序号越小,科室排列越靠前"
/>
</>
);
return (
<div style={{ padding: '20px 24px', display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', margin: 0 }}>科室管理</h2>
<div style={{ fontSize: 13, color: '#8c8c8c', marginTop: 2 }}>管理医院科室分类与排序</div>
</div>
<ProTable<Department>
headerTitle="科室管理"
tooltip="管理医院科室分类与排序"
headerTitle="科室列表"
rowKey="id"
actionRef={actionRef}
cardBordered
search={false}
search={{
labelWidth: 'auto',
optionRender: (searchConfig, formProps, dom) => [
...dom,
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => setAddModalVisible(true)}
>
添加科室
</Button>,
],
}}
options={{ density: true, reload: true, setting: true }}
request={async () => {
request={async (params) => {
const res = await adminApi.getDepartmentList();
return { data: res.data || [], success: true };
const list = res.data || [];
// Flatten tree for display
const rows: Department[] = [];
const flatten = (items: Department[]) => {
for (const item of items) {
rows.push(item);
if (item.children?.length) flatten(item.children);
}
};
flatten(Array.isArray(list) ? list : []);
// Client-side keyword filter
const keyword = params.keyword?.trim()?.toLowerCase();
const filtered = keyword
? rows.filter((d) => d.name.toLowerCase().includes(keyword))
: rows;
return { data: filtered, success: true, total: filtered.length };
}}
pagination={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingDept(null); setModalVisible(true); }}>
添加科室
</Button>,
]}
pagination={{ defaultPageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} 个科室` }}
toolBarRender={() => []}
columns={columns}
/>
{/* Add Department */}
<DrawerForm
title="添加科室"
open={addModalVisible}
onOpenChange={setAddModalVisible}
initialValues={{ sort_order: 1 }}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
onFinish={async (values) => {
try {
await adminApi.createDepartment(values);
message.success('科室创建成功');
actionRef.current?.reload();
return true;
} catch {
message.error('操作失败');
return false;
}
}}
>
{formContent}
</DrawerForm>
{/* Edit Department */}
<DrawerForm
title={editingDept ? '编辑科室' : '添加科室'}
open={modalVisible}
title="编辑科室"
open={editModalVisible}
onOpenChange={(open) => {
setModalVisible(open);
setEditModalVisible(open);
if (!open) setEditingDept(null);
}}
initialValues={editingDept || { sort_order: 1 }}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
onFinish={async (values) => {
if (!editingDept) return false;
try {
if (editingDept) {
await adminApi.updateDepartment(editingDept.id, values);
message.success('科室更新成功');
} else {
await adminApi.createDepartment(values);
message.success('科室创建成功');
}
await adminApi.updateDepartment(editingDept.id, values);
message.success('科室更新成功');
actionRef.current?.reload();
return true;
} catch {
......@@ -114,11 +246,9 @@ const AdminDepartmentsPage: React.FC = () => {
}
}}
>
<ProFormText name="name" label="科室名称" rules={[{ required: true, message: '请输入科室名称' }]} placeholder="请输入科室名称" />
<ProFormText name="icon" label="图标" placeholder="请输入Emoji图标" />
<ProFormDigit name="sort_order" label="排序号" min={1} fieldProps={{ style: { width: '100%' } }} />
{formContent}
</DrawerForm>
</>
</div>
);
};
......
......@@ -12,6 +12,7 @@ import {
} from '@ant-design/icons';
import {
ProTable, DrawerForm, ProFormText, ProFormSelect, ProFormTextArea,
ProFormDigit,
} from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { adminApi } from '../../../api/admin';
......@@ -221,6 +222,16 @@ const AdminDoctorsPage: React.FC = () => {
width: 150,
ellipsis: true,
},
{
title: '问诊价格',
dataIndex: 'price',
search: false,
width: 90,
render: (_, record) => {
const p = record.price;
return p ? <Text style={{ color: '#1890ff', fontWeight: 600 }}>¥{(p / 100).toFixed(0)}</Text> : <Text type="secondary">未设置</Text>;
},
},
{
title: '认证状态',
dataIndex: 'review_status',
......@@ -397,11 +408,20 @@ const AdminDoctorsPage: React.FC = () => {
placeholder="请输入执业证号(选填)"
colProps={{ span: 12 }}
/>
<ProFormDigit
name="price"
label="问诊价格(分)"
placeholder="例如 5000 = ¥50"
min={0}
fieldProps={{ precision: 0 }}
rules={[{ required: true, message: '请输入问诊价格' }]}
colProps={{ span: 12 }}
/>
<ProFormText.Password
name="password"
label="初始密码"
placeholder="默认密码:123456"
colProps={{ span: 24 }}
colProps={{ span: 12 }}
/>
<ProFormTextArea
name="introduction"
......@@ -432,6 +452,7 @@ const AdminDoctorsPage: React.FC = () => {
title: editingDoctor.title,
department_id: editingDoctor.department_id,
hospital: editingDoctor.hospital,
price: editingDoctor.price || 0,
}
: undefined
}
......@@ -480,7 +501,15 @@ const AdminDoctorsPage: React.FC = () => {
name="hospital"
label="医院"
placeholder="请输入医院名称"
colProps={{ span: 24 }}
colProps={{ span: 12 }}
/>
<ProFormDigit
name="price"
label="问诊价格(分)"
placeholder="例如 5000 = ¥50"
min={0}
fieldProps={{ precision: 0 }}
colProps={{ span: 12 }}
/>
</DrawerForm>
......@@ -501,6 +530,9 @@ const AdminDoctorsPage: React.FC = () => {
<Descriptions.Item label="科室">{currentDoctor.department_name || '-'}</Descriptions.Item>
<Descriptions.Item label="医院" span={2}>{currentDoctor.hospital || '-'}</Descriptions.Item>
<Descriptions.Item label="执业证号">{currentDoctor.license_no || '-'}</Descriptions.Item>
<Descriptions.Item label="问诊价格">
{currentDoctor.price ? `¥${(currentDoctor.price / 100).toFixed(0)}` : '未设置'}
</Descriptions.Item>
<Descriptions.Item label="认证状态">{getStatusTag(currentDoctor.review_status)}</Descriptions.Item>
<Descriptions.Item label="账号状态">
<Tag color={currentDoctor.user_status === 'active' ? 'green' : 'red'}>
......
'use client';
import React, { useState, useRef } from 'react';
import dynamic from 'next/dynamic';
import { Button, Space, Tag, Badge, Drawer, Popconfirm, App } from 'antd';
import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
} from '@ant-design/icons';
import {
ProTable, DrawerForm, ProFormText, ProFormTextArea, ProFormSelect,
} from '@ant-design/pro-components';
import type { ActionType, ProColumns } from '@ant-design/pro-components';
import { workflowApi } from '@/api/agent';
const VisualWorkflowEditor = dynamic(
() => import('@/components/workflow/VisualWorkflowEditor'),
{ ssr: false },
);
interface Workflow {
id: number;
workflow_id: string;
name: string;
description: string;
category: string;
status: string;
version: number;
definition?: string;
}
const statusColor: Record<string, 'success' | 'warning' | 'default'> = {
active: 'success', draft: 'warning', archived: 'default',
};
const statusLabel: Record<string, string> = {
active: '已启用', draft: '草稿', archived: '已归档',
};
const categoryLabel: Record<string, string> = {
pre_consult: '预问诊',
consult_created: '问诊创建',
consult_ended: '问诊结束',
follow_up: '随访',
prescription_created: '处方创建',
prescription_approved: '处方审核通过',
payment_completed: '支付完成',
renewal_requested: '续方申请',
health_alert: '健康预警',
doctor_review: '医生审核',
};
// 后端 definition 格式转 ReactFlow 格式
function convertDefinitionToReactFlow(definition: string | undefined) {
if (!definition) return undefined;
try {
const def = JSON.parse(definition);
let nodeArray: unknown[] = [];
if (def.nodes && !Array.isArray(def.nodes)) {
const entries = Object.values(def.nodes) as Array<{ id: string; type: string; name: string; config?: Record<string, unknown> }>;
nodeArray = entries.map((n, i) => ({
id: n.id,
type: 'custom',
position: { x: 250, y: 50 + i * 120 },
data: { label: n.name, nodeType: n.type, config: n.config },
}));
} else if (Array.isArray(def.nodes)) {
nodeArray = def.nodes;
}
let edgeArray: unknown[] = [];
if (Array.isArray(def.edges)) {
edgeArray = def.edges.map((e: { id: string; source_node?: string; source?: string; target_node?: string; target?: string }) => ({
id: e.id,
source: e.source_node || e.source,
target: e.target_node || e.target,
animated: true,
style: { stroke: '#1890ff' },
}));
}
return { nodes: nodeArray, edges: edgeArray };
} catch {
return undefined;
}
}
const AdminWorkflowsPage: React.FC = () => {
const { message } = App.useApp();
const actionRef = useRef<ActionType>(null);
const [addModalVisible, setAddModalVisible] = useState(false);
const [editorDrawer, setEditorDrawer] = useState(false);
const [editingWorkflow, setEditingWorkflow] = useState<Workflow | null>(null);
const handleExecute = async (workflowId: string) => {
try {
const result = await workflowApi.execute(workflowId);
message.success(`执行已启动: ${result.data?.execution_id}`);
} catch { message.error('执行失败'); }
};
const columns: ProColumns<Workflow>[] = [
{
title: '工作流', dataIndex: 'name',
render: (_, r) => (
<div>
<div style={{ fontWeight: 500 }}>{r.name}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{r.workflow_id}</div>
</div>
),
},
{ title: '描述', dataIndex: 'description', search: false, ellipsis: true },
{
title: '类别', dataIndex: 'category', width: 110,
valueEnum: Object.fromEntries(Object.entries(categoryLabel).map(([k, v]) => [k, { text: v }])),
render: (_, r) => <Tag color="blue">{categoryLabel[r.category] || r.category}</Tag>,
},
{ title: '版本', dataIndex: 'version', search: false, width: 70, render: (v) => `v${v as number}` },
{
title: '状态', dataIndex: 'status', width: 90,
valueEnum: { active: { text: '已启用', status: 'Success' }, draft: { text: '草稿', status: 'Warning' }, archived: { text: '已归档', status: 'Default' } },
render: (_, r) => <Badge status={statusColor[r.status] || 'default'} text={statusLabel[r.status] || r.status} />,
},
{
title: '操作', valueType: 'option', width: 260,
render: (_, r) => (
<Space size={0}>
<a onClick={() => { setEditingWorkflow(r); setEditorDrawer(true); }}><EditOutlined /> 编辑</a>
{r.status === 'active' && <a onClick={() => handleExecute(r.workflow_id)}><PlayCircleOutlined /> 执行</a>}
{r.status === 'draft' && (
<a onClick={async () => {
await workflowApi.publish(r.id);
message.success('已激活');
actionRef.current?.reload();
}}>激活</a>
)}
<Popconfirm title="确认删除?" onConfirm={async () => {
await workflowApi.delete(r.workflow_id);
message.success('删除成功');
actionRef.current?.reload();
}}>
<a style={{ color: '#ff4d4f' }}><DeleteOutlined /> 删除</a>
</Popconfirm>
</Space>
),
},
];
const editorData = editorDrawer && editingWorkflow ? convertDefinitionToReactFlow(editingWorkflow.definition) : undefined;
return (
<div style={{ padding: '20px 24px' }}>
<ProTable<Workflow>
headerTitle="工作流管理"
tooltip="设计和管理 AI 工作流,实现复杂业务流程自动化"
actionRef={actionRef}
rowKey="id"
columns={columns}
cardBordered
request={async () => {
const res = await workflowApi.list();
return {
data: (res.data as Workflow[]) || [],
total: (res.data as Workflow[])?.length || 0,
success: true,
};
}}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (t) => ` ${t} ` }}
search={{ labelWidth: 'auto' }}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setAddModalVisible(true)}>
新建工作流
</Button>,
]}
/>
{/* 新建工作流 DrawerForm */}
<DrawerForm
title="新建工作流"
open={addModalVisible}
onOpenChange={setAddModalVisible}
width={480}
drawerProps={{ placement: 'right', destroyOnClose: true }}
submitter={{
searchConfig: { submitText: '创建', resetText: '取消' },
resetButtonProps: { onClick: () => setAddModalVisible(false) },
}}
onFinish={async (values) => {
try {
const definition = {
id: values.workflow_id, name: values.name,
nodes: {
start: { id: 'start', type: 'start', name: '开始', config: {}, next_nodes: ['end'] },
end: { id: 'end', type: 'end', name: '结束', config: {}, next_nodes: [] },
},
edges: [{ id: 'e1', source_node: 'start', target_node: 'end' }],
};
await workflowApi.create({
workflow_id: values.workflow_id,
name: values.name,
description: values.description,
category: values.category,
definition: JSON.stringify(definition),
});
message.success('创建成功');
actionRef.current?.reload();
return true;
} catch {
message.error('创建失败');
return false;
}
}}
>
<ProFormText name="workflow_id" label="工作流 ID" placeholder="如: smart_pre_consult"
rules={[{ required: true, message: '请输入工作流ID' }]} />
<ProFormText name="name" label="名称" placeholder="请输入工作流名称"
rules={[{ required: true, message: '请输入名称' }]} />
<ProFormTextArea name="description" label="描述" placeholder="请输入描述(选填)"
fieldProps={{ rows: 2 }} />
<ProFormSelect name="category" label="类别" placeholder="选择类别"
options={Object.entries(categoryLabel).map(([value, label]) => ({ value, label }))} />
</DrawerForm>
{/* 可视化编辑器 Drawer */}
<Drawer
title={`编辑工作流 · ${editingWorkflow?.name || ''}`}
open={editorDrawer}
onClose={() => { setEditorDrawer(false); setEditingWorkflow(null); }}
placement="right"
destroyOnClose
width={960}
>
{editorData && (
<div style={{ height: 650 }}>
<VisualWorkflowEditor
workflowName={editingWorkflow?.name || '编辑工作流'}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialNodes={editorData.nodes as any}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialEdges={editorData.edges as any}
onSave={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
await workflowApi.update(editingWorkflow.id, { definition: JSON.stringify({ nodes, edges }) });
message.success('工作流已保存');
actionRef.current?.reload();
} catch { message.error('保存失败'); }
}}
onExecute={async (nodes, edges) => {
if (!editingWorkflow) return;
try {
const result = await workflowApi.execute(editingWorkflow.workflow_id, { workflow_data: { nodes, edges } });
message.success(`执行已启动: ${result.data?.execution_id}`);
} catch { message.error('执行失败'); }
}}
/>
</div>
)}
</Drawer>
</div>
);
};
export default AdminWorkflowsPage;
'use client';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { Card, Input, Button, List, Avatar, Typography, Space, Row, Col, Tag, Divider, message } from 'antd';
import { Input, Button, Avatar, Typography, Space, Tag, Divider, App, List, Empty, Tooltip } from 'antd';
import {
SendOutlined, RobotOutlined, UserOutlined, PhoneOutlined,
MedicineBoxOutlined, ClockCircleOutlined, ArrowLeftOutlined,
CheckOutlined,
CheckOutlined, MessageOutlined, PictureOutlined, SmileOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { consultApi, type ConsultMessage } from '../../../api/consult';
import { useUserStore } from '../../../store/userStore';
import dayjs from 'dayjs';
const { Text, Title } = Typography;
const { Text } = Typography;
const { TextArea } = Input;
const statusMap: Record<string, { text: string; color: string }> = {
pending: { text: '等待接诊', color: 'orange' },
waiting: { text: '等待接诊', color: 'orange' },
in_progress: { text: '问诊中', color: 'green' },
completed: { text: '已完成', color: 'default' },
cancelled: { text: '已取消', color: 'red' },
const statusMap: Record<string, { text: string; color: string; bg: string }> = {
pending: { text: '等待接诊', color: '#fa8c16', bg: '#fff7e6' },
waiting: { text: '等待接诊', color: '#fa8c16', bg: '#fff7e6' },
in_progress: { text: '问诊中', color: '#52c41a', bg: '#f6ffed' },
completed: { text: '已完成', color: '#8c8c8c', bg: '#fafafa' },
cancelled: { text: '已取消', color: '#ff4d4f', bg: '#fff2f0' },
};
const PatientTextConsultPage: React.FC = () => {
......@@ -29,6 +30,7 @@ const PatientTextConsultPage: React.FC = () => {
const id = params?.id;
const router = useRouter();
const { user } = useUserStore();
const { message } = App.useApp();
const queryClient = useQueryClient();
const [inputValue, setInputValue] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
......@@ -91,8 +93,16 @@ const PatientTextConsultPage: React.FC = () => {
if (isSystem) {
return (
<div key={msg.id} style={{ textAlign: 'center', margin: '12px 0' }}>
<Tag color="blue" style={{ fontSize: 12 }}>{msg.content}</Tag>
<div key={msg.id} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }}>
<div style={{
background: 'rgba(0,0,0,0.04)',
borderRadius: 12,
padding: '3px 14px',
fontSize: 11,
color: '#8c8c8c',
}}>
{msg.content}
</div>
</div>
);
}
......@@ -108,42 +118,58 @@ const PatientTextConsultPage: React.FC = () => {
>
{!isMe && (
<Avatar
icon={isAI ? <RobotOutlined /> : <UserOutlined />}
size={36}
icon={isAI ? <RobotOutlined /> : <MedicineBoxOutlined />}
src={!isAI ? consult?.doctor_avatar : undefined}
style={{ backgroundColor: isAI ? '#722ed1' : '#1890ff', marginRight: 8, flexShrink: 0 }}
style={{
backgroundColor: isAI ? '#722ed1' : '#52c41a',
marginRight: 10,
flexShrink: 0,
}}
/>
)}
<div style={{ maxWidth: '70%' }}>
{!isMe && (
<Text type="secondary" style={{ fontSize: 12, marginBottom: 4, display: 'block' }}>
{isAI ? 'AI助手' : consult?.doctor_name || '医生'}
</Text>
)}
<div style={{ maxWidth: '72%', display: 'flex', flexDirection: 'column', alignItems: isMe ? 'flex-end' : 'flex-start' }}>
<div style={{ fontSize: 11, color: '#aaa', marginBottom: 5 }}>
{isMe ? '' : isAI ? 'AI助手' : (consult?.doctor_name || '医生')}
{' · '}
{dayjs(msg.created_at).format('HH:mm')}
</div>
<div
style={{
background: isMe ? '#1890ff' : isAI ? '#f9f0ff' : '#f5f5f5',
color: isMe ? '#fff' : '#000',
padding: '10px 14px',
borderRadius: isMe ? '12px 12px 4px 12px' : '12px 12px 12px 4px',
borderRadius: isMe ? '12px 4px 12px 12px' : '4px 12px 12px 12px',
background: isMe ? '#1890ff' : isAI ? '#f9f0ff' : '#fff',
color: isMe ? '#fff' : '#1d2129',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 14,
lineHeight: 1.6,
}}
>
{msg.content}
</div>
<div style={{ textAlign: isMe ? 'right' : 'left', marginTop: 4, display: 'flex', alignItems: 'center', justifyContent: isMe ? 'flex-end' : 'flex-start', gap: 6 }}>
<Text type="secondary" style={{ fontSize: 11 }}>
{dayjs(msg.created_at).format('HH:mm')}
</Text>
{isMe && msg.read_at && (
<span style={{ fontSize: 10, color: '#52c41a' }}>
<CheckOutlined /><CheckOutlined style={{ marginLeft: -4 }} /> 已读
</span>
{msg.content_type === 'image' ? (
<img src={msg.media_url || msg.content} alt="图片" style={{ maxWidth: 200, borderRadius: 8 }} />
) : msg.content_type === 'prescription' ? (
<div style={{ padding: '4px 0' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: isMe ? '#fff' : '#d48806', marginBottom: 4 }}>
<FileTextOutlined style={{ marginRight: 4 }} />处方
</div>
<div style={{ fontSize: 13 }}>{msg.content}</div>
</div>
) : (
msg.content
)}
</div>
{isMe && msg.read_at && (
<div style={{ fontSize: 10, color: '#52c41a', marginTop: 2 }}>
<CheckOutlined /><CheckOutlined style={{ marginLeft: -4 }} /> 已读
</div>
)}
</div>
{isMe && (
<Avatar
style={{ marginLeft: 8, flexShrink: 0, backgroundColor: '#52c41a' }}
size={36}
style={{ marginLeft: 10, flexShrink: 0, backgroundColor: '#1890ff' }}
src={user?.avatar}
icon={<UserOutlined />}
/>
......@@ -153,80 +179,218 @@ const PatientTextConsultPage: React.FC = () => {
};
return (
<div style={{ height: 'calc(100vh - 120px)' }}>
<Button type="link" size="small" icon={<ArrowLeftOutlined />}
onClick={() => router.push('/patient/consult')} className="p-0! mb-1">返回列表</Button>
<Row gutter={8} style={{ height: 'calc(100% - 32px)' }}>
<Col span={6}>
<Card size="small" style={{ height: '100%' }} styles={{ body: { padding: 12 } }}>
<div className="text-center mb-2">
<Avatar size={48} src={consult?.doctor_avatar} icon={<UserOutlined />} style={{ backgroundColor: '#1890ff' }} />
<div className="text-sm font-bold mt-1">{consult?.doctor_name || '医生'}</div>
<Tag color={statusInfo.color}>{statusInfo.text}</Tag>
<div style={{ height: 'calc(100vh - 72px)', display: 'flex', flexDirection: 'column', padding: '4px 24px 16px' }}>
{/* 顶部标题栏 */}
<div style={{ flexShrink: 0, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => router.push('/patient/consult')}
style={{ color: '#8c8c8c', padding: '4px 8px' }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 20, fontWeight: 700, color: '#1d2129', display: 'flex', alignItems: 'center', gap: 8 }}>
<MessageOutlined style={{ color: '#1890ff' }} />
图文问诊
</div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 2 }}>
{consult?.doctor_name ? `${consult.doctor_name} 医生` : '在线问诊对话'}
</div>
</div>
<div style={{
padding: '4px 14px',
borderRadius: 8,
background: statusInfo.bg,
border: `1px solid ${statusInfo.color}30`,
fontSize: 13,
fontWeight: 500,
color: statusInfo.color,
}}>
{statusInfo.text}
</div>
</div>
</div>
{/* 主体区域 */}
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
{/* 左侧:医生信息面板 */}
<div style={{
width: 240,
flexShrink: 0,
borderRadius: 12,
border: '1px solid #edf2fc',
background: '#fff',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<div style={{ padding: 20, textAlign: 'center' }}>
<Avatar
size={56}
src={consult?.doctor_avatar}
icon={<MedicineBoxOutlined />}
style={{ backgroundColor: '#52c41a' }}
/>
<div style={{ fontSize: 16, fontWeight: 600, marginTop: 10, color: '#1d2129' }}>
{consult?.doctor_name || '医生'}
</div>
{consult?.doctor_title && (
<Tag color="blue" style={{ marginTop: 6, fontSize: 11 }}>{consult.doctor_title}</Tag>
)}
</div>
<Divider style={{ margin: 0 }} />
<div style={{ padding: '16px 20px', flex: 1 }}>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>
<MedicineBoxOutlined style={{ color: '#1890ff' }} />
问诊类型
</div>
<Tag color="green" style={{ marginLeft: 18 }}>图文问诊</Tag>
</div>
<Divider className="my-1!" />
<div className="text-xs space-y-2">
<div>
<MedicineBoxOutlined className="text-blue-500 mr-1" /><Text type="secondary">类型</Text>
<div className="ml-4 mt-0.5"><Tag color="green">图文</Tag></div>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>
<ClockCircleOutlined style={{ color: '#1890ff' }} />
发起时间
</div>
<div>
<ClockCircleOutlined className="text-blue-500 mr-1" /><Text type="secondary">发起</Text>
<div className="ml-4 mt-0.5">{consult?.created_at ? dayjs(consult.created_at).format('MM-DD HH:mm') : '-'}</div>
<div style={{ marginLeft: 18, fontSize: 13, color: '#1d2129' }}>
{consult?.created_at ? dayjs(consult.created_at).format('YYYY-MM-DD HH:mm') : '-'}
</div>
{consult?.started_at && (
<div>
<PhoneOutlined className="text-green-500 mr-1" /><Text type="secondary">接诊</Text>
<div className="ml-4 mt-0.5">{dayjs(consult.started_at).format('MM-DD HH:mm')}</div>
</div>
)}
</div>
<Divider className="my-1!" />
<div className="text-[11px] text-gray-400 mb-1">主诉</div>
<div className="text-xs bg-gray-50 rounded p-2">{consult?.chief_complaint || '未填写'}</div>
</Card>
</Col>
<Col span={18}>
<Card size="small"
title={<span className="text-xs">
<Avatar size={20} src={consult?.doctor_avatar} icon={<UserOutlined />} className="mr-1" />
{consult?.doctor_name || '医生'} <Tag color={statusInfo.color} className="ml-1">{statusInfo.text}</Tag>
</span>}
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
styles={{ body: { flex: 1, display: 'flex', flexDirection: 'column', padding: 0, overflow: 'hidden' } }}>
<div style={{ flex: 1, overflow: 'auto', padding: 12 }}>
{(!messagesData?.data || messagesData.data.length === 0) ? (
<div className="text-center py-10 text-xs text-gray-400">
{consult?.status === 'pending' || consult?.status === 'waiting' ? '等待医生接诊中..' : '暂无消息'}
{consult?.started_at && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>
<PhoneOutlined style={{ color: '#52c41a' }} />
接诊时间
</div>
) : (
<List dataSource={messagesData.data} renderItem={renderMessage} split={false} />
)}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-gray-100 p-2 bg-gray-50">
{canSendMessage ? (
<Space.Compact style={{ width: '100%' }}>
<TextArea value={inputValue} onChange={(e) => setInputValue(e.target.value)}
placeholder="输入消息..." autoSize={{ minRows: 1, maxRows: 3 }} size="small"
onPressEnter={(e) => { if (!e.shiftKey) { e.preventDefault(); handleSend(); } }} style={{ flex: 1 }} />
<Button type="primary" size="small" icon={<SendOutlined />} onClick={handleSend}
loading={sendMutation.isPending}>发送</Button>
</Space.Compact>
) : (
<div className="text-center text-xs text-gray-400 py-1">
{consult?.status === 'completed' ? '问诊已结束' : '等待医生接诊后可发送消息'}
<div style={{ marginLeft: 18, fontSize: 13, color: '#1d2129' }}>
{dayjs(consult.started_at).format('YYYY-MM-DD HH:mm')}
</div>
)}
</div>
)}
<Divider style={{ margin: '12px 0' }} />
<div>
<div style={{ fontSize: 12, color: '#8c8c8c', marginBottom: 6 }}>主诉</div>
<div style={{
fontSize: 13,
color: '#1d2129',
background: '#f5f7fb',
borderRadius: 8,
padding: '10px 12px',
lineHeight: 1.6,
}}>
{consult?.chief_complaint || '未填写'}
</div>
</div>
</div>
</div>
{/* 右侧:对话区域 */}
<div style={{
flex: 1,
minWidth: 0,
borderRadius: 12,
border: '1px solid #edf2fc',
background: '#fff',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
{/* 对话头部 */}
<div style={{
padding: '10px 16px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center',
gap: 10,
flexShrink: 0,
}}>
<Avatar size={32} src={consult?.doctor_avatar} icon={<MedicineBoxOutlined />} style={{ backgroundColor: '#52c41a' }} />
<div style={{ flex: 1 }}>
<Text strong style={{ fontSize: 14 }}>{consult?.doctor_name || '医生'}</Text>
<Tag
color={statusInfo.color === '#52c41a' ? 'success' : statusInfo.color === '#fa8c16' ? 'warning' : 'default'}
style={{ fontSize: 11, marginLeft: 8 }}
>
{statusInfo.text}
</Tag>
</div>
</Card>
</Col>
</Row>
</div>
{/* 消息区域 */}
<div style={{ flex: 1, overflow: 'auto', padding: 16, background: '#f5f7fb' }}>
{(!messagesData?.data || messagesData.data.length === 0) ? (
<Empty
description={
<span style={{ color: '#8c8c8c', fontSize: 13 }}>
{consult?.status === 'pending' || consult?.status === 'waiting'
? '等待医生接诊中...'
: '暂无消息'}
</span>
}
style={{ marginTop: 80 }}
/>
) : (
messagesData.data.map(renderMessage)
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div style={{ borderTop: '1px solid #f0f0f0', background: '#fff', flexShrink: 0 }}>
{canSendMessage ? (
<>
{/* 工具栏 */}
<div style={{ padding: '6px 12px 0', display: 'flex', gap: 2, borderBottom: '1px solid #f5f5f5' }}>
<Tooltip title="发送图片">
<Button type="text" size="small" icon={<PictureOutlined />} style={{ color: '#1890ff', fontSize: 13 }} />
</Tooltip>
<Tooltip title="表情">
<Button type="text" size="small" icon={<SmileOutlined />} style={{ color: '#8c8c8c', fontSize: 13 }} />
</Tooltip>
</div>
{/* 输入框 */}
<div style={{ padding: '8px 12px 10px', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<TextArea
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入消息,Enter 发送,Shift+Enter 换行..."
autoSize={{ minRows: 2, maxRows: 5 }}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
style={{ flex: 1, borderRadius: 8, resize: 'none' }}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sendMutation.isPending}
style={{ borderRadius: 8, height: 'auto', paddingTop: 8, paddingBottom: 8 }}
>
发送
</Button>
</div>
</>
) : (
<div style={{ padding: '14px 16px', textAlign: 'center', color: '#8c8c8c', fontSize: 13 }}>
{consult?.status === 'completed' ? '本次问诊已结束' : '等待医生接诊后可发送消息'}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default PatientTextConsultPage;
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