feat(team): add page

This commit is contained in:
2026-02-27 09:51:33 +08:00
parent 5325ef7b95
commit 6cb8f98004
29 changed files with 4557 additions and 11 deletions

View 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>
)
}