feat(workload): add workload list

This commit is contained in:
zhangheng
2026-05-14 15:12:32 +08:00
parent 781b002a7b
commit 5c3b45b247
12 changed files with 1508 additions and 34 deletions

View 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: "被审核任务数" },
{ 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: "被审核问题数" },
]}
/>
)
}

View 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: "提交审核任务数" },
{ key: "commit_reviewed_2", title: "被复审审核任务数" },
{ key: "reject_review_1", title: "审核驳回任务数" },
{ key: "rejected_review_1", title: "被驳回的审核任务数" },
{ 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: "审核问题数" },
]}
/>
)
}

View 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: "提交复审任务数" },
{ key: "reject_review_2", title: "复审驳回任务数" },
{ 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: "复审问题数" },
]}
/>
)
}

View File

@@ -0,0 +1,404 @@
"use client"
import { TimeFilter } from "@/components/label/api/workload/typing"
import {
SettingContentPanel,
SettingDataTable,
SettingFilterActions,
SettingFilterPanel,
SettingHeaderActions,
SettingInlineFilterField,
SettingListHeader,
settingSurfaceClassNames,
} from "@/components/setting/PageSurface"
import { Button, Collapse, Stack, TextInput } from "@mantine/core"
import { DateInput } from "@mantine/dates"
import { notifications } from "@mantine/notifications"
import {
IconChevronDown,
IconChevronUp,
IconDownload,
IconRefresh,
IconSearch,
} from "@tabler/icons-react"
import dayjs from "dayjs"
import "dayjs/locale/zh-cn"
import { DataTableColumn, DataTableSortStatus } from "mantine-datatable"
import { useEffect, useMemo, useState } from "react"
import * as XLSX from "xlsx-js-style"
import { DimensionType, GroupedData, MetricColumn } from "./config"
function emptyGroupedData(): GroupedData {
return {
all: [],
order_by_group_name: [],
order_by_project_name: [],
order_by_project_type: [],
order_by_user_name: [],
}
}
function createInitialFilters() {
const currentDate = dayjs().subtract(1, "day").format("YYYY-MM-DD")
return {
startDate: currentDate,
endDate: currentDate,
dimension: "all" as DimensionType,
projectName: "",
projectType: "",
userName: "",
groupName: "",
}
}
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 [filters, setFilters] = useState(() => createInitialFilters())
const [appliedFilters, setAppliedFilters] = useState(() =>
createInitialFilters()
)
const [data, setData] = useState<GroupedData>(emptyGroupedData())
const [loading, setLoading] = useState(false)
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: "work_time",
direction: "desc",
})
const refresh = async (nextFilters = appliedFilters) => {
if (!nextFilters.startDate || !nextFilters.endDate) {
notifications.show({
color: "yellow",
title: "参数不完整",
message: "请选择起止日期",
})
return
}
try {
setLoading(true)
const res = await fetchData({
start_date: nextFilters.startDate,
end_date: nextFilters.endDate,
})
setData({
...emptyGroupedData(),
...(res ?? {}),
})
} catch (e) {
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
setData(emptyGroupedData())
} finally {
setLoading(false)
}
}
useEffect(() => {
const initialFilters = createInitialFilters()
queueMicrotask(() => refresh(initialFilters))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const filteredRecords = useMemo(() => {
let records = [...(data[appliedFilters.dimension] ?? [])]
if (appliedFilters.projectName.trim()) {
records = records.filter((item) =>
String(item.project_name ?? "")
.toLowerCase()
.includes(appliedFilters.projectName.trim().toLowerCase())
)
}
if (appliedFilters.projectType.trim()) {
records = records.filter((item) =>
String(item.project_type ?? "")
.toLowerCase()
.includes(appliedFilters.projectType.trim().toLowerCase())
)
}
if (appliedFilters.userName.trim()) {
records = records.filter((item) =>
String(item.user_name ?? "")
.toLowerCase()
.includes(appliedFilters.userName.trim().toLowerCase())
)
}
if (appliedFilters.groupName.trim()) {
records = records.filter((item) =>
String(item.group_name ?? "")
.toLowerCase()
.includes(appliedFilters.groupName.trim().toLowerCase())
)
}
return records
}, [appliedFilters, data])
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: 280 },
{ 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: 320 },
],
order_by_user_name: [
{ accessor: "user_name", title: "用户名", width: 180 },
],
}
return allCols[appliedFilters.dimension]
}, [appliedFilters.dimension])
const columns = useMemo(() => {
const totalsByKey = Object.fromEntries(
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 [metric.key, sum]
})
) as Record<string, number>
const metricCols = metrics.map((metric) => {
return {
accessor: metric.key,
title: metric.title,
width: 140,
sortable: true,
footer: totalsByKey[metric.key] ?? 0,
} as DataTableColumn<any>
})
return [
{
accessor: "index",
title: "序号",
width: 80,
footer: "总计",
render: (_record: any, index: number) => index + 1,
} as DataTableColumn<any>,
...baseColumns,
...metricCols,
]
}, [baseColumns, 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 (appliedFilters.dimension === "all") {
return (record: any) =>
`${record.project_id ?? "p"}_${record.uid ?? "u"}_${record.project_name ?? ""}_${record.user_name ?? ""}`
}
if (appliedFilters.dimension === "order_by_group_name") return "group_name"
if (appliedFilters.dimension === "order_by_project_name")
return "project_name"
if (appliedFilters.dimension === "order_by_project_type")
return "project_type"
return "user_name"
}, [appliedFilters.dimension])
const handleSearch = () => {
setAppliedFilters(filters)
void refresh(filters)
}
const handleReset = () => {
const nextFilters = createInitialFilters()
setFilters(nextFilters)
setAppliedFilters(nextFilters)
void refresh(nextFilters)
}
const [searchExpanded, setSearchExpanded] = useState(false)
return (
<Stack h="100%" gap={12} style={{ minWidth: 0 }}>
<SettingFilterPanel>
<Stack gap={12}>
<div className={settingSurfaceClassNames.filterTopRow}>
<SettingInlineFilterField label="开始日期">
<DateInput
locale="zh-cn"
value={filters.startDate}
onChange={(e) =>
setFilters((s) => ({
...s,
startDate: dayjs(e).format("YYYY-MM-DD"),
}))
}
/>
</SettingInlineFilterField>
<SettingInlineFilterField label="结束日期">
<DateInput
locale="zh-cn"
value={filters.endDate}
onChange={(e) =>
setFilters((s) => ({
...s,
endDate: dayjs(e).format("YYYY-MM-DD"),
}))
}
/>
</SettingInlineFilterField>
<SettingInlineFilterField label="项目名称">
<TextInput
placeholder="请输入项目名称"
value={filters.projectName}
onChange={(e) =>
setFilters((prev) => ({
...prev,
projectName: e.target.value,
}))
}
/>
</SettingInlineFilterField>
<SettingFilterActions
mt={0}
className={settingSurfaceClassNames.filterActions}>
<Button
variant="transparent"
className={settingSurfaceClassNames.collapseToggle}
rightSection={
searchExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)
}
onClick={() => setSearchExpanded((v) => !v)}>
{searchExpanded ? "收起" : "展开"}
</Button>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</div>
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
<div className={settingSurfaceClassNames.filterExpandedGrid}>
<SettingInlineFilterField label="项目类型">
<TextInput
placeholder="请输入项目类型"
value={filters.projectType}
onChange={(e) =>
setFilters((prev) => ({
...prev,
projectType: e.target.value,
}))
}
/>
</SettingInlineFilterField>
</div>
</Collapse>
</Stack>
</SettingFilterPanel>
<SettingContentPanel
style={{ flex: 1, minHeight: 0, minWidth: 0, display: "flex" }}>
<Stack gap={12} style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<SettingListHeader
title={title}
count={`${sortedRecords.length}`}
actions={
<SettingHeaderActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => void refresh(appliedFilters)}
loading={loading}>
</Button>
<Button
variant="default"
leftSection={<IconDownload size={16} />}
onClick={handleExport}>
</Button>
</SettingHeaderActions>
}
/>
<SettingDataTable<any>
fetching={loading}
records={sortedRecords}
columns={columns}
idAccessor={rowKey as any}
minHeight={0}
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%", flex: 1, minWidth: 0, minHeight: 0 }}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
/>
</Stack>
</SettingContentPanel>
</Stack>
)
}

View 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[]
}

View File

@@ -0,0 +1,87 @@
"use client"
import {
SettingPage,
settingSurfaceClassNames,
settingTabsClassNames,
settingTabsStyles,
} from "@/components/setting/PageSurface"
import { Stack, Tabs, Text } from "@mantine/core"
import { useState } from "react"
import LabelWorkloadTable from "./LabelWorkloadTable"
import Review1WorkloadTable from "./Review1WorkloadTable"
import Review2WorkloadTable from "./Review2WorkloadTable"
type WorkloadTab = "label" | "review1" | "review2"
function createVisitedTabs() {
return {
label: true,
review1: false,
review2: false,
task: false,
}
}
export default function TeamWorkloadPage() {
const moduleTitle = "个人中心"
const [activeTab, setActiveTab] = useState<WorkloadTab>("label")
const [visitedTabs, setVisitedTabs] = useState(createVisitedTabs)
const handleTabChange = (value: string | null) => {
if (!value) return
const nextTab = value as WorkloadTab
setActiveTab(nextTab)
setVisitedTabs((current) =>
current[nextTab] ? current : { ...current, [nextTab]: true }
)
}
return (
<SettingPage>
<Stack className={settingSurfaceClassNames.pageIntro}>
<Text className={settingSurfaceClassNames.breadcrumb}>
{moduleTitle} /{" "}
<span className={settingSurfaceClassNames.breadcrumbCurrent}>
</span>
</Text>
</Stack>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="pills"
color="#1874FF"
autoContrast
radius="sm"
classNames={settingTabsClassNames}
styles={{
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
display: "flex",
flexDirection: "column",
},
...settingTabsStyles,
}}>
<Tabs.List>
<Tabs.Tab value="label"></Tabs.Tab>
<Tabs.Tab value="review1"></Tabs.Tab>
<Tabs.Tab value="review2"></Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="label">
{visitedTabs.label ? <LabelWorkloadTable /> : null}
</Tabs.Panel>
<Tabs.Panel value="review1">
{visitedTabs.review1 ? <Review1WorkloadTable /> : null}
</Tabs.Panel>
<Tabs.Panel value="review2">
{visitedTabs.review2 ? <Review2WorkloadTable /> : null}
</Tabs.Panel>
</Tabs>
</SettingPage>
)
}