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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import { projectConfig } from "@/components/label/api/project"
import { Project, ProjectDetail } from "@/components/label/api/project/typing"
import { SettingDataTable } from "@/components/setting/PageSurface"
import {
ActionIcon,
Badge,
@@ -22,7 +23,7 @@ import {
import { modals } from "@mantine/modals"
import { notifications } from "@mantine/notifications"
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 StageUserModal from "./StageUserModal"
import {
@@ -1429,9 +1430,7 @@ export default function TaskStageTabsContainer(props: {
</Button>
</Group>
<DataTable<StageUserRow>
withTableBorder
withRowBorders
<SettingDataTable<StageUserRow>
records={rows}
columns={userColumns(stage)}
noRecordsText="暂无人员"

View File

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

View File

@@ -7,13 +7,16 @@ import {
Breadcrumbs,
Flex,
Group,
Paper,
Stack,
Text,
UnstyledButton,
} from "@mantine/core"
import { useRouter, useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import {
SettingFilterPanel,
SettingPage,
} from "@/components/setting/PageSurface"
import InfoSettingContainer from "./InfoSettingContainer"
import OwnTaskTableContainer from "./OwnTaskTableContainer"
import TaskBoardContainer from "./TaskBoardContainer"
@@ -193,15 +196,15 @@ export default function CollectionDetailPage() {
}
return (
<Stack w="100%" h="calc(100vh - 56px)" p="md" gap="md">
<Paper withBorder p="md" radius="sm">
<SettingPage>
<SettingFilterPanel>
<Group justify="space-between" wrap="wrap" gap="sm">
<Flex gap="sm" w="100%" justify="space-between">
<Breadcrumbs separator=">">{breadcrumbsItems}</Breadcrumbs>
{DetailMenuItems()}
</Flex>
</Group>
</Paper>
</SettingFilterPanel>
<Stack style={{ flex: 1, minHeight: 0 }} gap="md">
{selectKey === "own" ? (
@@ -219,6 +222,6 @@ export default function CollectionDetailPage() {
/>
) : null}
</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 canvasRef = useRef<HTMLCanvasElement>(null)
const lastPointerRef = useRef<{ x: number; y: number } | null>(null)
const getFixed = (sparsity: number) => {
const pointIdx = String(sparsity).indexOf(".")
@@ -76,6 +77,7 @@ const CrosshairComponent = (
ctx.beginPath()
ctx.setLineDash([])
ctx.strokeStyle = "#EF4444"
ctx.fillStyle = "#EF4444"
let carveLineWidth = 1
ctx.lineWidth = carveLineWidth
@@ -100,6 +102,7 @@ const CrosshairComponent = (
sparsity
).toFixed(fixed)
const textWidth = ctx.measureText(text).width
ctx.fillStyle = "#EF4444"
ctx.fillText(text, indexX - textWidth / 2, centerY - tickLength)
} else {
ctx.moveTo(indexX, centerY - tickLength / 2 - carveLineWidth)
@@ -143,7 +146,11 @@ const CrosshairComponent = (
canvas.width = window.innerWidth
canvas.height = window.innerHeight
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])
@@ -153,6 +160,7 @@ const CrosshairComponent = (
const rect = canvas.getBoundingClientRect()
const centerX = event.clientX - rect.left
const centerY = event.clientY - rect.top
lastPointerRef.current = { x: centerX, y: centerY }
drawCrosshair(centerX, centerY)
}
}

View File

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

View File

@@ -42,6 +42,7 @@ import { getShowContextMenuPosition, usePaperStore } from "../usePaperStore"
import { usePaperSupportStore } from "../usePaperSupportStore"
import { useTopToolsStore } from "../useTopToolsStore"
import { getSubAttrStatus } from "../util"
import { toggleObjectHideByShortcut } from "../utils/objectVisibility"
import { labelTypeMap } from "../utils/constants"
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(
(operationId: string, hide: boolean) => {
updateOperationStatus(activeImage + operationId, "HIDE", hide)
@@ -414,51 +388,17 @@ const RightObjectTools: React.FC<RightObjectToolsComponentProps> = (props) => {
.get(activeImage)
?.get(operationId)
?.map((child) => {
updatePathStatus(activeImage + child[0], "HIDE", hide)
syncObjectItemsVisibility(child[0], hide)
// 隐藏时清空选中状态
if (hide) setSelectedItemFalse(child[0])
toggleObjectHideByShortcut(activeImage, operationId, child[0], hide)
})
},
[
activeImage,
storeLabel,
syncObjectItemsVisibility,
updateOperationStatus,
updatePathStatus,
]
[activeImage, storeLabel, updateOperationStatus]
)
const objectHide = useCallback(
(operationId: string, objectId: number, hide: boolean) => {
if (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)
}
toggleObjectHideByShortcut(activeImage, operationId, objectId, hide)
},
[
activeImage,
getItemById,
storeLabel,
syncObjectItemsVisibility,
updateOperationStatus,
updatePathStatus,
]
[activeImage]
)
// const renderObjectName = useCallback((name: string, input: string) => {

View File

@@ -87,6 +87,7 @@ import {
import { useBackUrlStore, usePermissionStore } from "../store/auth"
import { useBottomToolsStore } from "../useBottomToolsStore"
import { useDescToolsStore } from "../useDescToolsStore"
import { useKeyboardStore } from "../useKeyBoardStore"
import { usePaperStore } from "../usePaperStore"
import { useRightToolsStore } from "../useRightToolsStore"
import { useIntervalStore, useLabelTimeStore } from "../useTimerStore"
@@ -213,6 +214,10 @@ const TopTools = (
setImageFilter,
crosshairStatus,
setCrosshairStatus,
assistToolEnabled,
setAssistToolEnabled,
assistToolSize,
setAssistToolSize,
needBackup,
setNeedBackup,
checkSize,
@@ -1531,6 +1536,7 @@ const TopTools = (
}, [activeOperation, mode])
const operationSchema = getOperationSchema(activeOperation)
const drawAction = useKeyboardStore((state) => state.drawAction)
const subAttrForm = (
<Box w={256}>
@@ -1638,12 +1644,19 @@ const TopTools = (
useImperativeHandle(ref, () => ({
handleSave,
handleBackup,
commitTaskByActionKey,
}))
const renderEditIcon = useMemo(() => {
const iconStyle = { width: 20, height: 20 }
if (mode === "support") return <Paintbrush style={iconStyle} />
if (editMode) {
if (pressA && drawAction === "subtract") {
return <CopyMinus style={iconStyle} />
}
if (pressA && drawAction === "add") {
return <CopyPlus style={iconStyle} />
}
return pressA ? (
<CirclePlus style={iconStyle} />
) : (
@@ -1652,7 +1665,7 @@ const TopTools = (
} else {
return <MousePointer style={iconStyle} />
}
}, [mode, editMode, pressA])
}, [mode, editMode, pressA, drawAction])
const showTagConfigContainer = useMemo(() => {
const configs = ["ID", "类别", "外框", "点ID", "子属性", "属性值"]
@@ -2021,6 +2034,36 @@ const TopTools = (
/>
)}
</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" />
<Flex justify="center" align="center">
<ActionIcon

View File

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

View File

@@ -1,5 +1,6 @@
import { Comment } from "./api/label/typing"
import { Project } from "./api/project/typing"
import { notifications } from "@mantine/notifications"
import dayjs from "dayjs"
import paper from "paper"
import { DispatchWithoutAction } from "react"
@@ -12,12 +13,19 @@ import {
useObjectStore,
} from "./store"
import { useBottomToolsStore } from "./useBottomToolsStore"
import { useKeyboardStore } from "./useKeyBoardStore"
import { useOtherToolsStore } from "./useOtherToolsStore"
import { usePaperSupportStore } from "./usePaperSupportStore"
import { useRightToolsStore } from "./useRightToolsStore"
import { useTopToolsStore } from "./useTopToolsStore"
import { safeClone } from "./utils/clone"
interface ContinuePolygonTarget {
imageId: string
operationId: string
objectId: number
}
interface PaperState {
paperScope: paper.PaperScope | null
group: paper.Group | null
@@ -35,6 +43,7 @@ interface PaperState {
rasterScale: { [x: string]: number }
rasterSize: { [x: string]: [number, number] }
reciprocalRasterScale: { [x: string]: number }
continuePolygonTarget: ContinuePolygonTarget | null
loadingData: boolean
init: (
initEl: HTMLCanvasElement,
@@ -169,6 +178,7 @@ interface PaperState {
getAll: () => paper.Path[]
getItemById: (id: number) => paper.Path | null
getItemsById: (id: number) => Array<paper.Path | paper.CompoundPath>
setContinuePolygonTarget: (target: ContinuePolygonTarget | null) => void
setLoadingData: (flag: boolean) => void
}
@@ -519,6 +529,7 @@ export const usePaperStore = create<PaperState>((set) => ({
rasterScale: initialPaperState.rasterScale,
rasterSize: initialPaperState.rasterSize,
reciprocalRasterScale: initialPaperState.reciprocalRasterScale,
continuePolygonTarget: null,
currentMousePosition: [0, 0],
loadingData: false,
init: (
@@ -566,6 +577,11 @@ export const usePaperStore = create<PaperState>((set) => ({
...state,
loadingData: flag,
})),
setContinuePolygonTarget: (target) =>
set((state: PaperState) => ({
...state,
continuePolygonTarget: target,
})),
setCurrentMousePosition: (point: [number, number]) =>
set((state: PaperState) => ({
...state,
@@ -1409,27 +1425,17 @@ export const usePaperStore = create<PaperState>((set) => ({
})
// 标识位为true时选中路径隐藏tags; 没选中显示tags
const configs = useTopToolsStore.getState().showTagsConfigs
if (useTopToolsStore.getState().showTags)
if (panMode) {
lastTags.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
})
tags.forEach((tag) => {
tag.visible = false
})
} 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
})
if (useTopToolsStore.getState().showTags) {
const setTagVisibleByConfig = (tag: any) => {
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
}
lastTags.forEach(setTagVisibleByConfig)
tags.forEach(setTagVisibleByConfig)
}
console.log("鼠标按下 选中路径", activePath, "当前模式", panMode)
@@ -2678,6 +2684,9 @@ export const usePaperStore = create<PaperState>((set) => ({
let polygonTool = paperPolygonTool
let currentMagnetPoint: paper.Point | 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) => {
// 更新选中的点
@@ -2723,6 +2732,193 @@ export const usePaperStore = create<PaperState>((set) => ({
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 = () => {
polygon?.remove()
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: {
event: MouseEvent
point: paper.Point
@@ -2874,6 +3294,14 @@ export const usePaperStore = create<PaperState>((set) => ({
usePaperSupportStore.getState().clearAll()
}
if (
!polygon &&
!points.length &&
usePaperStore.getState().continuePolygonTarget
) {
if (!startContinuePolygonDraft()) return
}
// 先判断是否存在吸附点
let checkPoint = currentMagnetPoint ? currentMagnetPoint : e.point
// doubleclick
@@ -2897,35 +3325,107 @@ export const usePaperStore = create<PaperState>((set) => ({
// 初始化 compoundPolygon
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 模式下添加多边形
if (useTopToolsStore.getState().pressA) {
let ids =
useObjectStore.getState().selectedPath[
useBottomToolsStore.getState().activeImage
]
const selectedId = ids?.[0]
if (!selectedId) {
resetPolygonDraft()
return
}
let paths = usePaperStore.getState().getItemsById(ids[0])
let oldPolygon = polygon
// 闭合绘制路径
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 prevPath = paths[0]
const basePathData = getSafePathData(
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(
renderCompoundPath,
[prevPath, oldPolygon] as paper.Path[],
[...prevPaths, oldPolygon] as paper.Path[],
{
// option
...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) => {
if (e.key === "escape" || e.key === "alt") {
if (polygon || points.length) {
if (
polygon ||
points.length ||
usePaperStore.getState().continuePolygonTarget
) {
resetPolygonDraft()
clearContinuePolygonContext(true)
usePaperSupportStore.getState().handleBufferPaths(null)
}
} else if (e.key === "delete") {
@@ -4817,6 +5322,14 @@ export const usePaperStore = create<PaperState>((set) => ({
circles.forEach((circle) => {
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: () => {

View File

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

View File

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