Merge branch 'zh' into 'main'

Zh

See merge request prism/lable/labelimage!6
This commit is contained in:
刘耀勇
2026-04-07 09:59:11 +08:00
20 changed files with 2361 additions and 327 deletions

View File

@@ -9,12 +9,9 @@ import {
} from "@/components/label/store/auth" } from "@/components/label/store/auth"
import { OwnTaskStatusOpts } from "@/components/label/utils/constants" import { OwnTaskStatusOpts } from "@/components/label/utils/constants"
import { import {
ActionIcon,
Button, Button,
Collapse, Collapse,
Flex,
Group, Group,
Paper,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -28,9 +25,17 @@ import {
IconRefresh, IconRefresh,
IconSearch, IconSearch,
} from "@tabler/icons-react" } from "@tabler/icons-react"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import {
SettingContentPanel,
SettingDataTable,
SettingFilterActions,
SettingFilterPanel,
SettingHeaderActions,
SettingListHeader,
} from "@/components/setting/PageSurface"
import TaskStatusTag from "./components/TaskStatusTag" import TaskStatusTag from "./components/TaskStatusTag"
function createInitialFilters() { function createInitialFilters() {
@@ -109,7 +114,6 @@ export default function OwnTaskTableContainer(props: {
} }
return next return next
}, [ }, [
,
form.search_id, form.search_id,
form.search_status, form.search_status,
pageNumber, pageNumber,
@@ -173,6 +177,24 @@ export default function OwnTaskTableContainer(props: {
} }
} }
const handleSearch = () => {
setPageNumber(1)
setForm({ ...filters })
setQueryTrigger((v) => v + 1)
}
const handleReset = () => {
const next = createInitialFilters()
setFilters(next)
setForm(next)
setPageNumber(1)
setQueryTrigger((v) => v + 1)
}
const handleRefresh = () => {
setQueryTrigger((v) => v + 1)
}
const columns = useMemo(() => { const columns = useMemo(() => {
const cols: DataTableColumn<Task.DataProps>[] = [ const cols: DataTableColumn<Task.DataProps>[] = [
{ {
@@ -267,12 +289,11 @@ export default function OwnTaskTableContainer(props: {
return ( return (
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}> <Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
<Paper withBorder p="sm" radius="sm"> <SettingFilterPanel>
<Group justify="space-between" wrap="wrap" gap="sm"> <Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs"> <Group gap="xs">
<Text fw={700}></Text> <Text fw={700}></Text>
<Button <Button
size="xs"
variant="transparent" variant="transparent"
rightSection={ rightSection={
searchExpanded ? ( searchExpanded ? (
@@ -285,43 +306,20 @@ export default function OwnTaskTableContainer(props: {
{searchExpanded ? "收起筛选" : "展开筛选"} {searchExpanded ? "收起筛选" : "展开筛选"}
</Button> </Button>
</Group> </Group>
<Group gap="xs">
<Button
size="xs"
radius={"xs"}
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => {
const next = createInitialFilters()
setFilters(next)
setForm(next)
setPageNumber(1)
}}>
</Button>
<Button
size="xs"
radius={"xs"}
leftSection={<IconSearch size={16} />}
onClick={() => {
setPageNumber(1)
setForm({ ...filters })
}}>
</Button>
</Group>
</Group> </Group>
<Collapse <Collapse
in={searchExpanded} in={searchExpanded}
transitionDuration={250} transitionDuration={250}
transitionTimingFunction="ease" transitionTimingFunction="ease"
animateOpacity> animateOpacity>
<Stack gap="sm" mt="sm"> <Stack gap="md" mt="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm"> <SimpleGrid
cols={{ base: 1, sm: 2, md: 3, lg: 4 }}
spacing="md"
verticalSpacing="md">
<TextInput <TextInput
size="xs"
radius="xs"
label="任务ID" label="任务ID"
placeholder="请输入任务 ID"
value={filters.search_id} value={filters.search_id}
onChange={(e) => onChange={(e) =>
setFilters((s) => ({ setFilters((s) => ({
@@ -332,8 +330,6 @@ export default function OwnTaskTableContainer(props: {
style={{ width: 160 }} style={{ width: 160 }}
/> />
<Select <Select
size="xs"
radius="xs"
label="状态" label="状态"
data={Object.entries(OwnTaskStatusOpts).map( data={Object.entries(OwnTaskStatusOpts).map(
([value, label]) => ({ ([value, label]) => ({
@@ -346,60 +342,70 @@ export default function OwnTaskTableContainer(props: {
setFilters((s) => ({ ...s, search_status: v ?? "0" })) setFilters((s) => ({ ...s, search_status: v ?? "0" }))
} }
allowDeselect={false} allowDeselect={false}
style={{ width: 160 }}
/> />
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Collapse> </Collapse>
</Paper> <SettingFilterActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</SettingFilterPanel>
<Paper <SettingContentPanel
withBorder
p="md"
radius="sm"
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}>
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}> <Stack gap="sm" align="stretch" style={{ flex: 1, minHeight: 0 }}>
<Flex justify={"space-between"} align="center" w="100%"> <SettingListHeader
<Flex gap={12}> title="我的任务"
count={`${total}`}
actions={
<SettingHeaderActions>
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={handleRefresh}
loading={loading}>
</Button>
<Button <Button
size="xs"
radius="xs"
variant="default" variant="default"
disabled={!isLabel} disabled={!isLabel}
onClick={() => handleAcquireTask(1)}> onClick={() => handleAcquireTask(1)}>
</Button> </Button>
<Button <Button
size="xs"
radius="xs"
variant="default" variant="default"
disabled={!isReview1} disabled={!isReview1}
onClick={() => handleAcquireTask(2)}> onClick={() => handleAcquireTask(2)}>
</Button> </Button>
<Button <Button
size="xs"
radius="xs"
variant="default" variant="default"
disabled={!isReview2} disabled={!isReview2}
onClick={() => handleAcquireTask(3)}> onClick={() => handleAcquireTask(3)}>
</Button> </Button>
</Flex> </SettingHeaderActions>
<ActionIcon onClick={load} variant="transparent"> }
<IconRefresh size={16} /> />
</ActionIcon> <SettingDataTable<Task.DataProps>
</Flex>
<DataTable<Task.DataProps>
width="100%" width="100%"
style={{ width: "100%" }} style={{ width: "100%", flex: 1 }}
withTableBorder
withRowBorders
pinFirstColumn pinFirstColumn
pinLastColumn pinLastColumn
scrollAreaProps={{ type: "auto" }} scrollAreaProps={{ type: "auto" }}
@@ -415,11 +421,9 @@ export default function OwnTaskTableContainer(props: {
setPageNumber(1) setPageNumber(1)
}} }}
recordsPerPageOptions={[10, 15, 20]} recordsPerPageOptions={[10, 15, 20]}
noRecordsText="暂无数据"
recordsPerPageLabel="当前页数"
/> />
</Stack> </Stack>
</Paper> </SettingContentPanel>
</Stack> </Stack>
) )
} }

View File

@@ -12,13 +12,19 @@ import {
TaskStatusOpts, TaskStatusOpts,
} from "@/components/label/utils/constants" } from "@/components/label/utils/constants"
import { import {
ActionIcon, SettingContentPanel,
SettingDataTable,
SettingFilterActions,
SettingFilterPanel,
SettingHeaderActions,
SettingListHeader,
} from "@/components/setting/PageSurface"
import {
Button, Button,
Collapse, Collapse,
Flex, Flex,
Group, Group,
MultiSelect, MultiSelect,
Paper,
Select, Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
@@ -35,7 +41,7 @@ import {
} from "@tabler/icons-react" } from "@tabler/icons-react"
import dayjs from "dayjs" import dayjs from "dayjs"
import duration from "dayjs/plugin/duration" import duration from "dayjs/plugin/duration"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import * as XLSX from "xlsx-js-style" import * as XLSX from "xlsx-js-style"
@@ -125,7 +131,6 @@ export default function TaskTableContainer(props: {
} }
}) { }) {
const { info, userEnums } = props const { info, userEnums } = props
false && console.log(info)
const { user_id } = usePermissionStore() const { user_id } = usePermissionStore()
@@ -444,6 +449,24 @@ export default function TaskTableContainer(props: {
} catch {} } catch {}
} }
const handleSearch = () => {
setPageNumber(1)
setForm({ ...filters })
setQueryTrigger((v) => v + 1)
}
const handleReset = () => {
const next = createInitialFilters()
setFilters(next)
setForm(next)
setPageNumber(1)
setQueryTrigger((v) => v + 1)
}
const handleRefresh = () => {
setQueryTrigger((v) => v + 1)
}
const getTime = (time: number) => { const getTime = (time: number) => {
return dayjs.duration(time, "seconds").format("HH:mm:ss") return dayjs.duration(time, "seconds").format("HH:mm:ss")
} }
@@ -775,12 +798,11 @@ export default function TaskTableContainer(props: {
return ( return (
<> <>
<Stack gap="sm" style={{ flex: 1, minHeight: 0 }}> <Stack gap="sm" style={{ flex: 1, minHeight: 0 }}>
<Paper withBorder p="sm" radius="sm"> <SettingFilterPanel>
<Group justify="space-between" wrap="wrap" gap="sm"> <Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs"> <Group gap="xs">
<Text fw={700}></Text> <Text fw={700}></Text>
<Button <Button
size="xs"
variant="transparent" variant="transparent"
rightSection={ rightSection={
searchExpanded ? ( searchExpanded ? (
@@ -793,43 +815,20 @@ export default function TaskTableContainer(props: {
{searchExpanded ? "收起筛选" : "展开筛选"} {searchExpanded ? "收起筛选" : "展开筛选"}
</Button> </Button>
</Group> </Group>
<Group gap="xs">
<Button
size="xs"
radius={"xs"}
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => {
const next = createInitialFilters()
setFilters(next)
setForm(next)
setPageNumber(1)
}}>
</Button>
<Button
size="xs"
radius={"xs"}
leftSection={<IconSearch size={16} />}
onClick={() => {
setPageNumber(1)
setForm({ ...filters })
}}>
</Button>
</Group>
</Group> </Group>
<Collapse <Collapse
in={searchExpanded} in={searchExpanded}
transitionDuration={250} transitionDuration={250}
transitionTimingFunction="ease" transitionTimingFunction="ease"
animateOpacity> animateOpacity>
<Stack gap="sm" mt="sm"> <Stack gap="md" mt="md">
<SimpleGrid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing="sm"> <SimpleGrid
cols={{ base: 1, sm: 2, md: 3, lg: 4 }}
spacing="md"
verticalSpacing="md">
<TextInput <TextInput
size="xs"
radius="xs"
label="任务ID" label="任务ID"
placeholder="请输入任务 ID"
value={filters.search_id} value={filters.search_id}
onChange={(e) => onChange={(e) =>
setFilters((s) => ({ setFilters((s) => ({
@@ -839,8 +838,6 @@ export default function TaskTableContainer(props: {
} }
/> />
<Select <Select
size="xs"
radius="xs"
label="任务状态" label="任务状态"
data={Object.entries(TaskStatusOpts).map( data={Object.entries(TaskStatusOpts).map(
([value, label]) => ({ ([value, label]) => ({
@@ -855,8 +852,6 @@ export default function TaskTableContainer(props: {
allowDeselect={false} allowDeselect={false}
/> />
<Select <Select
size="xs"
radius="xs"
label="是否返工" label="是否返工"
data={[ data={[
{ label: "全部", value: "" }, { label: "全部", value: "" },
@@ -871,8 +866,6 @@ export default function TaskTableContainer(props: {
/> />
<MultiSelect <MultiSelect
label="当前负责人" label="当前负责人"
size="xs"
radius="xs"
data={Array.from(userEnums.allEnum.entries()).map( data={Array.from(userEnums.allEnum.entries()).map(
([x, y]) => ({ label: y, value: x.toString() }) ([x, y]) => ({ label: y, value: x.toString() })
)} )}
@@ -885,8 +878,6 @@ export default function TaskTableContainer(props: {
/> />
<MultiSelect <MultiSelect
label="标注员" label="标注员"
size="xs"
radius="xs"
data={Array.from(userEnums.labelEnum.entries()).map( data={Array.from(userEnums.labelEnum.entries()).map(
([x, y]) => ({ label: y, value: x.toString() }) ([x, y]) => ({ label: y, value: x.toString() })
)} )}
@@ -897,8 +888,6 @@ export default function TaskTableContainer(props: {
/> />
<MultiSelect <MultiSelect
label="审核员" label="审核员"
size="xs"
radius="xs"
data={Array.from(userEnums.review1Enum.entries()).map( data={Array.from(userEnums.review1Enum.entries()).map(
([x, y]) => ({ label: y, value: x.toString() }) ([x, y]) => ({ label: y, value: x.toString() })
)} )}
@@ -911,8 +900,6 @@ export default function TaskTableContainer(props: {
/> />
<MultiSelect <MultiSelect
label="复审员" label="复审员"
size="xs"
radius="xs"
data={Array.from(userEnums.review2Enum.entries()).map( data={Array.from(userEnums.review2Enum.entries()).map(
([x, y]) => ({ label: y, value: x.toString() }) ([x, y]) => ({ label: y, value: x.toString() })
)} )}
@@ -926,54 +913,69 @@ export default function TaskTableContainer(props: {
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Collapse> </Collapse>
</Paper> <SettingFilterActions>
<Paper <Button
withBorder variant="default"
p="md" leftSection={<IconRefresh size={16} />}
radius="sm" onClick={handleReset}>
</Button>
<Button
leftSection={<IconSearch size={16} />}
onClick={handleSearch}
loading={loading}>
</Button>
</SettingFilterActions>
</SettingFilterPanel>
<SettingContentPanel
style={{ style={{
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}}> }}>
<Stack gap={12} align="stretch" style={{ flex: 1, minHeight: 0 }}> <Stack gap="sm" align="stretch" style={{ flex: 1, minHeight: 0 }}>
<Flex justify={"space-between"} align="center" w="100%"> <SettingListHeader
<Flex gap={12}> title="任务列表"
count={
selectedRecords.length
? `${total} 条 / 已选 ${selectedRecords.length}`
: `${total}`
}
actions={
<SettingHeaderActions>
<Button <Button
size="xs" variant="default"
radius="xs" leftSection={<IconRefresh size={16} />}
variant="outline" onClick={handleRefresh}
loading={loading}>
</Button>
<Button
variant="default"
disabled={!selectedRecords.length} disabled={!selectedRecords.length}
onClick={handleExport}> onClick={handleExport}>
</Button> </Button>
<Button <Button
size="xs" variant="default"
radius="xs"
variant="outline"
disabled={!selectedRecords.length} disabled={!selectedRecords.length}
onClick={clickDispatchTask}> onClick={clickDispatchTask}>
</Button> </Button>
<Button <Button
size="xs" variant="default"
radius="xs"
variant="outline"
disabled={!selectedRecords.length} disabled={!selectedRecords.length}
onClick={clickReleaseTask}> onClick={clickReleaseTask}>
</Button> </Button>
</Flex> </SettingHeaderActions>
<ActionIcon onClick={load} variant="transparent"> }
<IconRefresh size={16} /> />
</ActionIcon> <SettingDataTable<Task.DataProps>
</Flex>
<DataTable<Task.DataProps>
width="100%" width="100%"
style={{ width: "100%" }} style={{ width: "100%", flex: 1 }}
withTableBorder
withRowBorders
pinFirstColumn pinFirstColumn
pinLastColumn pinLastColumn
scrollAreaProps={{ type: "auto" }} scrollAreaProps={{ type: "auto" }}
@@ -991,11 +993,9 @@ export default function TaskTableContainer(props: {
selectedRecords={selectedRecords} selectedRecords={selectedRecords}
onSelectedRecordsChange={setSelectedRecords} onSelectedRecordsChange={setSelectedRecords}
recordsPerPageOptions={[10, 15, 20]} recordsPerPageOptions={[10, 15, 20]}
noRecordsText="暂无数据"
recordsPerPageLabel="当前页数"
/> />
</Stack> </Stack>
</Paper> </SettingContentPanel>
</Stack> </Stack>
<WorkflowModal <WorkflowModal

View File

@@ -6,8 +6,9 @@ import {
selectModeMap, selectModeMap,
staticsColor, staticsColor,
} from "@/components/label/utils/constants" } from "@/components/label/utils/constants"
import { SettingDataTable } from "@/components/setting/PageSurface"
import { Badge, Button, Group, Modal, Paper, Stack, Text } from "@mantine/core" import { Badge, Button, Group, Modal, Paper, Stack, Text } from "@mantine/core"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { useState } from "react" import { useState } from "react"
type DetailRow = ProjectDetail.SubAttributesDescribe type DetailRow = ProjectDetail.SubAttributesDescribe
@@ -188,9 +189,7 @@ export default function LabelSchemeContainer(props: {
}}> }}>
<Stack gap="sm"> <Stack gap="sm">
<Text fw={700}></Text> <Text fw={700}></Text>
<DataTable<ProjectDetail.LabelSchemaList> <SettingDataTable<ProjectDetail.LabelSchemaList>
withTableBorder
withRowBorders
idAccessor="category_id" idAccessor="category_id"
records={schemas} records={schemas}
columns={columns} columns={columns}
@@ -209,9 +208,7 @@ export default function LabelSchemeContainer(props: {
title="子属性描述" title="子属性描述"
centered centered
size="70%"> size="70%">
<DataTable<DetailRow> <SettingDataTable<DetailRow>
withTableBorder
withRowBorders
records={detailRows} records={detailRows}
columns={detailColumns} columns={detailColumns}
noRecordsText="暂无数据" noRecordsText="暂无数据"

View File

@@ -3,6 +3,7 @@
import { projectConfig } from "@/components/label/api/project" import { projectConfig } from "@/components/label/api/project"
import { ProjectDetail } from "@/components/label/api/project/typing" import { ProjectDetail } from "@/components/label/api/project/typing"
import { usePermissionStore } from "@/components/label/store/auth" import { usePermissionStore } from "@/components/label/store/auth"
import { SettingDataTable } from "@/components/setting/PageSurface"
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -18,7 +19,7 @@ import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications" import { notifications } from "@mantine/notifications"
import { IconPlus, IconTrash } from "@tabler/icons-react" import { IconPlus, IconTrash } from "@tabler/icons-react"
import dayjs from "dayjs" import dayjs from "dayjs"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { useMemo, useState } from "react" import { useMemo, useState } from "react"
type RulePayload = { type RulePayload = {
@@ -221,9 +222,7 @@ export default function NoteRulesTable(props: {
</Text> </Text>
</Stack> </Stack>
<DataTable<RuleRow> <SettingDataTable<RuleRow>
withTableBorder
withRowBorders
records={rows} records={rows}
columns={columns} columns={columns}
noRecordsText="暂无规则" noRecordsText="暂无规则"

View File

@@ -2,6 +2,7 @@
import { projectConfig } from "@/components/label/api/project" import { projectConfig } from "@/components/label/api/project"
import { Project, ProjectDetail } from "@/components/label/api/project/typing" import { Project, ProjectDetail } from "@/components/label/api/project/typing"
import { SettingDataTable } from "@/components/setting/PageSurface"
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -22,7 +23,7 @@ import {
import { modals } from "@mantine/modals" import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications" import { notifications } from "@mantine/notifications"
import { IconEdit, IconPlus, IconX } from "@tabler/icons-react" import { IconEdit, IconPlus, IconX } from "@tabler/icons-react"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { ReactNode, useEffect, useMemo, useState } from "react" import { ReactNode, useEffect, useMemo, useState } from "react"
import StageUserModal from "./StageUserModal" import StageUserModal from "./StageUserModal"
import { import {
@@ -1429,9 +1430,7 @@ export default function TaskStageTabsContainer(props: {
</Button> </Button>
</Group> </Group>
<DataTable<StageUserRow> <SettingDataTable<StageUserRow>
withTableBorder
withRowBorders
records={rows} records={rows}
columns={userColumns(stage)} columns={userColumns(stage)}
noRecordsText="暂无人员" noRecordsText="暂无人员"

View File

@@ -1,9 +1,10 @@
"use client" "use client"
import { getTaskWorkFlow } from "@/components/label/api/task" import { getTaskWorkFlow } from "@/components/label/api/task"
import { SettingDataTable } from "@/components/setting/PageSurface"
import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core" import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"
import { notifications } from "@mantine/notifications" import { notifications } from "@mantine/notifications"
import { DataTable, DataTableColumn } from "mantine-datatable" import { DataTableColumn } from "mantine-datatable"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import TaskStatusTag from "./TaskStatusTag" import TaskStatusTag from "./TaskStatusTag"
@@ -139,15 +140,13 @@ export default function WorkflowModal(props: {
size={720}> size={720}>
<Stack gap="sm"> <Stack gap="sm">
<ScrollArea h={360} type="auto"> <ScrollArea h={360} type="auto">
<DataTable<WorkflowRow> <SettingDataTable<WorkflowRow>
withTableBorder
withRowBorders
pinLastColumn pinLastColumn
fetching={loading}
idAccessor="__rowId" idAccessor="__rowId"
records={rows} records={rows}
width={Math.max(columns.length * 140, 640)} width={Math.max(columns.length * 140, 640)}
columns={columns} columns={columns}
noRecordsText={loading ? "加载中..." : "暂无数据"}
minHeight={220} minHeight={220}
/> />
</ScrollArea> </ScrollArea>

View File

@@ -7,13 +7,16 @@ import {
Breadcrumbs, Breadcrumbs,
Flex, Flex,
Group, Group,
Paper,
Stack, Stack,
Text, Text,
UnstyledButton, UnstyledButton,
} from "@mantine/core" } from "@mantine/core"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import {
SettingFilterPanel,
SettingPage,
} from "@/components/setting/PageSurface"
import InfoSettingContainer from "./InfoSettingContainer" import InfoSettingContainer from "./InfoSettingContainer"
import OwnTaskTableContainer from "./OwnTaskTableContainer" import OwnTaskTableContainer from "./OwnTaskTableContainer"
import TaskBoardContainer from "./TaskBoardContainer" import TaskBoardContainer from "./TaskBoardContainer"
@@ -193,15 +196,15 @@ export default function CollectionDetailPage() {
} }
return ( return (
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md"> <SettingPage>
<Paper withBorder p="md" radius="sm"> <SettingFilterPanel>
<Group justify="space-between" wrap="wrap" gap="sm"> <Group justify="space-between" wrap="wrap" gap="sm">
<Flex gap="sm" w="100%" justify="space-between"> <Flex gap="sm" w="100%" justify="space-between">
<Breadcrumbs separator=">">{breadcrumbsItems}</Breadcrumbs> <Breadcrumbs separator=">">{breadcrumbsItems}</Breadcrumbs>
{DetailMenuItems()} {DetailMenuItems()}
</Flex> </Flex>
</Group> </Group>
</Paper> </SettingFilterPanel>
<Stack style={{ flex: 1, minHeight: 0 }} gap="md"> <Stack style={{ flex: 1, minHeight: 0 }} gap="md">
{selectKey === "own" ? ( {selectKey === "own" ? (
@@ -219,6 +222,6 @@ export default function CollectionDetailPage() {
/> />
) : null} ) : null}
</Stack> </Stack>
</Stack> </SettingPage>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
"use client"
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from "react"
interface AssistShapeComponentProps {
size: number
}
const AssistShapeComponent = (
props: AssistShapeComponentProps,
ref: React.Ref<unknown> | undefined
) => {
const { size } = props
const canvasRef = useRef<HTMLCanvasElement>(null)
const drawAssistShape = useCallback(
(centerX: number, centerY: number) => {
const canvas = canvasRef.current
const ctx = canvas?.getContext("2d")
if (!canvas || !ctx) return
const radius = Math.max(1, size)
const side = radius * 2
const left = centerX - radius
const top = centerY - radius
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.beginPath()
ctx.strokeStyle = "#EF4444"
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.strokeRect(left, top, side, side)
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2)
ctx.stroke()
},
[size]
)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const rect = canvas.getBoundingClientRect()
drawAssistShape(rect.width / 2, rect.height / 2)
}, [drawAssistShape])
const updateAssistShape = (event: { clientX: number; clientY: number }) => {
const canvas = canvasRef.current
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const centerX = event.clientX - rect.left
const centerY = event.clientY - rect.top
drawAssistShape(centerX, centerY)
}
useImperativeHandle(ref, () => ({ updateAssistShape }))
return (
<canvas
ref={canvasRef}
style={{
backgroundColor: "transparent",
position: "absolute",
zIndex: 52,
pointerEvents: "none",
}}
/>
)
}
export default forwardRef(AssistShapeComponent)

View File

@@ -21,6 +21,7 @@ const CrosshairComponent = (
const { scale } = useTopToolsStore() const { scale } = useTopToolsStore()
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
const lastPointerRef = useRef<{ x: number; y: number } | null>(null)
const getFixed = (sparsity: number) => { const getFixed = (sparsity: number) => {
const pointIdx = String(sparsity).indexOf(".") const pointIdx = String(sparsity).indexOf(".")
@@ -76,6 +77,7 @@ const CrosshairComponent = (
ctx.beginPath() ctx.beginPath()
ctx.setLineDash([]) ctx.setLineDash([])
ctx.strokeStyle = "#EF4444" ctx.strokeStyle = "#EF4444"
ctx.fillStyle = "#EF4444"
let carveLineWidth = 1 let carveLineWidth = 1
ctx.lineWidth = carveLineWidth ctx.lineWidth = carveLineWidth
@@ -100,6 +102,7 @@ const CrosshairComponent = (
sparsity sparsity
).toFixed(fixed) ).toFixed(fixed)
const textWidth = ctx.measureText(text).width const textWidth = ctx.measureText(text).width
ctx.fillStyle = "#EF4444"
ctx.fillText(text, indexX - textWidth / 2, centerY - tickLength) ctx.fillText(text, indexX - textWidth / 2, centerY - tickLength)
} else { } else {
ctx.moveTo(indexX, centerY - tickLength / 2 - carveLineWidth) ctx.moveTo(indexX, centerY - tickLength / 2 - carveLineWidth)
@@ -143,7 +146,11 @@ const CrosshairComponent = (
canvas.width = window.innerWidth canvas.width = window.innerWidth
canvas.height = window.innerHeight canvas.height = window.innerHeight
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect()
drawCrosshair(rect.width / 2, rect.height / 2) const center = lastPointerRef.current || {
x: rect.width / 2,
y: rect.height / 2,
}
drawCrosshair(center.x, center.y)
} }
}, [drawCrosshair]) }, [drawCrosshair])
@@ -153,6 +160,7 @@ const CrosshairComponent = (
const rect = canvas.getBoundingClientRect() const rect = canvas.getBoundingClientRect()
const centerX = event.clientX - rect.left const centerX = event.clientX - rect.left
const centerY = event.clientY - rect.top const centerY = event.clientY - rect.top
lastPointerRef.current = { x: centerX, y: centerY }
drawCrosshair(centerX, centerY) drawCrosshair(centerX, centerY)
} }
} }

View File

@@ -59,6 +59,7 @@ import { checkCommentsIsSame } from "../util"
import { safeClone } from "../utils/clone" import { safeClone } from "../utils/clone"
import { labelTypeMap } from "../utils/constants" import { labelTypeMap } from "../utils/constants"
import { adjustPoints } from "../utils/paperjs" import { adjustPoints } from "../utils/paperjs"
import AssistShapeComponent from "./AssistShapeComponent"
import CrosshairComponent from "./CrosshairComponent" import CrosshairComponent from "./CrosshairComponent"
import { renderOperationIcon } from "./RightObjectTools" import { renderOperationIcon } from "./RightObjectTools"
@@ -222,6 +223,8 @@ const PaperContainer = (
objectOperations, objectOperations,
imageFilter, imageFilter,
crosshairStatus, crosshairStatus,
assistToolEnabled,
assistToolSize,
showTags, showTags,
showTagsConfigs, showTagsConfigs,
} = useTopToolsStore() } = useTopToolsStore()
@@ -2363,10 +2366,14 @@ const PaperContainer = (
return ( return (
<Card <Card
ref={contextMenuRef} ref={contextMenuRef}
data-label-context-menu="true"
shadow="sm" shadow="sm"
padding="xs" padding="xs"
radius="md" radius="md"
withBorder withBorder
onWheel={(event) => {
event.stopPropagation()
}}
style={{ style={{
position: "absolute", position: "absolute",
zIndex: 1000, zIndex: 1000,
@@ -2667,9 +2674,11 @@ const PaperContainer = (
}, [activeImage]) }, [activeImage])
const crosshairComponentRef = useRef<any>(null) const crosshairComponentRef = useRef<any>(null)
const assistShapeComponentRef = useRef<any>(null)
const handleCrosshairMove = (event: { clientX: number; clientY: number }) => { const handleCrosshairMove = (event: { clientX: number; clientY: number }) => {
crosshairComponentRef.current?.updateCrosshair(event) crosshairComponentRef.current?.updateCrosshair(event)
assistShapeComponentRef.current?.updateAssistShape(event)
} }
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -2699,6 +2708,12 @@ const PaperContainer = (
ref={crosshairComponentRef} ref={crosshairComponentRef}
/> />
)} )}
{assistToolEnabled && (
<AssistShapeComponent
size={assistToolSize}
ref={assistShapeComponentRef}
/>
)}
{imgSize?.clientWidth && imgSize?.clientHeight && ( {imgSize?.clientWidth && imgSize?.clientHeight && (
<canvas <canvas
id="paper-canvas" id="paper-canvas"

View File

@@ -42,6 +42,7 @@ import { getShowContextMenuPosition, usePaperStore } from "../usePaperStore"
import { usePaperSupportStore } from "../usePaperSupportStore" import { usePaperSupportStore } from "../usePaperSupportStore"
import { useTopToolsStore } from "../useTopToolsStore" import { useTopToolsStore } from "../useTopToolsStore"
import { getSubAttrStatus } from "../util" import { getSubAttrStatus } from "../util"
import { toggleObjectHideByShortcut } from "../utils/objectVisibility"
import { labelTypeMap } from "../utils/constants" import { labelTypeMap } from "../utils/constants"
interface RightObjectToolsComponentProps { interface RightObjectToolsComponentProps {
@@ -379,33 +380,6 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
}) })
} }
const getTagVisibility = (tagCategory?: string) => {
const { showTags, showTagsConfigs } = useTopToolsStore.getState()
if (!showTags) return false
if (tagCategory === "mask") return showTagsConfigs.includes("外框")
if (tagCategory === "point_text") return showTagsConfigs.includes("点ID")
return true
}
const syncObjectItemsVisibility = useCallback(
(objectId: number, hide: boolean) => {
const sameIdItems = getItemsById(objectId)
if (!sameIdItems?.length) return
sameIdItems.forEach((item) => {
if (hide) {
item.visible = false
return
}
if (item.data?.type === "tag") {
item.visible = getTagVisibility(item.data?.tag_category)
return
}
item.visible = true
})
},
[getItemsById]
)
const operationHide = useCallback( const operationHide = useCallback(
(operationId: string, hide: boolean) => { (operationId: string, hide: boolean) => {
updateOperationStatus(activeImage + operationId, "HIDE", hide) updateOperationStatus(activeImage + operationId, "HIDE", hide)
@@ -414,51 +388,17 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
.get(activeImage) .get(activeImage)
?.get(operationId) ?.get(operationId)
?.map((child) => { ?.map((child) => {
updatePathStatus(activeImage + child[0], "HIDE", hide) toggleObjectHideByShortcut(activeImage, operationId, child[0], hide)
syncObjectItemsVisibility(child[0], hide)
// 隐藏时清空选中状态
if (hide) setSelectedItemFalse(child[0])
}) })
}, },
[ [activeImage, storeLabel, updateOperationStatus]
activeImage,
storeLabel,
syncObjectItemsVisibility,
updateOperationStatus,
updatePathStatus,
]
) )
const objectHide = useCallback( const objectHide = useCallback(
(operationId: string, objectId: number, hide: boolean) => { (operationId: string, objectId: number, hide: boolean) => {
if (hide) { toggleObjectHideByShortcut(activeImage, operationId, objectId, hide)
updatePathStatus(activeImage + objectId, "HIDE", hide)
syncObjectItemsVisibility(objectId, hide)
// 隐藏时清空选中状态
setSelectedItemFalse(objectId)
let childrenHideStatus = storeLabel
.get(activeImage)
?.get(operationId)
?.map((child) => {
return getItemById(child[0])!.visible
})
if (childrenHideStatus?.findIndex((bool) => bool === true) === -1) {
updateOperationStatus(activeImage + operationId, "HIDE", hide)
}
} else {
updateOperationStatus(activeImage + operationId, "HIDE", hide)
updatePathStatus(activeImage + objectId, "HIDE", hide)
syncObjectItemsVisibility(objectId, hide)
}
}, },
[ [activeImage]
activeImage,
getItemById,
storeLabel,
syncObjectItemsVisibility,
updateOperationStatus,
updatePathStatus,
]
) )
// const renderObjectName = useCallback((name: string, input: string) => { // const renderObjectName = useCallback((name: string, input: string) => {

View File

@@ -87,6 +87,7 @@ import {
import { useBackUrlStore, usePermissionStore } from "../store/auth" import { useBackUrlStore, usePermissionStore } from "../store/auth"
import { useBottomToolsStore } from "../useBottomToolsStore" import { useBottomToolsStore } from "../useBottomToolsStore"
import { useDescToolsStore } from "../useDescToolsStore" import { useDescToolsStore } from "../useDescToolsStore"
import { useKeyboardStore } from "../useKeyBoardStore"
import { usePaperStore } from "../usePaperStore" import { usePaperStore } from "../usePaperStore"
import { useRightToolsStore } from "../useRightToolsStore" import { useRightToolsStore } from "../useRightToolsStore"
import { useIntervalStore, useLabelTimeStore } from "../useTimerStore" import { useIntervalStore, useLabelTimeStore } from "../useTimerStore"
@@ -213,6 +214,10 @@ const TopTools = (
setImageFilter, setImageFilter,
crosshairStatus, crosshairStatus,
setCrosshairStatus, setCrosshairStatus,
assistToolEnabled,
setAssistToolEnabled,
assistToolSize,
setAssistToolSize,
needBackup, needBackup,
setNeedBackup, setNeedBackup,
checkSize, checkSize,
@@ -1531,6 +1536,7 @@ const TopTools = (
}, [activeOperation, mode]) }, [activeOperation, mode])
const operationSchema = getOperationSchema(activeOperation) const operationSchema = getOperationSchema(activeOperation)
const drawAction = useKeyboardStore((state) => state.drawAction)
const subAttrForm = ( const subAttrForm = (
<Box w={256}> <Box w={256}>
@@ -1638,12 +1644,19 @@ const TopTools = (
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
handleSave, handleSave,
handleBackup, handleBackup,
commitTaskByActionKey,
})) }))
const renderEditIcon = useMemo(() => { const renderEditIcon = useMemo(() => {
const iconStyle = { width: 20, height: 20 } const iconStyle = { width: 20, height: 20 }
if (mode === "support") return <Paintbrush style={iconStyle} /> if (mode === "support") return <Paintbrush style={iconStyle} />
if (editMode) { if (editMode) {
if (pressA && drawAction === "subtract") {
return <CopyMinus style={iconStyle} />
}
if (pressA && drawAction === "add") {
return <CopyPlus style={iconStyle} />
}
return pressA ? ( return pressA ? (
<CirclePlus style={iconStyle} /> <CirclePlus style={iconStyle} />
) : ( ) : (
@@ -1652,7 +1665,7 @@ const TopTools = (
} else { } else {
return <MousePointer style={iconStyle} /> return <MousePointer style={iconStyle} />
} }
}, [mode, editMode, pressA]) }, [mode, editMode, pressA, drawAction])
const showTagConfigContainer = useMemo(() => { const showTagConfigContainer = useMemo(() => {
const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"] const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"]
@@ -2021,6 +2034,36 @@ const TopTools = (
/> />
)} )}
</Flex> </Flex>
<Flex justify="space-between" align="center" p={8} gap={4}>
<ActionIcon
variant="transparent"
style={{ cursor: "pointer" }}
c={assistToolEnabled ? "blue" : "var(--mantine-color-text)"}
onClick={() => {
setAssistToolEnabled(!assistToolEnabled)
}}>
<Eclipse size={16} />
</ActionIcon>
<NumberInput
value={assistToolSize}
w={58}
min={1}
size="xs"
hideControls
onChange={(v) => {
const next = Number(v)
if (Number.isFinite(next) && next >= 1) {
setAssistToolSize(next)
}
}}
onFocus={() => {
useKeyEventStore.getState().setFocusInput(true)
}}
onBlur={() => {
useKeyEventStore.getState().setFocusInput(false)
}}
/>
</Flex>
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<Flex justify="center" align="center"> <Flex justify="center" align="center">
<ActionIcon <ActionIcon

View File

@@ -5,6 +5,8 @@ interface KeyboardStore {
setCtrl: (v: boolean) => void setCtrl: (v: boolean) => void
shift: boolean shift: boolean
setShift: (v: boolean) => void setShift: (v: boolean) => void
drawAction: "none" | "add" | "subtract"
setDrawAction: (mode: "none" | "add" | "subtract") => void
copyDataIds: number[] // ctrl + c 复制数据 copyDataIds: number[] // ctrl + c 复制数据
setCopyDataIds: (ids: number[]) => void setCopyDataIds: (ids: number[]) => void
} }
@@ -12,8 +14,10 @@ interface KeyboardStore {
export const useKeyboardStore = create<KeyboardStore>((set) => ({ export const useKeyboardStore = create<KeyboardStore>((set) => ({
ctrl: false, ctrl: false,
shift: false, shift: false,
drawAction: "none",
copyDataIds: [], copyDataIds: [],
setCtrl: (v) => set((state) => ({ ...state, ctrl: v })), setCtrl: (v) => set((state) => ({ ...state, ctrl: v })),
setShift: (v) => set((state) => ({ ...state, shift: v })), setShift: (v) => set((state) => ({ ...state, shift: v })),
setDrawAction: (mode) => set((state) => ({ ...state, drawAction: mode })),
setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })), setCopyDataIds: (ids) => set((state) => ({ ...state, copyDataIds: ids })),
})) }))

View File

@@ -1,5 +1,6 @@
import { Comment } from "./api/label/typing" import { Comment } from "./api/label/typing"
import { Project } from "./api/project/typing" import { Project } from "./api/project/typing"
import { notifications } from "@mantine/notifications"
import dayjs from "dayjs" import dayjs from "dayjs"
import paper from "paper" import paper from "paper"
import { DispatchWithoutAction } from "react" import { DispatchWithoutAction } from "react"
@@ -12,12 +13,19 @@ import {
useObjectStore, useObjectStore,
} from "./store" } from "./store"
import { useBottomToolsStore } from "./useBottomToolsStore" import { useBottomToolsStore } from "./useBottomToolsStore"
import { useKeyboardStore } from "./useKeyBoardStore"
import { useOtherToolsStore } from "./useOtherToolsStore" import { useOtherToolsStore } from "./useOtherToolsStore"
import { usePaperSupportStore } from "./usePaperSupportStore" import { usePaperSupportStore } from "./usePaperSupportStore"
import { useRightToolsStore } from "./useRightToolsStore" import { useRightToolsStore } from "./useRightToolsStore"
import { useTopToolsStore } from "./useTopToolsStore" import { useTopToolsStore } from "./useTopToolsStore"
import { safeClone } from "./utils/clone" import { safeClone } from "./utils/clone"
interface ContinuePolygonTarget {
imageId: string
operationId: string
objectId: number
}
interface PaperState { interface PaperState {
paperScope: paper.PaperScope | null paperScope: paper.PaperScope | null
group: paper.Group | null group: paper.Group | null
@@ -35,6 +43,7 @@ interface PaperState {
rasterScale: { [x: string]: number } rasterScale: { [x: string]: number }
rasterSize: { [x: string]: [number, number] } rasterSize: { [x: string]: [number, number] }
reciprocalRasterScale: { [x: string]: number } reciprocalRasterScale: { [x: string]: number }
continuePolygonTarget: ContinuePolygonTarget | null
loadingData: boolean loadingData: boolean
init: ( init: (
initEl: HTMLCanvasElement, initEl: HTMLCanvasElement,
@@ -169,6 +178,7 @@ interface PaperState {
getAll: () => paper.Path[] getAll: () => paper.Path[]
getItemById: (id: number) => paper.Path | null getItemById: (id: number) => paper.Path | null
getItemsById: (id: number) => Array<paper.Path | paper.CompoundPath> getItemsById: (id: number) => Array<paper.Path | paper.CompoundPath>
setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void
setLoadingData: (flag: boolean) => void setLoadingData: (flag: boolean) => void
} }
@@ -519,6 +529,7 @@ export const usePaperStore = create<PaperState>((set) => ({
rasterScale: initialPaperState.rasterScale, rasterScale: initialPaperState.rasterScale,
rasterSize: initialPaperState.rasterSize, rasterSize: initialPaperState.rasterSize,
reciprocalRasterScale: initialPaperState.reciprocalRasterScale, reciprocalRasterScale: initialPaperState.reciprocalRasterScale,
continuePolygonTarget: null,
currentMousePosition: [0, 0], currentMousePosition: [0, 0],
loadingData: false, loadingData: false,
init: ( init: (
@@ -566,6 +577,11 @@ export const usePaperStore = create<PaperState>((set) => ({
...state, ...state,
loadingData: flag, loadingData: flag,
})), })),
setContinuePolygonTarget: (target) =>
set((state: PaperState) => ({
...state,
continuePolygonTarget: target,
})),
setCurrentMousePosition: (point: [number, number]) => setCurrentMousePosition: (point: [number, number]) =>
set((state: PaperState) => ({ set((state: PaperState) => ({
...state, ...state,
@@ -1409,26 +1425,16 @@ export const usePaperStore = create<PaperState>((set) => ({
}) })
// 标识位为true时选中路径隐藏tags; 没选中显示tags // 标识位为true时选中路径隐藏tags; 没选中显示tags
const configs = useTopToolsStore.getState().showTagsConfigs const configs = useTopToolsStore.getState().showTagsConfigs
if (useTopToolsStore.getState().showTags) if (useTopToolsStore.getState().showTags) {
if (panMode) { const setTagVisibleByConfig = (tag: any) => {
lastTags.forEach((tag) => {
if (tag.data.tag_category === "mask") if (tag.data.tag_category === "mask")
tag.visible = configs.includes("外框") tag.visible = configs.includes("外框")
else if (tag.data.tag_category === "point_text") else if (tag.data.tag_category === "point_text")
tag.visible = configs.includes("点ID") tag.visible = configs.includes("点ID")
else tag.visible = true else tag.visible = true
}) }
tags.forEach((tag) => { lastTags.forEach(setTagVisibleByConfig)
tag.visible = false tags.forEach(setTagVisibleByConfig)
})
} else {
tags.forEach((tag) => {
if (tag.data.tag_category === "mask")
tag.visible = configs.includes("外框")
else if (tag.data.tag_category === "point_text")
tag.visible = configs.includes("点ID")
else tag.visible = true
})
} }
console.log("鼠标按下 选中路径", activePath, "当前模式", panMode) console.log("鼠标按下 选中路径", activePath, "当前模式", panMode)
@@ -2678,6 +2684,9 @@ export const usePaperStore = create<PaperState>((set) => ({
let polygonTool = paperPolygonTool let polygonTool = paperPolygonTool
let currentMagnetPoint: paper.Point | null = null let currentMagnetPoint: paper.Point | null = null
let draftId: number | null = null let draftId: number | null = null
let continueSourceItems: Array<paper.Path | paper.CompoundPath> = []
let continueTargetContourIndex: number | null = null
let continueTargetContext: ContinuePolygonTarget | null = null
const handleCircles = (path: paper.Path | paper.CompoundPath | null) => { const handleCircles = (path: paper.Path | paper.CompoundPath | null) => {
// 更新选中的点 // 更新选中的点
@@ -2723,6 +2732,193 @@ export const usePaperStore = create<PaperState>((set) => ({
currentMagnetPoint = null currentMagnetPoint = null
} }
const getPointFromSegment = (segment: any): paper.Point | null => {
if (segment instanceof paper.Point) return segment
if (segment instanceof paper.Segment)
return new paper.Point(segment.point)
if (
Array.isArray(segment) &&
segment.length >= 2 &&
typeof segment[0] === "number" &&
typeof segment[1] === "number"
) {
return new paper.Point(segment[0], segment[1])
}
if (
Array.isArray(segment) &&
Array.isArray(segment[0]) &&
segment[0].length >= 2 &&
typeof segment[0][0] === "number" &&
typeof segment[0][1] === "number"
) {
return new paper.Point(segment[0][0], segment[0][1])
}
if (
segment?.point &&
typeof segment.point.x === "number" &&
typeof segment.point.y === "number"
) {
return new paper.Point(segment.point.x, segment.point.y)
}
if (
segment?.point &&
Array.isArray(segment.point) &&
segment.point.length >= 2 &&
typeof segment.point[0] === "number" &&
typeof segment.point[1] === "number"
) {
return new paper.Point(segment.point[0], segment.point[1])
}
return null
}
const clearContinueSourceItems = (restoreVisible: boolean) => {
continueSourceItems.forEach((item) => {
if (restoreVisible) {
item.visible = true
} else {
item.remove()
}
})
continueSourceItems = []
}
const clearContinuePolygonContext = (restoreVisible: boolean) => {
clearContinueSourceItems(restoreVisible)
continueTargetContourIndex = null
continueTargetContext = null
usePaperStore.getState().setContinuePolygonTarget(null)
}
const startContinuePolygonDraft = () => {
const target = usePaperStore.getState().continuePolygonTarget
if (!target) return false
const imageLabel = useLabelStore.getState().label.get(target.imageId)
const operationPaths = imageLabel?.get(target.operationId) || []
const targetTuple = operationPaths.find(
(path) => path[0] === target.objectId
)
if (!targetTuple) {
notifications.show({
color: "yellow",
message: "未找到可继续标注的对象",
})
clearContinuePolygonContext(true)
return false
}
const nextPoints = safeClone(targetTuple[1] || [])
let contourIndex = -1
for (let i = nextPoints.length - 1; i >= 0; i -= 1) {
if (nextPoints[i]?.length > 3) {
contourIndex = i
break
}
}
if (contourIndex === -1) {
notifications.show({
color: "yellow",
message: "当前对象没有可继续标注的区域",
})
clearContinuePolygonContext(true)
return false
}
const contourSegments = safeClone(nextPoints[contourIndex] || [])
const continueSegments = contourSegments.slice().reverse()
continueSegments.pop()
const contourPoints = continueSegments
.map((segment: any) => getPointFromSegment(segment))
.filter((point: paper.Point | null): point is paper.Point => !!point)
if (contourPoints.length < 3) {
notifications.show({
color: "yellow",
message: "当前区域至少保留3个节点无法继续撤回",
})
clearContinuePolygonContext(true)
return false
}
clearContinueSourceItems(true)
continueSourceItems = usePaperStore
.getState()
.getItemsById(target.objectId)
.filter((item) => item.data?.type === "polygon")
continueSourceItems.forEach((item) => {
item.visible = false
})
continueTargetContourIndex = contourIndex
continueTargetContext = target
points = contourPoints
draftId = target.objectId
polygon?.remove()
polygon = usePaperStore.getState().addPolygon(
renderPath,
points,
{
...usePaperStore.getState().toolOption,
},
{
imageId: target.imageId,
operationId: target.operationId,
id: target.objectId,
type: "polygon",
}
)
handleCircles(polygon)
lastPoint = usePaperStore
.getState()
.group!.localToGlobal(points[points.length - 1])
lastTime = Date.now()
clearPolygonMagnet()
return true
}
const saveContinuePolygonDraft = (draftPath: paper.Path) => {
const target =
continueTargetContext || usePaperStore.getState().continuePolygonTarget
if (!target) return false
const imageLabel = useLabelStore.getState().label.get(target.imageId)
const operationPaths = imageLabel?.get(target.operationId) || []
const targetTuple = operationPaths.find(
(path) => path[0] === target.objectId
)
if (!targetTuple) return false
const pathData = JSON.parse(draftPath.exportJSON({ precision: 20 }))
const nextContour = pathData?.[1]?.segments
if (!nextContour?.length) return false
const nextPoints = safeClone(targetTuple[1] || [])
const contourIndex =
continueTargetContourIndex !== null &&
continueTargetContourIndex >= 0 &&
continueTargetContourIndex < nextPoints.length
? continueTargetContourIndex
: nextPoints.length - 1
if (contourIndex < 0) return false
nextPoints[contourIndex] = nextContour
useLabelStore
.getState()
.setOperation(target.imageId, target.operationId, [
target.objectId,
nextPoints,
safeClone(targetTuple[2] || initialDetail),
safeClone(targetTuple[3] || []),
])
useObjectStore
.getState()
.updateSelectedPath(target.imageId, "ADD", target.objectId)
clearContinuePolygonContext(false)
forceUpdate()
return true
}
const rebuildPolygonDraft = () => { const rebuildPolygonDraft = () => {
polygon?.remove() polygon?.remove()
polygon = null polygon = null
@@ -2855,6 +3051,230 @@ export const usePaperStore = create<PaperState>((set) => ({
}) })
} }
// paper path data里可能带函数(例如事件回调), 不能直接structuredClone
const getSafePathData = (data: any) => {
const nextData: Record<string, any> = {}
Object.entries(data || {}).forEach(([key, value]) => {
if (typeof value === "function") return
nextData[key] = value
})
return nextData
}
const getSegmentPoint = (segment: any): [number, number] | null => {
if (
Array.isArray(segment) &&
segment.length >= 2 &&
typeof segment[0] === "number" &&
typeof segment[1] === "number"
) {
return [segment[0], segment[1]]
}
if (
Array.isArray(segment) &&
Array.isArray(segment[0]) &&
segment[0].length >= 2 &&
typeof segment[0][0] === "number" &&
typeof segment[0][1] === "number"
) {
return [segment[0][0], segment[0][1]]
}
if (
segment?.point &&
typeof segment.point.x === "number" &&
typeof segment.point.y === "number"
) {
return [segment.point.x, segment.point.y]
}
if (
segment?.point &&
Array.isArray(segment.point) &&
segment.point.length >= 2 &&
typeof segment.point[0] === "number" &&
typeof segment.point[1] === "number"
) {
return [segment.point[0], segment.point[1]]
}
return null
}
const normalizePolygonFromSegments = (segments: any[]) => {
const polygon = (segments || [])
.map((segment) => getSegmentPoint(segment))
.filter((point): point is [number, number] => !!point)
if (polygon.length > 1) {
const [firstX, firstY] = polygon[0]
const [lastX, lastY] = polygon[polygon.length - 1]
if (
Math.abs(firstX - lastX) < 0.000001 &&
Math.abs(firstY - lastY) < 0.000001
) {
polygon.pop()
}
}
return polygon
}
const isPointInPolygon = (
point: [number, number],
polygon: [number, number][]
) => {
if (polygon.length < 3) return false
const [x, y] = point
let inside = false
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, yi] = polygon[i]
const [xj, yj] = polygon[j]
const dy = yj - yi
if (yi > y !== yj > y) {
const crossX = ((xj - xi) * (y - yi)) / (dy || 0.000001) + xi
if (x < crossX) inside = !inside
}
}
return inside
}
const getHollowFlagsByContainment = (allSegments: any[][]) => {
const polygons = allSegments.map((segments) =>
normalizePolygonFromSegments(segments)
)
return polygons.map((polygon, index) => {
if (polygon.length < 3) return false
const samplePoint = polygon[0]
let containDepth = 0
polygons.forEach((otherPolygon, otherIndex) => {
if (otherIndex === index || otherPolygon.length < 3) return
if (isPointInPolygon(samplePoint, otherPolygon)) containDepth += 1
})
return containDepth % 2 === 1
})
}
const handleSelectedPolygonSubtract = (draftPath: paper.Path) => {
const activeImage = useBottomToolsStore.getState().activeImage
const selectedIds =
useObjectStore.getState().selectedPath[activeImage] || []
if (selectedIds.length !== 1) return false
const selectedId = selectedIds[0]
const targetShapes = usePaperStore
.getState()
.getItemsById(selectedId)
.filter((item) => item.data?.type === "polygon")
const targetShape =
targetShapes.find((item) => item instanceof paper.CompoundPath) ||
targetShapes.find(
(item) => item instanceof paper.Path && !item.data?.isHollowPolygon
) ||
targetShapes.find((item) => item instanceof paper.Path)
if (!targetShape) return false
let clippedDraft: paper.Path | paper.CompoundPath | null =
draftPath.intersect(usePaperStore.getState().rasterPath!, {
insert: false,
trace: false,
}) as paper.Path | paper.CompoundPath
draftPath.remove()
const draftHasSegments =
clippedDraft instanceof paper.CompoundPath
? (clippedDraft.children as paper.Path[]).some(
(child) => child.segments?.length
)
: clippedDraft.segments?.length
if (!draftHasSegments) {
clippedDraft?.remove()
clippedDraft = null
return false
}
const targetData = getSafePathData(targetShape.data || {})
let nextPolygon = (
targetShape as paper.Path | paper.CompoundPath
).subtract(clippedDraft) as paper.Path | paper.CompoundPath
clippedDraft?.remove()
clippedDraft = null
targetShape.remove()
const points: any[] = []
const hollowPoints: any[] = []
const nextPaths =
nextPolygon instanceof paper.CompoundPath
? (nextPolygon.children as paper.Path[])
: ([nextPolygon] as paper.Path[])
const nextPathSegments = nextPaths.map((path) => {
if (!path.segments?.length) return null
const pathData = JSON.parse(path.exportJSON({ precision: 20 }))
return pathData?.[1]?.segments || null
})
const hollowFlags = getHollowFlagsByContainment(
nextPathSegments.map((segments) => segments || [])
)
if (nextPolygon instanceof paper.CompoundPath) {
nextPolygon.data = Object.assign({}, targetData)
}
nextPaths.forEach((path, index) => {
const segments = nextPathSegments[index]
if (!segments?.length) return
const isHollowPolygon = !!hollowFlags[index]
path.data = Object.assign({}, targetData, {
isHollowPolygon,
})
if (isHollowPolygon) {
hollowPoints.push(segments)
} else {
points.push(segments)
}
})
const operationId = (
targetData.operationId ||
useTopToolsStore.getState().activeOperation ||
""
).toString()
if (!operationId) {
nextPolygon.remove()
return false
}
const prevDetail = safeClone(
useLabelStore
.getState()
.getLabelDetailDataByKeys(activeImage, operationId, selectedId) ||
initialDetail
)
if (points.length) {
useLabelStore
.getState()
.setOperation(activeImage, operationId, [
selectedId,
points,
prevDetail,
hollowPoints,
])
useObjectStore
.getState()
.updateSelectedPath(activeImage, "ADD", selectedId)
} else {
nextPolygon.remove()
useLabelStore
.getState()
.deleteOperation(activeImage, operationId, selectedId)
useObjectStore.getState().updateSelectedPath(activeImage, "ADD")
const nextLabel = safeClone(useLabelStore.getState().label)
useLabelStore.getState().setLabel(nextLabel)
useLabelStore.getState().pushStateStack(nextLabel)
}
forceUpdate()
return true
}
polygonTool.onMouseDown = (e: { polygonTool.onMouseDown = (e: {
event: MouseEvent event: MouseEvent
point: paper.Point point: paper.Point
@@ -2874,6 +3294,14 @@ export const usePaperStore = create<PaperState>((set) => ({
usePaperSupportStore.getState().clearAll() usePaperSupportStore.getState().clearAll()
} }
if (
!polygon &&
!points.length &&
usePaperStore.getState().continuePolygonTarget
) {
if (!startContinuePolygonDraft()) return
}
// 先判断是否存在吸附点 // 先判断是否存在吸附点
let checkPoint = currentMagnetPoint ? currentMagnetPoint : e.point let checkPoint = currentMagnetPoint ? currentMagnetPoint : e.point
// doubleclick // doubleclick
@@ -2897,35 +3325,107 @@ export const usePaperStore = create<PaperState>((set) => ({
// 初始化 compoundPolygon // 初始化 compoundPolygon
compoundPolygon = new paper.Path() compoundPolygon = new paper.Path()
if (usePaperStore.getState().continuePolygonTarget) {
;(polygon as paper.Path).closePath()
const continueSaved = saveContinuePolygonDraft(
polygon as paper.Path
)
if (!continueSaved) {
polygon.remove()
clearContinuePolygonContext(true)
}
selectedCircles.forEach((item) => {
item.remove()
})
selectedCircles = []
clearPolygonMagnet()
useTopToolsStore.getState().setPressA(false)
draftId = null
lastPoint = null
lastTime = 0
points = []
polygon = null
return
}
if (useKeyboardStore.getState().drawAction === "subtract") {
;(polygon as paper.Path).closePath()
handleSelectedPolygonSubtract(polygon as paper.Path)
selectedCircles.forEach((item) => {
item.remove()
})
selectedCircles = []
clearPolygonMagnet()
useTopToolsStore.getState().setPressA(false)
draftId = null
lastPoint = null
lastTime = 0
points = []
polygon = null
return
}
// pressA 模式下添加多边形 // pressA 模式下添加多边形
if (useTopToolsStore.getState().pressA) { if (useTopToolsStore.getState().pressA) {
let ids = let ids =
useObjectStore.getState().selectedPath[ useObjectStore.getState().selectedPath[
useBottomToolsStore.getState().activeImage useBottomToolsStore.getState().activeImage
] ]
const selectedId = ids?.[0]
if (!selectedId) {
resetPolygonDraft()
return
}
let paths = usePaperStore.getState().getItemsById(ids[0]) let paths = usePaperStore.getState().getItemsById(ids[0])
let oldPolygon = polygon let oldPolygon = polygon
// 闭合绘制路径 // 闭合绘制路径
oldPolygon.closePath() oldPolygon.closePath()
const polygonPaths = paths.filter(
(path) => path.data?.type === "polygon"
)
const prevCompoundPath =
(polygonPaths.find(
(path) => path instanceof paper.CompoundPath
) as paper.CompoundPath | undefined) ||
((polygonPaths.find(
(path) => path.parent instanceof paper.CompoundPath
)?.parent as paper.CompoundPath | undefined) ??
undefined)
if (paths.length === 1) { const basePathData = getSafePathData(
const prevPath = paths[0] polygonPaths.find((path) => !!path.data)?.data || {}
)
oldPolygon.data = Object.assign({}, basePathData, oldPolygon.data, {
id: selectedId,
type: "polygon",
isHollowPolygon: !oldPolygon.clockwise,
})
if (prevCompoundPath) {
// 已是多区域对象, 直接追加 child
prevCompoundPath.addChild(oldPolygon)
prevCompoundPath.data = Object.assign(
{},
getSafePathData(prevCompoundPath.data || {}),
basePathData,
{ id: selectedId, type: "polygon" }
)
polygon = prevCompoundPath
} else {
const prevPaths = polygonPaths.filter(
(path): path is paper.Path => path instanceof paper.Path
)
polygon = usePaperStore.getState().addCompoundPath( polygon = usePaperStore.getState().addCompoundPath(
renderCompoundPath, renderCompoundPath,
[prevPath, oldPolygon] as paper.Path[], [...prevPaths, oldPolygon] as paper.Path[],
{ {
// option // option
...usePaperStore.getState().toolOption, ...usePaperStore.getState().toolOption,
}, },
{ ...oldPolygon.data, id: prevPath?.data.id } { ...basePathData, ...oldPolygon.data, id: selectedId }
) )
} else if (paths.length > 1) {
const prevPath = paths.filter(
(path) => path instanceof paper.CompoundPath
)[0]
// 直接将当前路径添加到原有父路径
prevPath.addChild(oldPolygon)
polygon = prevPath
} }
} }
@@ -3271,8 +3771,13 @@ export const usePaperStore = create<PaperState>((set) => ({
} }
polygonTool.onKeyDown = (e: paper.KeyEvent) => { polygonTool.onKeyDown = (e: paper.KeyEvent) => {
if (e.key === "escape" || e.key === "alt") { if (e.key === "escape" || e.key === "alt") {
if (polygon || points.length) { if (
polygon ||
points.length ||
usePaperStore.getState().continuePolygonTarget
) {
resetPolygonDraft() resetPolygonDraft()
clearContinuePolygonContext(true)
usePaperSupportStore.getState().handleBufferPaths(null) usePaperSupportStore.getState().handleBufferPaths(null)
} }
} else if (e.key === "delete") { } else if (e.key === "delete") {
@@ -4817,6 +5322,14 @@ export const usePaperStore = create<PaperState>((set) => ({
circles.forEach((circle) => { circles.forEach((circle) => {
circle.scale(1 / newScale) circle.scale(1 / newScale)
}) })
const tagItems = usePaperStore.getState().group!.getItems({
data: { type: "tag" },
}) as paper.Item[]
tagItems.forEach((item) => {
if (item.data?.tag_category === "mask") return
item.scale(1 / newScale)
})
} }
}, },
getAll: () => { getAll: () => {

View File

@@ -72,6 +72,7 @@ const useRenderTag = () => {
parent: group, parent: group,
visible, visible,
}) })
// 文本框 // 文本框
const textMask = new paper.Path.Rectangle(text.bounds) const textMask = new paper.Path.Rectangle(text.bounds)
textMask.set({ textMask.set({

View File

@@ -28,6 +28,10 @@ interface TopToolsState {
) => void ) => void
crosshairStatus: "hidden" | "line" | "carve" crosshairStatus: "hidden" | "line" | "carve"
setCrosshairStatus: (val: "hidden" | "line" | "carve") => void setCrosshairStatus: (val: "hidden" | "line" | "carve") => void
assistToolEnabled: boolean
setAssistToolEnabled: (val: boolean) => void
assistToolSize: number
setAssistToolSize: (val: number) => void
showTags: boolean showTags: boolean
setShowTags: (val: boolean) => void setShowTags: (val: boolean) => void
showTagsConfigs: string[] showTagsConfigs: string[]
@@ -85,6 +89,8 @@ const initialTopToolsState = {
brightness: 0, brightness: 0,
}, },
crosshairStatus: "hidden" as "hidden" | "line" | "carve", crosshairStatus: "hidden" as "hidden" | "line" | "carve",
assistToolEnabled: false,
assistToolSize: 10,
drawOption: "default" as "default" | "intersect" | "unite", drawOption: "default" as "default" | "intersect" | "unite",
saveCurrentScale: false, saveCurrentScale: false,
scale: 1, scale: 1,
@@ -136,7 +142,7 @@ export const useTopToolsStore = create<TopToolsState>()(
} }
let path = usePaperStore.getState().getItemById(ids[0]) let path = usePaperStore.getState().getItemById(ids[0])
if (path && path.data.type !== "rectangle") { if (path && path.data.type !== "rectangle") {
let flag = val && usePaperStore.getState().mode !== "rectangle" let flag = val
set((state: TopToolsState) => ({ set((state: TopToolsState) => ({
...state, ...state,
@@ -156,6 +162,18 @@ export const useTopToolsStore = create<TopToolsState>()(
...state, ...state,
crosshairStatus: val, crosshairStatus: val,
})), })),
assistToolEnabled: initialTopToolsState.assistToolEnabled,
setAssistToolEnabled: (val) =>
set((state: TopToolsState) => ({
...state,
assistToolEnabled: val,
})),
assistToolSize: initialTopToolsState.assistToolSize,
setAssistToolSize: (val) =>
set((state: TopToolsState) => ({
...state,
assistToolSize: Math.max(1, val),
})),
showTags: false, showTags: false,
setShowTags: (val) => setShowTags: (val) =>
set((state: TopToolsState) => ({ set((state: TopToolsState) => ({

View File

@@ -0,0 +1,71 @@
import paper from "paper"
import { useLabelStore, useObjectStore } from "../store"
import { usePaperStore } from "../usePaperStore"
import { useTopToolsStore } from "../useTopToolsStore"
const getTagVisibility = (tagCategory?: string) => {
const { showTags, showTagsConfigs } = useTopToolsStore.getState()
if (!showTags) return false
if (tagCategory === "mask") return showTagsConfigs.includes("外框")
if (tagCategory === "point_text") return showTagsConfigs.includes("点ID")
return true
}
const setSelectedItemFalse = (id: number) => {
if (!id) return
const sameIdItems = usePaperStore.getState().getItemsById(id)
sameIdItems.forEach((item) => {
if (item.data.type === "mask" || item.data.type === "text") {
item.remove()
}
if (item.data.selected) item.data.selected = false
})
}
const syncObjectItemsVisibility = (objectId: number, hide: boolean) => {
const sameIdItems = usePaperStore.getState().getItemsById(objectId)
if (!sameIdItems?.length) return
sameIdItems.forEach((item) => {
if (hide) {
item.visible = false
return
}
if (item.data?.type === "tag") {
item.visible = getTagVisibility(item.data?.tag_category)
return
}
item.visible = true
})
}
export const toggleObjectHideByShortcut = (
imageId: string,
operationId: string,
objectId: number,
hide: boolean
) => {
const { updatePathStatus, updateOperationStatus } = useObjectStore.getState()
const storeLabel = useLabelStore.getState().label
if (hide) {
updatePathStatus(imageId + objectId, "HIDE", hide)
syncObjectItemsVisibility(objectId, hide)
setSelectedItemFalse(objectId)
const operationChildren = storeLabel.get(imageId)?.get(operationId) || []
const hasVisibleChild = operationChildren.some((child) => {
const item = usePaperStore
.getState()
.getItemById(child[0]) as paper.Item | null
return !!item?.visible
})
if (!hasVisibleChild) {
updateOperationStatus(imageId + operationId, "HIDE", hide)
}
return
}
updateOperationStatus(imageId + operationId, "HIDE", hide)
updatePathStatus(imageId + objectId, "HIDE", hide)
syncObjectItemsVisibility(objectId, hide)
}

View File

@@ -0,0 +1,128 @@
.page {
width: 100%;
height: calc(100vh - 56px);
padding: 16px;
gap: 16px;
}
.panel {
border-color: #e6ebf2;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05);
}
.filterPanel {
position: relative;
}
.filterPanel :global(.mantine-InputWrapper-label) {
color: #1f2329;
font-size: 14px;
font-weight: 500;
line-height: 22px;
margin-bottom: 6px;
}
.filterPanel :global(.mantine-Input-input),
.filterPanel :global(.mantine-Select-input),
.filterPanel :global(.mantine-NumberInput-input),
.filterPanel :global(.mantine-PillsInput-input),
.filterPanel :global(.mantine-Textarea-input) {
min-height: 36px;
border-color: #d0d5dd;
background: #ffffff;
color: #31373d;
font-size: 14px;
box-shadow: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease,
background-color 0.15s ease;
}
.filterPanel :global(.mantine-Textarea-input) {
min-height: 96px;
}
.filterPanel :global(.mantine-Input-input::placeholder),
.filterPanel :global(.mantine-Select-input::placeholder),
.filterPanel :global(.mantine-NumberInput-input::placeholder),
.filterPanel :global(.mantine-PillsInput-input::placeholder),
.filterPanel :global(.mantine-Textarea-input::placeholder) {
color: #98a2b3;
}
.filterPanel :global(.mantine-Input-input:focus),
.filterPanel :global(.mantine-Select-input:focus),
.filterPanel :global(.mantine-NumberInput-input:focus),
.filterPanel :global(.mantine-PillsInput-input:focus-within),
.filterPanel :global(.mantine-Textarea-input:focus) {
border-color: #69c0ff;
box-shadow: 0 0 0 3px rgba(105, 192, 255, 0.14);
}
.filterPanel :global(.mantine-Button-root[data-variant="transparent"]) {
color: #168de8;
}
.filterPanel :global(.mantine-Button-root:not([data-variant="transparent"])),
.headerActions :global(.mantine-Button-root) {
min-height: 36px;
border-radius: 8px;
padding-inline: 14px;
font-size: 14px;
font-weight: 500;
}
.filterPanel :global(.mantine-ActionIcon-root),
.headerActions :global(.mantine-ActionIcon-root) {
width: 36px;
height: 36px;
border-radius: 8px;
}
.filterPanel :global(.mantine-Pill-root) {
background: #eef7ff;
color: #168de8;
}
.contentPanel {
border-color: #e6ebf2;
}
.filterActions,
.headerActions {
gap: 12px;
}
.listHeader {
margin-bottom: 16px;
}
.sectionHeader {
padding-bottom: 12px;
border-bottom: 1px solid #eef2f6;
}
.sectionEyebrow {
color: #168de8;
font-size: 12px;
font-weight: 700;
line-height: 18px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sectionTitle {
color: #1f2329;
font-size: 18px;
font-weight: 700;
line-height: 28px;
}
.sectionDescription {
color: #667085;
font-size: 13px;
line-height: 20px;
}

View File

@@ -0,0 +1,312 @@
"use client"
import {
Badge,
Group,
Paper,
Stack,
Text,
type GroupProps,
type PaperProps,
type StackProps,
} from "@mantine/core"
import type { PropsWithChildren, ReactNode } from "react"
import {
DataTable,
type DataTableDefaultColumnProps,
type DataTableProps,
} from "mantine-datatable"
import classes from "./PageSurface.module.css"
function mergeClassName(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(" ")
}
export function SettingPage({
className,
...props
}: PropsWithChildren<StackProps>) {
return (
<Stack
w="100%"
h="calc(100vh - 56px)"
p="md"
gap="md"
className={mergeClassName(classes.page, className)}
{...props}
/>
)
}
export function SettingPanel({
className,
withBorder = true,
radius = "lg",
p = "md",
shadow = "xs",
...props
}: PropsWithChildren<PaperProps>) {
return (
<Paper
withBorder={withBorder}
radius={radius}
p={p}
shadow={shadow}
className={mergeClassName(classes.panel, className)}
{...props}
/>
)
}
export function SettingFilterPanel({
className,
...props
}: PropsWithChildren<PaperProps>) {
return (
<SettingPanel
className={mergeClassName(classes.filterPanel, className)}
{...props}
/>
)
}
export function SettingContentPanel({
className,
...props
}: PropsWithChildren<PaperProps>) {
return (
<SettingPanel
className={mergeClassName(classes.contentPanel, className)}
{...props}
/>
)
}
type SettingSectionHeaderProps = GroupProps & {
eyebrow?: string
title: ReactNode
description?: ReactNode
actions?: ReactNode
}
export function SettingSectionHeader({
eyebrow,
title,
description,
actions,
className,
...props
}: SettingSectionHeaderProps) {
return (
<Group
justify="space-between"
align="flex-start"
gap="sm"
wrap="wrap"
className={mergeClassName(classes.sectionHeader, className)}
{...props}>
<Stack gap={2}>
{eyebrow ? (
<Text className={classes.sectionEyebrow}>{eyebrow}</Text>
) : null}
<Text className={classes.sectionTitle}>{title}</Text>
{description ? (
<Text className={classes.sectionDescription}>{description}</Text>
) : null}
</Stack>
{actions}
</Group>
)
}
type SettingListHeaderProps = {
title: ReactNode
count?: ReactNode
actions?: ReactNode
className?: string
}
export function SettingListHeader({
title,
count,
actions,
className,
}: SettingListHeaderProps) {
return (
<Group
justify="space-between"
align="center"
wrap="wrap"
gap="sm"
className={mergeClassName(classes.listHeader, className)}>
<Group gap="xs">
<Text fw={700}>{title}</Text>
{count !== undefined ? (
<Badge variant="light" color="gray">
{count}
</Badge>
) : null}
</Group>
{actions}
</Group>
)
}
export function SettingHeaderActions({
className,
gap = "sm",
...props
}: PropsWithChildren<GroupProps>) {
return (
<Group
gap={gap}
wrap="wrap"
className={mergeClassName(classes.headerActions, className)}
{...props}
/>
)
}
export function SettingFilterActions({
className,
gap = "sm",
justify = "flex-end",
mt = "md",
...props
}: PropsWithChildren<GroupProps>) {
return (
<Group
justify={justify}
gap={gap}
wrap="wrap"
mt={mt}
className={mergeClassName(classes.filterActions, className)}
{...props}
/>
)
}
const settingDataTableStyles = {
header: {
backgroundColor: "#F8F9FB",
borderBottom: "1px solid #EEF2F6",
color: "#56606A",
fontSize: "14px",
},
root: {
height: "100%",
fontSize: "14px",
color: "#31373D",
borderBottom: "1px solid #E8EDF3",
borderRadius: 10,
overflow: "hidden",
backgroundColor: "#FFFFFF",
},
pagination: {
padding: "14px 16px 18px",
},
} as const
const settingDataTableDefaultColumnProps: DataTableDefaultColumnProps<
Record<string, unknown>
> = {
titleStyle: {
whiteSpace: "nowrap",
fontWeight: 600,
},
}
export function SettingDataTable<T = Record<string, unknown>>(
props: DataTableProps<T>
) {
const {
styles,
defaultColumnProps,
withTableBorder,
withRowBorders,
striped,
highlightOnHover,
loaderBackgroundBlur,
loadingText,
noRecordsText,
minHeight,
page,
onPageChange,
totalRecords,
recordsPerPage,
recordsPerPageLabel,
paginationText,
...rest
} = props
const paginationProps =
page !== undefined &&
onPageChange !== undefined &&
recordsPerPage !== undefined
? {
page,
onPageChange,
totalRecords,
recordsPerPage,
loadingText: loadingText ?? "正在加载数据...",
recordsPerPageLabel: recordsPerPageLabel ?? "每页条数",
paginationText:
paginationText ??
(({
from,
to,
totalRecords,
}: {
from: number
to: number
totalRecords: number
}) => `${from}-${to} / ${totalRecords}`),
}
: {}
const tableProps = {
withTableBorder: withTableBorder ?? true,
withRowBorders: withRowBorders ?? true,
striped: striped ?? true,
highlightOnHover: highlightOnHover ?? true,
defaultColumnProps:
defaultColumnProps ??
(settingDataTableDefaultColumnProps as DataTableDefaultColumnProps<T>),
loaderBackgroundBlur: loaderBackgroundBlur ?? 1,
noRecordsText: noRecordsText ?? "暂无数据",
minHeight: minHeight ?? 360,
styles: styles
? { ...settingDataTableStyles, ...styles }
: settingDataTableStyles,
...paginationProps,
...rest,
} as DataTableProps<T>
return <DataTable {...tableProps} />
}
export const settingTabsStyles = {
list: {
padding: 4,
backgroundColor: "#F8F9FB",
border: "1px solid #EEF2F6",
borderRadius: 10,
gap: 8,
},
tab: {
minHeight: 36,
borderRadius: 8,
color: "#56606A",
fontWeight: 600,
"&[data-active]": {
backgroundColor: "#FFFFFF",
color: "#1F2329",
boxShadow: "0 6px 18px rgba(15, 23, 42, 0.08)",
},
},
panel: {
flex: 1,
minHeight: 0,
paddingTop: 16,
},
} as const