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>
</>
);
}
This diff is collapsed.
'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>
</>
);
}
This diff is collapsed.
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;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
'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'}>
......
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment