"use client" import { ProjectDetail } from "@/components/label/api/project/typing" import { getTaskList, taskRelease } from "@/components/label/api/task" import { Task } from "@/components/label/api/task/typing" import { useBackUrlStore, usePermissionStore, } from "@/components/label/store/auth" import { TaskStatusEnum, TaskStatusOpts, } from "@/components/label/utils/constants" import { SettingContentPanel, SettingDataTable, SettingFilterActions, SettingFilterPanel, SettingHeaderActions, SettingListHeader, } from "@/components/setting/PageSurface" import { Button, Collapse, Flex, Group, Modal, MultiSelect, Select, SimpleGrid, Stack, Text, TextInput, } from "@mantine/core" import { DatePickerInput, type DatesRangeValue } from "@mantine/dates" import { modals } from "@mantine/modals" import { notifications } from "@mantine/notifications" import { IconChevronDown, IconChevronUp, IconRefresh, IconSearch, } from "@tabler/icons-react" import dayjs from "dayjs" import "dayjs/locale/zh-cn" import duration from "dayjs/plugin/duration" import { DataTableColumn, type DataTableSortStatus } from "mantine-datatable" import { useRouter, useSearchParams } from "next/navigation" import { useCallback, useEffect, useMemo, useState } from "react" import * as XLSX from "xlsx-js-style" import DispatchModal from "./components/DispatchModal" import ReleaseModal from "./components/ReleaseModal" import TaskStatusTag from "./components/TaskStatusTag" import WorkflowModal from "./components/WorkflowModal" dayjs.extend(duration) interface FilterFormRecord { search_id: string search_status: string rejected: string current_uid: string[] label_user: string[] review_user1: string[] review_user2: string[] first_commit_label_time: DatesRangeValue first_commit_review1_time: DatesRangeValue } function createInitialFilters(): FilterFormRecord { return { search_id: "", search_status: "0", rejected: "", current_uid: [], label_user: [], review_user1: [], review_user2: [], first_commit_label_time: [null, null], first_commit_review1_time: [null, null], } } const DEFAULT_RECORDS_PER_PAGE_OPTIONS = [10, 15, 20] const INITIAL_SORT_ACCESSOR = "__initial__" type SortableTaskSizeAccessor = keyof Task.SizeProps const SORTABLE_TASK_SIZE_COLUMNS: Array<{ accessor: SortableTaskSizeAccessor title: string width: number }> = [ { accessor: "object_size", title: "对象总数", width: 110 }, { accessor: "valid_size", title: "有效对象数", width: 120 }, { accessor: "new_size", title: "新增对象数", width: 120 }, { accessor: "key_frame_size", title: "关键帧数", width: 120 }, { accessor: "dynamic_size", title: "动态对象数", width: 120 }, { accessor: "static_size", title: "静态对象数", width: 120 }, { accessor: "prelabel_size", title: "预标注对象数", width: 120 }, { accessor: "prelabel_modify_size", title: "预标注对象修改数", width: 160 }, { accessor: "question_size", title: "问题数", width: 120 }, { accessor: "total_qa_group_size", title: "总问答组数", width: 120 }, { accessor: "single_qa_group_size", title: "单轮问答组数", width: 120 }, { accessor: "multiple_qa_group_size", title: "多轮问答组数", width: 120 }, { accessor: "new_qa_group_size", title: "新增问答组数", width: 120 }, { accessor: "new_single_qa_group_size", title: "新增单轮问答组数", width: 160, }, { accessor: "new_multiple_qa_group_size", title: "新增多轮问答组数", width: 160, }, { accessor: "review_question_size", title: "审核问题数", width: 120 }, { accessor: "comment_question_size", title: "首次批注问题数", width: 140 }, { accessor: "review_size", title: "审核对象数", width: 120 }, { accessor: "review_dynamic_size", title: "审核动态对象数", width: 140 }, { accessor: "review_static_size", title: "审核静态对象数", width: 140 }, { accessor: "first_comment_size", title: "批注数(1)", width: 120 }, { accessor: "second_comment_size", title: "批注数(2)", width: 120 }, ] export interface selectedDataProps { status: number uid: number | null reject: boolean } const checkSelectedData: ( keys: React.Key[], records: Task.DataProps[] ) => { flag: boolean resultData: selectedDataProps } = (keys: React.Key[], records: Task.DataProps[]) => { const checkData = records.filter((item: any) => keys.includes(item.id)) const initStatus = checkData[0].label_status const initReject = checkData[0].rejected const initCurrentUid = checkData[0].current_uid || null const initCurrentName = checkData[0].current_username || "" if (checkData.length === 1) return { flag: true, resultData: { uid: initCurrentUid, status: initStatus, reject: initReject, }, } let statusFlag = true let currentFlag = true let rejectFlag = true checkData.forEach((item: any) => { const s = item.label_status const c = item.current_username || "" const r = item.rejected if (s !== initStatus) statusFlag = false if (c !== initCurrentName) currentFlag = false if (r !== initReject) rejectFlag = false }) return { flag: statusFlag && currentFlag && rejectFlag, resultData: { uid: initCurrentUid, status: initStatus, reject: initReject, }, } } export default function TaskTableContainer(props: { info: ProjectDetail.DataProps | undefined userEnums: { allEnum: Map labelEnum: Map review1Enum: Map review2Enum: Map } }) { const { info, userEnums } = props const { user_id, detailInfo } = usePermissionStore() const hasRolePermissions = Boolean(detailInfo?.roles?.length) const urlParams = useSearchParams() const id = urlParams.get("id") const type = urlParams.get("type") const projectId = typeof id === "string" && !isNaN(Number(id)) ? Number(id) : -1 const router = useRouter() const { setBackProps } = useBackUrlStore() const [total, setTotal] = useState(0) const [pageNumber, setPageNumber] = useState(1) const [pageSize, setPageSize] = useState(15) const [recordsPerPageOptions, setRecordsPerPageOptions] = useState( () => [...DEFAULT_RECORDS_PER_PAGE_OPTIONS] ) const [customPageSizeInput, setCustomPageSizeInput] = useState("") const [customPageSizeModalOpened, setCustomPageSizeModalOpened] = useState(false) const [searchExpanded, setSearchExpanded] = useState(false) const [filters, setFilters] = useState(() => createInitialFilters()) const [form, setForm] = useState(createInitialFilters()) const [records, setRecords] = useState([]) const [selectedRecords, setSelectedRecords] = useState([]) const [loading, setLoading] = useState(false) const [queryTrigger, setQueryTrigger] = useState(0) const [sortStatus, setSortStatus] = useState< DataTableSortStatus >({ columnAccessor: INITIAL_SORT_ACCESSOR, direction: "asc", }) const [workflowTaskId, setWorkflowTaskId] = useState(null) const [dispatchRecordModal, setDispatchModalConfig] = useState<{ open: boolean dispatchData?: selectedDataProps task_ids: Task.DataProps[] }>({ open: false, task_ids: [], }) const [releaseRecord, setReleaseRecord] = useState( null ) const userOptions = useMemo(() => { const arr: Array<{ label: string; value: string }> = [] userEnums.allEnum?.forEach((label, value) => { arr.push({ label: String(label), value: String(value) }) }) return arr }, [userEnums.allEnum]) const appliedParams = useMemo(() => { const obj: Task.ListRequest = { page_number: pageNumber ?? 1, page_size: pageSize ?? 20, project_id: projectId !== -1 ? [projectId] : undefined, get_data: false, } if (form.search_id) { const idValue = Number(form.search_id) if (Number.isFinite(idValue)) obj.id = [idValue] } if (form.search_status !== "0") { const statusValue = Number(form.search_status) if (Number.isFinite(statusValue)) obj.label_status = [statusValue] } else { delete obj.label_status } if (form.rejected !== "") { const rejected = Number(form.rejected) if (Number.isFinite(rejected)) obj.rejected = rejected === 1 ? true : false } if (form.current_uid?.length) { obj.current_uid = form.current_uid?.map((item) => Number(item)) } if (form.label_user?.length) { obj.label_user = form.label_user?.map((item) => Number(item)) } if (form.review_user1?.length) { obj.review_user1 = form.review_user1?.map((item) => Number(item)) } if (form.review_user2?.length) { obj.review_user2 = form.review_user2?.map((item) => Number(item)) } if (form.first_commit_label_time[0] && form.first_commit_label_time[1]) { obj.first_commit_label_time_start = dayjs( form.first_commit_label_time[0] ).format("YYYY-MM-DD") obj.first_commit_label_time_end = dayjs( form.first_commit_label_time[1] ).format("YYYY-MM-DD") } if ( form.first_commit_review1_time[0] && form.first_commit_review1_time[1] ) { obj.first_commit_review1_time_start = dayjs( form.first_commit_review1_time[0] ).format("YYYY-MM-DD") obj.first_commit_review1_time_end = dayjs( form.first_commit_review1_time[1] ).format("YYYY-MM-DD") } if (sortStatus.columnAccessor !== INITIAL_SORT_ACCESSOR) { obj.order_by = `${sortStatus.columnAccessor} ${sortStatus.direction}` } return obj }, [ form.current_uid, form.first_commit_label_time, form.first_commit_review1_time, form.label_user, form.rejected, form.review_user1, form.review_user2, form.search_id, form.search_status, pageNumber, pageSize, projectId, sortStatus.columnAccessor, sortStatus.direction, ]) const load = useCallback(async () => { if (!queryTrigger) return if (projectId === -1) return setLoading(true) try { const res = await getTaskList(appliedParams) setRecords(res?.task_list ?? []) setTotal(res?.total_items ?? 0) } catch (e) { setRecords([]) setTotal(0) notifications.show({ color: "red", title: "加载失败", message: e instanceof Error ? e.message : "请求失败", }) } finally { setLoading(false) } }, [appliedParams, projectId, queryTrigger, setTotal]) useEffect(() => { load() }, [load]) useEffect(() => { queueMicrotask(() => { setQueryTrigger((v) => v + 1) }) }, []) const openCustomPageSizeModal = useCallback(() => { setCustomPageSizeModalOpened(true) }, []) const closeCustomPageSizeModal = useCallback(() => { setCustomPageSizeModalOpened(false) setCustomPageSizeInput("") }, []) const handleAddCustomPageSize = useCallback(() => { const rawValue = customPageSizeInput.trim() if (!rawValue) return const size = Number(rawValue) if (!Number.isInteger(size) || size <= 0) { notifications.show({ color: "red", title: "输入无效", message: "请填写大于 0 的整数条数", }) return } if (recordsPerPageOptions.includes(size)) { notifications.show({ color: "blue", title: "已存在", message: `每页 ${size} 条已在可选项中`, }) return } setRecordsPerPageOptions((prev) => [...prev, size].sort((a, b) => a - b)) closeCustomPageSizeModal() notifications.show({ color: "green", title: "添加成功", message: `已新增每页 ${size} 条`, }) }, [closeCustomPageSizeModal, customPageSizeInput, recordsPerPageOptions]) const handleCustomPageSizeEnter = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter") return event.preventDefault() handleAddCustomPageSize() }, [handleAddCustomPageSize] ) const clickDispatchTask = () => { if (!selectedRecords.length) { notifications.show({ color: "yellow", title: "警告", message: "请先选择任务", }) return } const { flag, resultData } = checkSelectedData( selectedRecords.map((item) => item.id), records ) if (!flag) { notifications.show({ color: "yellow", title: "警告", message: "请保证选取任务的标注状态及负责人一致", }) return } setDispatchModalConfig({ open: true, dispatchData: resultData, task_ids: selectedRecords, }) } const clickReleaseTask = () => { if (!selectedRecords.length) { notifications.show({ color: "yellow", title: "警告", message: "请先选择任务", }) return } const { flag, resultData } = checkSelectedData( selectedRecords.map((item) => item.id), records ) if (!flag) { notifications.show({ color: "yellow", title: "警告", message: "请保证选取任务的标注状态及负责人一致", }) return } if (![2, 4, 6, 7].includes(resultData.status)) { notifications.show({ color: "red", title: "错误", message: "所选任务状态无法释放", }) return } modals.openConfirmModal({ title: "释放任务", centered: true, children: 是否释放所选所有任务?, labels: { confirm: "释放", cancel: "取消" }, confirmProps: { color: "red" }, onConfirm: async () => { const reqParams = { operation_uid: user_id, task_ids: selectedRecords.map((item) => item.id), uid: resultData.uid || 0, reject: resultData.reject, task_status: resultData.status, } try { await taskRelease(reqParams) notifications.show({ color: "green", title: "操作成功", message: "已释放所选所有任务", }) load() } catch {} }, }) } const handleExport = async () => { try { notifications.show({ color: "yellow", title: "提示", message: "导出统计数据中...", }) const res = await getTaskList({ id: selectedRecords.map((item) => item.id), page_number: 1, page_size: selectedRecords.length, get_data: false, }) const { task_list } = res const exportTableHeader = [ "任务ID", "状态", "当前负责人", "标注员", "审核员", "复审员", "对象总数", "有效对象数", "新增对象数", "关键帧数", "动态对象数", "静态对象数", "预标注对象数", "预标注对象修改数", "问题数", "总问答组数", "单轮问答组数", "多轮问答组数", "新增问答组数", "新增单轮问答组数", "新增多轮问答组数", "审核问题数", "首次批注问题数", "审核对象数", "审核动态对象数", "审核静态对象数", "批注数(1)", "批注数(2)", "标注首次提交时间", "审核首次提交时间", "驳回次数", "标注总耗时", "审核总耗时", "复审总耗时", "标注首次耗时", "审核首次耗时", "复审首次耗时", "审核首次驳回时间", ] let data = task_list.map((item) => { return [ item.id, `${TaskStatusEnum.get(item.label_status)}${ item.rejected ? "返工" : "" }`, item.current_username, item.label_username, item.review1_username, item.review2_username, item.label_object_size.object_size, item.label_object_size.valid_size, item.label_object_size.new_size, item.label_object_size.key_frame_size, item.label_object_size.dynamic_size, item.label_object_size.static_size, item.label_object_size.prelabel_size, item.label_object_size.prelabel_modify_size, item.label_object_size.question_size, item.label_object_size.total_qa_group_size, item.label_object_size.single_qa_group_size, item.label_object_size.multiple_qa_group_size, item.label_object_size.new_qa_group_size, item.label_object_size.new_single_qa_group_size, item.label_object_size.new_multiple_qa_group_size, item.label_object_size.review_question_size, item.label_object_size.comment_question_size, item.label_object_size.review_size, item.label_object_size.review_dynamic_size, item.label_object_size.review_static_size, item.label_object_size.first_comment_size, item.label_object_size.second_comment_size, item.first_commit_label_time, item.first_commit_review1_time, item.reject_times, item.work_time.label_work_time, item.work_time.first_review_work_time, item.work_time.second_review_work_time, item.label_first_work_time, item.review1_first_work_time, item.review2_first_work_time, item.first_reject_label_time, ] }) const exportData = [exportTableHeader, ...data] const wb = XLSX.utils.book_new() const ws = XLSX.utils.aoa_to_sheet(exportData) XLSX.utils.book_append_sheet(wb, ws, "任务列表数据") XLSX.writeFile(wb, `任务列表数据.xlsx`) } catch {} } const handleSearch = () => { setPageNumber(1) setForm({ ...filters }) setQueryTrigger((v) => v + 1) } const handleReset = () => { const next = createInitialFilters() setFilters(next) setForm(next) setPageNumber(1) setQueryTrigger((v) => v + 1) } const handleRefresh = () => { setQueryTrigger((v) => v + 1) } const getTime = (time: number) => { return dayjs.duration(time, "seconds").format("HH:mm:ss") } const columns = useMemo(() => { const createSortableSizeColumn = ({ accessor, title, width, }: { accessor: SortableTaskSizeAccessor title: string width: number }): DataTableColumn => ({ accessor, title, width, sortable: true, render: (record) => record.label_object_size?.[accessor] ?? "-", }) const cols: DataTableColumn[] = [ { accessor: "id", title: "任务ID", width: 90, render: (record) => ( ), }, { accessor: "label_status", title: "状态", width: 150, render: (record) => ( ), }, { accessor: "current_username", title: "当前负责人", width: 120, render: (record) => record.current_username ?? "-", }, { accessor: "label_username", title: "标注员", width: 120, render: (record) => record.label_username ?? "-", }, { accessor: "review1_username", title: "审核员", width: 120, render: (record) => record.review1_username ?? "-", }, { accessor: "review2_username", title: "复审员", width: 120, render: (record) => record.review2_username ?? "-", }, ...SORTABLE_TASK_SIZE_COLUMNS.map(createSortableSizeColumn), { accessor: "first_commit_label_time", title: "标注首次提交时间", width: 180, render: (record) => record.first_commit_label_time ?? "-", }, { accessor: "first_commit_review1_time", title: "审核首次提交时间", width: 180, render: (record) => record.first_commit_review1_time ?? "-", }, { accessor: "reject_times", title: "驳回次数", width: 120, render: (record) => record.reject_times ?? "-", }, { accessor: "work_time.label_work_time", title: "标注总耗时", width: 120, render: (record) => record.work_time?.label_work_time ? getTime(record.work_time?.label_work_time) : "-", }, { accessor: "work_time.first_review_work_time", title: "审核总耗时", width: 120, render: (record) => record.work_time?.first_review_work_time ? getTime(record.work_time?.first_review_work_time) : "-", }, { accessor: "work_time.second_review_work_time", title: "复审总耗时", width: 120, render: (record) => record.work_time?.second_review_work_time ? getTime(record.work_time?.second_review_work_time) : "-", }, { accessor: "label_first_work_time", title: "标注首次耗时", width: 120, render: (record) => record.label_first_work_time ? getTime(record.label_first_work_time) : "-", }, { accessor: "review1_first_work_time", title: "审核首次耗时", width: 120, render: (record) => record.review1_first_work_time ? getTime(record.review1_first_work_time) : "-", }, { accessor: "review2_first_work_time", title: "复审首次耗时", width: 120, render: (record) => record.review2_first_work_time ? getTime(record.review2_first_work_time) : "-", }, { accessor: "first_reject_label_time", title: "审核首次驳回时间", width: 180, render: (record) => record.first_reject_label_time ?? "-", }, { accessor: "operation", title: "操作", width: 140, textAlign: "center", render: (record) => ( {hasRolePermissions ? ( ) : null} ), }, ] return cols }, [hasRolePermissions, info, projectId, router, setBackProps, type]) return ( <> 筛选条件 setFilters((s) => ({ ...s, search_id: e.target.value, })) } /> setFilters((s) => ({ ...s, rejected: v ?? "" })) } allowDeselect={false} /> ({ label: y, value: x.toString() }) )} searchable clearable value={filters.current_uid} onChange={(v) => setFilters((s) => ({ ...s, current_uid: v })) } /> ({ label: y, value: x.toString() }) )} searchable clearable value={filters.label_user} onChange={(v) => setFilters((s) => ({ ...s, label_user: v }))} /> ({ label: y, value: x.toString() }) )} searchable clearable value={filters.review_user1} onChange={(v) => setFilters((s) => ({ ...s, review_user1: v })) } /> ({ label: y, value: x.toString() }) )} searchable clearable value={filters.review_user2} onChange={(v) => setFilters((s) => ({ ...s, review_user2: v })) } /> setFilters((s) => ({ ...s, first_commit_label_time: value, })) } /> setFilters((s) => ({ ...s, first_commit_review1_time: value, })) } /> {hasRolePermissions ? ( ) : null} {hasRolePermissions ? ( ) : null} {hasRolePermissions ? ( ) : null} } /> width="100%" minHeight={0} style={{ width: "100%", flex: 1 }} pinFirstColumn pinLastColumn scrollAreaProps={{ type: "auto" }} fetching={loading} records={records} columns={columns} sortStatus={sortStatus} onSortStatusChange={( nextSortStatus: DataTableSortStatus ) => { setPageNumber(1) setSortStatus(nextSortStatus) }} totalRecords={total} recordsPerPage={pageSize} page={pageNumber} onPageChange={(p) => setPageNumber(p)} onRecordsPerPageChange={(s) => { setPageSize(s) setPageNumber(1) }} selectedRecords={selectedRecords} onSelectedRecordsChange={setSelectedRecords} recordsPerPageOptions={recordsPerPageOptions} renderPagination={({ Controls }) => ( )} /> setCustomPageSizeInput(event.currentTarget.value) } onKeyDown={handleCustomPageSizeEnter} /> setWorkflowTaskId(null)} /> { setDispatchModalConfig({ open: false, task_ids: [], }) if (refresh) setQueryTrigger((v) => v + 1) }} /> { setReleaseRecord(null) if (refresh) setQueryTrigger((v) => v + 1) }} /> ) }