feat: AI Worker 平台 MVP v1.0.0 - 多租户/项目管理/AI Worker/任务编排/HITL审核/成本治理
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
DatePicker,
|
||||
InputNumber,
|
||||
Typography,
|
||||
Segmented,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
Spin,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { taskApi, projectApi, workerApi } from '@/api';
|
||||
import type { Task, TaskCreate, TaskStatus, Project, Worker } from '@/types';
|
||||
import { TaskStatusTag, PriorityTag, taskStatusConfig, taskStatusList } from '@/components/constants';
|
||||
import TaskDetailModal from '@/pages/TaskDetail';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const priorities = [
|
||||
{ value: 'low', label: '低' },
|
||||
{ value: 'medium', label: '中' },
|
||||
{ value: 'high', label: '高' },
|
||||
{ value: 'urgent', label: '紧急' },
|
||||
];
|
||||
|
||||
export default function Tasks() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [workers, setWorkers] = useState<Worker[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<string>('kanban');
|
||||
const [filterProject, setFilterProject] = useState<string | undefined>();
|
||||
const [filterStatus, setFilterStatus] = useState<TaskStatus | undefined>();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Task | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detailTask, setDetailTask] = useState<Task | null>(null);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
taskApi.list({ project_id: filterProject, status: filterStatus }),
|
||||
projectApi.list(),
|
||||
workerApi.list(),
|
||||
])
|
||||
.then(([t, p, w]) => {
|
||||
setTasks(t);
|
||||
setProjects(p);
|
||||
setWorkers(w);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterProject, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ priority: 'medium', max_retries: 3 });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (task: Task) => {
|
||||
setEditing(task);
|
||||
form.setFieldsValue({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
max_retries: task.max_retries,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
const payload: TaskCreate = {
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
priority: values.priority,
|
||||
max_retries: values.max_retries,
|
||||
};
|
||||
|
||||
if (values.due_date) {
|
||||
payload.due_date = values.due_date.toISOString();
|
||||
}
|
||||
|
||||
if (values.input_data) {
|
||||
try {
|
||||
payload.input_data = JSON.parse(values.input_data);
|
||||
} catch {
|
||||
message.error('输入数据 JSON 格式错误');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
await taskApi.update(editing.id, payload);
|
||||
message.success('任务更新成功');
|
||||
} else {
|
||||
const projectId = values.project_id || filterProject || projects[0]?.id;
|
||||
if (!projectId) {
|
||||
message.error('请先选择项目');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
await taskApi.create(projectId, payload);
|
||||
message.success('任务创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
// form validation error
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await taskApi.delete(id);
|
||||
message.success('删除成功');
|
||||
loadData();
|
||||
} catch {
|
||||
/* handled */
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async (task: Task) => {
|
||||
try {
|
||||
const result = await taskApi.execute(task.id);
|
||||
if (result.error) {
|
||||
message.error(`执行失败:${result.error}`);
|
||||
} else {
|
||||
message.success('任务执行成功');
|
||||
}
|
||||
loadData();
|
||||
} catch {
|
||||
/* handled */
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Kanban View ============
|
||||
const renderKanban = () => (
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={[12, 12]}>
|
||||
{taskStatusList.map((status) => {
|
||||
const colTasks = tasks.filter((t) => t.status === status);
|
||||
const cfg = taskStatusConfig[status];
|
||||
return (
|
||||
<Col xs={24} sm={12} md={6} key={status}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cfg.color,
|
||||
}}
|
||||
/>
|
||||
{cfg.label}
|
||||
<Tag>{colTasks.length}</Tag>
|
||||
</Space>
|
||||
}
|
||||
styles={{ body: { backgroundColor: '#f5f5f5', minHeight: 300, padding: 8 } }}
|
||||
>
|
||||
{colTasks.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 20, color: '#999' }}>
|
||||
暂无任务
|
||||
</div>
|
||||
) : (
|
||||
colTasks.map((task) => (
|
||||
<Card
|
||||
key={task.id}
|
||||
size="small"
|
||||
className="kanban-card"
|
||||
onClick={() => setDetailTask(task)}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{task.title}
|
||||
</div>
|
||||
<Space size={4}>
|
||||
<PriorityTag priority={task.priority} />
|
||||
{task.cost > 0 && (
|
||||
<Tag color="orange">¥{task.cost.toFixed(2)}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{task.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 4,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Spin>
|
||||
);
|
||||
|
||||
// ============ Table View ============
|
||||
const tableColumns = [
|
||||
{
|
||||
title: '任务名称',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (text: string, record: Task) => (
|
||||
<a onClick={() => setDetailTask(record)}>{text}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: TaskStatus) => <TaskStatusTag status={status} />,
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
render: (p: Task['priority']) => <PriorityTag priority={p} />,
|
||||
},
|
||||
{
|
||||
title: '花费',
|
||||
dataIndex: 'cost',
|
||||
key: 'cost',
|
||||
render: (v: number) => (v > 0 ? `¥${v.toFixed(2)}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
render: (t: string) => dayjs(t).format('MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 280,
|
||||
render: (_: unknown, record: Task) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setDetailTask(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleExecute(record)}
|
||||
disabled={record.status === 'done' || record.status === 'cancelled'}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除此任务?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
任务管理
|
||||
</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建任务
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
placeholder="筛选项目"
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
value={filterProject}
|
||||
onChange={setFilterProject}
|
||||
options={projects.map((p) => ({ value: p.id, label: p.name }))}
|
||||
/>
|
||||
<Select
|
||||
placeholder="筛选状态"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={taskStatusList.map((s) => ({
|
||||
value: s,
|
||||
label: taskStatusConfig[s].label,
|
||||
}))}
|
||||
/>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: '看板', value: 'kanban' },
|
||||
{ label: '列表', value: 'table' },
|
||||
]}
|
||||
value={viewMode}
|
||||
onChange={setViewMode}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{viewMode === 'kanban' ? (
|
||||
renderKanban()
|
||||
) : (
|
||||
<Card>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : tasks.length > 0 ? (
|
||||
<Table
|
||||
dataSource={tasks}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
pagination={{ pageSize: 15 }}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无任务" />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
title={editing ? '编辑任务' : '新建任务'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={saving}
|
||||
okText={editing ? '保存' : '创建'}
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{!editing && (
|
||||
<Form.Item
|
||||
name="project_id"
|
||||
label="所属项目"
|
||||
rules={[{ required: true, message: '请选择项目' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择项目"
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="任务标题"
|
||||
rules={[{ required: true, message: '请输入任务标题' }]}
|
||||
>
|
||||
<Input placeholder="请输入任务标题" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="任务描述">
|
||||
<TextArea rows={3} placeholder="请输入任务描述" />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select options={priorities} />
|
||||
</Form.Item>
|
||||
<Form.Item name="max_retries" label="最大重试次数">
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="due_date" label="截止时间">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="input_data" label="输入数据 (JSON)">
|
||||
<TextArea
|
||||
rows={4}
|
||||
placeholder='例如:{"prompt": "分析数据"}'
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<TaskDetailModal
|
||||
task={detailTask}
|
||||
workers={workers}
|
||||
onClose={() => setDetailTask(null)}
|
||||
onUpdated={() => {
|
||||
loadData();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user