feat(team): add page
This commit is contained in:
33
app/management/team/workload/LabelWorkloadTable.tsx
Normal file
33
app/management/team/workload/LabelWorkloadTable.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { getLabelStatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function LabelWorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="标注工时"
|
||||
sheetName="标注工时数据"
|
||||
fileName="标注工时数据.xlsx"
|
||||
fetchData={getLabelStatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "commit_label_1", title: "提交标注任务数" },
|
||||
{ key: "commit_reviewed_1", title: "被审核1审核任务数" },
|
||||
{ key: "reviewed_size", title: "被审核对象数" },
|
||||
{ key: "commented_size", title: "被批注对象数" },
|
||||
{ key: "rejected_label_1", title: "被驳回任务数" },
|
||||
{ key: "total_reviewed_size", title: "被审核过任务对象总数" },
|
||||
{ key: "dynamic_size", title: "新增动态对象数" },
|
||||
{ key: "static_size", title: "新增静态对象数" },
|
||||
{ key: "key_frame_size", title: "新增关键帧数" },
|
||||
{ key: "question_size", title: "新增问题数" },
|
||||
{ key: "commented_question_size", title: "被批注问题数" },
|
||||
{ key: "reviewed_question_size", title: "被审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
app/management/team/workload/Review1WorkloadTable.tsx
Normal file
35
app/management/team/workload/Review1WorkloadTable.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { getReview1StatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function Review1WorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="审核工时"
|
||||
sheetName="审核工时数据"
|
||||
fileName="审核工时数据.xlsx"
|
||||
fetchData={getReview1StatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "review_size", title: "审核对象数" },
|
||||
{ key: "comment_size", title: "批注对象数" },
|
||||
{ key: "reviewed_size", title: "被审核对象数" },
|
||||
{ key: "commented_size", title: "被批注对象数" },
|
||||
{ key: "commit_review_1", title: "提交审核1任务数" },
|
||||
{ key: "commit_reviewed_2", title: "被审核2审核任务数" },
|
||||
{ key: "reject_review_1", title: "审核1驳回任务数" },
|
||||
{ key: "rejected_review_1", title: "被驳回的审核1任务数" },
|
||||
{ key: "total_review_size", title: "已审核任务对象总数" },
|
||||
{ key: "review_dynamic_size", title: "审核动态对象数" },
|
||||
{ key: "review_static_size", title: "审核静态对象数" },
|
||||
{ key: "total_reviewed_size", title: "已被审核过任务对象总数" },
|
||||
{ key: "review_key_frame_size", title: "审核关键帧数" },
|
||||
{ key: "review_question_size", title: "审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
30
app/management/team/workload/Review2WorkloadTable.tsx
Normal file
30
app/management/team/workload/Review2WorkloadTable.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { getReview2StatisticsList } from "@/components/label/api/workload"
|
||||
import WorkloadMetricsTable from "./WorkloadMetricsTable"
|
||||
|
||||
export default function Review2WorkloadTable() {
|
||||
return (
|
||||
<WorkloadMetricsTable
|
||||
title="复审工时"
|
||||
sheetName="复审工时数据"
|
||||
fileName="复审工时数据.xlsx"
|
||||
fetchData={getReview2StatisticsList}
|
||||
metrics={[
|
||||
{ key: "work_time", title: "系统工时" },
|
||||
{ key: "object_size", title: "新增对象数" },
|
||||
{ key: "confirmed_prelabel_size", title: "确认预标注对象数" },
|
||||
{ key: "prelabel_object_size", title: "预标注对象数" },
|
||||
{ key: "review_size", title: "审核对象数" },
|
||||
{ key: "comment_size", title: "批注对象数" },
|
||||
{ key: "commit_review_2", title: "提交审核2任务数" },
|
||||
{ key: "reject_review_2", title: "审核2驳回任务数" },
|
||||
{ key: "total_review_size", title: "已审核任务对象总数" },
|
||||
{ key: "review_dynamic_size", title: "审核动态对象数" },
|
||||
{ key: "review_static_size", title: "审核静态对象数" },
|
||||
{ key: "review_key_frame_size", title: "审核关键帧数" },
|
||||
{ key: "review_question_size", title: "审核问题数" },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
480
app/management/team/workload/TaskListTable.tsx
Normal file
480
app/management/team/workload/TaskListTable.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
"use client"
|
||||
|
||||
import { getProjectList, getProjectTypeList } from "@/components/label/api/project"
|
||||
import { getTaskList } from "@/components/label/api/task"
|
||||
import { Task } from "@/components/label/api/task/typing"
|
||||
import TaskStatusTag from "@/components/label/components/TaskStatusTag"
|
||||
import { useAllUserStore } from "@/components/label/store/auth"
|
||||
import { TaskStatusEnum } from "@/components/label/utils/constants"
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Group,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconChevronDown, IconChevronUp, IconDownload, IconRefresh, IconSearch } from "@tabler/icons-react"
|
||||
import { DataTable, DataTableColumn } from "mantine-datatable"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as XLSX from "xlsx-js-style"
|
||||
import { flattenLeaderTree } from "../utils"
|
||||
|
||||
const emptyFilters = {
|
||||
page_size: 1000,
|
||||
project_type: [] as string[],
|
||||
project_id: [] as string[],
|
||||
label_status: [] as string[],
|
||||
current_uid: [] as string[],
|
||||
label_user: [] as string[],
|
||||
review_user1: [] as string[],
|
||||
review_user2: [] as string[],
|
||||
}
|
||||
|
||||
function toNumberArray(values?: string[]) {
|
||||
if (!values || values.length === 0) return undefined
|
||||
const arr = values
|
||||
.map((v) => Number(v))
|
||||
.filter((v) => Number.isFinite(v))
|
||||
return arr.length > 0 ? arr : undefined
|
||||
}
|
||||
|
||||
export default function TaskListTable() {
|
||||
const treeData = useAllUserStore((s) => s.treeData)
|
||||
const userOptions = useMemo(() => flattenLeaderTree(treeData ?? []), [treeData])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [projectTypeOpts, setProjectTypeOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [projectNameOpts, setProjectNameOpts] = useState<Array<{ label: string; value: string }>>(
|
||||
[]
|
||||
)
|
||||
const [filters, setFilters] = useState(emptyFilters)
|
||||
|
||||
const [records, setRecords] = useState<Task.DataProps[]>([])
|
||||
const [total, setTotal] = useState<number>(0)
|
||||
const [totalSize, setTotalSize] = useState<Task.SizeProps | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const [typesRes, projectsRes] = await Promise.allSettled([
|
||||
getProjectTypeList(),
|
||||
getProjectList({ page_number: 1, page_size: 10000 }),
|
||||
])
|
||||
|
||||
if (typesRes.status === "fulfilled") {
|
||||
setProjectTypeOpts((typesRes.value ?? []).map((t) => ({ label: t, value: t })))
|
||||
} else {
|
||||
setProjectTypeOpts([])
|
||||
}
|
||||
|
||||
if (projectsRes.status === "fulfilled") {
|
||||
const list = projectsRes.value?.project_list ?? []
|
||||
setProjectNameOpts(list.map((p) => ({ label: p.name, value: String(p.id) })))
|
||||
} else {
|
||||
setProjectNameOpts([])
|
||||
}
|
||||
}
|
||||
queueMicrotask(run)
|
||||
}, [])
|
||||
|
||||
const handleSearch = async (nextFilters = filters) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!nextFilters.page_size) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "参数不完整",
|
||||
message: "请填写展示数量",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
page_number: 1,
|
||||
page_size: nextFilters.page_size,
|
||||
get_data: false,
|
||||
}
|
||||
if (nextFilters.project_type.length > 0) payload.project_type = nextFilters.project_type
|
||||
if (nextFilters.project_id.length > 0) {
|
||||
payload.project_id = toNumberArray(nextFilters.project_id)
|
||||
}
|
||||
if (nextFilters.label_status.length > 0) {
|
||||
payload.label_status = toNumberArray(nextFilters.label_status)
|
||||
}
|
||||
if (nextFilters.current_uid.length > 0) {
|
||||
payload.current_uid = toNumberArray(nextFilters.current_uid)
|
||||
}
|
||||
if (nextFilters.label_user.length > 0) {
|
||||
payload.label_user = toNumberArray(nextFilters.label_user)
|
||||
}
|
||||
if (nextFilters.review_user1.length > 0) {
|
||||
payload.review_user1 = toNumberArray(nextFilters.review_user1)
|
||||
}
|
||||
if (nextFilters.review_user2.length > 0) {
|
||||
payload.review_user2 = toNumberArray(nextFilters.review_user2)
|
||||
}
|
||||
|
||||
const res = await getTaskList(payload)
|
||||
setRecords(res?.task_list ?? [])
|
||||
setTotal(res?.total_items ?? 0)
|
||||
setTotalSize(res?.total_size ?? null)
|
||||
} catch (e) {
|
||||
setRecords([])
|
||||
setTotal(0)
|
||||
setTotalSize(null)
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => handleSearch(emptyFilters))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const cols: DataTableColumn<Task.DataProps>[] = [
|
||||
{ accessor: "project_name", title: "项目名称", width: 220 },
|
||||
{ accessor: "id", title: "任务ID", width: 100 },
|
||||
{
|
||||
accessor: "label_status",
|
||||
title: "状态",
|
||||
width: 160,
|
||||
render: (record) => (
|
||||
<TaskStatusTag status={record.label_status} rejected={record.rejected} />
|
||||
),
|
||||
},
|
||||
{ accessor: "current_username", title: "当前负责人", width: 120 },
|
||||
{ accessor: "label_username", title: "标注员", width: 120 },
|
||||
{ accessor: "review1_username", title: "审核员", width: 120 },
|
||||
{ accessor: "review2_username", title: "复审员", width: 120 },
|
||||
{
|
||||
accessor: "object_size",
|
||||
title: "对象总数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.object_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "new_size",
|
||||
title: "新增对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.new_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "key_frame_size",
|
||||
title: "关键帧数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.key_frame_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "dynamic_size",
|
||||
title: "动态对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.dynamic_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "static_size",
|
||||
title: "静态对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.static_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "question_size",
|
||||
title: "问题数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.question_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "review_size",
|
||||
title: "审核对象数",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.review_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "first_comment_size",
|
||||
title: "批注数(1)",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.first_comment_size ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "second_comment_size",
|
||||
title: "批注数(2)",
|
||||
width: 120,
|
||||
render: (record) => record.label_object_size?.second_comment_size ?? 0,
|
||||
},
|
||||
{ accessor: "first_commit_label_time", title: "标注首次提交时间", width: 180 },
|
||||
{ accessor: "first_commit_review1_time", title: "审核首次提交时间", width: 180 },
|
||||
{ accessor: "reject_times", title: "驳回次数", width: 100 },
|
||||
{
|
||||
accessor: "label_work_time",
|
||||
title: "标注总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.label_work_time ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "first_review_work_time",
|
||||
title: "审核总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.first_review_work_time ?? 0,
|
||||
},
|
||||
{
|
||||
accessor: "second_review_work_time",
|
||||
title: "复审总耗时",
|
||||
width: 120,
|
||||
render: (record) => record.work_time?.second_review_work_time ?? 0,
|
||||
},
|
||||
]
|
||||
return cols
|
||||
}, [])
|
||||
|
||||
const handleExport = () => {
|
||||
if (records.length === 0) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "暂无数据",
|
||||
message: "当前无可导出任务数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
const header = [
|
||||
"项目名称",
|
||||
"任务ID",
|
||||
"状态",
|
||||
"当前负责人",
|
||||
"标注员",
|
||||
"审核员",
|
||||
"复审员",
|
||||
"对象总数",
|
||||
"新增对象数",
|
||||
"关键帧数",
|
||||
"动态对象数",
|
||||
"静态对象数",
|
||||
"问题数",
|
||||
"审核对象数",
|
||||
"批注数(1)",
|
||||
"批注数(2)",
|
||||
"标注首次提交时间",
|
||||
"审核首次提交时间",
|
||||
"驳回次数",
|
||||
"标注总耗时",
|
||||
"审核总耗时",
|
||||
"复审总耗时",
|
||||
]
|
||||
const rows = records.map((item) => [
|
||||
item.project_name,
|
||||
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 ?? 0,
|
||||
item.label_object_size?.new_size ?? 0,
|
||||
item.label_object_size?.key_frame_size ?? 0,
|
||||
item.label_object_size?.dynamic_size ?? 0,
|
||||
item.label_object_size?.static_size ?? 0,
|
||||
item.label_object_size?.question_size ?? 0,
|
||||
item.label_object_size?.review_size ?? 0,
|
||||
item.label_object_size?.first_comment_size ?? 0,
|
||||
item.label_object_size?.second_comment_size ?? 0,
|
||||
item.first_commit_label_time ?? "-",
|
||||
item.first_commit_review1_time ?? "-",
|
||||
item.reject_times ?? 0,
|
||||
item.work_time?.label_work_time ?? 0,
|
||||
item.work_time?.first_review_work_time ?? 0,
|
||||
item.work_time?.second_review_work_time ?? 0,
|
||||
])
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...rows])
|
||||
XLSX.utils.book_append_sheet(wb, ws, "任务列表数据")
|
||||
XLSX.writeFile(wb, "任务列表数据.xlsx")
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm">
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
label="展示数量"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
min={10}
|
||||
step={100}
|
||||
value={filters.page_size}
|
||||
onChange={(v) =>
|
||||
setFilters((s) => ({ ...s, page_size: Number(v) || 1000 }))
|
||||
}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
当前筛选条件下共 {records.length} / {total} 条数据
|
||||
</Text>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
rightSection={collapsed ? <IconChevronDown size={14} /> : <IconChevronUp size={14} />}
|
||||
onClick={() => setCollapsed((v) => !v)}>
|
||||
{collapsed ? "展开筛选" : "收起筛选"}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={() => handleSearch(filters)}>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => {
|
||||
setFilters(emptyFilters)
|
||||
handleSearch(emptyFilters)
|
||||
}}>
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleExport}>
|
||||
下载数据
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="sm">
|
||||
<MultiSelect
|
||||
label="项目类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectTypeOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.project_type}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, project_type: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={projectNameOpts}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.project_id}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, project_id: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="任务状态"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={Array.from(TaskStatusEnum.entries()).map(([value, label]) => ({
|
||||
label,
|
||||
value: String(value),
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.label_status}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, label_status: v }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Collapse in={!collapsed}>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="sm">
|
||||
<MultiSelect
|
||||
label="当前负责人"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.current_uid}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, current_uid: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="标注员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.label_user}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, label_user: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="审核员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.review_user1}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, review_user1: v }))}
|
||||
/>
|
||||
<MultiSelect
|
||||
label="复审员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={userOptions}
|
||||
searchable
|
||||
clearable
|
||||
value={filters.review_user2}
|
||||
onChange={(v) => setFilters((s) => ({ ...s, review_user2: v }))}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<DataTable<Task.DataProps>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={records}
|
||||
columns={columns}
|
||||
noRecordsText="暂无数据"
|
||||
rowKey="id"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Group wrap="wrap" gap="md">
|
||||
<Text size="sm" fw={700}>
|
||||
当前页总计
|
||||
</Text>
|
||||
<Text size="sm">对象总数:{totalSize?.object_size ?? 0}</Text>
|
||||
<Text size="sm">新增对象数:{totalSize?.new_size ?? 0}</Text>
|
||||
<Text size="sm">关键帧数:{totalSize?.key_frame_size ?? 0}</Text>
|
||||
<Text size="sm">动态对象数:{totalSize?.dynamic_size ?? 0}</Text>
|
||||
<Text size="sm">静态对象数:{totalSize?.static_size ?? 0}</Text>
|
||||
<Text size="sm">问题数:{totalSize?.question_size ?? 0}</Text>
|
||||
<Text size="sm">审核对象数:{totalSize?.review_size ?? 0}</Text>
|
||||
<Text size="sm">批注数(1):{totalSize?.first_comment_size ?? 0}</Text>
|
||||
<Text size="sm">批注数(2):{totalSize?.second_comment_size ?? 0}</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
352
app/management/team/workload/WorkloadMetricsTable.tsx
Normal file
352
app/management/team/workload/WorkloadMetricsTable.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { TimeFilter } from "@/components/label/api/workload/typing"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconDownload, IconRefresh } from "@tabler/icons-react"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
DataTableSortStatus,
|
||||
} from "mantine-datatable"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import * as XLSX from "xlsx-js-style"
|
||||
import { DimensionType, GroupedData, MetricColumn, dimensionOpts } from "./config"
|
||||
|
||||
function emptyGroupedData(): GroupedData {
|
||||
return {
|
||||
all: [],
|
||||
order_by_group_name: [],
|
||||
order_by_project_name: [],
|
||||
order_by_project_type: [],
|
||||
order_by_user_name: [],
|
||||
}
|
||||
}
|
||||
|
||||
export default function WorkloadMetricsTable(props: {
|
||||
title: string
|
||||
sheetName: string
|
||||
fileName: string
|
||||
metrics: MetricColumn[]
|
||||
fetchData: (params: TimeFilter) => Promise<GroupedData>
|
||||
}) {
|
||||
const { title, sheetName, fileName, metrics, fetchData } = props
|
||||
|
||||
const [startDate, setStartDate] = useState(dayjs().subtract(1, "day").format("YYYY-MM-DD"))
|
||||
const [endDate, setEndDate] = useState(dayjs().subtract(1, "day").format("YYYY-MM-DD"))
|
||||
const [dimension, setDimension] = useState<DimensionType>("all")
|
||||
const [data, setData] = useState<GroupedData>(emptyGroupedData())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [projectName, setProjectName] = useState("")
|
||||
const [projectType, setProjectType] = useState("")
|
||||
const [userName, setUserName] = useState("")
|
||||
const [groupName, setGroupName] = useState("")
|
||||
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
|
||||
columnAccessor: "work_time",
|
||||
direction: "desc",
|
||||
})
|
||||
|
||||
const refresh = async () => {
|
||||
if (!startDate || !endDate) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "参数不完整",
|
||||
message: "请选择起止日期",
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetchData({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
})
|
||||
setData({
|
||||
...emptyGroupedData(),
|
||||
...(res ?? {}),
|
||||
})
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "加载失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
setData(emptyGroupedData())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(refresh)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
let records = [...(data[dimension] ?? [])]
|
||||
if (projectName.trim()) {
|
||||
records = records.filter((item) =>
|
||||
String(item.project_name ?? "")
|
||||
.toLowerCase()
|
||||
.includes(projectName.trim().toLowerCase())
|
||||
)
|
||||
}
|
||||
if (projectType.trim()) {
|
||||
records = records.filter((item) =>
|
||||
String(item.project_type ?? "")
|
||||
.toLowerCase()
|
||||
.includes(projectType.trim().toLowerCase())
|
||||
)
|
||||
}
|
||||
if (userName.trim()) {
|
||||
records = records.filter((item) =>
|
||||
String(item.user_name ?? "")
|
||||
.toLowerCase()
|
||||
.includes(userName.trim().toLowerCase())
|
||||
)
|
||||
}
|
||||
if (groupName.trim()) {
|
||||
records = records.filter((item) =>
|
||||
String(item.group_name ?? "")
|
||||
.toLowerCase()
|
||||
.includes(groupName.trim().toLowerCase())
|
||||
)
|
||||
}
|
||||
return records
|
||||
}, [data, dimension, groupName, projectName, projectType, userName])
|
||||
|
||||
const sortedRecords = useMemo(() => {
|
||||
const list = [...filteredRecords]
|
||||
const accessor = sortStatus.columnAccessor
|
||||
const dir = sortStatus.direction === "asc" ? 1 : -1
|
||||
list.sort((a, b) => {
|
||||
const av = a?.[accessor as string]
|
||||
const bv = b?.[accessor as string]
|
||||
const an = Number(av)
|
||||
const bn = Number(bv)
|
||||
if (Number.isFinite(an) && Number.isFinite(bn)) {
|
||||
return (an - bn) * dir
|
||||
}
|
||||
return String(av ?? "").localeCompare(String(bv ?? "")) * dir
|
||||
})
|
||||
return list
|
||||
}, [filteredRecords, sortStatus.columnAccessor, sortStatus.direction])
|
||||
|
||||
const baseColumns = useMemo(() => {
|
||||
const allCols: Record<DimensionType, DataTableColumn<any>[]> = {
|
||||
all: [
|
||||
{ accessor: "project_name", title: "项目名称", width: 180 },
|
||||
{ accessor: "project_type", title: "项目类型", width: 180 },
|
||||
{ accessor: "user_name", title: "用户名", width: 120 },
|
||||
{ accessor: "group_name", title: "用户组", width: 160 },
|
||||
],
|
||||
order_by_group_name: [{ accessor: "group_name", title: "用户组", width: 180 }],
|
||||
order_by_project_name: [
|
||||
{ accessor: "project_name", title: "项目名称", width: 220 },
|
||||
],
|
||||
order_by_project_type: [
|
||||
{ accessor: "project_type", title: "项目类型", width: 220 },
|
||||
],
|
||||
order_by_user_name: [{ accessor: "user_name", title: "用户名", width: 180 }],
|
||||
}
|
||||
return allCols[dimension]
|
||||
}, [dimension])
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const metricCols = metrics.map((metric) => {
|
||||
return {
|
||||
accessor: metric.key,
|
||||
title: metric.title,
|
||||
width: 140,
|
||||
sortable: true,
|
||||
} as DataTableColumn<any>
|
||||
})
|
||||
return [
|
||||
{
|
||||
accessor: "index",
|
||||
title: "序号",
|
||||
width: 80,
|
||||
render: (_record: any, index: number) => index + 1,
|
||||
} as DataTableColumn<any>,
|
||||
...baseColumns,
|
||||
...metricCols,
|
||||
]
|
||||
}, [baseColumns, metrics])
|
||||
|
||||
const totals = useMemo(() => {
|
||||
return metrics.map((metric) => {
|
||||
const sum = sortedRecords.reduce((acc, item) => {
|
||||
const value = Number(item?.[metric.key] ?? 0)
|
||||
return acc + (Number.isFinite(value) ? value : 0)
|
||||
}, 0)
|
||||
return { title: metric.title, value: sum }
|
||||
})
|
||||
}, [metrics, sortedRecords])
|
||||
|
||||
const handleExport = () => {
|
||||
if (!sortedRecords.length) {
|
||||
notifications.show({
|
||||
color: "yellow",
|
||||
title: "暂无数据",
|
||||
message: "当前筛选条件下无可导出数据",
|
||||
})
|
||||
return
|
||||
}
|
||||
const baseHeaders = baseColumns.map((c) => String(c.title ?? c.accessor))
|
||||
const metricHeaders = metrics.map((m) => m.title)
|
||||
const header = ["序号", ...baseHeaders, ...metricHeaders]
|
||||
|
||||
const baseAccessors = baseColumns.map((c) => String(c.accessor))
|
||||
const rows = sortedRecords.map((item, index) => {
|
||||
return [
|
||||
index + 1,
|
||||
...baseAccessors.map((key) => item?.[key] ?? "-"),
|
||||
...metrics.map((m) => item?.[m.key] ?? 0),
|
||||
]
|
||||
})
|
||||
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws = XLSX.utils.aoa_to_sheet([header, ...rows])
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName)
|
||||
XLSX.writeFile(wb, fileName)
|
||||
}
|
||||
|
||||
const rowKey = useMemo(() => {
|
||||
if (dimension === "all") {
|
||||
return (record: any) =>
|
||||
`${record.project_id ?? "p"}_${record.uid ?? "u"}_${record.project_name ?? ""}_${record.user_name ?? ""}`
|
||||
}
|
||||
if (dimension === "order_by_group_name") return "group_name"
|
||||
if (dimension === "order_by_project_name") return "project_name"
|
||||
if (dimension === "order_by_project_type") return "project_type"
|
||||
return "user_name"
|
||||
}, [dimension])
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm">
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Group justify="space-between" wrap="wrap">
|
||||
<Group>
|
||||
<Text size="sm" fw={700}>
|
||||
{title}
|
||||
</Text>
|
||||
<TextInput
|
||||
label="开始日期"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="结束日期"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.currentTarget.value)}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={refresh}
|
||||
loading={loading}>
|
||||
更新
|
||||
</Button>
|
||||
</Group>
|
||||
<Group>
|
||||
<Select
|
||||
label="聚合维度"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
data={dimensionOpts}
|
||||
value={dimension}
|
||||
onChange={(v) => setDimension((v as DimensionType) || "all")}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
leftSection={<IconDownload size={16} />}
|
||||
onClick={handleExport}>
|
||||
下载数据
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }} spacing="sm" mt="sm">
|
||||
<TextInput
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="项目类型"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={projectType}
|
||||
onChange={(e) => setProjectType(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="用户名"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="用户组"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.currentTarget.value)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||
<DataTable<any>
|
||||
withTableBorder
|
||||
withRowBorders
|
||||
fetching={loading}
|
||||
records={sortedRecords}
|
||||
columns={columns}
|
||||
noRecordsText="暂无数据"
|
||||
rowKey={rowKey as any}
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
style={{ width: "100%" }}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="sm" radius="sm">
|
||||
<Group wrap="wrap" gap="md">
|
||||
<Text size="sm" fw={700}>
|
||||
总计
|
||||
</Text>
|
||||
{totals.map((item) => (
|
||||
<Text size="sm" key={item.title}>
|
||||
{item.title}:{item.value}
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
27
app/management/team/workload/config.ts
Normal file
27
app/management/team/workload/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export type DimensionType =
|
||||
| "all"
|
||||
| "order_by_user_name"
|
||||
| "order_by_group_name"
|
||||
| "order_by_project_name"
|
||||
| "order_by_project_type"
|
||||
|
||||
export const dimensionOpts = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "用户名", value: "order_by_user_name" },
|
||||
{ label: "用户组", value: "order_by_group_name" },
|
||||
{ label: "项目名称", value: "order_by_project_name" },
|
||||
{ label: "项目类型", value: "order_by_project_type" },
|
||||
]
|
||||
|
||||
export type MetricColumn = {
|
||||
key: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type GroupedData = {
|
||||
all: any[]
|
||||
order_by_group_name: any[]
|
||||
order_by_project_name: any[]
|
||||
order_by_project_type: any[]
|
||||
order_by_user_name: any[]
|
||||
}
|
||||
55
app/management/team/workload/page.tsx
Normal file
55
app/management/team/workload/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { Paper, Stack, Tabs } from "@mantine/core"
|
||||
import LabelWorkloadTable from "./LabelWorkloadTable"
|
||||
import Review1WorkloadTable from "./Review1WorkloadTable"
|
||||
import Review2WorkloadTable from "./Review2WorkloadTable"
|
||||
import TaskListTable from "./TaskListTable"
|
||||
|
||||
export default function TeamWorkloadPage() {
|
||||
return (
|
||||
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
|
||||
<Paper
|
||||
withBorder
|
||||
p="md"
|
||||
radius="md"
|
||||
style={{
|
||||
borderColor:
|
||||
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}>
|
||||
<Tabs
|
||||
defaultValue="label"
|
||||
variant="pills"
|
||||
radius="sm"
|
||||
styles={{
|
||||
root: { height: "100%", display: "flex", flexDirection: "column" },
|
||||
panel: { flex: 1, minHeight: 0, paddingTop: 12 },
|
||||
}}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="label">标注工时</Tabs.Tab>
|
||||
<Tabs.Tab value="review1">审核工时</Tabs.Tab>
|
||||
<Tabs.Tab value="review2">复审工时</Tabs.Tab>
|
||||
<Tabs.Tab value="task">全部任务</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="label">
|
||||
<LabelWorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review1">
|
||||
<Review1WorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="review2">
|
||||
<Review2WorkloadTable />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="task">
|
||||
<TaskListTable />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user