feat(project/audit): add audit page
This commit is contained in:
@@ -186,6 +186,7 @@ export default function PersonalProjectTable() {
|
||||
if (record.project_id === currentRowId)
|
||||
return "var(--mantine-datatable-highlight-on-hover-color-light, var(--mantine-datatable-highlight-on-hover-color))"
|
||||
}}
|
||||
idAccessor="project_id"
|
||||
scrollAreaProps={{ type: "auto" }}
|
||||
records={pageRecords}
|
||||
onRecordsPerPageChange={setPageSize}
|
||||
|
||||
459
app/management/project/audit/components/NewProjectModal.tsx
Normal file
459
app/management/project/audit/components/NewProjectModal.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
checkLabelDataSource,
|
||||
getProjectAdminList,
|
||||
getProjectTypeList,
|
||||
projectAdd,
|
||||
} from "@/components/label/api/project"
|
||||
import {
|
||||
getAllVersionByName,
|
||||
getLabelSchemeList,
|
||||
} from "@/components/label/api/scheme"
|
||||
import { usePermissionStore } from "@/components/label/store/auth"
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Radio,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { IconAlertTriangle } from "@tabler/icons-react"
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
export default function NewProjectModal(props: {
|
||||
opened: boolean
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}) {
|
||||
const { opened, onCloseAction } = props
|
||||
const userName = usePermissionStore((s) => s.user_name)
|
||||
|
||||
const [typeOpts, setTypeOpts] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([])
|
||||
const [labelSchemeOpts, setLabelSchemeOpts] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([])
|
||||
const [versionOpts, setVersionOpts] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([])
|
||||
const [adminUserOpts, setAdminUserOpts] = useState<
|
||||
Array<{ label: string; value: string }>
|
||||
>([])
|
||||
const [dataSize, setDataSize] = useState<number>(0)
|
||||
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
project_name: "",
|
||||
project_type: "",
|
||||
is_dynamic: false,
|
||||
data_source_radio: "" as "" | "0" | "1",
|
||||
data_source: "",
|
||||
label_schema_name: "",
|
||||
label_schema_version: "",
|
||||
is_embedding: false,
|
||||
admin_user: [] as string[],
|
||||
remark: "",
|
||||
},
|
||||
validate: {
|
||||
project_name: (v) => {
|
||||
if (!v) return "必填"
|
||||
if (/^\s*$/.test(v)) return "项目名称不能全为空格"
|
||||
return null
|
||||
},
|
||||
project_type: (v) => (!v ? "必填" : null),
|
||||
label_schema_name: (v) => (!v ? "必填" : null),
|
||||
label_schema_version: (v) => (!v ? "必填" : null),
|
||||
admin_user: (v) => (v && v.length ? null : "必填"),
|
||||
},
|
||||
})
|
||||
|
||||
const isDynamic = form.values.is_dynamic
|
||||
const projectType = form.values.project_type
|
||||
const labelSchema = form.values.label_schema_name
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
const initFormOpts = async () => {
|
||||
try {
|
||||
const [schemeRes, adminRes, typeRes] = await Promise.all([
|
||||
getLabelSchemeList({ page_number: 1, page_size: 5000 } as any),
|
||||
getProjectAdminList(),
|
||||
getProjectTypeList(),
|
||||
])
|
||||
|
||||
const schemes = (schemeRes as any)?.label_schema_list ?? []
|
||||
setLabelSchemeOpts(
|
||||
schemes.map((item: any) => ({ label: item.name, value: item.name }))
|
||||
)
|
||||
|
||||
setAdminUserOpts(
|
||||
(adminRes ?? []).map((item: any) => ({
|
||||
label: item.label,
|
||||
value: String(item.value),
|
||||
}))
|
||||
)
|
||||
|
||||
setTypeOpts(
|
||||
(typeRes ?? []).map((name) => ({ label: name, value: name }))
|
||||
)
|
||||
} catch {
|
||||
setLabelSchemeOpts([])
|
||||
setAdminUserOpts([])
|
||||
setTypeOpts([])
|
||||
}
|
||||
}
|
||||
initFormOpts()
|
||||
}, [opened])
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
const run = async () => {
|
||||
form.setFieldValue("label_schema_version", "")
|
||||
setVersionOpts([])
|
||||
if (!labelSchema) return
|
||||
try {
|
||||
const res = await getAllVersionByName(labelSchema)
|
||||
const list = (res as any)?.label_schema_version_list ?? []
|
||||
setVersionOpts(
|
||||
list.map((item: any) => ({
|
||||
label: item.version,
|
||||
value: item.version,
|
||||
}))
|
||||
)
|
||||
} catch {
|
||||
setVersionOpts([])
|
||||
}
|
||||
}
|
||||
run()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [labelSchema, opened])
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
if (isDynamic) {
|
||||
form.setFieldValue("data_source_radio", "")
|
||||
form.setFieldValue("data_source", "")
|
||||
queueMicrotask(() => setDataSize(0))
|
||||
form.clearFieldError("data_source")
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDynamic, opened])
|
||||
|
||||
const triggerCheckDataSource = (value: string) => {
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
|
||||
if (!value) {
|
||||
setDataSize(0)
|
||||
form.clearFieldError("data_source")
|
||||
return
|
||||
}
|
||||
const radio = form.values.data_source_radio
|
||||
if (!projectType) {
|
||||
setDataSize(0)
|
||||
form.setFieldError("data_source", "请先选择所属业务")
|
||||
return
|
||||
}
|
||||
if (radio !== "0" && radio !== "1") {
|
||||
setDataSize(0)
|
||||
form.setFieldError("data_source", "请先选择标注数据源对应来源类型")
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkLabelDataSource({
|
||||
project_type: projectType,
|
||||
nfs_path: value,
|
||||
flag: Number(radio),
|
||||
})
|
||||
const errorCode = (res as any)?.error_code
|
||||
const size = (res as any)?.data_size ?? 0
|
||||
|
||||
const errorMap = new Map<number, string>([
|
||||
[1, "nfs路径不存在"],
|
||||
[2, "nfs路径下不存在data目录"],
|
||||
[3, "nfs路径下不存在tag目录"],
|
||||
[4, "tag文件数量小于data文件数量"],
|
||||
[5, "pattern.json文件不存在"],
|
||||
[6, "clip.json文件不合法"],
|
||||
[7, "tag.json文件不合法"],
|
||||
[8, "pattern.json文件不合法"],
|
||||
])
|
||||
|
||||
if (errorCode === 0) {
|
||||
setDataSize(size)
|
||||
form.clearFieldError("data_source")
|
||||
} else if (errorMap.has(errorCode)) {
|
||||
setDataSize(0)
|
||||
form.setFieldError(
|
||||
"data_source",
|
||||
errorMap.get(errorCode) || "标注数据源填写有误"
|
||||
)
|
||||
} else {
|
||||
setDataSize(0)
|
||||
form.setFieldError("data_source", "标注数据源填写有误")
|
||||
}
|
||||
} catch (e) {
|
||||
setDataSize(0)
|
||||
form.setFieldError(
|
||||
"data_source",
|
||||
e instanceof Error ? e.message : "标注数据源校验失败"
|
||||
)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!opened) return false
|
||||
if (!form.values.project_name || /^\s*$/.test(form.values.project_name))
|
||||
return false
|
||||
if (!form.values.project_type) return false
|
||||
if (!form.values.label_schema_name || !form.values.label_schema_version)
|
||||
return false
|
||||
if (!form.values.admin_user.length) return false
|
||||
if (!form.values.is_dynamic) {
|
||||
if (!form.values.data_source_radio) return false
|
||||
if (!form.values.data_source) return false
|
||||
if (form.errors.data_source) return false
|
||||
}
|
||||
return true
|
||||
}, [form.errors.data_source, form.values, opened])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => onCloseAction()}
|
||||
title="新增标注任务"
|
||||
centered
|
||||
size="50%"
|
||||
closeOnClickOutside={false}>
|
||||
<Stack gap="sm">
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="xs"
|
||||
p="xs"
|
||||
icon={<IconAlertTriangle size={16} />}
|
||||
title="提示">
|
||||
如标注数据需预标或辅助标注,请先前往数据中心MDS模块进行数据处理
|
||||
</Alert>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!canSubmit) return
|
||||
const data: any = { ...values }
|
||||
data.is_dynamic = values.is_dynamic ? 1 : 0
|
||||
data.is_embedding = values.is_embedding ? 1 : 0
|
||||
data.data_source = values.data_source ?? ""
|
||||
data.create_user = userName ?? ""
|
||||
data.data_size = dataSize
|
||||
data.admin_user = values.admin_user
|
||||
.map((v) => Number(v))
|
||||
.filter((n) => Number.isFinite(n))
|
||||
data.remarks = values.remark || undefined
|
||||
delete data.remark
|
||||
delete data.data_source_radio
|
||||
try {
|
||||
await projectAdd(data)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "项目创建成功",
|
||||
message: "已创建项目",
|
||||
})
|
||||
form.reset()
|
||||
setDataSize(0)
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "创建失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
}
|
||||
})}>
|
||||
<div
|
||||
style={{ maxHeight: "58vh", overflowY: "auto", paddingRight: 6 }}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="项目名称"
|
||||
radius="xs"
|
||||
size="xs"
|
||||
placeholder="请输入项目名称"
|
||||
withAsterisk
|
||||
{...form.getInputProps("project_name")}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="所属业务"
|
||||
radius="xs"
|
||||
size="xs"
|
||||
placeholder="请选择所属业务"
|
||||
data={typeOpts}
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
value={form.values.project_type || null}
|
||||
onChange={(v) => form.setFieldValue("project_type", v ?? "")}
|
||||
error={form.errors.project_type}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="是否为动态任务"
|
||||
size="sm"
|
||||
checked={form.values.is_dynamic}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("is_dynamic", e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{!isDynamic ? (
|
||||
<>
|
||||
<Radio.Group
|
||||
label="标注数据源"
|
||||
size="xs"
|
||||
withAsterisk
|
||||
value={form.values.data_source_radio}
|
||||
onChange={(v) => {
|
||||
const next = v as "0" | "1" | ""
|
||||
form.setFieldValue("data_source_radio", next)
|
||||
if (form.values.data_source)
|
||||
triggerCheckDataSource(form.values.data_source)
|
||||
}}>
|
||||
<Group gap="md" mt={6}>
|
||||
<Radio
|
||||
value="0"
|
||||
label="新建数据源"
|
||||
size="xs"
|
||||
disabled={!projectType}
|
||||
/>
|
||||
<Radio
|
||||
value="1"
|
||||
label="现存数据源"
|
||||
size="xs"
|
||||
disabled={!projectType}
|
||||
/>
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
<TextInput
|
||||
label="标注数据源"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
placeholder="请输入标注数据源"
|
||||
withAsterisk
|
||||
disabled={!projectType}
|
||||
value={form.values.data_source}
|
||||
onChange={(e) => {
|
||||
const v = e.currentTarget.value
|
||||
form.setFieldValue("data_source", v)
|
||||
triggerCheckDataSource(v)
|
||||
}}
|
||||
error={form.errors.data_source}
|
||||
rightSection={
|
||||
dataSize > 0 && form.values.data_source ? (
|
||||
<Text size="xs" c="blue" style={{ paddingRight: 6 }}>
|
||||
{dataSize}
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Select
|
||||
label="标注方案"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
placeholder="请选择标注方案"
|
||||
data={labelSchemeOpts}
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
value={form.values.label_schema_name || null}
|
||||
onChange={(v) =>
|
||||
form.setFieldValue("label_schema_name", v ?? "")
|
||||
}
|
||||
error={form.errors.label_schema_name}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="方案版本"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
placeholder="请选择标注方案版本号"
|
||||
data={versionOpts}
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
value={form.values.label_schema_version || null}
|
||||
onChange={(v) =>
|
||||
form.setFieldValue("label_schema_version", v ?? "")
|
||||
}
|
||||
error={form.errors.label_schema_version}
|
||||
/>
|
||||
|
||||
<Switch
|
||||
label="是否生成embedding"
|
||||
size="sm"
|
||||
checked={form.values.is_embedding}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("is_embedding", e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<MultiSelect
|
||||
label="标注管理员"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
placeholder="请选择标注管理员"
|
||||
data={adminUserOpts}
|
||||
searchable
|
||||
clearable
|
||||
withAsterisk
|
||||
value={form.values.admin_user}
|
||||
onChange={(v) => form.setFieldValue("admin_user", v)}
|
||||
error={form.errors.admin_user}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="备注"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
placeholder="请输入"
|
||||
maxLength={50}
|
||||
autosize
|
||||
minRows={3}
|
||||
value={form.values.remark}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("remark", e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end" mt="md" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => onCloseAction()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" size="xs" radius="xs" disabled={!canSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
229
app/management/project/audit/components/ReviewModal.tsx
Normal file
229
app/management/project/audit/components/ReviewModal.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client"
|
||||
|
||||
import { projectReview } from "@/components/label/api/project"
|
||||
import { usePermissionStore } from "@/components/label/store/auth"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Radio,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
import { useEffect, useMemo } from "react"
|
||||
|
||||
export default function ReviewModal(props: {
|
||||
opened: boolean
|
||||
record: any
|
||||
onCloseAction: (refresh?: boolean) => void
|
||||
}) {
|
||||
const { opened, record, onCloseAction } = props
|
||||
const userName = usePermissionStore((s) => s.user_name)
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
test_proportion: undefined as number | undefined,
|
||||
pass: true,
|
||||
review_comment: "",
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) return
|
||||
form.setValues({
|
||||
test_proportion:
|
||||
record && typeof record.test_proportion === "number"
|
||||
? record.test_proportion
|
||||
: null,
|
||||
pass: true,
|
||||
review_comment: "",
|
||||
})
|
||||
form.resetDirty()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opened, record])
|
||||
|
||||
const showEditableTestProportion = useMemo(() => {
|
||||
return record?.status === 1
|
||||
}, [record?.status])
|
||||
|
||||
const showReadonlyTestProportion = useMemo(() => {
|
||||
return record?.status === 101 || record?.status === 102
|
||||
}, [record?.status])
|
||||
|
||||
const fullLabel = useMemo(() => {
|
||||
if (!record) return "-"
|
||||
const name = record.label_schema_name ?? ""
|
||||
const version = record.label_schema_version ?? ""
|
||||
if (!name) return record.full_label ?? "-"
|
||||
return version ? `${name} - ${version}` : name
|
||||
}, [record])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => onCloseAction()}
|
||||
title="审核项目"
|
||||
centered
|
||||
closeOnClickOutside={false}
|
||||
size={640}>
|
||||
<form
|
||||
onSubmit={form.onSubmit(async (values) => {
|
||||
if (!record?.id) return
|
||||
try {
|
||||
const params: any = {
|
||||
project_id: record.id,
|
||||
project_name: record.name,
|
||||
pass: values.pass,
|
||||
review_comment: values.review_comment || undefined,
|
||||
review_user: userName ?? "",
|
||||
}
|
||||
if (showEditableTestProportion) {
|
||||
if (typeof values.test_proportion !== "number") {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "校验失败",
|
||||
message: "请填写抽取比例",
|
||||
})
|
||||
return
|
||||
}
|
||||
params.test_proportion = values.test_proportion
|
||||
} else if (showReadonlyTestProportion) {
|
||||
if (typeof record.test_proportion === "number") {
|
||||
params.test_proportion = record.test_proportion
|
||||
}
|
||||
}
|
||||
|
||||
await projectReview(params)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
title: "操作成功",
|
||||
message: "已提交审核",
|
||||
})
|
||||
onCloseAction(true)
|
||||
} catch (e) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
title: "操作失败",
|
||||
message: e instanceof Error ? e.message : "请求失败",
|
||||
})
|
||||
}
|
||||
})}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="项目名称"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={record?.name ?? "-"}
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label="项目负责人"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={record?.create_user ?? record?.owner ?? "-"}
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label="数据量"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={
|
||||
record?.data_size || record?.data_size === 0
|
||||
? String(record.data_size)
|
||||
: "-"
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
<TextInput
|
||||
label="标注数据源"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={record?.data_source ?? "-"}
|
||||
readOnly
|
||||
/>
|
||||
<TextInput label="标注方案" value={fullLabel} readOnly />
|
||||
|
||||
{showEditableTestProportion ? (
|
||||
<NumberInput
|
||||
label="抽取比例"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
hideControls
|
||||
rightSection={
|
||||
<Text size="xs" c="dimmed" style={{ paddingRight: 8 }}>
|
||||
%
|
||||
</Text>
|
||||
}
|
||||
value={form.values.test_proportion}
|
||||
onChange={(v) =>
|
||||
form.setFieldValue(
|
||||
"test_proportion",
|
||||
typeof v === "number" ? v : undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showReadonlyTestProportion ? (
|
||||
<TextInput
|
||||
label="抽取比例"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
value={
|
||||
typeof record?.test_proportion === "number"
|
||||
? `${record.test_proportion}%`
|
||||
: "-"
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Radio.Group
|
||||
label="是否通过"
|
||||
size="xs"
|
||||
value={form.values.pass ? "true" : "false"}
|
||||
onChange={(v) => form.setFieldValue("pass", v === "true")}>
|
||||
<Group gap="md" mt={6}>
|
||||
<Radio value="true" label="通过" size="xs" />
|
||||
<Radio value="false" label="不通过" size="xs" />
|
||||
</Group>
|
||||
</Radio.Group>
|
||||
|
||||
<Textarea
|
||||
label="审核意见"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
minRows={3}
|
||||
autosize
|
||||
placeholder="请输入"
|
||||
value={form.values.review_comment}
|
||||
onChange={(e) =>
|
||||
form.setFieldValue("review_comment", e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm" mt="xs">
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="xs"
|
||||
onClick={() => onCloseAction()}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" size="xs" radius="xs">
|
||||
确定
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
596
app/management/project/audit/page.tsx
Normal file
596
app/management/project/audit/page.tsx
Normal file
@@ -0,0 +1,596 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user