feat: 实现任务拆分和分配功能

## 功能概述
实现完整的任务拆分、分配和进度跟踪功能,支持将任务拆分为子任务并分配给不同用户。

## Phase 1: 数据库层
- 新增 t_task_meta 表(任务元数据协调表)
- 新增 t_task_assignment_log 表(分配日志表)
- 新增 3 个权限条目(read/write/assign)
- 新增 SQLAlchemy ORM 模型

## Phase 2: 后端 API (Java)
- 新增 task-coordination-service 模块(32 个文件)
- 实现 11 个 API 端点:
  - 任务查询(列表、子任务、我的任务)
  - 任务拆分(支持 4 种策略)
  - 任务分配(单个、批量、重新分配、撤回)
  - 进度管理(查询、更新、聚合)
  - 分配日志
- 集成权限控制和路由规则

## Phase 3: 前端 UI (React + TypeScript)
- 新增 10 个文件(模型、API、组件、页面)
- 实现 5 个核心组件:
  - SplitTaskDialog - 任务拆分对话框
  - AssignTaskDialog - 任务分配对话框
  - BatchAssignDialog - 批量分配对话框
  - TaskProgressPanel - 进度面板
  - AssignmentLogDrawer - 分配记录
- 实现 2 个页面:
  - TaskCoordination - 任务管理主页
  - MyTasks - 我的任务页面
- 集成侧边栏菜单和路由

## 问题修复
- 修复 getMyTasks 分页参数缺失
- 修复子任务 assignee 信息缺失(批量查询优化)
- 修复 proportion 精度计算(余量分配)

## 技术亮点
- 零侵入设计:通过独立协调表实现,不修改现有模块
- 批量查询优化:避免 N+1 查询问题
- 4 种拆分策略:按比例/数量/文件/手动
- 进度自动聚合:子任务更新自动聚合到父任务
- 权限细粒度控制:read/write/assign 三级权限

## 验证
- Maven 编译: 零错误
- TypeScript 编译: 零错误
- Vite 生产构建: 成功
This commit is contained in:
2026-02-09 00:42:34 +08:00
parent 78624915b7
commit 71f8f7d1c3
51 changed files with 3765 additions and 0 deletions

View File

@@ -17,6 +17,9 @@ export const PermissionCodes = {
operatorMarketWrite: "module:operator-market:write",
orchestrationRead: "module:orchestration:read",
orchestrationWrite: "module:orchestration:write",
taskCoordinationRead: "module:task-coordination:read",
taskCoordinationWrite: "module:task-coordination:write",
taskCoordinationAssign: "module:task-coordination:assign",
contentGenerationUse: "module:content-generation:use",
agentUse: "module:agent:use",
userManage: "system:user:manage",
@@ -34,6 +37,7 @@ const routePermissionRules: Array<{ prefix: string; permission: string }> = [
{ prefix: "/data/knowledge-base", permission: PermissionCodes.knowledgeBaseRead },
{ prefix: "/data/operator-market", permission: PermissionCodes.operatorMarketRead },
{ prefix: "/data/orchestration", permission: PermissionCodes.orchestrationRead },
{ prefix: "/data/task-coordination", permission: PermissionCodes.taskCoordinationRead },
{ prefix: "/data/content-generation", permission: PermissionCodes.contentGenerationUse },
{ prefix: "/chat", permission: PermissionCodes.agentUse },
];

View File

@@ -9,6 +9,7 @@ import {
Zap,
Shield,
Sparkles,
ListChecks,
// Database,
// Store,
// Merge,
@@ -55,6 +56,24 @@ export const menuItems = [
description: "管理知识集与知识条目",
color: "bg-indigo-500",
},
{
id: "task-coordination",
title: "任务协调",
icon: ListChecks,
permissionCode: PermissionCodes.taskCoordinationRead,
description: "任务拆分、分配与进度跟踪",
color: "bg-amber-500",
children: [
{
id: "task-coordination",
title: "任务管理",
},
{
id: "task-coordination/my-tasks",
title: "我的任务",
},
],
},
// {
// id: "cleansing",
// title: "数据清洗",

View File

@@ -0,0 +1,482 @@
import { useState, useCallback } from "react";
import {
Card,
Table,
Button,
Tag,
Tooltip,
Progress,
Modal,
App,
} from "antd";
import type { ColumnsType } from "antd/es/table";
import {
PlusOutlined,
ScissorOutlined,
UserAddOutlined,
SwapOutlined,
UndoOutlined,
BarChartOutlined,
HistoryOutlined,
DeleteOutlined,
DownOutlined,
RightOutlined,
} from "@ant-design/icons";
import useFetchData from "@/hooks/useFetchData";
import { SearchControls } from "@/components/SearchControls";
import {
queryTaskMetasUsingGet,
getChildrenUsingGet,
deleteTaskMetaByIdUsingDelete,
revokeTaskUsingPost,
} from "../taskCoordination.api";
import {
TaskMetaDto,
TaskMetaStatus,
} from "../taskCoordination.model";
import {
renderTaskStatus,
getModuleLabel,
statusFilterOptions,
moduleFilterOptions,
} from "../taskCoordination.const";
import SplitTaskDialog from "../components/SplitTaskDialog";
import AssignTaskDialog from "../components/AssignTaskDialog";
import BatchAssignDialog from "../components/BatchAssignDialog";
import TaskProgressPanel from "../components/TaskProgressPanel";
import AssignmentLogDrawer from "../components/AssignmentLogDrawer";
function mapTaskMeta(data: Partial<TaskMetaDto>): TaskMetaDto {
return {
id: data.id || "",
parentId: data.parentId,
module: data.module || "",
refTaskId: data.refTaskId || "",
taskName: data.taskName || "-",
status: (data.status as TaskMetaStatus) || TaskMetaStatus.PENDING,
assignedTo: data.assignedTo,
assigneeName:
data.assignee?.fullName || data.assignee?.username || data.assigneeName,
progress: data.progress ?? 0,
totalItems: data.totalItems ?? 0,
completedItems: data.completedItems ?? 0,
failedItems: data.failedItems ?? 0,
splitStrategy: data.splitStrategy,
priority: data.priority ?? 0,
deadline: data.deadline,
remark: data.remark,
createdBy: data.createdBy,
createdAt: data.createdAt || "",
updatedAt: data.updatedAt || "",
childCount: data.childCount ?? 0,
assignee: data.assignee,
} as TaskMetaDto;
}
export default function TaskCoordinationPage() {
const { message, modal } = App.useApp();
// Dialog states
const [splitTask, setSplitTask] = useState<TaskMetaDto | null>(null);
const [assignTask, setAssignTask] = useState<TaskMetaDto | null>(null);
const [assignMode, setAssignMode] = useState<"assign" | "reassign">("assign");
const [batchAssignTasks, setBatchAssignTasks] = useState<TaskMetaDto[]>([]);
const [progressTaskId, setProgressTaskId] = useState<string | null>(null);
const [logTask, setLogTask] = useState<{ id: string; name: string } | null>(
null
);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [selectedRows, setSelectedRows] = useState<TaskMetaDto[]>([]);
// Expanded rows and children cache
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [childrenMap, setChildrenMap] = useState<
Record<string, TaskMetaDto[]>
>({});
const [childrenLoading, setChildrenLoading] = useState<
Record<string, boolean>
>({});
const {
loading,
tableData,
pagination,
searchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData<TaskMetaDto>(
queryTaskMetasUsingGet,
mapTaskMeta,
30000,
false,
[],
0
);
// Load children when expanding
const handleExpand = useCallback(
async (expanded: boolean, record: TaskMetaDto) => {
if (!expanded) {
setExpandedRowKeys((keys) => keys.filter((k) => k !== record.id));
return;
}
setExpandedRowKeys((keys) => [...keys, record.id]);
if (childrenMap[record.id]) return; // already loaded
setChildrenLoading((prev) => ({ ...prev, [record.id]: true }));
try {
const res: any = await getChildrenUsingGet(record.id, {
page: 0,
size: 100,
});
const children = (res?.data?.content ?? []).map(mapTaskMeta);
setChildrenMap((prev) => ({ ...prev, [record.id]: children }));
} catch {
message.error("加载子任务失败");
} finally {
setChildrenLoading((prev) => ({ ...prev, [record.id]: false }));
}
},
[childrenMap, message]
);
const handleRefresh = () => {
setChildrenMap({});
setExpandedRowKeys([]);
fetchData();
};
const handleDelete = (task: TaskMetaDto) => {
modal.confirm({
title: `确认删除任务「${task.taskName}」?`,
content: "删除后不可恢复,子任务也将一并删除。",
okText: "删除",
okType: "danger",
cancelText: "取消",
onOk: async () => {
try {
await deleteTaskMetaByIdUsingDelete(task.id);
message.success("删除成功");
handleRefresh();
} catch {
message.error("删除失败");
}
},
});
};
const handleRevoke = (task: TaskMetaDto) => {
modal.confirm({
title: `确认撤回任务「${task.taskName}」的分配?`,
okText: "撤回",
cancelText: "取消",
onOk: async () => {
try {
await revokeTaskUsingPost(task.id);
message.success("撤回成功");
handleRefresh();
} catch {
message.error("撤回失败");
}
},
});
};
const columns: ColumnsType<TaskMetaDto> = [
{
title: "任务名称",
dataIndex: "taskName",
key: "taskName",
fixed: "left",
width: 240,
ellipsis: true,
},
{
title: "模块",
dataIndex: "module",
key: "module",
width: 100,
render: (v) => <Tag>{getModuleLabel(v)}</Tag>,
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (v) => renderTaskStatus(v),
},
{
title: "负责人",
key: "assignee",
width: 120,
render: (_, record) => record.assigneeName || "-",
},
{
title: "进度",
key: "progress",
width: 180,
render: (_, record) => {
const pct = record.totalItems
? Math.round((record.completedItems / record.totalItems) * 100)
: record.progress || 0;
const status =
record.status === "COMPLETED"
? ("success" as const)
: record.status === "FAILED"
? ("exception" as const)
: ("active" as const);
return (
<div className="flex items-center gap-2">
<Progress
percent={pct}
size="small"
status={status}
className="flex-1 mb-0"
/>
<span className="text-xs text-gray-400 shrink-0">
{record.completedItems}/{record.totalItems}
</span>
</div>
);
},
},
{
title: "子任务",
dataIndex: "childCount",
key: "childCount",
width: 80,
align: "center",
render: (v) =>
v > 0 ? <Tag color="blue">{v}</Tag> : <span className="text-gray-400">-</span>,
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 160,
ellipsis: true,
},
{
title: "操作",
key: "actions",
fixed: "right",
width: 200,
render: (_, record) => {
const isParent = !record.parentId;
const canSplit = isParent && record.childCount === 0;
const canAssign =
!record.assignedTo &&
record.status === TaskMetaStatus.PENDING;
const canReassign = !!record.assignedTo;
return (
<div className="flex items-center gap-1">
{canSplit && (
<Tooltip title="拆分">
<Button
type="text"
size="small"
icon={<ScissorOutlined />}
onClick={() => setSplitTask(record)}
/>
</Tooltip>
)}
{canAssign && (
<Tooltip title="分配">
<Button
type="text"
size="small"
icon={<UserAddOutlined />}
onClick={() => {
setAssignMode("assign");
setAssignTask(record);
}}
/>
</Tooltip>
)}
{canReassign && (
<>
<Tooltip title="重新分配">
<Button
type="text"
size="small"
icon={<SwapOutlined />}
onClick={() => {
setAssignMode("reassign");
setAssignTask(record);
}}
/>
</Tooltip>
<Tooltip title="撤回分配">
<Button
type="text"
size="small"
icon={<UndoOutlined />}
onClick={() => handleRevoke(record)}
/>
</Tooltip>
</>
)}
<Tooltip title="进度详情">
<Button
type="text"
size="small"
icon={<BarChartOutlined />}
onClick={() => setProgressTaskId(record.id)}
/>
</Tooltip>
<Tooltip title="分配记录">
<Button
type="text"
size="small"
icon={<HistoryOutlined />}
onClick={() =>
setLogTask({ id: record.id, name: record.taskName })
}
/>
</Tooltip>
{isParent && (
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record)}
/>
</Tooltip>
)}
</div>
);
},
},
];
// Expanded row renders child tasks
const expandedRowRender = (record: TaskMetaDto) => {
if (childrenLoading[record.id]) {
return <div className="py-4 text-center text-gray-400">...</div>;
}
const children = childrenMap[record.id];
if (!children || children.length === 0) {
return (
<div className="py-4 text-center text-gray-400"></div>
);
}
return (
<Table
dataSource={children}
columns={columns.filter((c) => c.key !== "childCount")}
rowKey="id"
pagination={false}
size="small"
/>
);
};
const filterOptions = [
{ key: "status", label: "状态", options: statusFilterOptions },
{ key: "module", label: "模块", options: moduleFilterOptions },
];
return (
<div className="flex flex-col h-full gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center gap-2">
{selectedRowKeys.length > 0 && (
<Button
icon={<UserAddOutlined />}
onClick={() => setBatchAssignTasks(selectedRows)}
>
({selectedRowKeys.length})
</Button>
)}
</div>
</div>
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
showViewToggle={false}
onReload={handleRefresh}
/>
<Card>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
rowSelection={{
selectedRowKeys,
onChange: (keys, rows) => {
setSelectedRowKeys(keys as string[]);
setSelectedRows(rows);
},
}}
expandable={{
expandedRowKeys,
expandedRowRender,
onExpand: handleExpand,
expandIcon: ({ expanded, onExpand, record }) =>
record.childCount > 0 ? (
<Button
type="text"
size="small"
icon={expanded ? <DownOutlined /> : <RightOutlined />}
onClick={(e) => onExpand(record, e)}
/>
) : (
<span style={{ width: 32, display: "inline-block" }} />
),
rowExpandable: (record) => record.childCount > 0,
}}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
{/* Dialogs */}
<SplitTaskDialog
open={!!splitTask}
task={splitTask}
onClose={() => setSplitTask(null)}
onRefresh={handleRefresh}
/>
<AssignTaskDialog
open={!!assignTask}
task={assignTask}
mode={assignMode}
onClose={() => setAssignTask(null)}
onRefresh={handleRefresh}
/>
<BatchAssignDialog
open={batchAssignTasks.length > 0}
tasks={batchAssignTasks}
onClose={() => setBatchAssignTasks([])}
onRefresh={handleRefresh}
/>
<TaskProgressPanel
open={!!progressTaskId}
taskId={progressTaskId}
onClose={() => setProgressTaskId(null)}
/>
<AssignmentLogDrawer
open={!!logTask}
taskId={logTask?.id ?? null}
taskName={logTask?.name}
onClose={() => setLogTask(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useState } from "react";
import { Card, Table, Button, Progress, Tooltip, App } from "antd";
import type { ColumnsType } from "antd/es/table";
import { BarChartOutlined } from "@ant-design/icons";
import useFetchData from "@/hooks/useFetchData";
import { SearchControls } from "@/components/SearchControls";
import { getMyTasksUsingGet } from "../taskCoordination.api";
import { TaskMetaDto, TaskMetaStatus } from "../taskCoordination.model";
import {
renderTaskStatus,
getModuleLabel,
statusFilterOptions,
moduleFilterOptions,
} from "../taskCoordination.const";
import TaskProgressPanel from "../components/TaskProgressPanel";
function mapTaskMeta(data: Partial<TaskMetaDto>): TaskMetaDto {
return {
id: data.id || "",
parentId: data.parentId,
module: data.module || "",
refTaskId: data.refTaskId || "",
taskName: data.taskName || "-",
status: (data.status as TaskMetaStatus) || TaskMetaStatus.PENDING,
assignedTo: data.assignedTo,
assigneeName:
data.assignee?.fullName || data.assignee?.username || data.assigneeName,
progress: data.progress ?? 0,
totalItems: data.totalItems ?? 0,
completedItems: data.completedItems ?? 0,
failedItems: data.failedItems ?? 0,
priority: data.priority ?? 0,
deadline: data.deadline,
remark: data.remark,
createdBy: data.createdBy,
createdAt: data.createdAt || "",
updatedAt: data.updatedAt || "",
childCount: data.childCount ?? 0,
assignee: data.assignee,
} as TaskMetaDto;
}
export default function MyTasksPage() {
const { message } = App.useApp();
const [progressTaskId, setProgressTaskId] = useState<string | null>(null);
const {
loading,
tableData,
pagination,
searchParams,
fetchData,
handleFiltersChange,
handleKeywordChange,
} = useFetchData<TaskMetaDto>(
getMyTasksUsingGet,
mapTaskMeta,
30000,
true,
[],
0
);
const columns: ColumnsType<TaskMetaDto> = [
{
title: "任务名称",
dataIndex: "taskName",
key: "taskName",
fixed: "left",
width: 240,
ellipsis: true,
},
{
title: "模块",
dataIndex: "module",
key: "module",
width: 100,
render: (v) => getModuleLabel(v),
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (v) => renderTaskStatus(v),
},
{
title: "进度",
key: "progress",
width: 200,
render: (_, record) => {
const pct = record.totalItems
? Math.round((record.completedItems / record.totalItems) * 100)
: record.progress || 0;
const status =
record.status === "COMPLETED"
? ("success" as const)
: record.status === "FAILED"
? ("exception" as const)
: ("active" as const);
return (
<div className="flex items-center gap-2">
<Progress
percent={pct}
size="small"
status={status}
className="flex-1 mb-0"
/>
<span className="text-xs text-gray-400 shrink-0">
{record.completedItems}/{record.totalItems}
</span>
</div>
);
},
},
{
title: "截止时间",
dataIndex: "deadline",
key: "deadline",
width: 160,
ellipsis: true,
render: (v) => v || "-",
},
{
title: "创建时间",
dataIndex: "createdAt",
key: "createdAt",
width: 160,
ellipsis: true,
},
{
title: "操作",
key: "actions",
fixed: "right",
width: 80,
render: (_, record) => (
<Tooltip title="进度详情">
<Button
type="text"
size="small"
icon={<BarChartOutlined />}
onClick={() => setProgressTaskId(record.id)}
/>
</Tooltip>
),
},
];
const filterOptions = [
{ key: "status", label: "状态", options: statusFilterOptions },
{ key: "module", label: "模块", options: moduleFilterOptions },
];
return (
<div className="flex flex-col h-full gap-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
</div>
<SearchControls
searchTerm={searchParams.keyword}
onSearchChange={handleKeywordChange}
searchPlaceholder="搜索任务名称"
filters={filterOptions}
onFiltersChange={handleFiltersChange}
showViewToggle={false}
onReload={() => fetchData()}
/>
<Card>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={tableData}
pagination={pagination}
scroll={{ x: "max-content", y: "calc(100vh - 24rem)" }}
/>
</Card>
<TaskProgressPanel
open={!!progressTaskId}
taskId={progressTaskId}
onClose={() => setProgressTaskId(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { Modal, Form, Select, Input, App } from "antd";
import { UserOption, TaskMetaDto } from "../taskCoordination.model";
import {
assignTaskUsingPost,
reassignTaskUsingPost,
listUsersUsingGet,
} from "../taskCoordination.api";
interface AssignTaskDialogProps {
open: boolean;
task: TaskMetaDto | null;
mode: "assign" | "reassign";
onClose: () => void;
onRefresh: () => void;
}
export default function AssignTaskDialog({
open,
task,
mode,
onClose,
onRefresh,
}: AssignTaskDialogProps) {
const [form] = Form.useForm();
const { message } = App.useApp();
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<UserOption[]>([]);
useEffect(() => {
if (!open) return;
form.resetFields();
listUsersUsingGet()
.then((res: any) => {
setUsers(
(res?.data ?? []).map((u: any) => ({
id: u.id,
username: u.username,
fullName: u.fullName,
}))
);
})
.catch(() => {});
}, [open, form]);
const handleSubmit = async () => {
if (!task) return;
try {
const values = await form.validateFields();
setSubmitting(true);
const fn =
mode === "reassign" ? reassignTaskUsingPost : assignTaskUsingPost;
await fn(task.id, {
userId: values.userId,
remark: values.remark,
});
message.success(mode === "reassign" ? "重新分配成功" : "分配成功");
onRefresh();
onClose();
} catch (err: any) {
if (err?.errorFields) return;
message.error(err?.message || "操作失败,请稍后重试");
} finally {
setSubmitting(false);
}
};
return (
<Modal
title={`${mode === "reassign" ? "重新分配" : "分配"}任务:${task?.taskName || ""}`}
open={open}
onCancel={onClose}
onOk={handleSubmit}
confirmLoading={submitting}
okText="确认"
cancelText="取消"
width={480}
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item
label="分配给"
name="userId"
rules={[{ required: true, message: "请选择用户" }]}
>
<Select
showSearch
placeholder="搜索并选择用户"
optionFilterProp="label"
options={users.map((u) => ({
label: u.fullName ? `${u.fullName} (${u.username})` : u.username,
value: u.id,
}))}
/>
</Form.Item>
<Form.Item label="备注" name="remark">
<Input.TextArea rows={3} placeholder="可选,添加备注说明" />
</Form.Item>
</Form>
</Modal>
);
}

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from "react";
import { Drawer, Table, Tag, Spin, Timeline, App } from "antd";
import { TaskAssignmentLogDto } from "../taskCoordination.model";
import { getAssignmentLogsUsingGet } from "../taskCoordination.api";
import { AssignmentActionMap } from "../taskCoordination.const";
interface AssignmentLogDrawerProps {
open: boolean;
taskId: string | null;
taskName?: string;
onClose: () => void;
}
const actionColorMap: Record<string, string> = {
ASSIGN: "blue",
REASSIGN: "orange",
REVOKE: "red",
};
export default function AssignmentLogDrawer({
open,
taskId,
taskName,
onClose,
}: AssignmentLogDrawerProps) {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<TaskAssignmentLogDto[]>([]);
useEffect(() => {
if (!open || !taskId) return;
setLoading(true);
getAssignmentLogsUsingGet(taskId)
.then((res: any) => {
setLogs(res?.data ?? []);
})
.catch(() => {
message.error("加载分配记录失败");
})
.finally(() => {
setLoading(false);
});
}, [open, taskId, message]);
return (
<Drawer
title={`分配记录:${taskName || ""}`}
open={open}
onClose={onClose}
width={520}
>
<Spin spinning={loading}>
{logs.length === 0 && !loading ? (
<div className="text-center text-gray-400 py-8"></div>
) : (
<Timeline
items={logs.map((log) => ({
color: actionColorMap[log.action] || "gray",
children: (
<div className="pb-2">
<div className="flex items-center gap-2 mb-1">
<Tag
color={actionColorMap[log.action] || "default"}
>
{AssignmentActionMap[log.action] || log.action}
</Tag>
<span className="text-sm text-gray-500">
{log.createdAt}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500"></span>
{log.operatorName || "-"}
{log.userName && (
<>
<span className="text-gray-500 ml-3"></span>
{log.userName}
</>
)}
</div>
{log.remark && (
<div className="text-sm text-gray-500 mt-1">
{log.remark}
</div>
)}
</div>
),
}))}
/>
)}
</Spin>
</Drawer>
);
}

View File

@@ -0,0 +1,175 @@
import { useEffect, useState } from "react";
import { Modal, Form, Select, Input, Table, App } from "antd";
import { UserOption, TaskMetaDto } from "../taskCoordination.model";
import { batchAssignUsingPost, listUsersUsingGet } from "../taskCoordination.api";
interface BatchAssignDialogProps {
open: boolean;
tasks: TaskMetaDto[];
onClose: () => void;
onRefresh: () => void;
}
interface RowAssignment {
taskMetaId: string;
taskName: string;
userId?: number;
remark?: string;
}
export default function BatchAssignDialog({
open,
tasks,
onClose,
onRefresh,
}: BatchAssignDialogProps) {
const { message } = App.useApp();
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<UserOption[]>([]);
const [rows, setRows] = useState<RowAssignment[]>([]);
const [globalUserId, setGlobalUserId] = useState<number | undefined>();
useEffect(() => {
if (!open) return;
setGlobalUserId(undefined);
setRows(
tasks.map((t) => ({
taskMetaId: t.id,
taskName: t.taskName,
userId: undefined,
remark: undefined,
}))
);
listUsersUsingGet()
.then((res: any) => {
setUsers(
(res?.data ?? []).map((u: any) => ({
id: u.id,
username: u.username,
fullName: u.fullName,
}))
);
})
.catch(() => {});
}, [open, tasks]);
const handleRowChange = (
index: number,
field: keyof RowAssignment,
value: unknown
) => {
const updated = [...rows];
updated[index] = { ...updated[index], [field]: value };
setRows(updated);
};
const handleGlobalUserChange = (userId: number) => {
setGlobalUserId(userId);
setRows(rows.map((r) => ({ ...r, userId })));
};
const handleSubmit = async () => {
const validRows = rows.filter((r) => r.userId);
if (validRows.length === 0) {
message.warning("请至少为一个任务选择分配用户");
return;
}
try {
setSubmitting(true);
await batchAssignUsingPost({
assignments: validRows.map((r) => ({
taskMetaId: r.taskMetaId,
userId: r.userId!,
remark: r.remark,
})),
});
message.success(`成功分配 ${validRows.length} 个任务`);
onRefresh();
onClose();
} catch (err: any) {
message.error(err?.message || "批量分配失败,请稍后重试");
} finally {
setSubmitting(false);
}
};
const userOptions = users.map((u) => ({
label: u.fullName ? `${u.fullName} (${u.username})` : u.username,
value: u.id,
}));
const columns = [
{
title: "任务名称",
dataIndex: "taskName",
key: "taskName",
width: 200,
ellipsis: true,
},
{
title: "分配给",
key: "userId",
width: 200,
render: (_: unknown, _record: RowAssignment, index: number) => (
<Select
showSearch
allowClear
placeholder="选择用户"
optionFilterProp="label"
value={rows[index]?.userId}
onChange={(v) => handleRowChange(index, "userId", v)}
options={userOptions}
style={{ width: "100%" }}
/>
),
},
{
title: "备注",
key: "remark",
render: (_: unknown, _record: RowAssignment, index: number) => (
<Input
placeholder="可选备注"
value={rows[index]?.remark}
onChange={(e) => handleRowChange(index, "remark", e.target.value)}
/>
),
},
];
return (
<Modal
title={`批量分配任务(${tasks.length} 个)`}
open={open}
onCancel={onClose}
onOk={handleSubmit}
confirmLoading={submitting}
okText="确认分配"
cancelText="取消"
width={720}
>
<div className="mb-4 flex items-center gap-2">
<span className="text-sm text-gray-600 shrink-0"></span>
<Select
showSearch
allowClear
placeholder="选择用户(可选)"
optionFilterProp="label"
value={globalUserId}
onChange={handleGlobalUserChange}
options={userOptions}
style={{ width: 240 }}
/>
</div>
<Table
dataSource={rows}
columns={columns}
rowKey="taskMetaId"
pagination={false}
size="small"
scroll={{ y: 400 }}
/>
</Modal>
);
}

View File

@@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import {
Modal,
Form,
Select,
InputNumber,
Input,
Radio,
Button,
Table,
App,
} from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import {
SplitStrategy,
SplitAssignment,
UserOption,
TaskMetaDto,
} from "../taskCoordination.model";
import { SplitStrategyMap } from "../taskCoordination.const";
import { splitTaskUsingPost, listUsersUsingGet } from "../taskCoordination.api";
interface SplitTaskDialogProps {
open: boolean;
task: TaskMetaDto | null;
onClose: () => void;
onRefresh: () => void;
}
export default function SplitTaskDialog({
open,
task,
onClose,
onRefresh,
}: SplitTaskDialogProps) {
const [form] = Form.useForm();
const { message } = App.useApp();
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<UserOption[]>([]);
const [assignments, setAssignments] = useState<SplitAssignment[]>([
{ taskName: "", proportion: undefined, itemCount: undefined },
{ taskName: "", proportion: undefined, itemCount: undefined },
]);
const strategy: SplitStrategy = Form.useWatch("strategy", form);
useEffect(() => {
if (!open) return;
listUsersUsingGet()
.then((res: any) => {
setUsers(
(res?.data ?? []).map((u: any) => ({
id: u.id,
username: u.username,
fullName: u.fullName,
}))
);
})
.catch(() => {});
form.setFieldsValue({ strategy: SplitStrategy.BY_PERCENTAGE });
setAssignments([
{ taskName: "", proportion: undefined, itemCount: undefined },
{ taskName: "", proportion: undefined, itemCount: undefined },
]);
}, [open, form]);
const handleAddRow = () => {
setAssignments([
...assignments,
{ taskName: "", proportion: undefined, itemCount: undefined },
]);
};
const handleRemoveRow = (index: number) => {
if (assignments.length <= 2) return;
setAssignments(assignments.filter((_, i) => i !== index));
};
const handleAssignmentChange = (
index: number,
field: keyof SplitAssignment,
value: unknown
) => {
const updated = [...assignments];
updated[index] = { ...updated[index], [field]: value };
setAssignments(updated);
};
const handleSubmit = async () => {
if (!task) return;
try {
await form.validateFields();
const validAssignments = assignments.filter(
(a) => a.userId || a.itemCount || a.proportion
);
if (validAssignments.length < 2) {
message.warning("至少需要拆分为 2 个子任务");
return;
}
if (strategy === SplitStrategy.BY_PERCENTAGE) {
const totalProportion = validAssignments.reduce(
(sum, a) => sum + (a.proportion || 0),
0
);
if (Math.abs(totalProportion - 100) > 0.01) {
message.warning("比例之和必须为 100%");
return;
}
}
setSubmitting(true);
await splitTaskUsingPost(task.id, {
strategy,
assignments: validAssignments.map((a, i) => ({
...a,
taskName: a.taskName || `${task.taskName} - 子任务 ${i + 1}`,
})),
});
message.success("任务拆分成功");
onRefresh();
onClose();
} catch (err: any) {
if (err?.errorFields) return;
message.error(err?.message || "拆分失败,请稍后重试");
} finally {
setSubmitting(false);
}
};
const columns = [
{
title: "子任务名称",
key: "taskName",
width: 200,
render: (_: unknown, _record: SplitAssignment, index: number) => (
<Input
placeholder={`${task?.taskName || "任务"} - 子任务 ${index + 1}`}
value={assignments[index]?.taskName}
onChange={(e) =>
handleAssignmentChange(index, "taskName", e.target.value)
}
/>
),
},
{
title: "分配用户",
key: "userId",
width: 180,
render: (_: unknown, _record: SplitAssignment, index: number) => (
<Select
placeholder="选择用户"
allowClear
value={assignments[index]?.userId}
onChange={(v) => handleAssignmentChange(index, "userId", v)}
options={users.map((u) => ({
label: u.fullName || u.username,
value: u.id,
}))}
style={{ width: "100%" }}
/>
),
},
...(strategy === SplitStrategy.BY_PERCENTAGE
? [
{
title: "比例 (%)",
key: "proportion",
width: 120,
render: (_: unknown, _record: SplitAssignment, index: number) => (
<InputNumber
min={1}
max={100}
placeholder="比例"
value={assignments[index]?.proportion}
onChange={(v) =>
handleAssignmentChange(index, "proportion", v)
}
style={{ width: "100%" }}
/>
),
},
]
: []),
...(strategy === SplitStrategy.BY_COUNT || strategy === SplitStrategy.MANUAL
? [
{
title: "数量",
key: "itemCount",
width: 120,
render: (_: unknown, _record: SplitAssignment, index: number) => (
<InputNumber
min={1}
placeholder="数量"
value={assignments[index]?.itemCount}
onChange={(v) =>
handleAssignmentChange(index, "itemCount", v)
}
style={{ width: "100%" }}
/>
),
},
]
: []),
{
title: "",
key: "actions",
width: 50,
render: (_: unknown, _record: SplitAssignment, index: number) => (
<Button
type="text"
danger
icon={<DeleteOutlined />}
disabled={assignments.length <= 2}
onClick={() => handleRemoveRow(index)}
/>
),
},
];
return (
<Modal
title={`拆分任务:${task?.taskName || ""}`}
open={open}
onCancel={onClose}
width={720}
footer={
<>
<Button onClick={onClose} disabled={submitting}>
</Button>
<Button type="primary" onClick={handleSubmit} loading={submitting}>
</Button>
</>
}
>
<Form form={form} layout="vertical">
<Form.Item
label="拆分策略"
name="strategy"
rules={[{ required: true, message: "请选择拆分策略" }]}
>
<Radio.Group>
{Object.entries(SplitStrategyMap).map(([value, label]) => (
<Radio.Button key={value} value={value}>
{label}
</Radio.Button>
))}
</Radio.Group>
</Form.Item>
{task && (
<div className="mb-4 p-3 bg-gray-50 rounded text-sm text-gray-600">
<strong>{task.totalItems ?? "-"}</strong>
</div>
)}
</Form>
<Table
dataSource={assignments}
columns={columns}
rowKey={(_, index) => String(index)}
pagination={false}
size="small"
footer={() => (
<Button
type="dashed"
block
icon={<PlusOutlined />}
onClick={handleAddRow}
>
</Button>
)}
/>
</Modal>
);
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from "react";
import { Drawer, Progress, Spin, Table, Tag, App } from "antd";
import type { ColumnsType } from "antd/es/table";
import {
TaskProgressResponse,
ChildTaskProgressDto,
} from "../taskCoordination.model";
import { getProgressUsingGet } from "../taskCoordination.api";
import { renderTaskStatus } from "../taskCoordination.const";
interface TaskProgressPanelProps {
open: boolean;
taskId: string | null;
onClose: () => void;
}
export default function TaskProgressPanel({
open,
taskId,
onClose,
}: TaskProgressPanelProps) {
const { message } = App.useApp();
const [loading, setLoading] = useState(false);
const [data, setData] = useState<TaskProgressResponse | null>(null);
useEffect(() => {
if (!open || !taskId) return;
setLoading(true);
getProgressUsingGet(taskId)
.then((res: any) => {
setData(res?.data ?? null);
})
.catch(() => {
message.error("加载进度信息失败");
})
.finally(() => {
setLoading(false);
});
}, [open, taskId, message]);
const progressStatus = (() => {
if (!data) return "normal" as const;
switch (data.status) {
case "COMPLETED":
return "success" as const;
case "FAILED":
return "exception" as const;
case "IN_PROGRESS":
return "active" as const;
default:
return "normal" as const;
}
})();
const childColumns: ColumnsType<ChildTaskProgressDto> = [
{
title: "子任务",
dataIndex: "taskName",
key: "taskName",
ellipsis: true,
},
{
title: "负责人",
key: "assignee",
width: 100,
render: (_, record) => record.assignee?.fullName || record.assignee?.username || "-",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
render: (status) => renderTaskStatus(status),
},
{
title: "进度",
key: "progress",
width: 200,
render: (_, record) => {
const pct = record.totalItems
? Math.round((record.completedItems / record.totalItems) * 100)
: record.progress || 0;
return (
<div className="flex items-center gap-2">
<Progress percent={pct} size="small" className="flex-1 mb-0" />
<span className="text-xs text-gray-500 shrink-0">
{record.completedItems}/{record.totalItems}
</span>
</div>
);
},
},
{
title: "失败",
dataIndex: "failedItems",
key: "failedItems",
width: 60,
align: "center",
render: (v) =>
v > 0 ? <Tag color="error">{v}</Tag> : <span className="text-gray-400">0</span>,
},
];
return (
<Drawer
title={`任务进度:${data?.taskName || ""}`}
open={open}
onClose={onClose}
width={680}
>
<Spin spinning={loading}>
{data && (
<div className="flex flex-col gap-6">
{/* Overall progress */}
<div className="p-4 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-3">
</div>
<Progress
percent={data.overallProgress}
status={progressStatus}
strokeWidth={12}
/>
<div className="flex gap-6 mt-3 text-sm">
<span>
<strong>{data.totalItems}</strong>
</span>
<span>
<strong className="text-green-600">
{data.completedItems}
</strong>
</span>
<span>
<strong className="text-red-500">{data.failedItems}</strong>
</span>
</div>
</div>
{/* Child tasks */}
{data.children && data.children.length > 0 && (
<div>
<div className="text-sm font-medium text-gray-700 mb-3">
{data.children.length}
</div>
<Table
dataSource={data.children}
columns={childColumns}
rowKey="taskId"
pagination={false}
size="small"
/>
</div>
)}
</div>
)}
</Spin>
</Drawer>
);
}

View File

@@ -0,0 +1,76 @@
import { get, post, put, del } from "@/utils/request";
type RequestParams = Record<string, unknown>;
type RequestPayload = Record<string, unknown>;
// ── CRUD ─────────────────────────────────────────────────
export function createTaskMetaUsingPost(data: RequestPayload) {
return post("/api/task-meta", data);
}
export function getTaskMetaByIdUsingGet(id: string) {
return get(`/api/task-meta/${id}`);
}
export function deleteTaskMetaByIdUsingDelete(id: string) {
return del(`/api/task-meta/${id}`);
}
export function queryTaskMetasUsingGet(params?: RequestParams) {
return get("/api/task-meta", params);
}
export function getChildrenUsingGet(id: string, params?: RequestParams) {
return get(`/api/task-meta/${id}/children`, params);
}
export function getMyTasksUsingGet(params?: RequestParams) {
return get("/api/task-meta/my-tasks", params);
}
// ── Split ────────────────────────────────────────────────
export function splitTaskUsingPost(id: string, data: RequestPayload) {
return post(`/api/task-meta/${id}/split`, data);
}
// ── Assignment ───────────────────────────────────────────
export function assignTaskUsingPost(id: string, data: RequestPayload) {
return post(`/api/task-meta/${id}/assign`, data);
}
export function reassignTaskUsingPost(id: string, data: RequestPayload) {
return post(`/api/task-meta/${id}/reassign`, data);
}
export function revokeTaskUsingPost(id: string, remark?: string) {
return post(`/api/task-meta/${id}/revoke`, null, {
params: remark ? { remark } : undefined,
});
}
export function batchAssignUsingPost(data: RequestPayload) {
return post("/api/task-meta/batch-assign", data);
}
export function getAssignmentLogsUsingGet(id: string) {
return get(`/api/task-meta/${id}/assignment-logs`);
}
// ── Progress ─────────────────────────────────────────────
export function getProgressUsingGet(id: string) {
return get(`/api/task-meta/${id}/progress`);
}
export function updateProgressUsingPut(id: string, data: RequestPayload) {
return put(`/api/task-meta/${id}/progress`, data);
}
// ── Users (for assignment selectors) ─────────────────────
export function listUsersUsingGet() {
return get("/api/auth/users");
}

View File

@@ -0,0 +1,73 @@
import { Tag } from "antd";
import {
TaskMetaStatus,
SplitStrategy,
TaskModule,
AssignmentAction,
} from "./taskCoordination.model";
// ── Status ───────────────────────────────────────────────
interface StatusMeta {
label: string;
color: string;
}
export const TaskStatusMap: Record<TaskMetaStatus, StatusMeta> = {
[TaskMetaStatus.PENDING]: { label: "待处理", color: "default" },
[TaskMetaStatus.IN_PROGRESS]: { label: "进行中", color: "processing" },
[TaskMetaStatus.COMPLETED]: { label: "已完成", color: "success" },
[TaskMetaStatus.FAILED]: { label: "失败", color: "error" },
[TaskMetaStatus.STOPPED]: { label: "已停止", color: "warning" },
[TaskMetaStatus.CANCELLED]: { label: "已取消", color: "default" },
};
export function renderTaskStatus(status?: string) {
if (!status) return "-";
const meta = TaskStatusMap[status as TaskMetaStatus];
if (!meta) return <Tag>{status}</Tag>;
return <Tag color={meta.color}>{meta.label}</Tag>;
}
// ── Module ───────────────────────────────────────────────
export const TaskModuleMap: Record<TaskModule, string> = {
[TaskModule.ANNOTATION]: "数据标注",
[TaskModule.CLEANING]: "数据清洗",
[TaskModule.EVALUATION]: "数据评估",
[TaskModule.SYNTHESIS]: "数据合成",
[TaskModule.COLLECTION]: "数据归集",
[TaskModule.RATIO]: "数据配比",
};
export function getModuleLabel(module?: string): string {
if (!module) return "-";
return TaskModuleMap[module as TaskModule] || module;
}
// ── Split Strategy ───────────────────────────────────────
export const SplitStrategyMap: Record<SplitStrategy, string> = {
[SplitStrategy.BY_COUNT]: "按数量拆分",
[SplitStrategy.BY_FILE]: "按文件拆分",
[SplitStrategy.BY_PERCENTAGE]: "按比例拆分",
[SplitStrategy.MANUAL]: "手动拆分",
};
// ── Assignment Action ────────────────────────────────────
export const AssignmentActionMap: Record<AssignmentAction, string> = {
[AssignmentAction.ASSIGN]: "分配",
[AssignmentAction.REASSIGN]: "重新分配",
[AssignmentAction.REVOKE]: "撤回",
};
// ── Filter Options ───────────────────────────────────────
export const statusFilterOptions = Object.entries(TaskStatusMap).map(
([value, meta]) => ({ label: meta.label, value })
);
export const moduleFilterOptions = Object.entries(TaskModuleMap).map(
([value, label]) => ({ label, value })
);

View File

@@ -0,0 +1,155 @@
// ── Enums ────────────────────────────────────────────────
export enum TaskMetaStatus {
PENDING = "PENDING",
IN_PROGRESS = "IN_PROGRESS",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
STOPPED = "STOPPED",
CANCELLED = "CANCELLED",
}
export enum SplitStrategy {
BY_COUNT = "BY_COUNT",
BY_FILE = "BY_FILE",
BY_PERCENTAGE = "BY_PERCENTAGE",
MANUAL = "MANUAL",
}
export enum TaskModule {
ANNOTATION = "ANNOTATION",
CLEANING = "CLEANING",
EVALUATION = "EVALUATION",
SYNTHESIS = "SYNTHESIS",
COLLECTION = "COLLECTION",
RATIO = "RATIO",
}
export enum AssignmentAction {
ASSIGN = "ASSIGN",
REASSIGN = "REASSIGN",
REVOKE = "REVOKE",
}
// ── Response Interfaces ──────────────────────────────────
export interface AssigneeInfo {
id: number;
username: string;
fullName?: string;
}
export interface TaskMetaDto {
id: string;
parentId?: string;
module: string;
refTaskId: string;
taskName: string;
status: TaskMetaStatus;
assignedTo?: number;
assigneeName?: string;
progress: number;
totalItems: number;
completedItems: number;
failedItems: number;
splitStrategy?: string;
splitConfig?: string;
priority: number;
deadline?: string;
remark?: string;
createdBy?: number;
createdAt: string;
updatedAt: string;
childCount: number;
assignee?: AssigneeInfo;
}
export interface TaskAssignmentLogDto {
id: number;
taskMetaId: string;
action: AssignmentAction;
userId?: number;
userName?: string;
remark?: string;
operatorId?: number;
operatorName?: string;
createdAt: string;
}
export interface ChildTaskProgressDto {
taskId: string;
taskName: string;
assignee?: AssigneeInfo;
progress: number;
totalItems: number;
completedItems: number;
failedItems: number;
status: TaskMetaStatus;
}
export interface TaskProgressResponse {
taskId: string;
taskName: string;
overallProgress: number;
totalItems: number;
completedItems: number;
failedItems: number;
status: TaskMetaStatus;
children: ChildTaskProgressDto[];
}
// ── Request Interfaces ───────────────────────────────────
export interface CreateTaskMetaRequest {
module: string;
refTaskId: string;
taskName: string;
assignedTo?: number;
totalItems?: number;
priority?: number;
deadline?: string;
}
export interface SplitAssignment {
userId?: number;
proportion?: number;
itemCount?: number;
taskName?: string;
}
export interface SplitTaskRequest {
strategy: SplitStrategy;
splitConfig?: Record<string, unknown>;
assignments: SplitAssignment[];
}
export interface AssignTaskRequest {
userId: number;
remark?: string;
}
export interface BatchTaskAssignment {
taskMetaId: string;
userId: number;
remark?: string;
}
export interface BatchAssignRequest {
assignments: BatchTaskAssignment[];
}
export interface UpdateProgressRequest {
progress?: number;
totalItems?: number;
completedItems?: number;
failedItems?: number;
status?: TaskMetaStatus;
}
// ── User (for assignment selectors) ──────────────────────
export interface UserOption {
id: number;
username: string;
fullName?: string;
}

View File

@@ -39,6 +39,8 @@ import OperatorPluginCreate from "@/pages/OperatorMarket/Create/OperatorPluginCr
import OperatorPluginDetail from "@/pages/OperatorMarket/Detail/OperatorPluginDetail";
import RatioTasksPage from "@/pages/RatioTask/Home/RatioTask.tsx";
import CreateRatioTask from "@/pages/RatioTask/Create/CreateRatioTask.tsx";
import TaskCoordinationPage from "@/pages/TaskCoordination/Home/TaskCoordination";
import MyTasksPage from "@/pages/TaskCoordination/MyTasks/MyTasks";
import OrchestrationPage from "@/pages/Orchestration/Orchestration";
import WorkflowEditor from "@/pages/Orchestration/WorkflowEditor";
import { withErrorBoundary } from "@/components/ErrorBoundary";
@@ -285,6 +287,20 @@ const router = createBrowserRouter([
},
],
},
{
path: "task-coordination",
children: [
{
path: "",
index: true,
Component: TaskCoordinationPage,
},
{
path: "my-tasks",
Component: MyTasksPage,
},
],
},
{
path: "operator-market",
children: [