Files
labelimage/components/project/index.tsx
2026-03-16 21:04:13 +08:00

1145 lines
34 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,
getProjectTypeList,
projectAccept,
projectCollect,
projectDeliver,
projectEditById,
projectPreLabelReview,
} from "@/components/label/api/project"
import { Project } from "@/components/label/api/project/typing"
import {
initAllParams,
initCollectParams,
initDynamicParams,
useAllUserStore,
usePermissionStore,
useProjectStore,
} from "@/components/label/store/auth"
import {
projectLabelTypeOpts,
projectStatusOpts,
STATUS_CODE,
} from "@/components/label/utils/constants"
import {
ActionIcon,
Badge,
Button,
Collapse,
CopyButton,
Divider,
Flex,
Group,
Loader,
Modal,
MultiSelect,
Pagination,
Paper,
Select,
SimpleGrid,
Stack,
Text,
Textarea,
TextInput,
Tooltip,
UnstyledButton,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import {
IconCalendar,
IconCheck,
IconChevronDown,
IconChevronUp,
IconCopy,
IconEdit,
IconFileUpload,
IconInfoCircle,
IconRefresh,
IconSearch,
IconStar,
IconStarFilled,
} from "@tabler/icons-react"
import dayjs from "dayjs"
import { User2 } from "lucide-react"
import { useRouter } from "next/navigation"
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import useAuth from "../label/hooks/useAuth"
import { CardCoverIcon } from "./components/ProjectIcon"
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 parseStatusValue(value?: string) {
if (!value) return undefined
if (value.includes(",")) {
return value
.split(",")
.map((v) => Number(v))
.filter((n) => Number.isFinite(n))
}
const n = Number(value)
return Number.isFinite(n) ? [n] : undefined
}
function formatPercent(value?: number | null) {
if (value === 0) return "0.00%"
if (!value) return "-"
return `${(value * 100).toFixed(2)}%`
}
function getProcessText(record: any) {
const rateSpeed = record?.rate_speed
if (!rateSpeed || typeof rateSpeed !== "object") return "-"
const map = new Map([
["标注", "label_speed"],
["审核", "review1_speed"],
["复审", "review2_speed"],
])
const arr = ["标注", "审核", "复审"]
const parts = arr.map((process) => {
const key = map.get(process)
const staticKey = key ? `static_${key}` : ""
const dynamicKey = key ? `dynamic_${key}` : ""
const staticValue =
staticKey && (rateSpeed[staticKey] || rateSpeed[staticKey] === 0)
? rateSpeed[staticKey]
: "-"
const dynamicValue =
dynamicKey && (rateSpeed[dynamicKey] || rateSpeed[dynamicKey] === 0)
? rateSpeed[dynamicKey]
: "-"
return `${process}(${staticValue}/${dynamicValue})`
})
return parts.join("")
}
export default function ProjectInfoPage({
type,
}: {
type: "dynamic" | "collect" | ""
}) {
const router = useRouter()
const { userOpts } = useAllUserStore()
const userName = usePermissionStore((s) => s.user_name)
const {
allParams,
allTotal,
collectParams,
collectTotal,
dynamicParams,
dynamicTotal,
resetParams,
setSearchParams,
setTotal,
}: any = useProjectStore()
const storeParams = useMemo(() => {
return type === "collect"
? collectParams
: type === "dynamic"
? dynamicParams
: allParams
}, [allParams, collectParams, dynamicParams, type])
const total = useMemo(() => {
return type === "collect"
? collectTotal
: type === "dynamic"
? dynamicTotal
: allTotal
}, [allTotal, collectTotal, dynamicTotal, type])
const [typeOpts, setTypeOpts] = useState<
Array<{ label: string; value: string }>
>([])
const [loading, setLoading] = useState(false)
const [records, setRecords] = useState<Project.DataProps[]>([])
const [searchExpanded, setSearchExpanded] = useState(false)
const [filters, setFilters] = useState(() => {
return {
project_name: "",
project_type: "",
status: "",
owner: "",
label_type: "",
admin_user: "",
start_date: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
end_date: dayjs().format("YYYY-MM-DD"),
}
})
const [appliedFilters, setAppliedFilters] = useState(filters)
const [queryTrigger, setQueryTrigger] = useState(0)
useEffect(() => {
const initFormOpts = async () => {
const res = await getProjectTypeList()
setTypeOpts(
(Object.keys(res) ?? []).map((name) => ({ label: name, value: name }))
)
}
initFormOpts()
}, [])
const queryParams = useMemo(() => {
const base =
type === "collect"
? { ...initCollectParams }
: type === "dynamic"
? { ...initDynamicParams }
: { ...initAllParams }
const params: Project.ListRequest & any = {
...base,
...storeParams,
page_number: storeParams.page_number ?? 1,
page_size: storeParams.page_size ?? 15,
project_name: appliedFilters.project_name || undefined,
project_type: appliedFilters.project_type || undefined,
owner: appliedFilters.owner || undefined,
admin_user: appliedFilters.admin_user || undefined,
start_date: appliedFilters.start_date || undefined,
end_date: appliedFilters.end_date || undefined,
label_type: appliedFilters.label_type
? Number(appliedFilters.label_type)
: undefined,
// 0: 图片标注 1: 点云标注 2: 视频标注
project_data_type: 0,
status: parseStatusValue(appliedFilters.status),
}
return params
}, [appliedFilters, storeParams, type])
const load = useCallback(async () => {
if (!queryTrigger) return
setLoading(true)
try {
const res = await getProjectList(queryParams)
setRecords(res?.project_list ?? [])
setTotal(res?.total_items ?? 0, type)
} catch (e) {
setRecords([])
setTotal(0, type)
notifications.show({
color: "red",
title: "加载失败",
message: e instanceof Error ? e.message : "请求失败",
})
} finally {
setLoading(false)
}
}, [queryParams, queryTrigger, setTotal, type])
useEffect(() => {
load()
}, [load])
useEffect(() => {
queueMicrotask(() => {
setQueryTrigger((v) => v + 1)
})
}, [])
const currentPage = storeParams.page_number ?? 1
const pageSize = storeParams.page_size ?? 15
const [infoModalRecord, setInfoModalRecord] =
useState<Project.DataProps | null>(null)
const [editModalRecord, setEditModalRecord] =
useState<Project.DataProps | null>(null)
const [confirmAction, setConfirmAction] = useState<{
record: Project.DataProps | null
action: "试标完成" | "试标通过" | "提交验收" | "验收通过" | null
}>({ record: null, action: null })
const editForm = useForm<{
project_name: string
// owner: string
remarks?: string
members?: string[]
}>({
initialValues: {
project_name: "",
// owner: "",
remarks: "",
members: [],
},
})
const confirmActionTitle = useMemo(() => {
return confirmAction.action ?? ""
}, [confirmAction.action])
const runConfirmAction = useCallback(async () => {
const record = confirmAction.record
const action = confirmAction.action
if (!record || !action) return
try {
if (action === "试标完成") {
await projectPreLabelReview({
id: record.id,
review_user: userName ?? "",
action: 1,
})
} else if (action === "试标通过") {
await projectPreLabelReview({
id: record.id,
review_user: userName ?? "",
action: 2,
})
} else if (action === "提交验收") {
await projectDeliver({ project_id: record.id })
} else if (action === "验收通过") {
await projectAccept({ project_id: record.id, pass: true })
}
notifications.show({
color: "green",
title: "操作成功",
message: `${action} 已提交`,
})
setConfirmAction({ record: null, action: null })
setQueryTrigger((v) => v + 1)
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
}, [confirmAction.action, confirmAction.record, userName])
const { isShow } = useAuth()
const ProjectCard = (record: Project.DataProps) => {
const status = record.status
const canEdit = status < 600
const actionKey =
status === 201
? "试标完成"
: status === 202
? "试标通过"
: status === 500
? "提交验收"
: status === 580
? "验收通过"
: null
const canDoAction = !!actionKey
return (
<Paper
key={record.id}
withBorder
radius="md"
shadow="sm"
style={{
height: 160,
padding: 14,
paddingBottom: 0,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Flex flex={1}>
<CardCoverIcon width={68} height={68} />
<Stack gap={0}>
<Flex align="center">
<UnstyledButton
variant="subtle"
size="sm"
style={{
paddingLeft: 6,
paddingRight: 6,
justifyContent: "flex-start",
width: "100%",
}}
onClick={() => {
const url = type
? `/project/detail?id=${record.id}&type=${type}`
: `/project/detail?id=${record.id}`
router.push(url)
}}>
<Text
fw={700}
c={"brand"}
style={{
maxWidth: "100%",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{record.name}
</Text>
</UnstyledButton>
<Badge
color={statusBadgeColor(record.status)}
style={{
width: "-webkit-fill-available",
}}>
{STATUS_CODE.get(record.status) ?? record.status}
</Badge>
</Flex>
<Flex align="center" px={6} gap={6}>
<User2
color="var(--mantine-color-dimmed)"
width={14}
height={14}
/>
<Text size="sm">{record.owner ?? "-"}</Text>
</Flex>
<Flex align="center" px={6} gap={6}>
<IconCalendar
color="var(--mantine-color-dimmed)"
width={14}
height={14}
/>
<Text size="sm">
{record.create_at ? record.create_at.split(" ")[0] : "-"}
</Text>
</Flex>
</Stack>
</Flex>
<ActionIcon
variant="subtle"
display="none"
color={record.is_collect ? "yellow" : "gray"}
onClick={async () => {
try {
await projectCollect({
project_id: record.id,
is_collect: !record.is_collect,
})
notifications.show({
color: "green",
title: "操作成功",
message: record.is_collect ? "已取消收藏" : "已收藏",
})
setQueryTrigger((v) => v + 1)
} catch (e) {
notifications.show({
color: "red",
title: "操作失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
}}>
{record.is_collect ? (
<IconStarFilled size={16} />
) : (
<IconStar size={16} />
)}
</ActionIcon>
</Group>
<Stack gap={0} style={{ flex: 1, minHeight: 0, marginTop: 8 }}>
<Text
size="sm"
c="dimmed"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 2,
}}>
{record.remarks ? record.remarks : "暂无备注"}
</Text>
<Divider
style={{
marginTop: 4,
marginBottom: 4,
borderColor: "var(--mantine-color-brand-text)",
opacity: 0.3,
}}
/>
<div style={{ display: "flex", gap: 6, height: 32 }}>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
{isShow("edit") && (
<Tooltip label="编辑">
<ActionIcon
variant="transparent"
radius="md"
disabled={!canEdit}
onClick={() => {
if (!canEdit) return
editForm.setValues({
project_name: record.name ?? "",
// owner: record.owner ?? "",
remarks: record.remarks ?? "",
members: record.members?.map((m) => String(m)) ?? [],
})
editForm.resetDirty()
setEditModalRecord(record)
}}>
<IconEdit
size={16}
color="var(--mantine-color-brand-text)"
/>
</ActionIcon>
</Tooltip>
)}
</div>
<div
style={{
width: 1,
height: 32,
background: "var(--mantine-color-brand-text)",
opacity: 0.3,
}}
/>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<Tooltip label={actionKey ?? "无可用操作"}>
<ActionIcon
variant="transparent"
radius="md"
disabled={!canDoAction}
onClick={() => {
if (!actionKey) return
setConfirmAction({ record, action: actionKey })
}}>
<IconFileUpload
size={16}
color="var(--mantine-color-brand-text)"
/>
</ActionIcon>
</Tooltip>
</div>
<div
style={{
width: 1,
height: 32,
background: "var(--mantine-color-brand-text)",
opacity: 0.3,
}}
/>
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
<Tooltip label="项目信息">
<ActionIcon
variant="transparent"
radius="md"
onClick={() => setInfoModalRecord(record)}>
<IconInfoCircle
size={16}
color="var(--mantine-color-brand-text)"
/>
</ActionIcon>
</Tooltip>
</div>
</div>
</Stack>
</Paper>
)
}
const PaginationBar = (props: {
currentPage: number
pageSize: number
total: number
onChangePage: (page: number) => void
onChangePageSize: (size: number) => void
}) => {
const pageCount = Math.max(
1,
Math.ceil((props.total ?? 0) / props.pageSize)
)
return (
<div
style={{
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 12,
paddingLeft: 12,
paddingRight: 12,
}}>
<Select
size="xs"
value={String(props.pageSize)}
data={[15, 45, 99].map((n) => ({
label: `${n}/页`,
value: String(n),
}))}
onChange={(v) => {
const next = Number(v)
if (!Number.isFinite(next)) return
props.onChangePageSize(next)
}}
allowDeselect={false}
style={{ width: 92 }}
/>
<Pagination
size="sm"
withControls
total={pageCount}
value={Math.min(props.currentPage, pageCount)}
onChange={props.onChangePage}
/>
</div>
)
}
return (
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
<Paper withBorder p="md" radius="md">
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={700}>
{type === "collect"
? "我的收藏"
: type === "dynamic"
? "动态项目"
: "我的项目"}
</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={() => {
resetParams(type)
const next = {
project_name: "",
project_type: "",
status: "",
owner: "",
label_type: "",
admin_user: "",
start_date: dayjs().subtract(6, "day").format("YYYY-MM-DD"),
end_date: dayjs().format("YYYY-MM-DD"),
}
setFilters(next)
setAppliedFilters(next)
setQueryTrigger((v) => v + 1)
}}>
</Button>
<Button
size="xs"
radius="xs"
leftSection={<IconSearch size={16} />}
onClick={() => {
setSearchParams({ ...storeParams, page_number: 1 }, type)
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,
}))
}
/>
<Select
label="所属业务"
size="xs"
radius="xs"
searchable
clearable
data={typeOpts}
value={filters.project_type || null}
onChange={(v) =>
setFilters((s) => ({ ...s, project_type: v ?? "" }))
}
/>
<Select
label="状态"
size="xs"
radius="xs"
searchable
data={projectStatusOpts.map((o) => ({
label: o.label,
value: o.label,
}))}
value={filters.status}
onChange={(v) => setFilters((s) => ({ ...s, status: v ?? "" }))}
/>
<TextInput
label="负责人"
size="xs"
radius="xs"
value={filters.owner}
onChange={(e) =>
setFilters((s) => ({ ...s, owner: e.currentTarget.value }))
}
/>
<Select
label="标注类型"
size="xs"
radius="xs"
searchable
clearable
data={projectLabelTypeOpts.map((o) => ({
label: o.label,
value: String(o.value),
}))}
value={filters.label_type || null}
onChange={(v) =>
setFilters((s) => ({ ...s, label_type: v ?? "" }))
}
/>
<Select
label="标注管理员"
size="xs"
radius="xs"
searchable
clearable
data={userOpts.map((o) => ({
label: o.label,
value: String(o.value),
}))}
value={filters.admin_user || null}
onChange={(v) =>
setFilters((s) => ({ ...s, admin_user: v ?? "" }))
}
/>
<TextInput
label="开始日期"
size="xs"
radius="xs"
type="date"
value={filters.start_date}
onChange={(e) =>
setFilters((s) => ({
...s,
start_date: e.currentTarget.value,
}))
}
/>
<TextInput
label="结束日期"
size="xs"
radius="xs"
type="date"
value={filters.end_date}
onChange={(e) =>
setFilters((s) => ({ ...s, end_date: e.currentTarget.value }))
}
/>
</SimpleGrid>
</Stack>
</Collapse>
</Paper>
<Paper
withBorder
p="md"
radius="md"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto",
paddingRight: 2,
}}>
{loading ? (
<div
style={{
height: "100%",
minHeight: 220,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<Loader size="sm" />
</div>
) : records.length ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 16,
}}>
{records.map(ProjectCard)}
</div>
) : (
<div
style={{
height: "100%",
minHeight: 220,
border: "1px solid rgba(0,0,0,0.06)",
borderRadius: 12,
background: "rgba(0,0,0,0.02)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
<Text size="lg" c="dimmed" fw={600}>
</Text>
</div>
)}
</div>
<div style={{ height: 48, marginTop: 16 }}>
<PaginationBar
currentPage={currentPage}
pageSize={pageSize}
total={total ?? 0}
onChangePage={(p) => {
setSearchParams({ ...storeParams, page_number: p }, type)
setQueryTrigger((v) => v + 1)
}}
onChangePageSize={(s) => {
setSearchParams(
{ ...storeParams, page_number: 1, page_size: s },
type
)
setQueryTrigger((v) => v + 1)
}}
/>
</div>
</Paper>
<Modal
opened={infoModalRecord !== null}
onClose={() => setInfoModalRecord(null)}
title="项目信息"
centered
size={850}>
{(() => {
const record = infoModalRecord
const adminUsers =
record?.admin_user && Array.isArray(record.admin_user)
? record.admin_user
.map((uid) => userOpts.find((o) => o.value === uid)?.label)
.filter(Boolean)
: []
const labelSchemaText = record?.label_schema_name
? `${record.label_schema_name}${
record.label_schema_version
? ` - ${record.label_schema_version}`
: ""
}`
: "-"
const progressText = `${formatPercent(record?.labeled_scale)} / ${formatPercent(
record?.reviewed_scale
)}`
const processText = record ? getProcessText(record) : "-"
const Row = (props: { title: ReactNode; children: ReactNode }) => (
<div
style={{
display: "flex",
alignItems: "flex-center",
lineHeight: "24px",
}}>
<div style={{ width: 108, fontWeight: 600 }}>{props.title}</div>
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
alignItems: "center",
}}>
{props.children}
</div>
</div>
)
const EllipsisText = (props: {
value?: string | null
copyable?: boolean
}) => {
const value = props.value
if (!value) return <Text size="sm">-</Text>
const textNode = (
<Text
size="sm"
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{value}
</Text>
)
return props.copyable ? (
<Group gap={6} wrap="nowrap">
<Tooltip label={value} withArrow>
<div style={{ flex: 1, minWidth: 0 }}>{textNode}</div>
</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>
) : (
<Tooltip label={value} withArrow>
<div>{textNode}</div>
</Tooltip>
)
}
return (
<div
style={{
maxHeight: "70vh",
overflowY: "auto",
paddingBottom: 8,
}}>
<Stack gap="sm">
<Row title="项目ID">
<Text size="sm">{record?.id ?? "-"}</Text>
</Row>
<Row title="项目名称">
<EllipsisText value={record?.name ?? null} />
</Row>
<Row title="所属业务">
<EllipsisText value={record?.project_type ?? null} />
</Row>
<Row title="标注方案">
<EllipsisText
value={labelSchemaText === "-" ? null : labelSchemaText}
/>
</Row>
<Row title="项目状态">
{record?.status ? (
<Badge
color={statusBadgeColor(record.status)}
variant="light">
{STATUS_CODE.get(record.status) ?? record.status}
</Badge>
) : (
<Text size="sm">-</Text>
)}
</Row>
<Row title="项目进度">
<Text size="sm">{progressText}</Text>
</Row>
<Row title="总数据量">
<Text size="sm">{record?.data_size ?? "-"}</Text>
</Row>
<Row title="项目负责人">
<Text size="sm">{record?.owner ?? "-"}</Text>
</Row>
<Row
title={
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
</Text>
<Tooltip label="(静态倍速/动态倍速)" withArrow>
<IconInfoCircle
size={14}
color="var(--mantine-color-dimmed)"
/>
</Tooltip>
</Group>
}>
<EllipsisText
value={processText === "-" ? null : processText}
/>
</Row>
<Row title="项目管理员">
<EllipsisText
value={adminUsers.length ? adminUsers.join("") : null}
/>
</Row>
<Row title="数据源">
<EllipsisText value={record?.data_source ?? null} copyable />
</Row>
<Row title="备注">
<EllipsisText value={record?.remarks ?? null} />
</Row>
<Row title="创建时间">
<Text size="sm">{record?.create_at ?? "-"}</Text>
</Row>
<Row title="更新时间">
<Text size="sm">{record?.update_at ?? "-"}</Text>
</Row>
</Stack>
</div>
)
})()}
</Modal>
<Modal
opened={editModalRecord !== null}
onClose={() => setEditModalRecord(null)}
title="编辑项目"
centered
size={480}>
<form
onSubmit={editForm.onSubmit(async (values) => {
const record = editModalRecord
if (!record) return
try {
await projectEditById(
{
project_name: values.project_name,
// owner: values.owner || undefined,
remarks: values.remarks || undefined,
members: values.members?.map((v) => Number(v)) || undefined,
},
record.id
)
notifications.show({
color: "green",
title: "编辑成功",
message: "已保存",
})
setEditModalRecord(null)
setQueryTrigger((v) => v + 1)
} catch (e) {
notifications.show({
color: "red",
title: "编辑失败",
message: e instanceof Error ? e.message : "请求失败",
})
}
})}>
<Stack gap="sm">
<TextInput
label="项目名称"
size="xs"
radius="xs"
{...editForm.getInputProps("project_name")}
/>
{/* <TextInput
label="负责人"
size="xs"
radius="xs"
{...editForm.getInputProps("owner")}
/> */}
<MultiSelect
label="成员"
size="xs"
radius="xs"
searchable
clearable
data={userOpts.map((o) => ({
label: o.label,
value: String(o.value),
}))}
{...editForm.getInputProps("members")}
/>
<Textarea
label="备注"
size="xs"
radius="xs"
minRows={3}
autosize
{...editForm.getInputProps("remarks")}
/>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => setEditModalRecord(null)}>
</Button>
<Button type="submit" size="xs" radius="xs">
</Button>
</Group>
</Stack>
</form>
</Modal>
<Modal
opened={confirmAction.record !== null && confirmAction.action !== null}
onClose={() => setConfirmAction({ record: null, action: null })}
title={confirmActionTitle}
centered
size={520}>
<Stack gap="sm">
<Text size="sm">
{confirmAction.record?.name ?? "-"}
{confirmAction.action ?? "-"}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="default"
size="xs"
radius="xs"
onClick={() => setConfirmAction({ record: null, action: null })}>
</Button>
<Button onClick={runConfirmAction} size="xs" radius="xs">
</Button>
</Group>
</Stack>
</Modal>
</Stack>
)
}