You've already forked DataMate
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:
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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: "数据清洗",
|
||||
|
||||
482
frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx
Normal file
482
frontend/src/pages/TaskCoordination/Home/TaskCoordination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
frontend/src/pages/TaskCoordination/MyTasks/MyTasks.tsx
Normal file
188
frontend/src/pages/TaskCoordination/MyTasks/MyTasks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/TaskCoordination/taskCoordination.api.ts
Normal file
76
frontend/src/pages/TaskCoordination/taskCoordination.api.ts
Normal 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");
|
||||
}
|
||||
@@ -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 })
|
||||
);
|
||||
155
frontend/src/pages/TaskCoordination/taskCoordination.model.ts
Normal file
155
frontend/src/pages/TaskCoordination/taskCoordination.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user