597 lines
17 KiB
TypeScript
597 lines
17 KiB
TypeScript
"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: "rgba(0,0,0,0.06)" }}>
|
||
<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.currentTarget.value,
|
||
}))
|
||
}
|
||
/>
|
||
<TextInput
|
||
label="所属业务"
|
||
size="xs"
|
||
radius="xs"
|
||
value={filters.project_type}
|
||
onChange={(e) =>
|
||
setFilters((s) => ({
|
||
...s,
|
||
project_type: e.currentTarget.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.currentTarget.value,
|
||
}))
|
||
}
|
||
/>
|
||
<TextInput
|
||
label="结束日期"
|
||
size="xs"
|
||
radius="xs"
|
||
type="date"
|
||
value={filters.create_at_end}
|
||
onChange={(e) =>
|
||
setFilters((s) => ({
|
||
...s,
|
||
create_at_end: e.currentTarget.value,
|
||
}))
|
||
}
|
||
/>
|
||
</SimpleGrid>
|
||
</Stack>
|
||
</Collapse>
|
||
</Paper>
|
||
|
||
<Paper
|
||
withBorder
|
||
p="md"
|
||
radius="md"
|
||
style={{
|
||
borderColor: "rgba(0,0,0,0.06)",
|
||
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>
|
||
)
|
||
}
|