Files
labelmain/app/management/project/audit/page.tsx
2026-02-24 17:49:09 +08:00

601 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import {
getProjectList,
getProjectReviewers,
regenerateProjectEmbedding,
} from "@/components/label/api/project"
import { Project, ReviewUsers } from "@/components/label/api/project/typing"
import {
useAllUserStore,
usePermissionStore,
} from "@/components/label/store/auth"
import { CREATED_CODE, STATUS_CODE } from "@/components/label/utils/constants"
import {
ActionIcon,
Badge,
Button,
Collapse,
CopyButton,
Flex,
Group,
MultiSelect,
Paper,
SimpleGrid,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core"
import { notifications } from "@mantine/notifications"
import {
IconCheck,
IconChevronDown,
IconChevronUp,
IconCopy,
IconPlus,
IconRefresh,
IconSearch,
} from "@tabler/icons-react"
import dayjs from "dayjs"
import { DataTable, DataTableColumn } from "mantine-datatable"
import { useCallback, useEffect, useMemo, useState } from "react"
import NewProjectModal from "./components/NewProjectModal"
import ReviewModal from "./components/ReviewModal"
function statusBadgeColor(status?: number | null) {
if (!status) return "gray"
if ([191, 199, 700].includes(status)) return "red"
if ([580].includes(status)) return "yellow"
if ([600].includes(status)) return "green"
if ([201, 202, 203].includes(status)) return "cyan"
return "blue"
}
function toTrimmed(value: string) {
return value.trim()
}
function createInitialFilters() {
return {
project_name: "",
project_type: "",
admin_users: [] as string[],
create_at_start: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
create_at_end: dayjs().format("YYYY-MM-DD"),
}
}
export default function ProjectAuditPage() {
const { userOpts } = useAllUserStore()
const permission = usePermissionStore((s) => s.detailInfo?.permissions)
const isShow = useCallback(
(type: string) => {
const v = permission?.project?.[type]?.[0]
return typeof v === "boolean" ? v : true
},
[permission]
)
const [reviewers, setReviewers] = useState<ReviewUsers | null>(null)
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState<Project.DataProps[]>([])
const [total, setTotal] = useState(0)
const [queryTrigger, setQueryTrigger] = useState(0)
const [pageNumber, setPageNumber] = useState(1)
const [pageSize, setPageSize] = useState(15)
const [searchExpanded, setSearchExpanded] = useState(false)
const [filters, setFilters] = useState(() => createInitialFilters())
const [appliedFilters, setAppliedFilters] = useState(() =>
createInitialFilters()
)
const adminOptions = useMemo(() => {
return userOpts.map((u) => ({
label: u.label,
value: String(u.value),
}))
}, [userOpts])
const queryParams = useMemo(() => {
const obj: Project.ListRequest & any = {
page_number: pageNumber,
page_size: pageSize,
status: CREATED_CODE,
project_name: appliedFilters.project_name
? toTrimmed(appliedFilters.project_name)
: undefined,
project_type: appliedFilters.project_type
? toTrimmed(appliedFilters.project_type)
: undefined,
admin_user: appliedFilters.admin_users.length
? appliedFilters.admin_users.map(String).join(",")
: undefined,
start_date: appliedFilters.create_at_start || undefined,
end_date: appliedFilters.create_at_end || undefined,
}
return obj
}, [appliedFilters, pageNumber, pageSize])
const load = useCallback(async () => {
if (!queryTrigger) return
setLoading(true)
try {
const res = await getProjectList(queryParams)
setRecords(res?.project_list ?? [])
setTotal(res?.total_items ?? 0)
} catch (e) {
setRecords([])
setTotal(0)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [queryParams, queryTrigger])
useEffect(() => {
load()
}, [load])
useEffect(() => {
queueMicrotask(() => {
setQueryTrigger((v) => v + 1)
})
}, [])
useEffect(() => {
const run = async () => {
try {
const res = await getProjectReviewers()
setReviewers(res)
} catch {
setReviewers(null)
}
}
run()
}, [])
const [createOpen, setCreateOpen] = useState(false)
const [reviewOpen, setReviewOpen] = useState(false)
const [selected, setSelected] = useState<any>(null)
const reviewerText = useCallback(
(status?: number) => {
if (!reviewers || !status) return "-"
if (status === 1) return reviewers.reviewer1.join("、")
if (status === 101) return reviewers.reviewer2.join("、")
if (status === 102) return reviewers.reviewer3.join("、")
return "-"
},
[reviewers]
)
const columns = useMemo(() => {
const cols: DataTableColumn<Project.DataProps>[] = [
{ accessor: "id", title: "项目ID", width: 90 },
{
accessor: "name",
title: "项目名称",
width: 220,
render: (record) => (
<Tooltip label={record.name} withArrow>
<Text
size="sm"
style={{
maxWidth: 220,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{record.name}
</Text>
</Tooltip>
),
},
{
accessor: "project_type",
title: "所属业务",
width: 180,
render: (record) => record.project_type ?? "-",
},
{
accessor: "label_schema_name",
title: "标注方案",
width: 200,
render: (record) =>
record.label_schema_name ? (
<Tooltip label={record.label_schema_name} withArrow>
<Text
size="sm"
style={{
maxWidth: 200,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{record.label_schema_name}
</Text>
</Tooltip>
) : (
"-"
),
},
{
accessor: "status",
title: "项目状态",
width: 130,
render: (record) => (
<Badge color={statusBadgeColor(record.status)} variant="light">
{STATUS_CODE.get(record.status) ?? record.status}
</Badge>
),
},
{
accessor: "create_user",
title: "项目负责人",
width: 140,
render: (record: any) => record.create_user ?? record.owner ?? "-",
},
{
accessor: "admin_user",
title: "标注管理员",
width: 180,
render: (record) => {
const ids = Array.isArray((record as any).admin_user)
? ((record as any).admin_user as any[])
: []
const names = ids
.map((uid) => userOpts.find((o) => o.value === uid)?.label)
.filter(Boolean)
return names.length ? (
<Tooltip label={names.join("")} withArrow>
<Text
size="sm"
style={{
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{names.join("")}
</Text>
</Tooltip>
) : (
"-"
)
},
},
{
accessor: "data_size",
title: "总数据量",
width: 120,
render: (record: any) => record.data_size ?? "-",
},
{
accessor: "data_source",
title: "数据源",
width: 260,
render: (record: any) => {
const value = record.data_source
if (!value) return "-"
return (
<Group gap={6} wrap="nowrap">
<Tooltip label={value} withArrow>
<Text
size="sm"
style={{
maxWidth: 220,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{value}
</Text>
</Tooltip>
<CopyButton value={value} timeout={1200}>
{({ copied, copy }) => (
<ActionIcon
variant="subtle"
color={copied ? "teal" : "gray"}
onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
)}
</CopyButton>
</Group>
)
},
},
{
accessor: "remarks",
title: "备注",
width: 180,
render: (record: any) => record.remarks ?? record.remark ?? "-",
},
{
accessor: "create_at",
title: "创建时间",
width: 160,
render: (record: any) => record.create_at ?? "-",
},
{
accessor: "latest_review_user",
title: "上一节点审核人",
width: 140,
render: (record: any) => record.latest_review_user ?? "-",
},
{
accessor: "current_reviewer",
title: "当前节点审核人",
width: 160,
render: (record) => reviewerText((record as any).status),
},
{
accessor: "operation",
title: "操作",
width: 80,
textAlign: "center",
render: (record: any) => {
return (
<Group gap={8} justify="center" wrap="nowrap">
{isShow("review") && record.status < 180 ? (
<Button
size="xs"
variant="transparent"
onClick={() => {
setSelected({
...record,
full_label: `${record.label_schema_name ?? ""}${
record.label_schema_version
? ` - ${record.label_schema_version}`
: ""
}`,
})
setReviewOpen(true)
}}>
</Button>
) : null}
{record.status === 191 ? (
<Button
size="xs"
variant="subtle"
onClick={async () => {
try {
await regenerateProjectEmbedding({
project_id: record.id,
})
notifications.show({
color: "green",
title: "操作成功",
message: "重新生成中",
})
load()
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
}}>
</Button>
) : null}
</Group>
)
},
},
]
return cols
}, [isShow, load, reviewerText, userOpts])
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" 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="xs">
<Button
size="xs"
radius={"xs"}
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => {
const next = createInitialFilters()
setFilters(next)
setAppliedFilters(next)
setPageNumber(1)
setQueryTrigger((v) => v + 1)
}}>
</Button>
<Button
size="xs"
radius={"xs"}
leftSection={<IconSearch size={16} />}
onClick={() => {
setPageNumber(1)
setAppliedFilters({ ...filters })
setQueryTrigger((v) => v + 1)
}}>
</Button>
</Group>
</Group>
<Collapse
in={searchExpanded}
transitionDuration={250}
transitionTimingFunction="ease"
animateOpacity>
<Stack gap="sm" mt="sm">
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm">
<TextInput
label="项目名称"
size="xs"
radius="xs"
value={filters.project_name}
onChange={(e) =>
setFilters((s) => ({
...s,
project_name: e.target.value,
}))
}
/>
<TextInput
label="所属业务"
size="xs"
radius="xs"
value={filters.project_type}
onChange={(e) =>
setFilters((s) => ({
...s,
project_type: e.target.value,
}))
}
/>
<MultiSelect
label="标注管理员"
size="xs"
radius="xs"
data={adminOptions}
value={filters.admin_users}
onChange={(v) => setFilters((s) => ({ ...s, admin_users: v }))}
searchable
clearable
/>
<TextInput
label="开始日期"
size="xs"
radius="xs"
type="date"
value={filters.create_at_start}
onChange={(e) =>
setFilters((s) => ({
...s,
create_at_start: e.target.value,
}))
}
/>
<TextInput
label="结束日期"
size="xs"
radius="xs"
type="date"
value={filters.create_at_end}
onChange={(e) => {
setFilters((s) => ({
...s,
create_at_end: e.target.value,
}))
}}
/>
</SimpleGrid>
</Stack>
</Collapse>
</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",
}}>
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}>
<Flex justify={"space-between"} align="center" w="100%">
{isShow("add") ? (
<Button
size="xs"
radius={"xs"}
leftSection={<IconPlus size={16} />}
onClick={() => setCreateOpen(true)}>
</Button>
) : null}
<ActionIcon onClick={load} variant="transparent">
<IconRefresh size={16} />
</ActionIcon>
</Flex>
<DataTable<Project.DataProps>
withTableBorder
withRowBorders
pinFirstColumn
pinLastColumn
fetching={loading}
records={records}
columns={columns}
totalRecords={total}
recordsPerPage={pageSize}
page={pageNumber}
onPageChange={(p) => setPageNumber(p)}
onRecordsPerPageChange={(s) => {
setPageSize(s)
setPageNumber(1)
}}
recordsPerPageOptions={[15, 45, 99]}
noRecordsText="暂无数据"
recordsPerPageLabel="当前页数"
width="100%"
style={{ width: "100%" }}
scrollAreaProps={{ type: "auto" }}
/>
</Stack>
</Paper>
<NewProjectModal
opened={createOpen}
onCloseAction={(refresh) => {
setCreateOpen(false)
if (refresh) load()
}}
/>
<ReviewModal
opened={reviewOpen}
record={selected}
onCloseAction={(refresh) => {
setReviewOpen(false)
setSelected(null)
if (refresh) load()
}}
/>
</Stack>
)
}