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,26 @@
"use client"
import { Center, Paper, Stack, Text } from "@mantine/core"
export default function TeamBoardPage() {
return (
<Center w="100%" h="calc(100vh - 56px)" p="md">
<Paper
withBorder
p="xl"
radius="md"
style={{
borderColor:
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
minWidth: 320,
}}>
<Stack gap={6}>
<Text fw={700}></Text>
<Text size="sm" c="dimmed">
</Text>
</Stack>
</Paper>
</Center>
)
}

View File

@@ -0,0 +1,197 @@
"use client"
import {
downloadSettlementForm,
exportSettlementForm,
getDownloadLog,
} from "@/components/label/api/workload"
import useAuth from "@/components/label/hooks/useAuth"
import {
ActionIcon,
Button,
Group,
Paper,
Stack,
Text,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import { IconDownload, IconFileExport, IconRefresh } from "@tabler/icons-react"
import dayjs from "dayjs"
import { DataTable, DataTableColumn } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
function triggerDownload(blob: Blob, fileName: string) {
const url = window.URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
link.click()
window.URL.revokeObjectURL(url)
}
type LogRecord = {
id: number
create_at: string
file_name: string
create_user: string
}
export default function TeamCostPage() {
const { isShow } = useAuth()
const [records, setRecords] = useState<LogRecord[]>([])
const [loading, setLoading] = useState(false)
const [exporting, setExporting] = useState(false)
const load = useCallback(async () => {
try {
setLoading(true)
const res = await getDownloadLog()
const list = (res ?? []) as LogRecord[]
setRecords(list.sort((a, b) => (b.id ?? 0) - (a.id ?? 0)))
} catch (e) {
setRecords([])
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
const handleExport = async () => {
try {
setExporting(true)
const time = dayjs().subtract(1, "month")
const year = time.year()
const month = time.month() + 1
const blob = await exportSettlementForm({ year, month })
const fileName = `结算表格_${year}_${String(month).padStart(2, "0")}.zip`
triggerDownload(blob, fileName)
notifications.show({
color: "green",
title: "导出成功",
message: "已开始下载结算表格",
})
load()
} catch (e) {
notifications.show({
color: "red",
title: "导出失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setExporting(false)
}
}
const columns = useMemo(() => {
const cols: DataTableColumn<LogRecord>[] = [
{ accessor: "id", title: "序号", width: 80 },
{
accessor: "create_at",
title: "导出时间",
width: 200,
render: (record) =>
record.create_at ? dayjs(record.create_at).format("YYYY-MM-DD HH:mm:ss") : "-",
},
{ accessor: "file_name", title: "导出文件名", width: 280 },
{ accessor: "create_user", title: "操作人", width: 160 },
{
accessor: "operation",
title: "操作",
width: 90,
textAlign: "center",
render: (record) => (
<ActionIcon
variant="subtle"
color="blue"
disabled={!isShow("download")}
onClick={async () => {
try {
const blob = await downloadSettlementForm({ id: record.id })
const fileName = record.file_name || `结算表格_${record.id}.zip`
triggerDownload(blob, fileName)
notifications.show({
color: "green",
title: "下载成功",
message: "已开始下载文件",
})
} catch (e) {
notifications.show({
color: "red",
title: "下载失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
}}>
<IconDownload size={16} />
</ActionIcon>
),
},
]
return cols
}, [isShow])
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))",
}}>
<Group justify="space-between" wrap="wrap">
<Group>
<Button
size="xs"
radius="xs"
leftSection={<IconFileExport size={16} />}
onClick={handleExport}
loading={exporting}
disabled={!isShow("export")}>
</Button>
<ActionIcon onClick={load} variant="default" loading={loading}>
<IconRefresh size={16} />
</ActionIcon>
</Group>
<Text size="sm" c="dimmed">
</Text>
</Group>
</Paper>
<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",
}}>
<DataTable<LogRecord>
withTableBorder
withRowBorders
fetching={loading}
records={records}
columns={columns}
noRecordsText="暂无数据"
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%" }}
/>
</Paper>
</Stack>
)
}

View File

@@ -1,5 +1,730 @@
"use client"
export default function DailypaperPage() {
return <></>
import {
deleteDailyWorkData,
getDailyWorkList,
lockDailyWorkData,
} from "@/components/label/api/daily"
import { Daily } from "@/components/label/api/daily/typing"
import { getSelfProjectList } from "@/components/label/api/project"
import useAuth from "@/components/label/hooks/useAuth"
import { useAllUserStore } from "@/components/label/store/auth"
import {
ActionIcon,
Badge,
Button,
Collapse,
Flex,
Group,
MultiSelect,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
import {
IconChevronDown,
IconChevronUp,
IconEdit,
IconLock,
IconLockOpen,
IconRefresh,
IconSearch,
IconTrash,
} from "@tabler/icons-react"
import dayjs from "dayjs"
import {
DataTable,
DataTableColumn,
DataTableSortStatus,
} from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
import * as XLSX from "xlsx-js-style"
import DailyReportModal from "../../person/report/components/DailyReportModal"
import {
accountTypeOpts,
actualTypeOpts,
objTypeOpts,
objTypeTextMap,
performanceOpts,
performanceTextMap,
setTypeOpts,
workTypeOpts,
} from "../../person/report/config"
import { flattenLeaderTree, toNumberArray, weekDayText } from "../utils"
function toBoolean(value?: string) {
if (value === "true") return true
if (value === "false") return false
return undefined
}
function toNumber(value?: string) {
if (!value) return undefined
const n = Number(value)
return Number.isFinite(n) ? n : undefined
}
const setTypeMap = new Map(setTypeOpts.map((i) => [i.value, i.label]))
const actualTypeMap = new Map(actualTypeOpts.map((i) => [i.value, i.label]))
const workTypeMap = new Map(workTypeOpts.map((i) => [i.value, i.label]))
const accountTypeMap = new Map(accountTypeOpts.map((i) => [i.value, i.label]))
export default function TeamDailypaperPage() {
const { isShow } = useAuth()
const treeData = useAllUserStore((s) => s.treeData)
const leaderOptions = useMemo(() => flattenLeaderTree(treeData ?? []), [treeData])
const defaultFilters = useMemo(() => {
return {
date_start: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
date_end: dayjs().format("YYYY-MM-DD"),
uid: [] as string[],
set_type: "",
actual_type: "",
leader_uid: [] as string[],
work_type: "",
accounting_type: "",
project_id: [] as string[],
performance_accounting: "",
obj_type: "",
}
}, [])
const [projectOptions, setProjectOptions] = useState<
Array<{ label: string; value: string }>
>([])
const [filters, setFilters] = useState(defaultFilters)
const [appliedFilters, setAppliedFilters] = useState(defaultFilters)
const [queryTrigger, setQueryTrigger] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [totalItems, setTotalItems] = useState(0)
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState<Daily.DailyWorkList[]>([])
const [summary, setSummary] = useState<Daily.Summary | null>(null)
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: "id",
direction: "desc",
})
const [modalOpened, setModalOpened] = useState(false)
const [editingRecord, setEditingRecord] = useState<Daily.DailyWorkList | null>(null)
const [searchExpanded, setSearchExpanded] = useState(false)
const queryParams = useMemo(() => {
const params: Daily.Request = {
page_number: page,
page_size: pageSize,
flag: 1,
date_start: appliedFilters.date_start || undefined,
date_end: appliedFilters.date_end || undefined,
uid: toNumberArray(appliedFilters.uid),
set_type: appliedFilters.set_type || undefined,
actual_type: appliedFilters.actual_type || undefined,
leader_uid: toNumberArray(appliedFilters.leader_uid),
work_type: appliedFilters.work_type || undefined,
accounting_type: appliedFilters.accounting_type || undefined,
project_id: toNumberArray(appliedFilters.project_id),
performance_accounting: toBoolean(appliedFilters.performance_accounting),
obj_type: toNumber(appliedFilters.obj_type),
order_by: `${sortStatus.columnAccessor} ${sortStatus.direction}`,
}
Object.keys(params).forEach((k) => {
const key = k as keyof Daily.Request
const value = params[key]
if (value === "" || value === null) delete params[key]
if (Array.isArray(value) && value.length === 0) delete params[key]
if (value !== 0 && value !== false && value !== "" && !value) {
delete params[key]
}
})
return params
}, [appliedFilters, page, pageSize, sortStatus.columnAccessor, sortStatus.direction])
const load = useCallback(async () => {
if (!queryTrigger) return
setLoading(true)
try {
const res = await getDailyWorkList(queryParams)
setRecords(res?.daily_work_list ?? [])
setTotalItems(res?.total_items ?? 0)
setSummary(res?.total_summary ?? null)
} catch (e) {
setRecords([])
setTotalItems(0)
setSummary(null)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [queryParams, queryTrigger])
useEffect(() => {
load()
}, [load])
useEffect(() => {
queueMicrotask(() => setQueryTrigger((v) => v + 1))
}, [])
useEffect(() => {
const run = async () => {
const res = await getSelfProjectList()
setProjectOptions((res ?? []).map((p) => ({ label: p.label, value: String(p.value) })))
}
queueMicrotask(run)
}, [])
const handleExportData = () => {
if (!records.length) {
notifications.show({
color: "yellow",
title: "暂无数据",
message: "当前无可导出日报数据",
})
return
}
const exportTableHeader = [
"员工姓名",
"日期",
"星期",
"规定类型",
"实际类型",
"任务组长",
"工作类别",
"核算方式",
"项目名称",
"绩效核算",
"对象类型",
"余量正负",
"开始时间",
"结束时间",
"工时",
"额定倍率",
"标准量",
"完成量",
"余量",
"余量工时",
"备注",
]
const exportData = records.map((item) => [
item.username ?? "-",
item.date ?? "-",
item.date ? weekDayText(dayjs(item.date).day()) : "-",
setTypeMap.get(item.set_type ?? "") ?? "-",
actualTypeMap.get(item.actual_type ?? "") ?? "-",
item.leader_name ?? "-",
workTypeMap.get(item.work_type ?? "") ?? "-",
accountTypeMap.get(item.accounting_type ?? "") ?? "-",
item.project_name ?? "-",
item.performance_accounting === true
? "是"
: item.performance_accounting === false
? "否"
: "-",
item.obj_type === 0 || item.obj_type === 1
? (objTypeTextMap.get(item.obj_type) ?? "-")
: "-",
item.residual_pm === true ? "正" : item.residual_pm === false ? "负" : "-",
item.start_time ?? "-",
item.end_time ?? "-",
item.work_time ?? 0,
item.rate ?? 0,
item.standard_size ?? 0,
item.completed_size ?? 0,
item.residual_size ?? 0,
item.residual_work_time ?? 0,
item.remarks ?? "-",
])
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet([exportTableHeader, ...exportData])
XLSX.utils.book_append_sheet(wb, ws, "团队日报数据")
XLSX.writeFile(wb, "团队日报数据.xlsx")
}
const columns = useMemo(() => {
const cols: DataTableColumn<Daily.DailyWorkList>[] = [
{ accessor: "id", title: "ID", width: 80, sortable: true },
{
accessor: "username",
title: "员工姓名",
width: 120,
render: (record) => record.username ?? "-",
},
{
accessor: "date",
title: "日期",
width: 120,
sortable: true,
render: (record) => record.date ?? "-",
},
{
accessor: "week",
title: "星期",
width: 90,
render: (record) => (record.date ? weekDayText(dayjs(record.date).day()) : "-"),
},
{
accessor: "set_type",
title: "规定类型",
width: 120,
render: (record) => setTypeMap.get(record.set_type ?? "") ?? "-",
},
{
accessor: "actual_type",
title: "实际类型",
width: 120,
render: (record) => actualTypeMap.get(record.actual_type ?? "") ?? "-",
},
{ accessor: "leader_name", title: "任务组长", width: 120 },
{
accessor: "work_type",
title: "工作类别",
width: 140,
render: (record) => workTypeMap.get(record.work_type ?? "") ?? "-",
},
{
accessor: "accounting_type",
title: "核算方式",
width: 140,
render: (record) => accountTypeMap.get(record.accounting_type ?? "") ?? "-",
},
{
accessor: "project_name",
title: "项目名称",
width: 220,
render: (record) => (
<Text
size="sm"
style={{
maxWidth: 220,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{record.project_name ?? "-"}
</Text>
),
},
{
accessor: "performance_accounting",
title: "绩效核算",
width: 110,
render: (record) => {
if (record.performance_accounting === true) {
return <Badge color="green">{performanceTextMap.get(true)}</Badge>
}
if (record.performance_accounting === false) {
return <Badge color="gray">{performanceTextMap.get(false)}</Badge>
}
return "-"
},
},
{
accessor: "obj_type",
title: "对象类型",
width: 100,
render: (record) => {
if (record.obj_type === 0 || record.obj_type === 1) {
return objTypeTextMap.get(record.obj_type) ?? "-"
}
return "-"
},
},
{
accessor: "residual_pm",
title: "余量正负",
width: 100,
render: (record) =>
record.residual_pm === true ? "正" : record.residual_pm === false ? "负" : "-",
},
{ accessor: "start_time", title: "开始时间", width: 120 },
{ accessor: "end_time", title: "结束时间", width: 120 },
{ accessor: "work_time", title: "工时", width: 90, sortable: true },
{ accessor: "rate", title: "额定倍率", width: 90 },
{ accessor: "standard_size", title: "标准量", width: 90, sortable: true },
{ accessor: "completed_size", title: "完成量", width: 90, sortable: true },
{ accessor: "residual_size", title: "余量", width: 90, sortable: true },
{
accessor: "residual_work_time",
title: "余量工时",
width: 110,
sortable: true,
},
{
accessor: "remarks",
title: "备注",
width: 200,
render: (record) => (
<Text
size="sm"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{record.remarks ?? "-"}
</Text>
),
},
{
accessor: "operation",
title: "操作",
width: 120,
textAlign: "center",
render: (record) => {
const locked = !!record.lock_status
return (
<Group gap={4} justify="center" wrap="nowrap">
{isShow("edit") ? (
<ActionIcon
variant="subtle"
color="blue"
disabled={locked}
onClick={() => {
if (locked) return
setEditingRecord(record)
setModalOpened(true)
}}>
<IconEdit size={16} />
</ActionIcon>
) : null}
{isShow("lock") ? (
<ActionIcon
variant="subtle"
color={locked ? "yellow" : "gray"}
onClick={async () => {
if (!record.id) return
await lockDailyWorkData(!locked, record.id)
notifications.show({
color: "green",
title: "操作成功",
message: locked ? "已解锁日报" : "已锁定日报",
})
load()
}}>
{locked ? <IconLock size={16} /> : <IconLockOpen size={16} />}
</ActionIcon>
) : null}
{isShow("delete") ? (
<ActionIcon
variant="subtle"
color="red"
disabled={locked}
onClick={() => {
if (locked || !record.id) return
modals.openConfirmModal({
title: "删除日报",
centered: true,
children: (
<Text size="sm"></Text>
),
labels: { confirm: "删除", cancel: "取消" },
confirmProps: { color: "red" },
onConfirm: async () => {
await deleteDailyWorkData(record.id as number)
notifications.show({
color: "green",
title: "操作成功",
message: "已删除日报",
})
load()
},
})
}}>
<IconTrash size={16} />
</ActionIcon>
) : null}
</Group>
)
},
},
]
return cols
}, [isShow, load])
const summaryText = useMemo(() => {
return {
total_work_time: summary?.total_work_time ?? 0,
total_standard_size: summary?.total_standard_size ?? 0,
total_completed_size: summary?.total_completed_size ?? 0,
total_residual_size: summary?.total_residual_size ?? 0,
total_residual_work_time: summary?.total_residual_work_time ?? 0,
}
}, [summary])
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))",
}}>
<Stack gap="sm">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={700}></Text>
<Button
size="xs"
variant="transparent"
rightSection={
searchExpanded ? (
<IconChevronUp size={16} />
) : (
<IconChevronDown size={16} />
)
}
onClick={() => setSearchExpanded((v) => !v)}>
{searchExpanded ? "收起" : "展开"}
</Button>
</Group>
<Group gap="sm">
<Button
variant="default"
size="xs"
radius="xs"
leftSection={<IconRefresh size={16} />}
onClick={() => {
setFilters(defaultFilters)
setAppliedFilters(defaultFilters)
setPage(1)
setQueryTrigger((v) => v + 1)
}}>
</Button>
<Button
size="xs"
radius="xs"
leftSection={<IconSearch size={16} />}
onClick={() => {
setAppliedFilters(filters)
setPage(1)
setQueryTrigger((v) => v + 1)
}}>
</Button>
</Group>
</Group>
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm">
<TextInput
label="开始日期"
size="xs"
radius="xs"
type="date"
value={filters.date_start}
onChange={(e) => setFilters((s) => ({ ...s, date_start: e.target.value }))}
/>
<TextInput
label="结束日期"
size="xs"
radius="xs"
type="date"
value={filters.date_end}
onChange={(e) => setFilters((s) => ({ ...s, date_end: e.target.value }))}
/>
<MultiSelect
label="员工姓名"
size="xs"
radius="xs"
data={leaderOptions}
searchable
clearable
value={filters.uid}
onChange={(v) => setFilters((s) => ({ ...s, uid: v }))}
/>
<MultiSelect
label="项目名称"
size="xs"
radius="xs"
data={projectOptions}
searchable
clearable
value={filters.project_id}
onChange={(v) => setFilters((s) => ({ ...s, project_id: v }))}
/>
<MultiSelect
label="任务组长"
size="xs"
radius="xs"
data={leaderOptions}
searchable
clearable
value={filters.leader_uid}
onChange={(v) => setFilters((s) => ({ ...s, leader_uid: v }))}
/>
<MultiSelect
label="规定类型"
size="xs"
radius="xs"
data={setTypeOpts}
searchable
clearable
value={filters.set_type ? [filters.set_type] : []}
onChange={(v) => setFilters((s) => ({ ...s, set_type: v[0] ?? "" }))}
/>
<MultiSelect
label="出勤类型"
size="xs"
radius="xs"
data={actualTypeOpts}
searchable
clearable
value={filters.actual_type ? [filters.actual_type] : []}
onChange={(v) => setFilters((s) => ({ ...s, actual_type: v[0] ?? "" }))}
/>
<MultiSelect
label="工作类别"
size="xs"
radius="xs"
data={workTypeOpts}
searchable
clearable
value={filters.work_type ? [filters.work_type] : []}
onChange={(v) => setFilters((s) => ({ ...s, work_type: v[0] ?? "" }))}
/>
<MultiSelect
label="核算方式"
size="xs"
radius="xs"
data={accountTypeOpts}
searchable
clearable
value={filters.accounting_type ? [filters.accounting_type] : []}
onChange={(v) =>
setFilters((s) => ({ ...s, accounting_type: v[0] ?? "" }))
}
/>
<MultiSelect
label="绩效核算"
size="xs"
radius="xs"
data={performanceOpts}
searchable
clearable
value={
filters.performance_accounting ? [filters.performance_accounting] : []
}
onChange={(v) =>
setFilters((s) => ({ ...s, performance_accounting: v[0] ?? "" }))
}
/>
<MultiSelect
label="对象类型"
size="xs"
radius="xs"
data={objTypeOpts}
searchable
clearable
value={filters.obj_type ? [filters.obj_type] : []}
onChange={(v) => setFilters((s) => ({ ...s, obj_type: v[0] ?? "" }))}
/>
</SimpleGrid>
</Collapse>
</Stack>
</Paper>
<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",
gap: 12,
}}>
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}>
<Flex justify="space-between" align="center" w="100%">
<Button
size="xs"
radius="xs"
onClick={handleExportData}>
</Button>
<ActionIcon onClick={load} variant="transparent">
<IconRefresh size={16} />
</ActionIcon>
</Flex>
<DataTable<Daily.DailyWorkList>
width="100%"
style={{ width: "100%" }}
withTableBorder
withRowBorders
pinFirstColumn
pinLastColumn
scrollAreaProps={{ type: "auto" }}
fetching={loading}
records={records}
columns={columns}
totalRecords={totalItems}
recordsPerPage={pageSize}
page={page}
onPageChange={setPage}
onRecordsPerPageChange={(v) => {
setPageSize(v)
setPage(1)
}}
recordsPerPageOptions={[10, 15, 20, 50]}
noRecordsText="暂无数据"
recordsPerPageLabel="当前页数"
sortStatus={sortStatus}
onSortStatusChange={(s) => {
setSortStatus(s)
setPage(1)
}}
/>
<Paper withBorder radius="xs" p="sm">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={700}>
</Text>
<Group gap="md">
<Text size="sm">{summaryText.total_work_time}</Text>
<Text size="sm">{summaryText.total_standard_size}</Text>
<Text size="sm">{summaryText.total_completed_size}</Text>
<Text size="sm">{summaryText.total_residual_size}</Text>
<Text size="sm">
{summaryText.total_residual_work_time}
</Text>
</Group>
</Group>
</Paper>
</Stack>
</Paper>
<DailyReportModal
opened={modalOpened}
record={editingRecord}
projectOptions={projectOptions}
onCloseAction={(refresh) => {
setModalOpened(false)
setEditingRecord(null)
if (refresh) load()
}}
/>
</Stack>
)
}

View File

@@ -0,0 +1,131 @@
"use client"
import { getUserGroupAll, userAdd } from "@/components/label/api/user"
import {
Button,
Group,
Modal,
Select,
Stack,
TextInput,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import { useEffect, useState } from "react"
interface ComponentProps {
opened: boolean
onCloseAction: (refresh?: boolean) => void
}
export default function CreateUserModal({
opened,
onCloseAction,
}: ComponentProps) {
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
[]
)
const [submitting, setSubmitting] = useState(false)
const form = useForm({
initialValues: {
user_name: "",
group_uuid: "",
base_city: "",
},
validate: {
user_name: (v) => (v.trim() ? null : "请输入用户名"),
group_uuid: (v) => (v ? null : "请选择组织"),
base_city: (v) => (v.trim() ? null : "请输入所在地"),
},
})
useEffect(() => {
if (!opened) return
const run = async () => {
const res = await getUserGroupAll()
setGroupOpts(res ?? [])
}
queueMicrotask(run)
}, [opened])
const submit = form.onSubmit(async (values) => {
try {
setSubmitting(true)
await userAdd({
user_name: values.user_name.trim(),
group_uuid: values.group_uuid,
base_city: values.base_city.trim(),
})
notifications.show({
color: "green",
title: "操作成功",
message: "已新增用户",
})
form.reset()
onCloseAction(true)
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setSubmitting(false)
}
})
return (
<Modal
opened={opened}
onClose={() => onCloseAction()}
title="新增用户"
centered
size={560}
closeOnClickOutside={false}>
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
label="用户名"
size="xs"
radius="xs"
withAsterisk
{...form.getInputProps("user_name")}
/>
<Select
label="组织"
size="xs"
radius="xs"
withAsterisk
searchable
data={groupOpts}
{...form.getInputProps("group_uuid")}
/>
<TextInput
label="所在地"
size="xs"
radius="xs"
withAsterisk
{...form.getInputProps("base_city")}
/>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => onCloseAction()}>
</Button>
<Button
type="submit"
size="xs"
radius="xs"
loading={submitting}>
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,168 @@
"use client"
import { getUserGroupAll, userEditById } from "@/components/label/api/user"
import { User } from "@/components/label/api/user/typing"
import {
Button,
Group,
Modal,
Select,
Stack,
TextInput,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import { useEffect, useMemo, useState } from "react"
interface ComponentProps {
opened: boolean
data: User.UserInfo | null
onCloseAction: (refresh?: boolean) => void
}
export default function EditUserModal({
opened,
data,
onCloseAction,
}: ComponentProps) {
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
[]
)
const [submitting, setSubmitting] = useState(false)
const groupValueByName = useMemo(() => {
const map = new Map<string, string>()
groupOpts.forEach((g) => map.set(g.label, g.value))
return map
}, [groupOpts])
const form = useForm({
initialValues: {
name: "",
group_uuid: "",
base_city: "",
work_status: "1",
},
validate: {
name: (v) => (v.trim() ? null : "请输入用户名"),
group_uuid: (v) => (v ? null : "请选择组织"),
base_city: (v) => (v.trim() ? null : "请输入所在地"),
work_status: (v) => (v ? null : "请选择状态"),
},
})
useEffect(() => {
if (!opened) return
const run = async () => {
const res = await getUserGroupAll()
setGroupOpts(res ?? [])
}
queueMicrotask(run)
}, [opened])
useEffect(() => {
if (!opened || !data) return
form.setValues({
name: data.name ?? "",
group_uuid: groupValueByName.get(data.group_name ?? "") ?? "",
base_city: data.base_city ?? "",
work_status: data.work_status === 0 ? "0" : "1",
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [opened, data, groupValueByName])
const submit = form.onSubmit(async (values) => {
if (!data?.uid) return
try {
setSubmitting(true)
await userEditById(
{
user_name: values.name.trim(),
group_uuid: values.group_uuid,
base_city: values.base_city.trim(),
work_status: Number(values.work_status),
},
data.uid
)
notifications.show({
color: "green",
title: "操作成功",
message: "已更新用户信息",
})
onCloseAction(true)
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setSubmitting(false)
}
})
return (
<Modal
opened={opened}
onClose={() => onCloseAction()}
title="编辑用户"
centered
size={560}
closeOnClickOutside={false}>
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
label="用户名"
size="xs"
radius="xs"
withAsterisk
{...form.getInputProps("name")}
/>
<Select
label="组织"
size="xs"
radius="xs"
withAsterisk
searchable
data={groupOpts}
{...form.getInputProps("group_uuid")}
/>
<TextInput
label="所在地"
size="xs"
radius="xs"
withAsterisk
{...form.getInputProps("base_city")}
/>
<Select
label="状态"
size="xs"
radius="xs"
withAsterisk
data={[
{ label: "在职", value: "1" },
{ label: "离职", value: "0" },
]}
{...form.getInputProps("work_status")}
/>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => onCloseAction()}>
</Button>
<Button
type="submit"
size="xs"
radius="xs"
loading={submitting}>
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,265 @@
"use client"
import { userImport } from "@/components/label/api/user"
import {
Alert,
Button,
FileInput,
Group,
Modal,
Paper,
Stack,
Text,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import { IconAlertCircle, IconDownload, IconUpload } from "@tabler/icons-react"
import { useState } from "react"
import * as XLSX from "xlsx-js-style"
interface ComponentProps {
opened: boolean
onCloseAction: (refresh?: boolean) => void
}
type ImportItem = {
user_name: string
group_name: string
base_city: string
}
export default function ImportModal({ opened, onCloseAction }: ComponentProps) {
const [file, setFile] = useState<File | null>(null)
const [sheetList, setSheetList] = useState<ImportItem[]>([])
const [failedDataTips, setFailedDataTips] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const clearState = () => {
setFile(null)
setSheetList([])
setFailedDataTips([])
}
const parseFile = async (nextFile: File | null) => {
setFile(nextFile)
setSheetList([])
setFailedDataTips([])
if (!nextFile) return
if (!nextFile.name.endsWith(".xlsx")) {
notifications.show({
color: "red",
title: "文件格式错误",
message: "仅支持 .xlsx 文件",
})
setFile(null)
return
}
try {
const data = await nextFile.arrayBuffer()
const workbook = XLSX.read(data, { type: "array" })
const sheetName = workbook.SheetNames[0]
const ws = workbook.Sheets[sheetName]
const arr = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, raw: false })
const tableData = arr.slice(4).filter((row) => row.length > 0)
if (tableData.length === 0) {
setSheetList([])
return
}
const checkList: Record<number, string> = {
0: "用户名",
1: "组织",
2: "所在地",
}
const tipRows = 4
const errors: string[] = []
const list: ImportItem[] = tableData.map((item: any[], index: number) => {
Object.entries(checkList).forEach(([key, fieldName]) => {
const value = item[Number(key)]
if (!value || (typeof value === "string" && !value.trim())) {
errors.push(
`${index + 1 + tipRows}行数据缺少必填项【${fieldName}】或该必填项全为空格`
)
}
})
return {
user_name: item[0] ? String(item[0]).trim() : "",
group_name: item[1] ? String(item[1]).trim() : "",
base_city: item[2] ? String(item[2]).trim() : "",
}
})
if (errors.length > 0) {
setFailedDataTips(errors)
setSheetList([])
return
}
setSheetList(list)
} catch (e) {
notifications.show({
color: "red",
title: "解析失败",
message: e instanceof Error ? e.message : "读取文件失败",
})
setFile(null)
}
}
const exportTemplateFile = () => {
const tips = [
[`提示:\n1.下列字段均为必填项\n2.【组织】仅可以填入一个组织名称`],
[""],
[""],
]
const col = [["用户名", "组织", "所在地"]]
const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet([...tips, ...col])
XLSX.utils.book_append_sheet(wb, ws, "用户信息")
wb.Sheets["用户信息"]["!rows"] = [{ hpx: 20 }, { hpx: 20 }, { hpx: 20 }]
wb.Sheets["用户信息"]["!cols"] = [{ wch: 20 }, { wch: 20 }, { wch: 20 }]
wb.Sheets["用户信息"]["!merges"] = [{ s: { c: 0, r: 0 }, e: { c: 2, r: 2 } }]
if (wb.Sheets["用户信息"]["A1"]) {
wb.Sheets["用户信息"]["A1"].s = { alignment: { wrapText: true } }
}
XLSX.writeFile(wb, "用户信息批量导入模板.xlsx")
}
const submit = async () => {
if (sheetList.length === 0) {
notifications.show({
color: "yellow",
title: "无导入数据",
message: "请先上传并校验 Excel 文件",
})
return
}
try {
setSubmitting(true)
const res = await userImport({ user_infos: sheetList })
if (Array.isArray(res) && res.length > 0) {
setFailedDataTips(res)
notifications.show({
color: "red",
title: "导入失败",
message: "请根据错误提示修正后重试",
})
return
}
notifications.show({
color: "green",
title: "导入成功",
message: "批量新增用户数据成功",
})
clearState()
onCloseAction(true)
} catch (e) {
notifications.show({
color: "red",
title: "导入失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setSubmitting(false)
}
}
return (
<Modal
opened={opened}
onClose={() => {
clearState()
onCloseAction()
}}
title="批量导入用户信息"
centered
size={760}
closeOnClickOutside={false}>
<Stack gap="sm">
<Paper
withBorder
p="sm"
radius="sm"
style={{
borderColor:
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<Group gap={8}>
<Text size="sm"></Text>
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconDownload size={14} />}
onClick={exportTemplateFile}>
</Button>
<Text size="sm"></Text>
</Group>
</Paper>
<FileInput
label="上传文件"
description="支持单个 xlsx 文件上传"
size="xs"
radius="xs"
accept=".xlsx"
placeholder="请选择文件"
leftSection={<IconUpload size={16} />}
value={file}
onChange={parseFile}
clearable
/>
<Paper
withBorder
p="xs"
radius="sm"
style={{
borderColor:
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
}}>
<Text size="sm">{sheetList.length} </Text>
</Paper>
{failedDataTips.length > 0 ? (
<Alert
icon={<IconAlertCircle size={16} />}
color="red"
radius="sm"
title="错误信息提示">
<Stack gap={4} style={{ maxHeight: 180, overflowY: "auto" }}>
{failedDataTips.map((item) => (
<Text key={item} size="sm">
{item}
</Text>
))}
</Stack>
</Alert>
) : null}
<Group justify="flex-end">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => {
clearState()
onCloseAction()
}}>
</Button>
<Button
size="xs"
radius="xs"
onClick={submit}
loading={submitting}>
</Button>
</Group>
</Stack>
</Modal>
)
}

View File

@@ -0,0 +1,288 @@
"use client"
import { getUserList, userEditById } from "@/components/label/api/user"
import { User } from "@/components/label/api/user/typing"
import useAuth from "@/components/label/hooks/useAuth"
import { useAllUserStore } from "@/components/label/store/auth"
import {
ActionIcon,
Badge,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
import { IconEdit, IconPlus, IconRefresh, IconSearch, IconUpload } from "@tabler/icons-react"
import { DataTable, DataTableColumn } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
import CreateUserModal from "./components/CreateUserModal"
import EditUserModal from "./components/EditUserModal"
import ImportModal from "./components/ImportModal"
export default function TeamEmployeePage() {
const { isShow } = useAuth()
const setFlag = useAllUserStore((s) => s.setFlag)
const [name, setName] = useState("")
const [appliedName, setAppliedName] = useState("")
const [records, setRecords] = useState<User.UserInfo[]>([])
const [loading, setLoading] = useState(false)
const [totalItems, setTotalItems] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [createOpened, setCreateOpened] = useState(false)
const [editOpened, setEditOpened] = useState(false)
const [importOpened, setImportOpened] = useState(false)
const [selectedUser, setSelectedUser] = useState<User.UserInfo | null>(null)
const load = useCallback(async () => {
try {
setLoading(true)
const params: User.ListRequest = {
page_number: page,
page_size: pageSize,
}
if (appliedName.trim()) params.name = appliedName.trim()
const res = await getUserList(params)
setRecords(res?.user_list ?? [])
setTotalItems(res?.total_items ?? 0)
} catch (e) {
setRecords([])
setTotalItems(0)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [appliedName, page, pageSize])
useEffect(() => {
queueMicrotask(load)
}, [load])
const columns = useMemo(() => {
const cols: DataTableColumn<User.UserInfo>[] = [
{ accessor: "uid", title: "UID", width: 120, textAlign: "center" },
{ accessor: "name", title: "姓名", width: 120, textAlign: "center" },
{
accessor: "work_status",
title: "状态",
width: 100,
textAlign: "center",
render: (record) =>
record.work_status === 1 ? (
<Badge color="green" variant="light">
</Badge>
) : (
<Badge color="gray" variant="light">
</Badge>
),
},
{
accessor: "group_name",
title: "组织",
width: 180,
textAlign: "center",
},
{
accessor: "base_city",
title: "所在地",
width: 180,
textAlign: "center",
},
{
accessor: "operation",
title: "操作",
width: 220,
textAlign: "center",
render: (record) => (
<Group justify="center" gap={4} wrap="nowrap">
{isShow("edit") ? (
<Button
variant="subtle"
size="compact-xs"
onClick={() => {
modals.openConfirmModal({
title: "重置密码",
centered: true,
children: (
<Text size="sm"> 123456</Text>
),
labels: { confirm: "确定", cancel: "取消" },
onConfirm: async () => {
await userEditById({ password: "123456" }, record.uid)
notifications.show({
color: "green",
title: "操作成功",
message: "已重置密码",
})
},
})
}}>
</Button>
) : null}
{isShow("edit") ? (
<ActionIcon
variant="subtle"
color="blue"
onClick={() => {
setSelectedUser(record)
setEditOpened(true)
}}>
<IconEdit size={16} />
</ActionIcon>
) : null}
</Group>
),
},
]
return cols
}, [isShow])
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))",
}}>
<Group justify="space-between" wrap="wrap">
<Group>
<TextInput
placeholder="请输入姓名"
size="xs"
radius="xs"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
<Button
size="xs"
radius="xs"
leftSection={<IconSearch size={16} />}
onClick={() => {
setAppliedName(name)
setPage(1)
}}>
</Button>
<Button
variant="default"
size="xs"
radius="xs"
leftSection={<IconRefresh size={16} />}
onClick={() => {
setName("")
setAppliedName("")
setPage(1)
setPageSize(20)
}}>
</Button>
</Group>
<Group>
{isShow("add") ? (
<Button
size="xs"
radius="xs"
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpened(true)}>
</Button>
) : null}
{isShow("add") ? (
<Button
size="xs"
radius="xs"
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => setImportOpened(true)}>
</Button>
) : null}
</Group>
</Group>
</Paper>
<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",
}}>
<DataTable<User.UserInfo>
withTableBorder
withRowBorders
fetching={loading}
records={records}
columns={columns}
noRecordsText="暂无数据"
totalRecords={totalItems}
recordsPerPage={pageSize}
page={page}
onPageChange={setPage}
onRecordsPerPageChange={(v) => {
setPageSize(v)
setPage(1)
}}
recordsPerPageOptions={[10, 20, 50, 100]}
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%" }}
/>
</Paper>
<CreateUserModal
opened={createOpened}
onCloseAction={(refresh) => {
setCreateOpened(false)
if (refresh) {
setFlag(true)
load()
}
}}
/>
<ImportModal
opened={importOpened}
onCloseAction={(refresh) => {
setImportOpened(false)
if (refresh) {
setFlag(true)
load()
}
}}
/>
<EditUserModal
opened={editOpened}
data={selectedUser}
onCloseAction={(refresh) => {
setEditOpened(false)
setSelectedUser(null)
if (refresh) {
setFlag(true)
load()
}
}}
/>
</Stack>
)
}

View File

@@ -1,5 +0,0 @@
"use client"
export default function EmployeePage() {
return <></>
}

View File

@@ -0,0 +1,140 @@
"use client"
import {
getUserAll,
getUserGroupAll,
userGroupAdd,
} from "@/components/label/api/user"
import { useAllUserStore } from "@/components/label/store/auth"
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import { useEffect, useMemo, useState } from "react"
interface ComponentProps {
opened: boolean
onCloseAction: (refresh?: boolean) => void
}
export default function CreateModal({ opened, onCloseAction }: ComponentProps) {
const storeUserOpts = useAllUserStore((s) => s.userOpts)
const [groupOpts, setGroupOpts] = useState<Array<{ label: string; value: string }>>(
[]
)
const [userOpts, setUserOpts] = useState<Array<{ label: string; value: string }>>(
[]
)
const [submitting, setSubmitting] = useState(false)
const finalUserOpts = useMemo(() => {
return userOpts.length > 0 ? userOpts : (storeUserOpts as any[])
}, [storeUserOpts, userOpts])
const form = useForm({
initialValues: {
group_name: "",
leader_uid: "",
parent_group_uuid: "",
},
validate: {
group_name: (v) => (v.trim() ? null : "请输入用户组名称"),
leader_uid: (v) => (v ? null : "请选择用户组负责人"),
},
})
useEffect(() => {
if (!opened) return
const run = async () => {
const [groupRes, userRes] = await Promise.all([
getUserGroupAll(),
getUserAll(),
])
setGroupOpts(groupRes ?? [])
setUserOpts((userRes ?? []).map((u) => ({ label: u.label, value: String(u.value) })))
}
queueMicrotask(run)
}, [opened])
const submit = form.onSubmit(async (values) => {
try {
setSubmitting(true)
await userGroupAdd({
group_name: values.group_name.trim(),
leader_uid: Number(values.leader_uid),
parent_group_uuid: values.parent_group_uuid,
})
notifications.show({
color: "green",
title: "操作成功",
message: "已新增用户组",
})
form.reset()
onCloseAction(true)
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setSubmitting(false)
}
})
return (
<Modal
opened={opened}
onClose={() => onCloseAction()}
title="新增用户组"
centered
size={560}
closeOnClickOutside={false}>
<form onSubmit={submit}>
<Stack gap="sm">
<TextInput
label="用户组名称"
size="xs"
radius="xs"
withAsterisk
{...form.getInputProps("group_name")}
/>
<Select
label="用户组负责人"
size="xs"
radius="xs"
withAsterisk
searchable
data={finalUserOpts}
{...form.getInputProps("leader_uid")}
/>
<Select
label="上级用户组"
size="xs"
radius="xs"
searchable
clearable
data={groupOpts}
{...form.getInputProps("parent_group_uuid")}
/>
<Group justify="flex-end" mt="xs">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => onCloseAction()}>
</Button>
<Button
type="submit"
size="xs"
radius="xs"
loading={submitting}>
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}

View File

@@ -1,5 +1,262 @@
"use client"
export default function OrganizationPage() {
return <></>
import { getUserGroupTree } from "@/components/label/api/user"
import { Organization } from "@/components/label/api/user/typing"
import useAuth from "@/components/label/hooks/useAuth"
import { useAllUserStore } from "@/components/label/store/auth"
import {
ActionIcon,
Button,
Group,
Paper,
ScrollArea,
Stack,
Text,
TextInput,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import { IconPlus, IconRefresh, IconSearch, IconUserCircle } from "@tabler/icons-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import CreateModal from "./components/CreateModal"
type NodeItem = {
key: string
name: string
isUser?: boolean
isManager?: boolean
children: NodeItem[]
}
function toNodeTree(data: Organization.Response[]): NodeItem[] {
return (data ?? []).map((item) => {
const children: NodeItem[] = []
if (Array.isArray(item.children) && item.children.length > 0) {
children.push(...toNodeTree(item.children))
}
if (item.leader_uid && item.leader_name) {
children.push({
key: `${item.group_uuid}/${item.leader_uid}`,
name: item.leader_name,
isUser: true,
isManager: true,
children: [],
})
}
if (Array.isArray(item.members) && item.members.length > 0) {
item.members.forEach((member) => {
if (member.id === item.leader_uid) return
children.push({
key: `${item.group_uuid}/${member.id}`,
name: member.name,
isUser: true,
children: [],
})
})
}
return {
key: item.group_uuid,
name: item.name,
children,
}
})
}
function filterTree(nodes: NodeItem[], keyword: string): NodeItem[] {
if (!keyword.trim()) return nodes
const lower = keyword.trim().toLowerCase()
return nodes
.map((node) => {
const matched = node.name.toLowerCase().includes(lower)
const children = filterTree(node.children, keyword)
if (!matched && children.length === 0) return null
return { ...node, children }
})
.filter((n): n is NodeItem => !!n)
}
type FlatNode = NodeItem & { depth: number }
function flattenTree(nodes: NodeItem[], depth = 0): FlatNode[] {
const list: FlatNode[] = []
nodes.forEach((node) => {
list.push({ ...node, depth })
if (node.children.length > 0) {
list.push(...flattenTree(node.children, depth + 1))
}
})
return list
}
function HighlightText(props: { text: string; keyword: string }) {
const { text, keyword } = props
if (!keyword.trim()) return <>{text}</>
const index = text.toLowerCase().indexOf(keyword.trim().toLowerCase())
if (index < 0) return <>{text}</>
const before = text.slice(0, index)
const match = text.slice(index, index + keyword.length)
const after = text.slice(index + keyword.length)
return (
<>
{before}
<Text component="span" c="blue" fw={700}>
{match}
</Text>
{after}
</>
)
}
export default function TeamOrganizationPage() {
const { isShow } = useAuth()
const setFlag = useAllUserStore((s) => s.setFlag)
const [open, setOpen] = useState(false)
const [groupName, setGroupName] = useState("")
const [data, setData] = useState<NodeItem[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
try {
setLoading(true)
const res = await getUserGroupTree()
setData(toNodeTree(res ?? []))
} catch (e) {
setData([])
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
queueMicrotask(load)
}, [load])
const rows = useMemo(() => {
const filtered = filterTree(data, groupName)
return flattenTree(filtered)
}, [data, groupName])
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))",
}}>
<Group justify="space-between" wrap="wrap">
<Group>
<TextInput
placeholder="请输入用户组名称或用户名称进行搜索"
size="xs"
radius="xs"
leftSection={<IconSearch size={14} />}
value={groupName}
onChange={(e) => setGroupName(e.currentTarget.value)}
style={{ minWidth: 360 }}
/>
<ActionIcon
variant="default"
size="md"
onClick={load}
loading={loading}>
<IconRefresh size={16} />
</ActionIcon>
</Group>
{isShow("group_add") ? (
<Button
size="xs"
radius="xs"
leftSection={<IconPlus size={16} />}
onClick={() => setOpen(true)}>
</Button>
) : null}
</Group>
</Paper>
<Paper
withBorder
p="md"
radius="md"
style={{
borderColor:
"light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))",
flex: 1,
minHeight: 0,
}}>
<ScrollArea h="100%" type="auto">
<Stack gap={6}>
{rows.map((node) => {
return (
<Paper
key={node.key}
withBorder
radius="sm"
p="xs"
style={{
marginLeft: node.depth * 18,
borderColor:
"light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))",
backgroundColor: node.isUser
? "var(--mantine-color-gray-0)"
: "var(--mantine-color-blue-0)",
}}>
<Group gap={8}>
{node.isUser ? <IconUserCircle size={16} /> : null}
<Text size="sm">
<HighlightText text={node.name} keyword={groupName} />
{!node.isUser && node.children.length > 0
? `${node.children.length}`
: ""}
</Text>
{node.isManager ? (
<Text
size="xs"
c="blue"
style={{
backgroundColor: "var(--mantine-color-blue-0)",
padding: "2px 6px",
borderRadius: 4,
}}>
</Text>
) : null}
</Group>
</Paper>
)
})}
{rows.length === 0 ? (
<Text c="dimmed" size="sm">
</Text>
) : null}
</Stack>
</ScrollArea>
</Paper>
<CreateModal
opened={open}
onCloseAction={(refresh) => {
setOpen(false)
if (refresh) {
setFlag(true)
load()
}
}}
/>
</Stack>
)
}

View File

@@ -0,0 +1,14 @@
"use client"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function TeamPage() {
const router = useRouter()
useEffect(() => {
router.push("/management/team/employee")
}, [router])
return <></>
}

View File

@@ -0,0 +1,279 @@
"use client"
import {
getDataSyncServerList,
updateSyncServerLocation,
} from "@/components/label/api/sync"
import { Response } from "@/components/label/api/sync/typing"
import useAuth from "@/components/label/hooks/useAuth"
import {
ActionIcon,
Badge,
Button,
Group,
Modal,
Paper,
Select,
Stack,
TextInput,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import { IconEdit, IconRefresh, IconSearch } from "@tabler/icons-react"
import { DataTable, DataTableColumn } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
export default function TeamSyncServerPage() {
const { isShow } = useAuth()
const [records, setRecords] = useState<Response.SyncServer[]>([])
const [loading, setLoading] = useState(false)
const [totalItems, setTotalItems] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [location, setLocation] = useState("")
const [appliedLocation, setAppliedLocation] = useState("")
const [online, setOnline] = useState<string | null>(null)
const [appliedOnline, setAppliedOnline] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<Response.SyncServer | null>(null)
const [editingLocation, setEditingLocation] = useState("")
const [submitting, setSubmitting] = useState(false)
const load = useCallback(async () => {
try {
setLoading(true)
const params: any = {
page_number: page,
page_size: pageSize,
}
if (appliedLocation.trim()) params.location = appliedLocation.trim()
if (appliedOnline === "true") params.is_online = true
if (appliedOnline === "false") params.is_online = false
const res = await getDataSyncServerList(params)
setRecords(res?.sync_servers ?? [])
setTotalItems(res?.total_items ?? 0)
} catch (e) {
setRecords([])
setTotalItems(0)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [appliedLocation, appliedOnline, page, pageSize])
useEffect(() => {
load()
}, [load])
const columns = useMemo(() => {
const cols: DataTableColumn<Response.SyncServer>[] = [
{ accessor: "id", title: "ID", width: 80 },
{ accessor: "ip", title: "IP", width: 180 },
{ accessor: "location", title: "IP所在地", width: 180 },
{
accessor: "is_online",
title: "状态",
width: 100,
render: (record) =>
record.is_online ? (
<Badge color="green" variant="light">
线
</Badge>
) : (
<Badge color="red" variant="light">
线
</Badge>
),
},
{ accessor: "create_at", title: "创建时间", width: 180 },
{ accessor: "update_at", title: "更新时间", width: 180 },
{
accessor: "operation",
title: "操作",
width: 100,
textAlign: "center",
render: (record) =>
isShow("server_edit") ? (
<ActionIcon
variant="subtle"
color="blue"
onClick={() => {
setEditingRecord(record)
setEditingLocation(record.location ?? "")
setOpen(true)
}}>
<IconEdit size={16} />
</ActionIcon>
) : null,
},
]
return cols
}, [isShow])
const saveLocation = async () => {
if (!editingRecord?.id) return
try {
setSubmitting(true)
await updateSyncServerLocation(editingLocation.trim(), editingRecord.id)
notifications.show({
color: "green",
title: "操作成功",
message: "已更新同步服务所在地",
})
setOpen(false)
setEditingRecord(null)
setEditingLocation("")
load()
} catch (e) {
notifications.show({
color: "red",
title: "更新失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setSubmitting(false)
}
}
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))",
}}>
<Group wrap="wrap">
<TextInput
placeholder="请输入IP所在地"
size="xs"
radius="xs"
value={location}
onChange={(e) => setLocation(e.currentTarget.value)}
/>
<Select
placeholder="状态"
size="xs"
radius="xs"
data={[
{ label: "在线", value: "true" },
{ label: "离线", value: "false" },
]}
clearable
value={online}
onChange={setOnline}
/>
<Button
size="xs"
radius="xs"
leftSection={<IconSearch size={16} />}
onClick={() => {
setAppliedLocation(location)
setAppliedOnline(online)
setPage(1)
}}>
</Button>
<Button
variant="default"
size="xs"
radius="xs"
leftSection={<IconRefresh size={16} />}
onClick={() => {
setLocation("")
setOnline(null)
setAppliedLocation("")
setAppliedOnline(null)
setPage(1)
setPageSize(20)
}}>
</Button>
</Group>
</Paper>
<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",
}}>
<DataTable<Response.SyncServer>
withTableBorder
withRowBorders
fetching={loading}
records={records}
columns={columns}
totalRecords={totalItems}
recordsPerPage={pageSize}
page={page}
onPageChange={setPage}
onRecordsPerPageChange={(v) => {
setPageSize(v)
setPage(1)
}}
recordsPerPageOptions={[10, 20, 50, 100]}
noRecordsText="暂无数据"
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%" }}
/>
</Paper>
<Modal
opened={open}
onClose={() => {
setOpen(false)
setEditingRecord(null)
setEditingLocation("")
}}
title="更新同步服务所在地"
centered
size={520}
closeOnClickOutside={false}>
<Stack gap="sm">
<TextInput
label="所在地"
size="xs"
radius="xs"
value={editingLocation}
onChange={(e) => setEditingLocation(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => {
setOpen(false)
setEditingRecord(null)
setEditingLocation("")
}}>
</Button>
<Button
size="xs"
radius="xs"
onClick={saveLocation}
loading={submitting}>
</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}

View File

@@ -0,0 +1,455 @@
"use client"
import { getProjectList } from "@/components/label/api/project"
import {
getAllSyncServerList,
getDataSyncTaskList,
newSyncTask,
updateSyncTaskStatus,
} from "@/components/label/api/sync"
import { Response } from "@/components/label/api/sync/typing"
import useAuth from "@/components/label/hooks/useAuth"
import {
ActionIcon,
Button,
Group,
Modal,
Paper,
Select,
Stack,
Text,
} from "@mantine/core"
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
import { IconPlayerPause, IconPlayerPlay, IconPlus, IconRefresh, IconSearch, IconTrash, IconX } from "@tabler/icons-react"
import { DataTable, DataTableColumn } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
const StatusEnum = new Map<number, string>([
[1, "待同步"],
[2, "同步中"],
[3, "同步失败"],
[4, "同步成功"],
[5, "待删除"],
[6, "删除中"],
[7, "删除失败"],
[8, "删除完成"],
[9, "等待暂停"],
[10, "已暂停"],
[11, "等待关闭"],
[12, "已关闭"],
])
export default function TeamSyncTaskPage() {
const { isShow } = useAuth()
const [records, setRecords] = useState<Response.SyncTask[]>([])
const [loading, setLoading] = useState(false)
const [totalItems, setTotalItems] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [status, setStatus] = useState<string | null>(null)
const [projectId, setProjectId] = useState<string | null>(null)
const [serverId, setServerId] = useState<string | null>(null)
const [appliedStatus, setAppliedStatus] = useState<string | null>(null)
const [appliedProjectId, setAppliedProjectId] = useState<string | null>(null)
const [appliedServerId, setAppliedServerId] = useState<string | null>(null)
const [projectOpt, setProjectOpt] = useState<Array<{ label: string; value: string }>>([])
const [serverAllOpt, setServerAllOpt] = useState<Array<{ label: string; value: string }>>([])
const [serverOnlineOpt, setServerOnlineOpt] = useState<Array<{ label: string; value: string }>>(
[]
)
const [serverMap, setServerMap] = useState<Map<number, string>>(new Map())
const [open, setOpen] = useState(false)
const [newProjectId, setNewProjectId] = useState<string | null>(null)
const [newServerId, setNewServerId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const loadOptions = useCallback(async () => {
const [projectRes, serverRes] = await Promise.all([
getProjectList({ page_number: 1, page_size: 10000 }),
getAllSyncServerList(),
])
const projects = projectRes?.project_list ?? []
setProjectOpt(projects.map((p) => ({ label: p.name, value: String(p.id) })))
const allServers = serverRes ?? []
setServerAllOpt(allServers.map((s) => ({ label: s.ip, value: String(s.id) })))
setServerOnlineOpt(
allServers
.filter((s) => s.is_online)
.map((s) => ({ label: s.ip, value: String(s.id) }))
)
setServerMap(new Map(allServers.map((s) => [s.id, s.ip])))
}, [])
const load = useCallback(async () => {
try {
setLoading(true)
const params: any = {
page_number: page,
page_size: pageSize,
}
if (appliedStatus) params.status = Number(appliedStatus)
if (appliedProjectId) params.project_id = Number(appliedProjectId)
if (appliedServerId) params.sync_server_id = Number(appliedServerId)
const res = await getDataSyncTaskList(params)
setRecords(res?.sync_tasks ?? [])
setTotalItems(res?.total_items ?? 0)
} catch (e) {
setRecords([])
setTotalItems(0)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [appliedProjectId, appliedServerId, appliedStatus, page, pageSize])
useEffect(() => {
queueMicrotask(loadOptions)
}, [loadOptions])
useEffect(() => {
load()
}, [load])
const performStatusAction = async (id: number, statusTo: number, successText: string) => {
try {
await updateSyncTaskStatus(id, statusTo)
notifications.show({
color: "green",
title: "操作成功",
message: successText,
})
load()
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
}
const columns = useMemo(() => {
const cols: DataTableColumn<Response.SyncTask>[] = [
{ accessor: "id", title: "ID", width: 80 },
{ accessor: "uuid", title: "UUID", width: 320 },
{
accessor: "status",
title: "状态",
width: 120,
render: (record) => StatusEnum.get(record.status ?? 0) ?? "-",
},
{ accessor: "project_name", title: "项目名称", width: 260 },
{
accessor: "sync_server_id",
title: "同步服务IP",
width: 160,
render: (record) =>
record.sync_server_id ? serverMap.get(record.sync_server_id) ?? "-" : "-",
},
{ accessor: "location", title: "IP所在地", width: 140 },
{ accessor: "create_at", title: "创建时间", width: 180 },
{ accessor: "update_at", title: "更新时间", width: 180 },
{
accessor: "operation",
title: "操作",
width: 200,
textAlign: "center",
render: (record) => {
const couldRun = isShow("task_edit") && [10].includes(record.status ?? -1)
const couldStop = isShow("task_edit") && [1, 2].includes(record.status ?? -1)
const couldDelete =
isShow("task_edit") && [1, 2, 3, 4, 9, 10].includes(record.status ?? -1)
const couldClose =
isShow("task_edit") && ![11, 12].includes(record.status ?? -1)
return (
<Group justify="center" gap={4} wrap="nowrap">
{couldRun ? (
<ActionIcon
variant="subtle"
color="green"
onClick={() =>
performStatusAction(record.id as number, 1, "已恢复同步")
}>
<IconPlayerPlay size={16} />
</ActionIcon>
) : null}
{couldStop ? (
<ActionIcon
variant="subtle"
color="yellow"
onClick={() => {
modals.openConfirmModal({
title: "暂停任务",
centered: true,
children: (
<Text size="sm"></Text>
),
labels: { confirm: "确定", cancel: "取消" },
onConfirm: () =>
performStatusAction(record.id as number, 9, "已暂停任务"),
})
}}>
<IconPlayerPause size={16} />
</ActionIcon>
) : null}
{couldDelete ? (
<ActionIcon
variant="subtle"
color="red"
onClick={() => {
modals.openConfirmModal({
title: "删除任务",
centered: true,
children: (
<Text size="sm"></Text>
),
labels: { confirm: "确定", cancel: "取消" },
confirmProps: { color: "red" },
onConfirm: () =>
performStatusAction(record.id as number, 5, "已提交删除"),
})
}}>
<IconTrash size={16} />
</ActionIcon>
) : null}
{couldClose ? (
<ActionIcon
variant="subtle"
color="gray"
onClick={() => {
modals.openConfirmModal({
title: "关闭任务",
centered: true,
children: (
<Text size="sm"></Text>
),
labels: { confirm: "确定", cancel: "取消" },
onConfirm: () =>
performStatusAction(record.id as number, 11, "已关闭任务"),
})
}}>
<IconX size={16} />
</ActionIcon>
) : null}
</Group>
)
},
},
]
return cols
}, [isShow, serverMap])
const createTask = async () => {
if (!newProjectId || !newServerId) {
notifications.show({
color: "yellow",
title: "参数不完整",
message: "请先选择项目和同步服务",
})
return
}
try {
setCreating(true)
await newSyncTask({
project_id: Number(newProjectId),
sync_server_id: Number(newServerId),
})
notifications.show({
color: "green",
title: "操作成功",
message: "已新增同步任务",
})
setOpen(false)
setNewProjectId(null)
setNewServerId(null)
load()
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setCreating(false)
}
}
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))",
}}>
<Group justify="space-between" wrap="wrap">
<Group>
<Select
placeholder="任务状态"
size="xs"
radius="xs"
data={Array.from(StatusEnum.entries()).map(([value, label]) => ({
label,
value: String(value),
}))}
clearable
searchable
value={status}
onChange={setStatus}
/>
<Select
placeholder="项目名称"
size="xs"
radius="xs"
data={projectOpt}
clearable
searchable
value={projectId}
onChange={setProjectId}
/>
<Select
placeholder="同步服务IP"
size="xs"
radius="xs"
data={serverAllOpt}
clearable
searchable
value={serverId}
onChange={setServerId}
/>
<Button
size="xs"
radius="xs"
leftSection={<IconSearch size={16} />}
onClick={() => {
setAppliedStatus(status)
setAppliedProjectId(projectId)
setAppliedServerId(serverId)
setPage(1)
}}>
</Button>
<Button
variant="default"
size="xs"
radius="xs"
leftSection={<IconRefresh size={16} />}
onClick={() => {
setStatus(null)
setProjectId(null)
setServerId(null)
setAppliedStatus(null)
setAppliedProjectId(null)
setAppliedServerId(null)
setPage(1)
setPageSize(20)
}}>
</Button>
</Group>
{isShow("task_add") ? (
<Button
size="xs"
radius="xs"
leftSection={<IconPlus size={16} />}
onClick={() => {
setNewProjectId(null)
setNewServerId(null)
setOpen(true)
}}>
</Button>
) : null}
</Group>
</Paper>
<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",
}}>
<DataTable<Response.SyncTask>
withTableBorder
withRowBorders
fetching={loading}
records={records}
columns={columns}
totalRecords={totalItems}
recordsPerPage={pageSize}
page={page}
onPageChange={setPage}
onRecordsPerPageChange={(v) => {
setPageSize(v)
setPage(1)
}}
recordsPerPageOptions={[10, 20, 50, 100]}
noRecordsText="暂无数据"
scrollAreaProps={{ type: "auto" }}
style={{ width: "100%" }}
/>
</Paper>
<Modal
opened={open}
onClose={() => setOpen(false)}
title="新增同步任务"
centered
size={520}
closeOnClickOutside={false}>
<Stack gap="sm">
<Select
label="项目名称"
size="xs"
radius="xs"
data={projectOpt}
searchable
value={newProjectId}
onChange={setNewProjectId}
/>
<Select
label="同步服务IP仅在线"
size="xs"
radius="xs"
data={serverOnlineOpt}
searchable
value={newServerId}
onChange={setNewServerId}
/>
<Group justify="flex-end">
<Button variant="default" size="xs" radius="xs" onClick={() => setOpen(false)}>
</Button>
<Button
size="xs"
radius="xs"
loading={creating}
onClick={createTask}>
</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}

View File

@@ -0,0 +1,47 @@
export type LeaderTreeNode = {
title?: string
value?: string | number
children?: LeaderTreeNode[]
}
export function flattenLeaderTree(
nodes: LeaderTreeNode[],
prefixTitle: string[] = []
): Array<{ label: string; value: string }> {
const result: Array<{ label: string; value: string }> = []
nodes.forEach((node) => {
const title = node.title ?? ""
const nextPrefix = title ? [...prefixTitle, title] : prefixTitle
const children = node.children ?? []
const valueRaw = node.value
if (children.length > 0) {
result.push(...flattenLeaderTree(children, nextPrefix))
return
}
if (valueRaw === undefined || valueRaw === null) return
if (typeof valueRaw === "string" && valueRaw.includes("-")) return
result.push({
label: nextPrefix.join(" / "),
value: String(valueRaw),
})
})
return result
}
export function toNumberArray(values?: string[]) {
if (!values || values.length === 0) return undefined
const numbers = values
.map((v) => Number(v))
.filter((v) => Number.isFinite(v))
return numbers.length > 0 ? numbers : undefined
}
export function weekDayText(day: number) {
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
return list[day] ?? "-"
}

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

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: "提交审核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: "审核问题数" },
]}
/>
)
}

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: "提交审核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: "审核问题数" },
]}
/>
)
}

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

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

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

View File

@@ -0,0 +1,65 @@
import httpFetch from "@/api/fetch"
import { BASE_LABEL_API } from "../const"
import { Params, Response } from "./typing"
// 获取同步服务列表
export const getDataSyncServerList = (params: Params.ServerRequest) => {
return httpFetch<Response.SyncServerRes>({
url: BASE_LABEL_API + "/api/v1/label_server/data_sync/list",
method: "GET",
data: params,
})
}
// 更新同步服务所在地
export const updateSyncServerLocation = (location: string, id: number) => {
return httpFetch({
url: BASE_LABEL_API + `/api/v1/label_server/data_sync/update/${id}`,
method: "PUT",
body: JSON.stringify({ location }),
})
}
// 获取所有同步服务
export const getAllSyncServerList = () => {
return httpFetch<
{
id: number
ip: string
is_online: 0 | 1
}[]
>({
url: BASE_LABEL_API + "/api/v1/label_server/data_sync/all",
method: "GET",
})
}
// 获取同步任务列表
export const getDataSyncTaskList = (params: Params.TaskRequest) => {
return httpFetch<Response.SyncTaskRes>({
url: BASE_LABEL_API + "/api/v1/label_server/data_sync/sync_task_list",
method: "GET",
data: params,
})
}
// 新增同步任务
export const newSyncTask = (data: {
project_id: number
sync_server_id: number
}) => {
return httpFetch<any>({
url: BASE_LABEL_API + "/api/v1/label_server/data_sync/add_sync_task",
method: "POST",
body: JSON.stringify(data),
})
}
// 更新同步任务状态
export const updateSyncTaskStatus = (id: number, status: number) => {
return httpFetch({
url: BASE_LABEL_API + `/api/v1/label_server/data_sync/update_sync_task/${id}`,
method: "PUT",
body: JSON.stringify({ status }),
})
}

View File

@@ -0,0 +1,64 @@
export interface TimeFilter {
start_date: string
end_date: string
}
export namespace Params {
export interface ServerRequest {
is_online?: boolean
location?: string
page_number: number
page_size: number
[property: string]: any
}
export interface TaskRequest {
location?: string
page_number: number
page_size: number
project_id?: number
status?: number
sync_server_id?: number
[property: string]: any
}
}
export namespace Response {
export interface SyncServerRes {
sync_servers: SyncServer[]
total_items: number
total_pages: number
[property: string]: any
}
export interface SyncServer {
create_at?: string
id?: number
ip?: string
is_online?: boolean
location?: string
update_at?: string
[property: string]: any
}
export interface SyncTaskRes {
sync_tasks: SyncTask[]
total_items: number
total_pages: number
[property: string]: any
}
export interface SyncTask {
create_at?: string
id?: number
ip?: string
location?: string
project_id?: number
project_name?: string
status?: number
sync_server_id?: number
update_at?: string
uuid?: string
[property: string]: any
}
}

View File

@@ -1,15 +1,72 @@
import httpFetch from "@/api/fetch"
import { BASE_LABEL_API } from "../const"
import { Organization } from "./typing"
import { Organization, User } from "./typing"
// 获取用户列表
export const getUserList = (params: User.ListRequest) => {
return httpFetch<User.ListResponse>({
url: BASE_LABEL_API + "/api/v1/label_server/user/list",
method: "GET",
data: params,
})
}
// 新增用户
export const userAdd = (data: User.CreateProps) => {
return httpFetch({
url: BASE_LABEL_API + "/api/v1/label_server/user/add",
method: "POST",
body: JSON.stringify(data),
})
}
// 批量导入用户
export const userImport = (data: { user_infos: any[] }) => {
return httpFetch<string[]>({
url: BASE_LABEL_API + "/api/v1/label_server/user/batch_add",
method: "POST",
body: JSON.stringify(data),
})
}
// 编辑用户
export const userEditById = (data: User.EditProps, id: number) => {
return httpFetch({
url: BASE_LABEL_API + `/api/v1/label_server/user/edit/${id}`,
method: "PUT",
body: JSON.stringify(data),
})
}
// 获取用户组织树
export const getUserGroupTree = () => {
return httpFetch<Organization.Response[]>({
url: "/api/v1/label_server/user/group/tree",
url: BASE_LABEL_API + "/api/v1/label_server/user/group/tree",
method: "GET",
})
}
// 新增用户组
export const userGroupAdd = (data: Organization.CreateProps) => {
return httpFetch({
url: BASE_LABEL_API + "/api/v1/label_server/user/group/add",
method: "POST",
body: JSON.stringify(data),
})
}
// 修改用户密码
export const modifyUserPassword = (data: {
old_password: string
new_password: string
}) => {
return httpFetch({
url: BASE_LABEL_API + "/api/v1/label_server/user/modify_password",
method: "PUT",
body: JSON.stringify(data),
})
}
/* ****************************** Options ****************************** */
interface Option {
label: string

View File

@@ -1,4 +1,53 @@
export namespace User {
export interface UserInfo {
uid: number
name: string
all_projects: { [x: number]: number[] }
collect_projects: number[]
group_name: string
work_status: number
base_city: string
}
export interface LoginInfo extends UserInfo {
token: string
refresh_token: string
}
export interface ListRequest {
name?: string
page_number?: number
page_size?: number
}
export interface ListResponse {
user_list: UserInfo[]
total_pages: number
total_items: number
}
export interface CreateProps {
user_name: string
group_uuid: string
base_city: string
}
export interface EditProps {
user_name?: string
password?: string
group_uuid?: string
base_city?: string
work_status?: number
}
}
export namespace Organization {
export interface CreateProps {
group_name: string
leader_uid: number
parent_group_uuid: string
}
interface Member {
base_city: string
id: number

View File

@@ -0,0 +1,65 @@
import httpFetch from "@/api/fetch"
import { BASE_LABEL_API } from "../const"
import { TimeFilter, WorkLoad } from "./typing"
// 标注工作量统计
export const getLabelStatisticsList = (params: TimeFilter) => {
return httpFetch<WorkLoad.LabelResponse>({
url: BASE_LABEL_API + "/api/v1/label_server/workload/label_statistics/list",
method: "GET",
data: params,
})
}
// 审核1工作量统计
export const getReview1StatisticsList = (params: TimeFilter) => {
return httpFetch<WorkLoad.Review1Response>({
url:
BASE_LABEL_API + "/api/v1/label_server/workload/review1_statistics/list",
method: "GET",
data: params,
})
}
// 审核2工作量统计
export const getReview2StatisticsList = (params: TimeFilter) => {
return httpFetch<WorkLoad.Review2Response>({
url:
BASE_LABEL_API + "/api/v1/label_server/workload/review2_statistics/list",
method: "GET",
data: params,
})
}
// 结算表格列表
export const getDownloadLog = () => {
return httpFetch<any[]>({
url: BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/list",
method: "GET",
})
}
// 导出结算表格
export const exportSettlementForm = (params: {
year: number
month: number
}) => {
return httpFetch<Blob>({
url:
BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/export",
method: "GET",
data: params,
isDownload: true,
})
}
// 下载结算表格
export const downloadSettlementForm = (params: { id: number }) => {
return httpFetch<Blob>({
url:
BASE_LABEL_API + "/api/v1/label_server/workload/settlement_form/download",
method: "GET",
data: params,
isDownload: true,
})
}

View File

@@ -0,0 +1,107 @@
export interface TimeFilter {
start_date: string
end_date: string
}
export namespace WorkLoad {
export interface LabelData {
commented_question_size: number
commented_size: number
commit_label_1: number
commit_reviewed_1: number
confirmed_prelabel_size: number
dynamic_size: number
group_name?: string
key_frame_size: number
object_size: number
prelabel_object_size: number
project_id: number
project_name: string
project_type: string
question_size: number
rejected_label_1: number
reviewed_question_size: number
reviewed_size: number
static_size: number
total_reviewed_size: number
uid: number
user_name: string
work_time: number
[property: string]: any
}
export interface LabelResponse {
all: LabelData[]
order_by_group_name: any[]
order_by_project_name: any[]
order_by_project_type: any[]
order_by_user_name: any[]
}
export interface Review1Data {
comment_size: number
commented_size: number
commit_review_1: number
commit_reviewed_2: number
confirmed_prelabel_size: number
group_name?: string
object_size: number
prelabel_object_size: number
project_id: number
project_name: string
project_type: string
reject_review_1: number
rejected_review_1: number
review_dynamic_size: number
review_key_frame_size: number
review_question_size: number
review_size: number
review_static_size: number
reviewed_size: number
total_review_size: number
total_reviewed_size: number
uid: number
user_name: string
work_time: number
[property: string]: any
}
export interface Review1Response {
all: Review1Data[]
order_by_group_name: any[]
order_by_project_name: any[]
order_by_project_type: any[]
order_by_user_name: any[]
}
export interface Review2Data {
comment_size: number
commit_review_2: number
confirmed_prelabel_size: number
group_name?: string
object_size: number
prelabel_object_size: number
project_id: number
project_name: string
project_type: string
reject_review_2: number
review_dynamic_size: number
review_key_frame_size: number
review_question_size: number
review_size: number
review_static_size: number
total_review_size: number
uid: number
user_name: string
work_time: number
[property: string]: any
}
export interface Review2Response {
all: Review2Data[]
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,120 @@
"use client"
import { usePermissionStore } from "@/components/label/store/auth"
import { usePathname } from "next/navigation"
interface PermissionItem {
[x: string]: [boolean, null, string]
}
type PermissionMap = {
[x: string]: PermissionItem
}
const mappingList: { [x: string]: [string, string] } = {
"/management/team/employee": ["user", "view"],
"/management/team/organization": ["user", "group_view"],
"/management/team/dailypaper": ["daily_work", "team_view"],
"/management/team/cost": ["settlement_form", "view"],
"/management/team/workload": ["workload", "view"],
"/management/team/sync_server": ["data_sync", "server_view"],
"/management/team/sync_task": ["data_sync", "task_view"],
"/management/team/board": ["user", "view"],
}
export const headerBarCheckItems = (data: PermissionMap) => {
const projectTitle = [
["daily_work", "self_view"],
["label_schema", "view"],
["project", "view"],
["task", "view"],
] as const
let projectVisible = false
projectTitle.forEach(([prop, subProp]) => {
if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) {
projectVisible = true
}
})
const staffTitle = [
["daily_work", "team_view"],
["user", "view"],
["user", "group_view"],
] as const
let staffVisible = false
staffTitle.forEach(([prop, subProp]) => {
if (data[prop] && data[prop][subProp] && data[prop][subProp][0]) {
staffVisible = true
}
})
return [projectVisible, staffVisible]
}
const useAuth = () => {
const pathname = usePathname()
const detailInfo = usePermissionStore((s) => s.detailInfo)
const permission = detailInfo?.permissions as PermissionMap | undefined
const roleName: string[] = detailInfo?.role_name ?? []
return {
hasPermission: () => {
if (
[
"/",
"/login",
"/management",
"/management/person/dashboard",
"/management/team",
].includes(pathname)
) {
return true
}
if (!permission) return false
if (mappingList[pathname]) {
const [prop, subProp] = mappingList[pathname]
return (
!!permission[prop] &&
!!permission[prop][subProp] &&
!!permission[prop][subProp][0]
)
}
return false
},
// path 跳转是否可见
canNv: (path: string, type = "view") => {
if (!permission) return false
if (mappingList[path]) {
const [prop] = mappingList[path]
return (
!!permission[prop] &&
!!permission[prop][type] &&
!!permission[prop][type][0]
)
}
return false
},
// 当前页面是否可见某个操作
isShow: (type = "view") => {
if (!permission) return false
if (mappingList[pathname]) {
const [prop] = mappingList[pathname]
return (
!!permission[prop] &&
!!permission[prop][type] &&
!!permission[prop][type][0]
)
}
return false
},
checkRole: (name: string) => {
if (!name) return false
return roleName.includes(name)
},
}
}
export default useAuth

View File

@@ -72,6 +72,26 @@ export const componentList: MenuItem[] = [
title: "团队日报",
url: "dailypaper",
},
{
title: "数据统计",
url: "workload",
},
{
title: "结算管理",
url: "cost",
},
{
title: "同步服务",
url: "sync_server",
},
{
title: "同步任务",
url: "sync_task",
},
{
title: "项目参与",
url: "board",
},
],
},
],