添加Docker自包含部署分支

- 新增 Seg_Server_Docker 自包含部署内容,包含前后端、FastAPI、Celery、PostgreSQL、Redis、MinIO、演示视频和 DICOM 数据。

- 保留 demo 数据以支持恢复演示出厂设置,排除 SAM 2.1 .pt 权重并在 README 中补充下载命令。

- 补充 GPU 部署、backend/worker 镜像复用、frpc/frps + NPM 公网域名反代部署说明。

- 在 .env/.env.example 中用 # XXXX 标注局域网和公网域名部署需要修改的配置项。

- 添加部署分支 .gitignore,忽略本地模型权重、构建产物、缓存和日志。
This commit is contained in:
2026-05-07 19:06:07 +08:00
commit b5413066a0
396 changed files with 32742 additions and 0 deletions

60
src/App.tsx Normal file
View File

@@ -0,0 +1,60 @@
import React, { useEffect } from 'react';
import { useStore } from './store/useStore';
import { getCurrentUser, getProjects } from './lib/api';
import { Sidebar } from './components/Sidebar';
import { Dashboard } from './components/Dashboard';
import { ProjectLibrary } from './components/ProjectLibrary';
import { VideoWorkspace } from './components/VideoWorkspace';
import { TemplateRegistry } from './components/TemplateRegistry';
import { AISegmentation } from './components/AISegmentation';
import { Login } from './components/Login';
import { UserAdmin } from './components/UserAdmin';
export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates' | 'admin';
export default function App() {
const isAuthenticated = useStore((state) => state.isAuthenticated);
const activeModule = useStore((state) => state.activeModule);
const setActiveModule = useStore((state) => state.setActiveModule);
const setProjects = useStore((state) => state.setProjects);
const setError = useStore((state) => state.setError);
const setCurrentUser = useStore((state) => state.setCurrentUser);
const logout = useStore((state) => state.logout);
const currentUser = useStore((state) => state.currentUser);
useEffect(() => {
if (isAuthenticated) {
Promise.all([getCurrentUser(), getProjects()])
.then(([user, projects]) => {
setCurrentUser(user);
setProjects(projects);
})
.catch((err) => {
console.error('Failed to fetch projects:', err);
if (err?.response?.status === 401) {
logout();
return;
}
setError('获取项目列表失败');
});
}
}, [isAuthenticated, logout, setCurrentUser, setProjects, setError]);
if (!isAuthenticated) {
return <Login />;
}
return (
<div className="flex h-screen w-full bg-[#0a0a0a] text-gray-200 overflow-hidden font-sans">
<Sidebar activeModule={activeModule as ActiveModule} setActiveModule={setActiveModule} />
<main className="flex-1 flex flex-col min-w-0 h-full relative">
{activeModule === 'dashboard' && <Dashboard />}
{activeModule === 'projects' && <ProjectLibrary onProjectSelect={() => setActiveModule('workspace')} />}
{activeModule === 'ai' && <AISegmentation onSendToWorkspace={() => setActiveModule('workspace')} />}
{activeModule === 'workspace' && <VideoWorkspace onNavigateToAI={() => setActiveModule('ai')} />}
{activeModule === 'templates' && <TemplateRegistry />}
{activeModule === 'admin' && currentUser?.role === 'admin' && <UserAdmin />}
</main>
</div>
);
}

View File

@@ -0,0 +1,734 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resetStore } from '../test/storeTestUtils';
import { useStore } from '../store/useStore';
import { AISegmentation } from './AISegmentation';
const apiMock = vi.hoisted(() => ({
getAiModelStatus: vi.fn(),
predictMask: vi.fn(),
}));
vi.mock('../lib/api', () => ({
getAiModelStatus: apiMock.getAiModelStatus,
predictMask: apiMock.predictMask,
}));
describe('AISegmentation', () => {
beforeEach(() => {
resetStore();
vi.clearAllMocks();
useStore.setState({
frames: [{ id: 'frame-1', projectId: 'project-1', index: 0, url: '/frame.jpg', width: 640, height: 360 }],
});
apiMock.getAiModelStatus.mockResolvedValue({
selected_model: 'sam2.1_hiera_tiny',
gpu: { available: true, device: 'cuda', name: 'RTX 4090', torch_available: true },
models: [
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Tiny ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Small ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
{ id: 'sam2.1_hiera_base_plus', label: 'SAM 2.1 Base+', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Base+ ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
{ id: 'sam2.1_hiera_large', label: 'SAM 2.1 Large', available: true, loaded: false, device: 'cuda', supports: ['point', 'box'], message: 'SAM 2.1 Large ready', package_available: true, checkpoint_exists: true, python_ok: true, torch_ok: true, cuda_required: false },
],
});
});
it('shows the SAM2.1 variant selector without exposing SAM3', async () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(await screen.findByText('SAM 2.1 Tiny')).toBeInTheDocument();
expect(screen.getByText('tiny')).toBeInTheDocument();
expect(screen.getByText('small')).toBeInTheDocument();
expect(screen.getByText('base+')).toBeInTheDocument();
expect(screen.getByText('large')).toBeInTheDocument();
expect(screen.queryByText('SAM3')).not.toBeInTheDocument();
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_tiny');
});
it('does not render the legacy upload-replace-background mock button', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.queryByText('上传替换底图')).not.toBeInTheDocument();
});
it('shows an empty state instead of a demo image when no project frame is selected', () => {
useStore.setState({ frames: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.getByText('请先在项目库选择项目并生成帧')).toBeInTheDocument();
});
it('shows contextual guidance for prompt tools', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
expect(screen.getByText(/点击目标内部添加正向点/)).toBeInTheDocument();
fireEvent.click(screen.getByText('边界框选'));
expect(screen.getByText(/按住并拖拽建立框选区域/)).toBeInTheDocument();
});
it('passes enabled inference parameters to the backend', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.getByText('局部专注模式(自动裁剪无锚区域)')).toBeInTheDocument();
expect(screen.getByText('严格除杂模式(自动清理干涉点)')).toBeInTheDocument();
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
imageId: 'frame-1',
imageWidth: 640,
imageHeight: 360,
model: 'sam2.1_hiera_tiny',
points: [{ x: 120, y: 80, type: 'pos' }],
options: {
crop_to_prompt: false,
auto_filter_background: true,
min_score: 0.05,
},
}));
});
it('sends the selected SAM2.1 variant to prediction', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(await screen.findByText('small'));
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.getAiModelStatus).toHaveBeenCalledWith('sam2.1_hiera_small');
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
model: 'sam2.1_hiera_small',
}));
});
it('does not render masks that were created in the workspace', async () => {
useStore.setState({
masks: [
{
id: 'workspace-mask',
frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'Manual Mask',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
metadata: { source: 'manual' },
},
],
selectedMaskIds: ['workspace-mask'],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.queryAllByTestId('konva-path')).toHaveLength(0);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
});
it('requires point prompts before running SAM2 inference', async () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.predictMask).not.toHaveBeenCalled();
expect(await screen.findByText('请先放置正/反向提示点或框选区域。')).toBeInTheDocument();
});
it('disables unavailable model variants and skips AI inference', async () => {
apiMock.getAiModelStatus.mockResolvedValue({
selected_model: 'sam2.1_hiera_tiny',
gpu: { available: false, device: 'cpu', name: null, torch_available: false },
models: [
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false },
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', available: false, loaded: false, device: 'cpu', supports: [], message: 'missing PyTorch, sam2 package, checkpoint', package_available: false, checkpoint_exists: false, python_ok: false, torch_ok: false, cuda_required: false },
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(await screen.findByText('当前模型不可用')).toBeDisabled();
expect(screen.getByRole('button', { name: /tiny/i })).toBeDisabled();
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(screen.getByText('当前模型不可用'));
expect(apiMock.predictMask).not.toHaveBeenCalled();
});
it('uses a dragged box prompt for AI page inference without adding a point on click', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('边界框选'));
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
expect(screen.getByTestId('konva-rect')).toHaveAttribute('data-width', '140');
expect(await screen.findByText('已框选区域,可执行分割,或继续添加正/反向点细化。')).toBeInTheDocument();
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(apiMock.predictMask).not.toHaveBeenCalled();
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
imageId: 'frame-1',
imageWidth: 640,
imageHeight: 360,
model: 'sam2.1_hiera_tiny',
points: undefined,
box: { x1: 120, y1: 80, x2: 260, y2: 200 },
options: {
crop_to_prompt: false,
auto_filter_background: true,
min_score: 0.05,
},
}));
});
it('handles stage drag end for move-tool canvas panning', () => {
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
expect(screen.getByTestId('konva-stage')).toHaveAttribute('data-has-drag-end', 'true');
});
it('centers the active frame with a large default fit inside the AI canvas', async () => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => 1000 });
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, get: () => 700 });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
await waitFor(() => {
const stage = screen.getByTestId('konva-stage');
expect(Number(stage.getAttribute('data-scale-x'))).toBeCloseTo(1.34375, 4);
expect(Number(stage.getAttribute('data-x'))).toBeCloseTo(70, 0);
expect(Number(stage.getAttribute('data-y'))).toBeCloseTo(108, 0);
});
});
it('combines the AI page box prompt with later positive and negative refinement points', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('边界框选'));
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 100, clientY: 60 });
fireEvent.mouseMove(stage, { clientX: 300, clientY: 180 });
fireEvent.mouseUp(stage, { clientX: 300, clientY: 180 });
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(stage, { clientX: 160, clientY: 100 });
fireEvent.click(screen.getByText('反向选点'));
fireEvent.click(stage, { clientX: 260, clientY: 150 });
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
points: [
{ x: 160, y: 100, type: 'pos' },
{ x: 260, y: 150, type: 'neg' },
],
box: { x1: 100, y1: 60, x2: 300, y2: 180 },
}));
});
it('replaces the previous AI page candidate when running the same box prompt again', async () => {
useStore.setState({
masks: [
{
id: 'workspace-mask',
frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'Manual Mask',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
metadata: { source: 'manual' },
},
],
});
apiMock.predictMask
.mockResolvedValueOnce({
masks: [
{
id: 'sam2-first',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
})
.mockResolvedValueOnce({
masks: [
{
id: 'sam2-second',
pathData: 'M 20 20 L 50 20 L 50 50 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[20, 20, 50, 20, 50, 50]],
bbox: [20, 20, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('边界框选'));
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 120, clientY: 80 });
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-first']));
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-first']);
fireEvent.click(screen.getByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-second']));
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-second']);
expect(screen.getAllByTestId('konva-path')).toHaveLength(1);
});
it('deletes prompt points individually and can remove the latest point', async () => {
apiMock.predictMask.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
fireEvent.click(screen.getByText('反向选点'));
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 220, clientY: 140 });
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4));
fireEvent.click(screen.getAllByTestId('konva-circle')[0]);
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
expect(apiMock.predictMask).toHaveBeenCalledWith(expect.objectContaining({
points: [{ x: 220, y: 140, type: 'neg' }],
}));
fireEvent.click(screen.getByLabelText('删除最近锚点'));
await waitFor(() => expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0));
});
it('keeps only the best SAM2 candidate when the backend returns overlapping alternatives', async () => {
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-best',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10],
area: 100,
},
{
id: 'sam2-alt',
pathData: 'M 1 1 L 11 1 L 11 11 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[1, 1, 11, 1, 11, 11]],
bbox: [1, 1, 10, 10],
area: 100,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
expect(useStore.getState().masks[0].id).toBe('sam2-best');
expect(useStore.getState().masks[0].metadata).toEqual(expect.objectContaining({ source: 'ai_segmentation' }));
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-best']);
expect(await screen.findByText('SAM 2.1 Tiny 返回 2 个候选,已采用最高分区域。')).toBeInTheDocument();
});
it('adjusts the AI mask preview opacity without changing mask data', async () => {
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity'));
expect(maskGroup()).toHaveAttribute('data-opacity', '0.5');
fireEvent.change(screen.getByLabelText('AI 遮罩透明度'), { target: { value: '35' } });
expect(maskGroup()).toHaveAttribute('data-opacity', '0.35');
expect(useStore.getState().maskPreviewOpacity).toBe(35);
expect(useStore.getState().masks[0].segmentation).toEqual([[10, 10, 40, 10, 40, 40]]);
});
it('updates AI candidate opacity when the shared ontology opacity slider changes', async () => {
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
const maskGroup = () => screen.getAllByTestId('konva-group').find((group) => group.getAttribute('data-opacity'));
fireEvent.change(screen.getByLabelText('遮罩透明度'), { target: { value: '80' } });
expect(maskGroup()).toHaveAttribute('data-opacity', '0.8');
});
it('lets positive and negative prompt points be added on top of an AI mask', async () => {
apiMock.predictMask
.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
})
.mockResolvedValueOnce({ masks: [] });
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'), { clientX: 120, clientY: 80 });
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
fireEvent.click(screen.getByText('反向选点'));
fireEvent.click(screen.getByTestId('konva-path'), { clientX: 220, clientY: 140 });
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(4));
fireEvent.click(screen.getByText('执行高精度语义分割'));
expect(apiMock.predictMask).toHaveBeenLastCalledWith(expect.objectContaining({
points: [
{ x: 120, y: 80, type: 'pos' },
{ x: 220, y: 140, type: 'neg' },
],
}));
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
});
it('clears only AI page candidates and keeps workspace masks in the store', async () => {
useStore.setState({
masks: [
{
id: 'workspace-mask',
frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'Manual Mask',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
metadata: { source: 'manual' },
},
],
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
await waitFor(() => expect(screen.getAllByTestId('konva-circle')).toHaveLength(2));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask', 'sam2-mask']));
fireEvent.click(screen.getByText('清空全体锚点'));
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']);
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('deletes only the selected AI candidate and preserves workspace masks', async () => {
useStore.setState({
masks: [
{
id: 'workspace-mask',
frameId: 'frame-1',
pathData: 'M 0 0 L 10 0 L 10 10 Z',
label: 'Manual Mask',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
metadata: { source: 'manual' },
},
],
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
fireEvent.click(screen.getByLabelText('删除选中候选'));
await waitFor(() => expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['workspace-mask']));
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('lets Delete remove the selected AI candidate after a mask click selects it', async () => {
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(screen.getByTestId('konva-path')).toBeInTheDocument());
fireEvent.click(screen.getByText('视口控制'));
fireEvent.click(screen.getByTestId('konva-path'));
fireEvent.keyDown(window, { key: 'Delete' });
await waitFor(() => expect(useStore.getState().masks).toEqual([]));
});
it('lets a SAM2 result be selected and relabeled from the ontology panel', async () => {
useStore.setState({
templates: [
{
id: 'template-1',
name: '腹腔镜模板',
classes: [
{ id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
{ id: 'class-2', name: '肝脏', color: '#00ff00', zIndex: 20 },
],
rules: [],
},
],
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
fireEvent.click(screen.getByText('肝脏'));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
templateId: 'template-1',
classId: 'class-2',
className: '肝脏',
classZIndex: 20,
label: '肝脏',
color: '#00ff00',
saveStatus: 'draft',
}));
});
it('keeps the generated SAM2 mask selected when sending it to the workspace editor', async () => {
const onSendToWorkspace = vi.fn();
useStore.setState({
activeTemplateId: 'template-1',
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
activeClassId: 'class-1',
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={onSendToWorkspace} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
fireEvent.click(screen.getByText('推送至工作区编辑'));
expect(useStore.getState().activeTool).toBe('edit_polygon');
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
expect(onSendToWorkspace).toHaveBeenCalled();
});
it('blocks sending an AI candidate to the workspace until a semantic class is selected', async () => {
const onSendToWorkspace = vi.fn();
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
render(<AISegmentation onSendToWorkspace={onSendToWorkspace} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']));
fireEvent.click(screen.getByText('推送至工作区编辑'));
const toast = screen.getByRole('status');
expect(toast).toHaveTextContent('请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。');
expect(toast.className).toContain('bg-red-950');
expect(useStore.getState().activeTool).toBe('point_pos');
expect(onSendToWorkspace).not.toHaveBeenCalled();
});
it('removes unclassified AI candidates when leaving the AI page', async () => {
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
const { unmount } = render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks).toHaveLength(1));
unmount();
expect(useStore.getState().masks).toEqual([]);
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('keeps classified AI candidates when leaving the AI page', async () => {
useStore.setState({
activeTemplateId: 'template-1',
activeClass: { id: 'class-1', name: '胆囊', color: '#ff0000', zIndex: 30 },
activeClassId: 'class-1',
});
apiMock.predictMask.mockResolvedValueOnce({
masks: [
{
id: 'sam2-mask',
pathData: 'M 10 10 L 40 10 L 40 40 Z',
label: 'AI Mask',
color: '#06b6d4',
segmentation: [[10, 10, 40, 10, 40, 40]],
bbox: [10, 10, 30, 30],
area: 900,
},
],
});
const { unmount } = render(<AISegmentation onSendToWorkspace={vi.fn()} />);
fireEvent.click(screen.getByText('正向选点'));
fireEvent.click(screen.getByTestId('konva-stage'));
fireEvent.click(await screen.findByText('执行高精度语义分割'));
await waitFor(() => expect(useStore.getState().masks[0]?.classId).toBe('class-1'));
unmount();
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().selectedMaskIds).toEqual(['sam2-mask']);
});
});

View File

@@ -0,0 +1,798 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Undo, Redo, Loader2, XCircle, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group, Rect } from 'react-konva';
import useImage from 'use-image';
import { OntologyInspector } from './OntologyInspector';
import { TransientNotice, type NoticeState } from './TransientNotice';
import { SAM2_MODEL_OPTIONS, useStore, type Mask } from '../store/useStore';
import { getAiModelStatus, predictMask, type AiRuntimeStatus } from '../lib/api';
interface AISegmentationProps {
onSendToWorkspace: () => void;
}
type PromptPoint = { x: number; y: number; type: 'pos' | 'neg' };
type PromptBox = { x1: number; y1: number; x2: number; y2: number };
type ToolHint = { title: string; body: string };
const DEFAULT_IMAGE_FIT_RATIO = 0.86;
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
const canvasContainerRef = useRef<HTMLDivElement>(null);
const storeActiveTool = useStore((state) => state.activeTool);
const setActiveTool = useStore((state) => state.setActiveTool);
const masks = useStore((state) => state.masks);
const setMasks = useStore((state) => state.setMasks);
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const maskHistory = useStore((state) => state.maskHistory);
const maskFuture = useStore((state) => state.maskFuture);
const undoMasks = useStore((state) => state.undoMasks);
const redoMasks = useStore((state) => state.redoMasks);
const frames = useStore((state) => state.frames);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const aiModel = useStore((state) => state.aiModel);
const setAiModel = useStore((state) => state.setAiModel);
const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClass = useStore((state) => state.activeClass);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
const [modelStatus, setModelStatus] = useState<AiRuntimeStatus | null>(null);
const [autoDeleteBg, setAutoDeleteBg] = useState(true);
const [cropMode, setCropMode] = useState(false);
const [isInferencing, setIsInferencing] = useState(false);
const [inferenceMessage, setInferenceMessage] = useState('');
const [notice, setNotice] = useState<NoticeState | null>(null);
const [aiMaskIds, setAiMaskIds] = useState<string[]>([]);
// Canvas state
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const lastAutoFitKeyRef = useRef('');
const [points, setPoints] = useState<PromptPoint[]>([]);
const [promptBox, setPromptBox] = useState<PromptBox | null>(null);
const [boxStart, setBoxStart] = useState<{ x: number; y: number } | null>(null);
const [boxCurrent, setBoxCurrent] = useState<{ x: number; y: number } | null>(null);
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
const currentFrame = frames[currentFrameIndex] || null;
const [image] = useImage(currentFrame?.url || '');
const aiMaskIdSet = new Set(aiMaskIds);
const frameMasks = currentFrame
? masks.filter((mask) => mask.frameId === currentFrame.id && aiMaskIdSet.has(mask.id))
: masks.filter((mask) => aiMaskIdSet.has(mask.id));
const selectedAiMasks = frameMasks.filter((mask) => selectedMaskIds.includes(mask.id));
const aiMasksToSend = selectedAiMasks.length > 0 ? selectedAiMasks : frameMasks;
const selectedModelStatus = modelStatus?.models.find((model) => model.id === aiModel);
const modelStatusLoaded = Boolean(modelStatus);
const modelCanInfer = modelStatusLoaded && Boolean(selectedModelStatus?.available);
const effectiveTool = storeActiveTool;
useEffect(() => {
return () => {
if (aiMaskIds.length === 0) return;
const state = useStore.getState();
const aiIds = new Set(aiMaskIds);
const unclassifiedAiIds = new Set(
state.masks
.filter((mask) => aiIds.has(mask.id) && !mask.classId && !mask.className)
.map((mask) => mask.id),
);
if (unclassifiedAiIds.size === 0) return;
useStore.setState({
masks: state.masks.filter((mask) => !unclassifiedAiIds.has(mask.id)),
selectedMaskIds: state.selectedMaskIds.filter((id) => !unclassifiedAiIds.has(id)),
});
};
}, [aiMaskIds]);
useEffect(() => {
const handleResize = () => {
if (!canvasContainerRef.current) return;
setStageSize({
width: canvasContainerRef.current.clientWidth,
height: canvasContainerRef.current.clientHeight,
});
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (!currentFrame?.id || stageSize.width <= 0 || stageSize.height <= 0) return;
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
if (imageWidth <= 0 || imageHeight <= 0) return;
const fitKey = `${currentFrame.id}:${stageSize.width}x${stageSize.height}:${imageWidth}x${imageHeight}`;
if (lastAutoFitKeyRef.current === fitKey) return;
lastAutoFitKeyRef.current = fitKey;
const nextScale = Math.max(
0.05,
Math.min(stageSize.width / imageWidth, stageSize.height / imageHeight) * DEFAULT_IMAGE_FIT_RATIO,
);
setScale(nextScale);
setPosition({
x: (stageSize.width - imageWidth * nextScale) / 2,
y: (stageSize.height - imageHeight * nextScale) / 2,
});
}, [currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, stageSize.height, stageSize.width]);
const toolHint = React.useMemo<ToolHint | null>(() => {
if (!currentFrame) return null;
if (effectiveTool === 'point_pos') {
return {
title: '正向选点',
body: '点击目标内部添加正向点;点击已有提示点可删除。完成提示后点击“执行高精度语义分割”。',
};
}
if (effectiveTool === 'point_neg') {
return {
title: '反向选点',
body: '点击不应包含的区域添加反向点;可和框选/正向点一起使用来细化结果。',
};
}
if (effectiveTool === 'box_select') {
return {
title: promptBox ? '边界框已建立' : '边界框选',
body: promptBox
? '当前框会随推理一起发送;也可以继续添加正向/反向点细化。重新拖拽会替换框。'
: '按住并拖拽建立框选区域,松开后保留框,再点击“执行高精度语义分割”。',
};
}
if (effectiveTool === 'move') {
return { title: '视口控制', body: '拖拽移动画布,滚轮缩放;切回正向/反向点或框选后继续放置提示。' };
}
return null;
}, [currentFrame, effectiveTool, promptBox]);
const boxRect = React.useMemo(() => {
const activeBox = boxStart && boxCurrent
? {
x1: Math.min(boxStart.x, boxCurrent.x),
y1: Math.min(boxStart.y, boxCurrent.y),
x2: Math.max(boxStart.x, boxCurrent.x),
y2: Math.max(boxStart.y, boxCurrent.y),
}
: promptBox;
if (!activeBox) return null;
return {
x: activeBox.x1,
y: activeBox.y1,
width: activeBox.x2 - activeBox.x1,
height: activeBox.y2 - activeBox.y1,
};
}, [boxCurrent, boxStart, promptBox]);
useEffect(() => {
let cancelled = false;
getAiModelStatus(aiModel)
.then((status) => {
if (!cancelled) setModelStatus(status);
})
.catch(() => {
if (!cancelled) setModelStatus(null);
});
return () => {
cancelled = true;
};
}, [aiModel]);
useEffect(() => {
const visibleIds = new Set(frameMasks.map((mask) => mask.id));
const nextSelectedMaskIds = selectedMaskIds.filter((id) => visibleIds.has(id));
const changed = nextSelectedMaskIds.length !== selectedMaskIds.length
|| nextSelectedMaskIds.some((id, index) => id !== selectedMaskIds[index]);
if (changed) {
setSelectedMaskIds(nextSelectedMaskIds);
}
}, [frameMasks, selectedMaskIds, setSelectedMaskIds]);
const handleWheel = (e: any) => {
e.evt.preventDefault();
const scaleBy = 1.1;
const stage = e.target.getStage();
const oldScale = stage.scaleX();
const mousePointTo = {
x: stage.getPointerPosition().x / oldScale - stage.x() / oldScale,
y: stage.getPointerPosition().y / oldScale - stage.y() / oldScale,
};
const newScale = e.evt.deltaY < 0 ? oldScale * scaleBy : oldScale / scaleBy;
setScale(newScale);
setPosition({
x: -(mousePointTo.x - stage.getPointerPosition().x / newScale) * newScale,
y: -(mousePointTo.y - stage.getPointerPosition().y / newScale) * newScale,
});
};
const handleStageDragEnd = (e: any) => {
const stage = e.target;
setPosition({
x: stage.x(),
y: stage.y(),
});
};
const handleMouseMove = (e: any) => {
const stage = e.target.getStage();
if (!stage) return;
const pos = stage.getPointerPosition();
if (pos) {
const imageX = (pos.x - position.x) / scale;
const imageY = (pos.y - position.y) / scale;
setCursorPos({ x: imageX, y: imageY });
}
if (effectiveTool === 'box_select' && boxStart) {
const relPos = stage.getRelativePointerPosition?.();
if (relPos) {
setBoxCurrent({ x: relPos.x, y: relPos.y });
}
}
};
const runInference = useCallback(async () => {
if (points.length === 0 && !promptBox) {
setInferenceMessage('请先放置正/反向提示点或框选区域。');
return;
}
if (!currentFrame?.id) {
console.warn('AI inference skipped: no project frame is selected');
setInferenceMessage('请先在项目工作区选择一帧图像。');
return;
}
if (!modelCanInfer) {
setInferenceMessage(selectedModelStatus?.message ? `当前模型不可用:${selectedModelStatus.message}` : '当前模型不可用,请检查 GPU、PyTorch/SAM2 和模型权重。');
return;
}
const imageWidth = currentFrame.width || image?.naturalWidth || image?.width || 0;
const imageHeight = currentFrame.height || image?.naturalHeight || image?.height || 0;
if (imageWidth <= 0 || imageHeight <= 0) {
console.warn('AI inference skipped: active frame dimensions are unavailable');
setInferenceMessage('当前帧缺少宽高信息,无法推理。');
return;
}
setIsInferencing(true);
setInferenceMessage('');
try {
const result = await predictMask({
imageId: currentFrame.id,
imageWidth,
imageHeight,
model: aiModel,
points: points.length > 0 ? points.map((p) => ({ x: p.x, y: p.y, type: p.type })) : undefined,
box: promptBox || undefined,
options: {
crop_to_prompt: cropMode,
auto_filter_background: autoDeleteBg,
min_score: autoDeleteBg ? 0.05 : 0,
},
});
const masksToApply = result.masks.slice(0, 1);
if (masksToApply.length === 0) {
setInferenceMessage('模型没有返回可用区域,请调整提示点后重试。');
} else {
setInferenceMessage(result.masks.length > 1
? `${selectedModelStatus?.label || 'SAM 2.1'} 返回 ${result.masks.length} 个候选,已采用最高分区域。`
: `已生成 ${masksToApply.length} 个候选区域。`);
}
const generatedMasks: Mask[] = masksToApply.map((m) => {
const label = activeClass?.name || m.label;
const color = activeClass?.color || m.color;
return {
id: m.id,
frameId: currentFrame.id,
templateId: activeTemplateId || undefined,
classId: activeClass?.id,
className: activeClass?.name,
classZIndex: activeClass?.zIndex,
classMaskId: activeClass?.maskId,
saveStatus: 'draft',
saved: false,
pathData: m.pathData,
label,
color,
segmentation: m.segmentation,
bbox: m.bbox,
area: m.area,
metadata: {
source: 'ai_segmentation',
promptBox: promptBox || null,
promptPointCount: points.length,
promptNegativePointCount: points.filter((point) => point.type === 'neg').length,
},
};
});
const previousAiMaskIds = new Set(aiMaskIds);
const generatedMaskIds = generatedMasks.map((mask) => mask.id);
setMasks([
...masks.filter((mask) => !previousAiMaskIds.has(mask.id)),
...generatedMasks,
]);
setAiMaskIds(generatedMaskIds);
if (generatedMaskIds.length > 0) {
setSelectedMaskIds(generatedMaskIds);
} else {
setSelectedMaskIds(selectedMaskIds.filter((id) => !previousAiMaskIds.has(id)));
}
} catch (err) {
console.error('AI inference failed:', err);
const detail = (err as any)?.response?.data?.detail;
setInferenceMessage(detail || 'AI 推理失败,请查看模型状态或后端日志。');
} finally {
setIsInferencing(false);
}
}, [activeClass, activeTemplateId, aiMaskIds, aiModel, autoDeleteBg, cropMode, currentFrame?.height, currentFrame?.id, currentFrame?.width, image?.height, image?.naturalHeight, image?.naturalWidth, image?.width, masks, modelCanInfer, points, promptBox, selectedMaskIds, selectedModelStatus?.label, selectedModelStatus?.message, setMasks, setSelectedMaskIds]);
const clearAiLayer = useCallback(() => {
setPoints([]);
setPromptBox(null);
setBoxStart(null);
setBoxCurrent(null);
if (aiMaskIds.length === 0) return;
const idsToRemove = new Set(aiMaskIds);
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id)));
setAiMaskIds([]);
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
const deleteAiMasksById = useCallback((maskIds: string[]) => {
const aiIds = new Set(aiMaskIds);
const idsToRemove = new Set(maskIds.filter((id) => aiIds.has(id)));
if (idsToRemove.size === 0) return;
setMasks(masks.filter((mask) => !idsToRemove.has(mask.id)));
setAiMaskIds((currentIds) => currentIds.filter((id) => !idsToRemove.has(id)));
setSelectedMaskIds(selectedMaskIds.filter((id) => !idsToRemove.has(id)));
}, [aiMaskIds, masks, selectedMaskIds, setMasks, setSelectedMaskIds]);
const deleteSelectedAiMasks = useCallback(() => {
deleteAiMasksById(selectedMaskIds);
}, [deleteAiMasksById, selectedMaskIds]);
const handleSendToWorkspace = useCallback(() => {
if (aiMasksToSend.length === 0) {
setInferenceMessage('请先执行分割并选择一个 AI 候选区域。');
return;
}
const hasMissingSemantic = aiMasksToSend.some((mask) => !mask.classId && !mask.className);
if (hasMissingSemantic) {
setInferenceMessage('');
setNotice({
id: Date.now(),
message: '请先在右侧语义分类树为 AI 候选区域选择语义分类,再推送至工作区。',
tone: 'error',
});
return;
}
setInferenceMessage('');
setActiveTool('edit_polygon');
onSendToWorkspace();
}, [aiMasksToSend, onSendToWorkspace, setActiveTool]);
const removePromptPoint = useCallback((pointIndex: number) => {
setPoints((currentPoints) => currentPoints.filter((_, index) => index !== pointIndex));
}, []);
const removeLastPromptPoint = useCallback(() => {
setPoints((currentPoints) => currentPoints.slice(0, -1));
}, []);
const addPromptPointFromEvent = useCallback((event: any) => {
if (effectiveTool !== 'point_pos' && effectiveTool !== 'point_neg') return false;
const stage = event.target?.getStage?.();
const pos = stage?.getRelativePointerPosition?.();
if (!pos) return false;
setPoints((currentPoints) => [
...currentPoints,
{ x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' },
]);
return true;
}, [effectiveTool]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const tagName = target?.tagName?.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || target?.isContentEditable) return;
if (event.key !== 'Delete' && event.key !== 'Backspace') return;
const selectedAiIds = selectedMaskIds.filter((id) => aiMaskIds.includes(id));
if (selectedAiIds.length === 0) return;
event.preventDefault();
deleteAiMasksById(selectedAiIds);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [aiMaskIds, deleteAiMasksById, selectedMaskIds]);
const handleStageMouseDown = useCallback((event: any) => {
if (effectiveTool !== 'box_select') return;
const stage = event.target?.getStage?.();
const pos = stage?.getRelativePointerPosition?.();
if (!pos) return;
setBoxStart({ x: pos.x, y: pos.y });
setBoxCurrent({ x: pos.x, y: pos.y });
setInferenceMessage('');
}, [effectiveTool]);
const handleStageMouseUp = useCallback(() => {
if (effectiveTool !== 'box_select' || !boxStart || !boxCurrent) return;
const x1 = Math.min(boxStart.x, boxCurrent.x);
const y1 = Math.min(boxStart.y, boxCurrent.y);
const x2 = Math.max(boxStart.x, boxCurrent.x);
const y2 = Math.max(boxStart.y, boxCurrent.y);
if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) {
setPromptBox({ x1, y1, x2, y2 });
setPoints([]);
setInferenceMessage('已框选区域,可执行分割,或继续添加正/反向点细化。');
}
setBoxStart(null);
setBoxCurrent(null);
}, [boxCurrent, boxStart, effectiveTool]);
const handleStageClick = (e: any) => {
if (effectiveTool === 'move') return;
if (effectiveTool === 'box_select') return;
addPromptPointFromEvent(e);
};
return (
<div className="w-full h-full flex bg-[#0a0a0a]">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
{/* Left AI Controller Panel */}
<aside className="w-80 bg-[#0d0d0d] flex flex-col border-r border-white/5 shrink-0 z-10 overflow-hidden">
<div className="h-16 border-b border-white/5 flex items-center px-6 shrink-0 justify-between">
<div className="flex items-center font-medium text-[11px] uppercase tracking-widest text-cyan-400">
<Sparkles size={16} className="mr-3 text-cyan-400" />
AI智能分割引擎
</div>
</div>
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-8">
{/* Model Status */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="bg-[#111] border border-white/5 p-3 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-xs uppercase tracking-wider font-mono text-white">{selectedModelStatus?.label || 'SAM 2.1'}</span>
<span className={cn("text-xs", modelCanInfer ? "text-emerald-400" : "text-amber-400")}>
{modelCanInfer ? '可用' : '不可用'}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{SAM2_MODEL_OPTIONS.map((option) => {
const status = modelStatus?.models.find((model) => model.id === option.id);
const available = status?.available ?? false;
const selected = aiModel === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => {
if (available) setAiModel(option.id);
}}
disabled={!available}
title={available ? option.label : status?.message || `${option.label} 不可用`}
className={cn(
"h-8 rounded border px-2 text-[10px] uppercase tracking-wider transition-colors flex items-center justify-between",
selected
? "bg-cyan-500/10 border-cyan-400/40 text-cyan-300"
: "bg-white/[0.03] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200",
!available && "opacity-45 cursor-not-allowed hover:bg-white/[0.03] hover:text-gray-400"
)}
>
<span>{option.shortLabel}</span>
<span className={cn("h-1.5 w-1.5 rounded-full", available ? "bg-emerald-400" : "bg-amber-400")} />
</button>
);
})}
</div>
</div>
<div className="mt-2 text-[10px] text-gray-500 leading-relaxed">
<div>{selectedModelStatus?.message || '正在读取模型状态...'}</div>
<div>GPU: {modelStatus?.gpu.available ? `${modelStatus.gpu.name || 'CUDA'} 可用` : '不可用或未检测到 CUDA'}</div>
</div>
</div>
{/* Prompt Tools */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setActiveTool('point_pos')}
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'point_pos' ? "bg-green-500/10 border-green-500/30 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
>
<PlusCircle size={22} className="mb-3" />
<span className="text-[10px] uppercase tracking-wider font-semibold"></span>
</button>
<button
onClick={() => setActiveTool('point_neg')}
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'point_neg' ? "bg-red-500/10 border-red-500/30 text-red-500 shadow-[0_0_15px_rgba(239,68,68,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
>
<MinusCircle size={22} className="mb-3" />
<span className="text-[10px] uppercase tracking-wider font-semibold"></span>
</button>
<button
onClick={() => setActiveTool('box_select')}
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'box_select' ? "bg-blue-500/10 border-blue-500/30 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
>
<SquareDashed size={22} className="mb-3" />
<span className="text-[10px] uppercase tracking-wider font-semibold"></span>
</button>
<button
onClick={() => setActiveTool('move')}
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'move' ? "bg-white/10 border-white/20 text-white shadow-[0_0_15px_rgba(255,255,255,0.05)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
>
<Target size={22} className="mb-3" />
<span className="text-[10px] uppercase tracking-wider font-semibold"></span>
</button>
</div>
</div>
{/* Parameters */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex items-center gap-2"></h3>
<div className="space-y-4 bg-[#111] rounded-lg p-5 border border-white/5">
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setCropMode(!cropMode)}>
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors"></span>
<button className={cn("w-8 h-4 rounded-full transition-colors relative", cropMode ? "bg-cyan-500" : "bg-white/20")}>
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", cropMode ? "translate-x-4" : "")} />
</button>
</div>
<div className="flex items-center justify-between cursor-pointer group" onClick={() => setAutoDeleteBg(!autoDeleteBg)}>
<span className="text-[11px] text-gray-400 uppercase tracking-wider font-medium group-hover:text-gray-200 transition-colors"></span>
<button className={cn("w-8 h-4 rounded-full transition-colors relative", autoDeleteBg ? "bg-cyan-500" : "bg-white/20")}>
<div className={cn("absolute top-0.5 left-0.5 w-3 h-3 bg-white rounded-full transition-transform shadow-sm", autoDeleteBg ? "translate-x-4" : "")} />
</button>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label htmlFor="ai-mask-opacity" className="text-[11px] text-gray-400 uppercase tracking-wider font-medium">AI </label>
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
</div>
<input
id="ai-mask-opacity"
aria-label="AI 遮罩透明度"
type="range"
min="10"
max="100"
step="5"
value={maskPreviewOpacity}
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
className="w-full accent-cyan-400"
/>
</div>
</div>
</div>
</div>
<div className="p-6 bg-[#0a0a0a] border-t border-white/5 shrink-0 flex flex-col gap-3">
<button
onClick={runInference}
disabled={isInferencing || !currentFrame || !modelCanInfer}
className={cn(
"w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all shadow-lg font-medium tracking-wide text-xs uppercase",
isInferencing || !currentFrame || !modelCanInfer
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
: "bg-cyan-500 hover:bg-cyan-400 text-black shadow-cyan-500/20 hover:shadow-cyan-500/40"
)}
>
{isInferencing ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
{isInferencing ? '推理中...' : modelCanInfer ? '执行高精度语义分割' : modelStatusLoaded ? '当前模型不可用' : '正在读取模型状态'}
</button>
{inferenceMessage && (
<div className="rounded border border-white/10 bg-white/5 px-3 py-2 text-[11px] leading-relaxed text-gray-300">
{inferenceMessage}
</div>
)}
<button
onClick={handleSendToWorkspace}
title="AI 候选区域必须先选择语义分类,才能推送到工作区"
className="w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all font-medium tracking-wide text-xs uppercase bg-white/5 hover:bg-white/10 text-gray-300 border border-white/5 hover:border-white/10"
>
<SendToBack size={16} />
</button>
</div>
</aside>
{/* Right Canvas Area */}
<main className="flex-1 bg-[#151515] relative overflow-hidden flex flex-col">
<header className="h-16 border-b border-white/5 bg-[#111] flex items-center justify-between px-6 shrink-0">
<div className="flex flex-col">
<h2 className="text-sm font-semibold tracking-wide text-white"> (Visualizer)</h2>
<span className="text-[10px] text-gray-500 uppercase tracking-widest font-mono">SAM 2.1 </span>
</div>
<div className="flex items-center gap-4">
<button
onClick={undoMasks}
disabled={maskHistory.length === 0}
className="w-8 h-8 rounded text-gray-400 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
title="撤销操作 (Ctrl+Z)"
>
<Undo size={14} />
</button>
<button
onClick={redoMasks}
disabled={maskFuture.length === 0}
className="w-8 h-8 rounded text-gray-400 hover:bg-white/5 hover:text-white flex items-center justify-center transition-colors disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-gray-400 disabled:cursor-not-allowed"
title="重做操作 (Ctrl+Shift+Z)"
>
<Redo size={14} />
</button>
<div className="w-px h-4 bg-white/10 mx-1"></div>
<button
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed"
onClick={removeLastPromptPoint}
disabled={points.length === 0}
title="删除最近锚点"
aria-label="删除最近锚点"
>
<XCircle size={14} />
</button>
<button
className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5 disabled:opacity-30 disabled:hover:bg-white/5 disabled:hover:text-gray-400 disabled:cursor-not-allowed"
onClick={deleteSelectedAiMasks}
disabled={!selectedMaskIds.some((id) => aiMaskIds.includes(id))}
title="删除选中候选"
aria-label="删除选中候选"
>
<Trash2 size={14} />
</button>
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={clearAiLayer}>
</button>
</div>
</header>
<div className="flex-1 relative p-8">
<div ref={canvasContainerRef} className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair">
{!currentFrame && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[#151515] text-xs text-gray-500">
</div>
)}
{toolHint && (
<div className="absolute top-4 left-4 z-20 max-w-sm rounded-lg border border-cyan-400/20 bg-[#0d0d0d]/95 px-3 py-2 shadow-xl pointer-events-none">
<div className="text-[10px] font-semibold uppercase tracking-widest text-cyan-300">{toolHint.title}</div>
<div className="mt-1 text-xs leading-relaxed text-gray-300">{toolHint.body}</div>
</div>
)}
<Stage
width={stageSize.width}
height={stageSize.height}
onWheel={handleWheel}
onMouseMove={handleMouseMove}
onMouseDown={handleStageMouseDown}
onMouseUp={handleStageMouseUp}
onClick={handleStageClick}
scaleX={scale}
scaleY={scale}
x={position.x}
y={position.y}
draggable={effectiveTool === 'move'}
onDragEnd={handleStageDragEnd}
>
<Layer>
{/* Background Image */}
{image && (
<KonvaImage
image={image}
x={0}
y={0}
opacity={0.8}
/>
)}
{boxRect && (
<Rect
x={boxRect.x}
y={boxRect.y}
width={boxRect.width}
height={boxRect.height}
fill="rgba(59, 130, 246, 0.12)"
stroke="#60a5fa"
strokeWidth={2 / scale}
dash={[5 / scale, 5 / scale]}
/>
)}
{/* AI Returned Masks */}
{frameMasks.map((mask) => {
const isSelected = selectedMaskIds.includes(mask.id);
const baseOpacity = Math.min(Math.max(maskPreviewOpacity / 100, 0.1), 1);
const previewOpacity = isSelected
? baseOpacity
: Math.max(0.12, baseOpacity * 0.62);
return (
<Group key={mask.id} opacity={previewOpacity}>
<Path
data={mask.pathData}
fill={mask.color}
stroke={mask.color}
strokeWidth={(isSelected ? 2.5 : 1) / scale}
onClick={(event: any) => {
if (addPromptPointFromEvent(event)) {
event.cancelBubble = true;
return;
}
event.cancelBubble = true;
setSelectedMaskIds([mask.id]);
}}
onTap={(event: any) => {
if (addPromptPointFromEvent(event)) {
event.cancelBubble = true;
return;
}
event.cancelBubble = true;
setSelectedMaskIds([mask.id]);
}}
/>
</Group>
);
})}
{/* Points */}
{points.map((p, i) => (
<Group key={i} x={p.x} y={p.y}>
<Circle
radius={6 / scale}
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'}
stroke="#ffffff"
strokeWidth={2 / scale}
shadowColor="black"
shadowBlur={4}
onClick={(event: any) => {
event.cancelBubble = true;
removePromptPoint(i);
}}
onTap={(event: any) => {
event.cancelBubble = true;
removePromptPoint(i);
}}
/>
<Circle
radius={1.5 / scale}
fill="#ffffff"
onClick={(event: any) => {
event.cancelBubble = true;
removePromptPoint(i);
}}
onTap={(event: any) => {
event.cancelBubble = true;
removePromptPoint(i);
}}
/>
</Group>
))}
</Layer>
</Stage>
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
<span>: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
<span>: {(scale * 100).toFixed(0)}%</span>
<span>: {frameMasks.length}</span>
</div>
</div>
</div>
</main>
{/* Right Ontology / Label Assignment Panel */}
<OntologyInspector />
</div>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { BrainCircuit, Sparkles } from 'lucide-react';
interface AiAutoInferenceIconProps {
size?: number;
strokeWidth?: number;
}
export function AiAutoInferenceIcon({ size = 20, strokeWidth = 2 }: AiAutoInferenceIconProps) {
const sparkleSize = Math.max(8, Math.round(size * 0.42));
return (
<span
data-testid="ai-auto-inference-icon"
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
<BrainCircuit size={size} strokeWidth={strokeWidth} />
<Sparkles
size={sparkleSize}
strokeWidth={Math.max(strokeWidth, 2.1)}
className="absolute -right-1 -top-1 text-emerald-200 drop-shadow-[0_0_4px_rgba(110,231,183,0.75)]"
/>
</span>
);
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { Bot, Sparkles } from 'lucide-react';
interface AiSegmentationIconProps {
size?: number;
strokeWidth?: number;
}
export function AiSegmentationIcon({ size = 20, strokeWidth = 2 }: AiSegmentationIconProps) {
const sparkleSize = Math.max(9, Math.round(size * 0.48));
return (
<span
data-testid="ai-segmentation-icon"
className="relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
>
<Bot size={size} strokeWidth={strokeWidth} />
<Sparkles
size={sparkleSize}
strokeWidth={Math.max(strokeWidth, 2.2)}
className="absolute -right-1 -top-1 text-cyan-300 drop-shadow-[0_0_4px_rgba(34,211,238,0.75)]"
/>
</span>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,444 @@
import React, { useState, useEffect } from 'react';
import { Activity, AlertTriangle, Clock, Folders, CheckCircle2, Info, Loader2, RotateCcw, XCircle } from 'lucide-react';
import { progressWS, type ConnectionStatus, type ProgressMessage } from '../lib/websocket';
import { cn } from '../lib/utils';
import {
cancelTask,
getDashboardOverview,
getTask,
retryTask,
type DashboardActivity,
type DashboardOverview,
type DashboardTask,
type ProcessingTask,
} from '../lib/api';
const emptySummary: DashboardOverview['summary'] = {
project_count: 0,
parsing_task_count: 0,
annotation_count: 0,
frame_count: 0,
template_count: 0,
system_load_percent: 0,
};
export function Dashboard() {
const [summary, setSummary] = useState<DashboardOverview['summary']>(emptySummary);
const [tasks, setTasks] = useState<DashboardTask[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [activityLog, setActivityLog] = useState<DashboardActivity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [selectedTask, setSelectedTask] = useState<ProcessingTask | null>(null);
const [taskActionMessage, setTaskActionMessage] = useState('');
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const taskFromProcessingTask = (task: ProcessingTask, name = `任务 ${task.id}`): DashboardTask => ({
id: `task-${task.id}`,
task_id: task.id,
project_id: task.project_id ?? 0,
name,
progress: task.progress,
status: task.message || task.status,
raw_status: task.status,
error: task.error,
frame_count: Number(task.result?.frames_extracted || task.result?.processed_frame_count || 0),
updated_at: task.updated_at,
});
const prependActivity = (message: string, project = '系统') => {
setActivityLog((prev) => [
{ id: `task-action-${Date.now()}`, kind: 'task', time: new Date().toISOString(), message, project },
...prev.slice(0, 9),
]);
};
useEffect(() => {
let cancelled = false;
const loadOverview = () => {
getDashboardOverview()
.then((overview) => {
if (cancelled) return;
setSummary(overview.summary);
setTasks((prev) => {
if (prev.length === 0) return overview.tasks;
const overviewIds = new Set(overview.tasks.map((task) => task.id));
const wsOnly = prev.filter((task) => !task.id.startsWith('task-') && !overviewIds.has(task.id) && task.progress < 100);
return [...overview.tasks, ...wsOnly];
});
setActivityLog((prev) => {
if (prev.length === 0) return overview.activity;
const byId = new Map(prev.map((item) => [item.id, item]));
overview.activity.forEach((item) => byId.set(item.id, item));
return Array.from(byId.values()).slice(0, 10);
});
setLoadError('');
})
.catch((err) => {
console.error('Failed to load dashboard overview:', err);
if (!cancelled) setLoadError('Dashboard 数据加载失败');
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
};
loadOverview();
const overviewInterval = setInterval(loadOverview, 5000);
return () => {
cancelled = true;
clearInterval(overviewInterval);
};
}, []);
useEffect(() => {
let mounted = true;
const taskTitle = (data: ProgressMessage) => data.filename || data.projectName || data.taskId || '后台任务';
const timer = setTimeout(() => {
if (mounted) progressWS.connect();
}, 500);
const unsubscribe = progressWS.onProgress((data: ProgressMessage) => {
if (!mounted) return;
setIsConnected(progressWS.isConnected());
if (data.type === 'progress' && data.taskId) {
setTasks((prev) => {
const exists = prev.find((t) => t.id === data.taskId);
if (exists) {
return prev.map((t) =>
t.id === data.taskId
? { ...t, progress: data.progress ?? t.progress, status: data.status ?? t.status }
: t
);
}
return [
...prev,
{
id: data.taskId!,
project_id: data.project_id ?? Number(data.task_id || 0),
name: taskTitle(data),
progress: data.progress ?? 0,
status: data.status ?? '处理中',
raw_status: 'running',
error: data.error,
frame_count: 0,
updated_at: new Date().toISOString(),
},
];
});
}
if (data.type === 'complete' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId ? { ...t, progress: 100, status: '已完成', raw_status: 'success' } : t
)
);
setActivityLog((prev) => [
{ id: `ws-complete-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析完成: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'cancelled' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId
? { ...t, progress: 100, status: data.message || '任务已取消', raw_status: 'cancelled', error: data.error }
: t
)
);
setActivityLog((prev) => [
{ id: `ws-cancelled-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `任务已取消: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'error' && data.taskId) {
setTasks((prev) =>
prev.map((t) =>
t.id === data.taskId
? { ...t, progress: data.progress ?? t.progress, status: `错误: ${data.error || data.message || '未知错误'}`, raw_status: 'failed', error: data.error }
: t
)
);
setActivityLog((prev) => [
{ id: `ws-error-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || `解析失败: ${taskTitle(data)}`, project: data.projectName || '系统' },
...prev.slice(0, 9),
]);
}
if (data.type === 'status') {
setActivityLog((prev) => [
{ id: `ws-status-${Date.now()}`, kind: 'websocket', time: new Date().toISOString(), message: data.message || '状态更新', project: '系统' },
...prev.slice(0, 9),
]);
}
});
const unsubscribeStatus = progressWS.onStatus((status: ConnectionStatus) => {
if (mounted) setIsConnected(status === 'connected');
});
const checkConnection = setInterval(() => {
if (mounted) setIsConnected(progressWS.isConnected());
}, 5000);
return () => {
mounted = false;
clearTimeout(timer);
unsubscribe();
unsubscribeStatus();
clearInterval(checkConnection);
progressWS.disconnect();
};
}, []);
const stats = [
{ label: '项目总数', value: summary.project_count.toString(), icon: Folders, color: 'text-blue-400', bg: 'bg-blue-400/10' },
{ label: '处理任务', value: summary.parsing_task_count.toString(), icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
{ label: '已存标注', value: summary.annotation_count.toString(), icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-400/10' },
{ label: '系统负载', value: `${summary.system_load_percent}%`, icon: Activity, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
];
function formatActivityTime(value: string | null): string {
if (!value) return '未知时间';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
const taskRawStatus = (task: DashboardTask): string => task.raw_status || (
task.status.includes('取消') ? 'cancelled'
: task.status.includes('失败') || task.status.includes('错误') ? 'failed'
: task.progress >= 100 ? 'success'
: 'running'
);
const canCancel = (task: DashboardTask): boolean => ['queued', 'running'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const canRetry = (task: DashboardTask): boolean => ['failed', 'cancelled'].includes(taskRawStatus(task)) && Boolean(task.task_id);
const handleCancelTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const updated = await cancelTask(task.task_id);
setTasks((prev) => prev.map((item) => (
item.id === task.id ? taskFromProcessingTask(updated, task.name) : item
)));
prependActivity(`任务已取消 #${updated.id}`, task.name);
} catch (err) {
console.error('Cancel task failed:', err);
setTaskActionMessage('任务取消失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleRetryTask = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
const retried = await retryTask(task.task_id);
const dashboardTask = taskFromProcessingTask(retried, task.name);
setTasks((prev) => [dashboardTask, ...prev.filter((item) => item.id !== dashboardTask.id)]);
prependActivity(`重试任务已入队 #${retried.id}`, task.name);
} catch (err) {
console.error('Retry task failed:', err);
setTaskActionMessage('任务重试失败,请检查后端服务');
} finally {
setBusyTaskId(null);
}
};
const handleOpenTaskDetail = async (task: DashboardTask) => {
if (!task.task_id) return;
setBusyTaskId(task.id);
setTaskActionMessage('');
try {
setSelectedTask(await getTask(task.task_id));
} catch (err) {
console.error('Load task detail failed:', err);
setTaskActionMessage('失败详情加载失败');
} finally {
setBusyTaskId(null);
}
};
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<header className="mb-8">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-medium tracking-tight text-white"></h1>
<div className={cn(
"flex items-center gap-1.5 text-[10px] uppercase font-mono px-2 py-1 rounded border",
isConnected
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
)}>
<div className={cn("w-1.5 h-1.5 rounded-full", isConnected ? "bg-emerald-500" : "bg-amber-500 animate-pulse")} />
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
</div>
</div>
<p className="text-gray-400 text-sm mt-1"></p>
{loadError && <p className="text-red-400 text-xs mt-2">{loadError}</p>}
{taskActionMessage && <p className="text-amber-400 text-xs mt-2">{taskActionMessage}</p>}
</header>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((stat, i) => {
const Icon = stat.icon;
return (
<div key={i} className="bg-[#111] border border-white/5 p-5 rounded-xl block transition-all hover:border-white/20">
<div className="flex items-center gap-4 mb-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${stat.bg} ${stat.color}`}>
<Icon size={20} />
</div>
<div className="text-xl font-mono text-gray-100">{stat.value}</div>
</div>
<div className="text-sm font-medium text-gray-500 uppercase tracking-widest">{stat.label}</div>
</div>
);
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6"> ( / )</h2>
<div className="space-y-4">
{isLoading && (
<div className="text-sm text-gray-500 text-center py-12"> Dashboard ...</div>
)}
{tasks.map((task) => (
<div key={task.id} className="bg-[#0d0d0d] border border-white/5 p-4 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-mono text-sm text-gray-200">{task.name}</span>
<span className="text-xs text-cyan-400 font-mono">{task.progress}%</span>
</div>
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden mb-2">
<div className="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 rounded-full transition-all duration-500" style={{ width: `${task.progress}%` }} />
</div>
<div className="text-xs text-gray-500 flex items-center gap-2">
{taskRawStatus(task) === 'success' || task.status === '已完成' ? (
<CheckCircle2 size={12} className="text-emerald-400" />
) : taskRawStatus(task) === 'failed' ? (
<AlertTriangle size={12} className="text-red-400" />
) : taskRawStatus(task) === 'cancelled' ? (
<XCircle size={12} className="text-amber-400" />
) : (
<Loader2 size={12} className="text-cyan-400 animate-spin" />
)}
{task.status}
<span className="text-gray-600">: {task.frame_count}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{canCancel(task) && (
<button
onClick={() => handleCancelTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-red-500/20 bg-red-500/10 px-2 py-1 text-[11px] text-red-300 hover:bg-red-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<XCircle size={12} />
</button>
)}
{canRetry(task) && (
<button
onClick={() => handleRetryTask(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 text-[11px] text-cyan-300 hover:bg-cyan-500/20 disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw size={12} />
</button>
)}
{task.task_id && (
<button
onClick={() => handleOpenTaskDetail(task)}
disabled={busyTaskId === task.id}
className="inline-flex items-center gap-1 rounded border border-white/10 bg-white/5 px-2 py-1 text-[11px] text-gray-300 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Info size={12} />
</button>
)}
</div>
</div>
))}
{!isLoading && tasks.length === 0 && (
<div className="text-sm text-gray-500 text-center py-12"></div>
)}
</div>
</div>
<div className="bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6"></h2>
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-[11px] before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent">
{isLoading && (
<div className="text-sm text-gray-500 text-center py-12">...</div>
)}
{activityLog.map((log) => (
<div key={log.id} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
<div className="flex items-center justify-center w-6 h-6 rounded-full border border-white/10 bg-[#111] group-[.is-active]:bg-cyan-500 group-[.is-active]:border-cyan-400 text-slate-500 group-[.is-active]:text-black shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 z-10" />
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] bg-[#0d0d0d] p-3 rounded border border-white/5">
<div className="text-xs text-gray-400 mb-1">{formatActivityTime(log.time)}</div>
<div className="text-sm font-medium text-gray-200">{log.message}</div>
<div className="text-xs text-gray-500">: {log.project}</div>
</div>
</div>
))}
{!isLoading && activityLog.length === 0 && (
<div className="text-sm text-gray-500 text-center py-12"></div>
)}
</div>
</div>
</div>
{selectedTask && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-2xl rounded-lg border border-white/10 bg-[#111] p-5 shadow-2xl">
<div className="flex items-center justify-between gap-3 border-b border-white/10 pb-3">
<div>
<h3 className="text-sm font-semibold text-white"> #{selectedTask.id}</h3>
<p className="mt-1 text-xs text-gray-500">{selectedTask.message || selectedTask.status}</p>
</div>
<button
onClick={() => setSelectedTask(null)}
className="rounded border border-white/10 bg-white/5 px-2 py-1 text-xs text-gray-300 hover:bg-white/10"
>
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-gray-400">
<div>: <span className="text-gray-200">{selectedTask.status}</span></div>
<div>: <span className="text-gray-200">{selectedTask.progress}%</span></div>
<div> ID: <span className="text-gray-200">{selectedTask.project_id ?? '-'}</span></div>
<div>Celery ID: <span className="text-gray-200">{selectedTask.celery_task_id || '-'}</span></div>
<div>: <span className="text-gray-200">{selectedTask.created_at}</span></div>
<div>: <span className="text-gray-200">{selectedTask.finished_at || '-'}</span></div>
</div>
{selectedTask.error && (
<div className="mt-4 rounded border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-200">
{selectedTask.error}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.payload || {}, null, 2)}
</pre>
<pre className="max-h-48 overflow-auto rounded border border-white/10 bg-[#0a0a0a] p-3 text-[11px] text-gray-300">
{JSON.stringify(selectedTask.result || {}, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,541 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Play, Pause } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
interface FrameTimelineProps {
propagationRange?: {
startFrame: number;
endFrame: number;
};
propagationHistory?: Array<{
id: string;
startFrame: number;
endFrame: number;
colorIndex: number;
label?: string;
}>;
propagationRangeSelectionActive?: boolean;
propagationRangeDisabled?: boolean;
onPropagationRangeChange?: (startFrame: number, endFrame: number) => void;
}
export function FrameTimeline({
propagationRange,
propagationHistory = [],
propagationRangeSelectionActive = false,
propagationRangeDisabled = false,
onPropagationRangeChange,
}: FrameTimelineProps = {}) {
const frames = useStore((state) => state.frames);
const currentProject = useStore((state) => state.currentProject);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const masks = useStore((state) => state.masks);
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
const [isPlaying, setIsPlaying] = useState(false);
const [rangeDragAnchorFrame, setRangeDragAnchorFrame] = useState<number | null>(null);
const totalFrames = frames.length;
const currentFrame = totalFrames > 0 ? currentFrameIndex + 1 : 0;
const playbackFps = useMemo(() => {
const fps = currentProject?.parse_fps || currentProject?.original_fps || 12;
return Math.min(Math.max(fps, 1), 30);
}, [currentProject?.original_fps, currentProject?.parse_fps]);
const timeBaseFps = useMemo(() => {
const fps = currentProject?.parse_fps || currentProject?.original_fps || 12;
return Math.max(fps, 1);
}, [currentProject?.original_fps, currentProject?.parse_fps]);
const currentSeconds = totalFrames > 0 ? currentFrameIndex / timeBaseFps : 0;
const totalSeconds = totalFrames > 0 ? Math.max(totalFrames - 1, 0) / timeBaseFps : 0;
const isPropagatedMask = (mask: (typeof masks)[number]) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source.toLowerCase() : '';
return source.includes('propagat')
|| mask.metadata?.propagated_from_frame_id !== undefined
|| mask.metadata?.source_annotation_id !== undefined
|| mask.metadata?.source_mask_id !== undefined
|| mask.metadata?.propagation_seed_key !== undefined
|| mask.metadata?.propagation_seed_signature !== undefined;
};
const propagatedFrameMarkers = useMemo(() => {
const frameIds = new Set(frames.map((frame) => frame.id));
const propagatedIds = new Set(
masks
.filter((mask) => frameIds.has(mask.frameId))
.filter(isPropagatedMask)
.map((mask) => mask.frameId),
);
return frames
.map((frame, index) => ({ frame, index }))
.filter(({ frame }) => propagatedIds.has(frame.id));
}, [frames, masks]);
const propagatedFrameIds = useMemo(
() => new Set(propagatedFrameMarkers.map(({ frame }) => frame.id)),
[propagatedFrameMarkers],
);
const propagatedFrameNumbers = useMemo(
() => new Set(propagatedFrameMarkers.map(({ index }) => index + 1)),
[propagatedFrameMarkers],
);
const annotatedFrameMarkers = useMemo(() => {
const frameIds = new Set(frames.map((frame) => frame.id));
const annotatedIds = new Set(
masks
.filter((mask) => frameIds.has(mask.frameId))
.filter((mask) => !isPropagatedMask(mask))
.map((mask) => mask.frameId),
);
return frames
.map((frame, index) => ({ frame, index }))
.filter(({ frame }) => annotatedIds.has(frame.id));
}, [frames, masks]);
const annotatedFrameIds = useMemo(
() => new Set(annotatedFrameMarkers.map(({ frame }) => frame.id)),
[annotatedFrameMarkers],
);
const formatTime = (seconds: number) => {
const safeSeconds = Math.max(0, seconds);
const minutes = Math.floor(safeSeconds / 60);
const wholeSeconds = Math.floor(safeSeconds % 60);
const centiseconds = Math.floor((safeSeconds % 1) * 100);
return `${minutes.toString().padStart(2, '0')}:${wholeSeconds.toString().padStart(2, '0')}.${centiseconds.toString().padStart(2, '0')}`;
};
const clampFrame = (frame: number) => Math.min(Math.max(frame, 1), Math.max(totalFrames, 1));
const normalizeRange = (startFrame: number, endFrame: number) => ({
startFrame: Math.min(clampFrame(startFrame), clampFrame(endFrame)),
endFrame: Math.max(clampFrame(startFrame), clampFrame(endFrame)),
});
const selectedRange = propagationRange
? normalizeRange(propagationRange.startFrame, propagationRange.endFrame)
: null;
const visibleSelectedRange = propagationRangeSelectionActive ? selectedRange : null;
const rangeLeft = visibleSelectedRange && totalFrames > 0 ? ((visibleSelectedRange.startFrame - 1) / totalFrames) * 100 : 0;
const rangeWidth = visibleSelectedRange && totalFrames > 0
? ((visibleSelectedRange.endFrame - visibleSelectedRange.startFrame + 1) / totalFrames) * 100
: 0;
const frameLineLeft = (frame: number) => {
if (totalFrames <= 1) return 0;
return ((clampFrame(frame) - 1) / (totalFrames - 1)) * 100;
};
const currentFrameLineLeft = totalFrames > 0 ? frameLineLeft(currentFrame) : 0;
const rangeStartLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.startFrame) : 0;
const rangeEndLineLeft = visibleSelectedRange ? frameLineLeft(visibleSelectedRange.endFrame) : 0;
const propagationHistoryColor = (ageFromNewest: number) => {
const step = Math.min(Math.max(ageFromNewest, 0), 4);
const lightness = 58 - step * 7;
const alpha = 0.88 - step * 0.085;
return {
fill: `hsla(212, 88%, ${lightness}%, ${Math.max(alpha, 0.52)})`,
glow: `hsla(212, 88%, ${Math.min(lightness + 10, 76)}%, ${0.38 - step * 0.045})`,
border: `hsla(212, 90%, ${Math.min(lightness + 18, 84)}%, ${0.72 - step * 0.045})`,
};
};
const visiblePropagationHistory = useMemo(() => (
propagationHistory
.flatMap((segment, order) => {
const range = normalizeRange(segment.startFrame, segment.endFrame);
const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4);
const chunks: Array<typeof segment & { startFrame: number; endFrame: number; order: number; ageFromNewest: number }> = [];
let chunkStart: number | null = null;
for (let frameNumber = range.startFrame; frameNumber <= range.endFrame; frameNumber += 1) {
if (propagatedFrameNumbers.has(frameNumber)) {
chunkStart ??= frameNumber;
continue;
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === range.startFrame && frameNumber - 1 === range.endFrame
? segment.id
: `${segment.id}-${chunkStart}-${frameNumber - 1}`,
startFrame: chunkStart,
endFrame: frameNumber - 1,
order,
ageFromNewest,
});
chunkStart = null;
}
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === range.startFrame ? segment.id : `${segment.id}-${chunkStart}-${range.endFrame}`,
startFrame: chunkStart,
endFrame: range.endFrame,
order,
ageFromNewest,
});
}
return chunks;
})
.filter((segment) => totalFrames > 0 && segment.endFrame >= 1 && segment.startFrame <= totalFrames)
), [propagatedFrameNumbers, propagationHistory, totalFrames]);
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0;
return clampFrame(Math.round(Math.min(Math.max(ratio, 0), 1) * Math.max(totalFrames - 1, 0)) + 1);
};
const jumpToFrame = (frame: number) => {
if (totalFrames === 0) return;
setIsPlaying(false);
setCurrentFrame(clampFrame(frame) - 1);
};
const updatePropagationRangeFromPointer = (
event: React.PointerEvent<HTMLElement>,
anchorFrame = rangeDragAnchorFrame,
) => {
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
const frame = frameFromPointerEvent(event);
const startFrame = anchorFrame ?? frame;
const nextRange = normalizeRange(startFrame, frame);
onPropagationRangeChange(nextRange.startFrame, nextRange.endFrame);
};
const handleRangePointerDown = (event: React.PointerEvent<HTMLElement>) => {
if (!propagationRangeSelectionActive || propagationRangeDisabled || totalFrames === 0 || !onPropagationRangeChange) return;
event.preventDefault();
setIsPlaying(false);
const frame = frameFromPointerEvent(event);
setRangeDragAnchorFrame(frame);
event.currentTarget.setPointerCapture?.(event.pointerId);
onPropagationRangeChange(frame, frame);
};
const handleProcessingBarPointerDown = (event: React.PointerEvent<HTMLElement>) => {
if (propagationRangeSelectionActive) {
handleRangePointerDown(event);
return;
}
if (totalFrames === 0) return;
event.preventDefault();
jumpToFrame(frameFromPointerEvent(event));
};
const handleFrameMarkerClick = (event: React.MouseEvent<HTMLElement>, frame: number) => {
event.stopPropagation();
if (propagationRangeSelectionActive) return;
jumpToFrame(frame);
};
const handleRangePointerMove = (event: React.PointerEvent<HTMLElement>) => {
if (rangeDragAnchorFrame === null) return;
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
};
const handleRangePointerUp = (event: React.PointerEvent<HTMLElement>) => {
if (rangeDragAnchorFrame === null) return;
updatePropagationRangeFromPointer(event, rangeDragAnchorFrame);
setRangeDragAnchorFrame(null);
};
useEffect(() => {
if (!isPlaying || totalFrames <= 1) return;
const timer = window.setTimeout(() => {
if (currentFrameIndex >= totalFrames - 1) {
setIsPlaying(false);
return;
}
setCurrentFrame(currentFrameIndex + 1);
}, 1000 / playbackFps);
return () => window.clearTimeout(timer);
}, [currentFrameIndex, isPlaying, playbackFps, setCurrentFrame, totalFrames]);
useEffect(() => {
if (totalFrames === 0) {
setIsPlaying(false);
}
}, [totalFrames]);
useEffect(() => {
const isEditableTarget = (target: EventTarget | null) => {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target) || totalFrames <= 1) return;
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
setIsPlaying(false);
const direction = event.key === 'ArrowRight' ? 1 : -1;
const nextIndex = Math.min(Math.max(currentFrameIndex + direction, 0), totalFrames - 1);
if (nextIndex !== currentFrameIndex) {
setCurrentFrame(nextIndex);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentFrameIndex, setCurrentFrame, totalFrames]);
// show frames around current frame
const frameWindow = 20;
const displayIndices = totalFrames > 0
? Array.from({ length: 41 }, (_, i) => currentFrameIndex - frameWindow + i)
: [];
return (
<div className="h-36 bg-[#111] border-t border-white/5 flex flex-col shrink-0 z-20">
<div className="h-12 bg-[#0d0d0d] flex flex-col justify-center group relative">
<div className="absolute left-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
{formatTime(currentSeconds)}
</div>
<div className="absolute right-3 -top-5 text-[10px] font-mono text-gray-500 pointer-events-none">
{formatTime(totalSeconds)}
</div>
<input
type="range"
min="1"
max={Math.max(totalFrames, 1)}
value={currentFrame}
onChange={(e) => setCurrentFrame(parseInt(e.target.value) - 1)}
className={cn(
"w-full absolute left-0 right-0 top-0 h-7 opacity-0 cursor-ew-resize z-20",
propagationRangeSelectionActive && "pointer-events-none",
)}
disabled={totalFrames === 0}
/>
<div
data-testid="playback-progress-bar"
className={cn(
"h-1 bg-white/10 w-full relative group-hover:h-2 transition-all",
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
)}
onPointerDown={handleRangePointerDown}
onPointerMove={handleRangePointerMove}
onPointerUp={handleRangePointerUp}
onPointerCancel={() => setRangeDragAnchorFrame(null)}
>
{visibleSelectedRange && (
<div
data-testid="propagation-range-overlay"
className="absolute inset-y-0 z-10 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
/>
)}
<div
className="h-full bg-cyan-500 absolute left-0"
style={{ width: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
/>
<div
className="absolute -top-7 -translate-x-1/2 rounded bg-black/80 border border-white/10 px-2 py-0.5 text-[10px] font-mono text-cyan-300 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
style={{ left: `${totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 0}%` }}
>
{formatTime(currentSeconds)}
</div>
</div>
{totalFrames > 0 && (
<div
data-testid="current-frame-line"
aria-hidden="true"
className="pointer-events-none absolute top-[18px] bottom-[8px] z-[60] w-[2px] -translate-x-1/2 rounded-full bg-white shadow-[0_0_10px_rgba(255,255,255,0.85)]"
style={{ left: `${currentFrameLineLeft}%` }}
/>
)}
{visibleSelectedRange && (
<>
<div
data-testid="range-boundary-line"
aria-hidden="true"
title={`范围开始帧 ${visibleSelectedRange.startFrame}`}
className="pointer-events-none absolute top-[16px] bottom-[7px] z-[65] w-[2px] -translate-x-1/2 rounded-full bg-fuchsia-400 shadow-[0_0_12px_rgba(244,114,182,0.9)]"
style={{ left: `${rangeStartLineLeft}%` }}
/>
<div
data-testid="range-boundary-line"
aria-hidden="true"
title={`范围结束帧 ${visibleSelectedRange.endFrame}`}
className="pointer-events-none absolute top-[16px] bottom-[7px] z-[65] w-[2px] -translate-x-1/2 rounded-full bg-lime-300 shadow-[0_0_12px_rgba(190,242,100,0.9)]"
style={{ left: `${rangeEndLineLeft}%` }}
/>
</>
)}
<div
className={cn(
"mt-2 h-2.5 w-full relative bg-zinc-700/80 border-y border-white/10 shadow-inner",
propagationRangeSelectionActive && !propagationRangeDisabled && "cursor-crosshair",
)}
aria-label="视频处理进度条"
onPointerDown={handleProcessingBarPointerDown}
onPointerMove={handleRangePointerMove}
onPointerUp={handleRangePointerUp}
onPointerCancel={() => setRangeDragAnchorFrame(null)}
>
{visibleSelectedRange && (
<div
data-testid="propagation-range-overlay"
className="absolute inset-y-0 z-30 rounded-sm border border-amber-300/80 bg-amber-300/30 shadow-[0_0_12px_rgba(251,191,36,0.45)]"
style={{ left: `${rangeLeft}%`, width: `${rangeWidth}%` }}
/>
)}
{propagatedFrameMarkers.map(({ frame, index }) => {
const left = totalFrames > 0 ? (index / totalFrames) * 100 : 0;
const width = totalFrames > 0 ? 100 / totalFrames : 0;
return (
<button
type="button"
key={frame.id}
data-testid="propagated-frame-segment"
title={`自动传播帧 ${index + 1}`}
aria-label={`跳转到自动传播帧 ${index + 1}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
className={cn(
"absolute inset-y-0 z-10 border-0 bg-blue-500/85 p-0 shadow-[0_0_8px_rgba(59,130,246,0.65)]",
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-blue-400",
)}
style={{ left: `${left}%`, width: `${width}%` }}
/>
);
})}
{visiblePropagationHistory.map((segment) => {
const color = propagationHistoryColor(segment.ageFromNewest);
const left = totalFrames > 0 ? ((segment.startFrame - 1) / totalFrames) * 100 : 0;
const width = totalFrames > 0 ? ((segment.endFrame - segment.startFrame + 1) / totalFrames) * 100 : 0;
return (
<div
key={segment.id}
data-testid="propagation-history-segment"
data-recency-level={segment.ageFromNewest}
title={segment.label || `自动传播记录:第 ${segment.startFrame}-${segment.endFrame}`}
className="pointer-events-none absolute inset-y-0 z-[15] rounded-[2px] border-x"
style={{
left: `${left}%`,
width: `${width}%`,
backgroundColor: color.fill,
borderColor: color.border,
boxShadow: `0 0 10px ${color.glow}`,
}}
/>
);
})}
{annotatedFrameMarkers.map(({ frame, index }) => {
const left = totalFrames > 1 ? (index / Math.max(totalFrames - 1, 1)) * 100 : 0;
return (
<button
type="button"
key={frame.id}
data-testid="annotated-frame-marker"
title={`人工/AI 标注帧 ${index + 1}`}
aria-label={`跳转到人工/AI 标注帧 ${index + 1}`}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => handleFrameMarkerClick(event, index + 1)}
className={cn(
"absolute -top-1 z-20 h-4 w-0.5 -translate-x-1/2 rounded-full border-0 bg-red-500 p-0 shadow-[0_0_9px_rgba(239,68,68,0.8)]",
propagationRangeSelectionActive ? "pointer-events-none" : "cursor-pointer hover:bg-red-400",
)}
style={{ left: `${left}%` }}
/>
);
})}
</div>
<div className="absolute bottom-0 right-3 text-[9px] font-mono text-gray-500 pointer-events-none">
/AI {annotatedFrameMarkers.length} · {propagatedFrameMarkers.length}
</div>
</div>
<div className="flex-1 flex items-center px-4 gap-6">
<div className="flex flex-col items-center gap-2 px-4 border-r border-white/10 shrink-0">
<button
className="p-2 rounded-full bg-white/5 text-white hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={totalFrames <= 1}
onClick={() => {
if (currentFrameIndex >= totalFrames - 1) {
setCurrentFrame(0);
}
setIsPlaying(!isPlaying);
}}
>
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" />}
</button>
<span className="text-[10px] font-mono text-gray-500">{isPlaying ? '暂停 (SPACE)' : '播放序列 (F5)'}</span>
</div>
<div
className="flex-1 flex gap-px flex-nowrap items-center overflow-hidden justify-center"
style={{ WebkitMaskImage: 'linear-gradient(to right, transparent, black 10%, black 90%, transparent)' }}
>
{totalFrames === 0 ? (
<div className="text-xs text-gray-600 font-mono"></div>
) : (
displayIndices.map((idx) => {
if (idx < 0 || idx >= totalFrames) {
return <div key={`empty-${idx}`} className="w-16 h-12 opacity-0 shrink-0" />
}
const frame = frames[idx];
const isCurrent = idx === currentFrameIndex;
const isPropagatedFrame = propagatedFrameIds.has(frame.id);
const isAnnotatedFrame = annotatedFrameIds.has(frame.id);
return (
<div
key={frame.id}
onClick={() => setCurrentFrame(idx)}
title={
isAnnotatedFrame
? `人工/AI 标注帧 ${idx + 1}`
: isPropagatedFrame
? `自动传播帧 ${idx + 1}`
: `视频帧 ${idx + 1}`
}
className={cn(
"relative shrink-0 rounded-sm transition-all cursor-pointer flex items-center justify-center overflow-hidden group mx-0.5",
isCurrent
? cn(
"w-28 h-16 border-2 border-cyan-500 bg-gray-700 z-10",
isAnnotatedFrame
? "shadow-[inset_0_0_0_2px_rgba(239,68,68,0.95),0_0_15px_rgba(6,182,212,0.3)]"
: isPropagatedFrame
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.65),0_0_15px_rgba(6,182,212,0.3)]"
: "shadow-[0_0_15px_rgba(6,182,212,0.3)]",
)
: isAnnotatedFrame
? cn(
"w-16 h-12 border border-red-500 bg-red-950/30 opacity-85 hover:opacity-100",
isPropagatedFrame
? "shadow-[inset_0_0_0_2px_rgba(59,130,246,0.85),0_0_10px_rgba(239,68,68,0.22)]"
: "shadow-[0_0_10px_rgba(239,68,68,0.22)]",
)
: isPropagatedFrame
? "w-16 h-12 border border-blue-500 bg-blue-950/30 opacity-80 shadow-[0_0_10px_rgba(59,130,246,0.22)] hover:opacity-100"
: "w-16 h-12 border border-white/5 bg-gray-800/50 opacity-40 hover:opacity-100"
)}
>
{frame.url ? (
<img
src={frame.url}
alt={`frame-${idx}`}
className="absolute inset-0 w-full h-full object-cover"
loading="lazy"
draggable={false}
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-gray-800 to-gray-900 opacity-20" />
)}
<span className={cn("text-[9px] font-mono text-white text-center z-10 drop-shadow", isCurrent ? "text-[10px] font-bold" : "")}>
{(idx + 1).toString().padStart(4, '0')}
</span>
</div>
);
})
)}
</div>
<div className="w-48 text-right shrink-0">
<div className="text-2xl font-mono text-white">{currentFrame}<span className="text-xs text-gray-500"> / {totalFrames}</span></div>
<div className="text-xs font-mono text-cyan-300 mt-1">
{formatTime(currentSeconds)} <span className="text-gray-600">/</span> {formatTime(totalSeconds)}
</div>
</div>
</div>
</div>
);
}

87
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { login as loginApi } from '../lib/api';
export function Login() {
const storeLogin = useStore((state) => state.login);
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const data = await loginApi(username, password);
storeLogin(data.token, data.user);
} catch (err: any) {
const msg = err?.response?.data?.detail || err?.response?.data?.error || '登录失败,请检查网络或凭证';
setError(msg);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-screen w-full items-center justify-center bg-[#0a0a0a] text-gray-200">
<div className="absolute inset-0 z-0 opacity-10 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-cyan-500 via-[#0a0a0a] to-transparent pointer-events-none"></div>
<div className="relative z-10 w-full max-w-md p-8 bg-[#111] border border-white/5 rounded-2xl shadow-2xl scale-in shadow-black/50">
<div className="flex flex-col items-center mb-8">
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center text-cyan-500 shadow-lg shadow-cyan-500/20 mb-4 overflow-hidden border border-white/10">
<img src="/logo.png" alt="Logo" className="h-full w-full object-contain" />
</div>
<h1 className="text-center text-2xl font-bold text-white tracking-wider mb-2"></h1>
<p className="text-sm text-gray-500"></p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
placeholder="输入账号"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all font-mono"
placeholder="输入密码"
/>
</div>
{error && <div className="text-red-400 text-sm font-medium p-3 bg-red-400/10 rounded-lg border border-red-500/20 text-center">{error}</div>}
<button
type="submit"
disabled={isLoading}
className={cn(
"w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all shadow-lg font-bold tracking-wider text-sm",
isLoading ? "bg-cyan-500/50 cursor-not-allowed" : "bg-cyan-500 hover:bg-cyan-400 text-black shadow-cyan-500/20 hover:shadow-cyan-500/40"
)}
>
{isLoading ? '验证中...' : '安全登录'}
</button>
</form>
<div className="mt-8 pt-6 border-t border-white/5 text-center px-4">
<p className="text-[10px] text-gray-600">访</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import React, { useEffect, useState } from 'react';
import { Cpu, Loader2 } from 'lucide-react';
import { getAiModelStatus, type AiRuntimeStatus } from '../lib/api';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
interface ModelStatusBadgeProps {
compact?: boolean;
}
export function ModelStatusBadge({ compact = false }: ModelStatusBadgeProps) {
const aiModel = useStore((state) => state.aiModel);
const [status, setStatus] = useState<AiRuntimeStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
getAiModelStatus(aiModel)
.then((data) => {
if (!cancelled) setStatus(data);
})
.catch(() => {
if (!cancelled) setStatus(null);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [aiModel]);
const model = status?.models.find((item) => item.id === aiModel);
const ready = Boolean(model?.available);
const gpuReady = Boolean(status?.gpu.available);
const label = compact
? (gpuReady ? 'GPU' : 'CPU')
: `${model?.label || aiModel.toUpperCase()} ${ready ? '可用' : '不可用'}`;
return (
<div
className={cn(
"inline-flex items-center gap-1.5 rounded border font-mono uppercase",
compact ? "w-8 h-8 justify-center text-[9px]" : "px-2 py-0.5 text-[10px]",
ready
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
)}
title={model?.message || 'AI 模型状态读取中'}
>
{isLoading ? <Loader2 size={compact ? 12 : 10} className="animate-spin" /> : <Cpu size={compact ? 12 : 10} />}
<span>{label}</span>
</div>
);
}

View File

@@ -0,0 +1,851 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ChevronDown, Tag, Eye, Plus, X, Loader2, GripVertical } from 'lucide-react';
import { useStore } from '../store/useStore';
import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, deleteAnnotation, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
const isRequestAbortError = (err: unknown) => {
const error = err as { code?: string; message?: string; name?: string } | null;
const message = error?.message || '';
return error?.code === 'ERR_CANCELED'
|| error?.code === 'ECONNABORTED'
|| error?.name === 'AbortError'
|| /request aborted|aborted|cancell?ed/i.test(message);
};
function metadataNumber(value: unknown): number | null {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function isNotFoundError(error: unknown): boolean {
const maybeError = error as { response?: { status?: number }; status?: number } | null;
return maybeError?.response?.status === 404 || maybeError?.status === 404;
}
async function deleteAnnotationIfExists(annotationId: string) {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
}
function propagationSourceMaskTokens(value: unknown): string[] {
if (typeof value !== 'string' || value.length === 0) return [];
const tokens = [`mask:${value}`];
const annotationMatch = value.match(/^annotation-(\d+)$/);
if (annotationMatch) {
tokens.push(`annotation:${annotationMatch[1]}`);
}
return tokens;
}
function propagationLineageTokens(mask: { id: string; annotationId?: string; metadata?: Record<string, unknown> }): Set<string> {
const metadata = mask.metadata || {};
const tokens = new Set<string>([`mask:${mask.id}`]);
if (mask.annotationId) {
tokens.add(`annotation:${mask.annotationId}`);
}
const sourceAnnotationId = metadataNumber(metadata.source_annotation_id);
if (sourceAnnotationId !== null) {
tokens.add(`annotation:${sourceAnnotationId}`);
}
propagationSourceMaskTokens(metadata.source_mask_id).forEach((token) => tokens.add(token));
if (typeof metadata.propagation_seed_key === 'string' && metadata.propagation_seed_key.length > 0) {
tokens.add(`seed-key:${metadata.propagation_seed_key}`);
}
return tokens;
}
function findPropagationChainMaskIds(selectedMask: Pick<Mask, 'id' | 'annotationId' | 'metadata'>, masks: Mask[]): Set<string> {
const selectedTokens = propagationLineageTokens(selectedMask);
return new Set(
masks
.filter((mask) => Array.from(selectedTokens).some((token) => propagationLineageTokens(mask).has(token)))
.map((mask) => mask.id),
);
}
export function OntologyInspector() {
const templates = useStore((state) => state.templates);
const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClassId = useStore((state) => state.activeClassId);
const activeClass = useStore((state) => state.activeClass);
const frames = useStore((state) => state.frames);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const masks = useStore((state) => state.masks);
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const maskPreviewOpacity = useStore((state) => state.maskPreviewOpacity);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
const setActiveClass = useStore((state) => state.setActiveClass);
const setMaskPreviewOpacity = useStore((state) => state.setMaskPreviewOpacity);
const [showAddForm, setShowAddForm] = useState(false);
const [newClassName, setNewClassName] = useState('');
const [newClassColor, setNewClassColor] = useState('#06b6d4');
const [isSavingClass, setIsSavingClass] = useState(false);
const [classSaveMessage, setClassSaveMessage] = useState('');
const [dragClassId, setDragClassId] = useState<string | null>(null);
const [maskAnalysis, setMaskAnalysis] = useState<MaskAnalysisResult | null>(null);
const [analysisMessage, setAnalysisMessage] = useState('');
const [smoothingStrength, setSmoothingStrength] = useState(0);
const [isPreviewingSmoothing, setIsPreviewingSmoothing] = useState(false);
const [isSmoothingMask, setIsSmoothingMask] = useState(false);
const [pendingTemplateSwitch, setPendingTemplateSwitch] = useState<{ templateId: string | null } | null>(null);
const [isSwitchingTemplate, setIsSwitchingTemplate] = useState(false);
const activeTemplate = getActiveTemplate(templates, activeTemplateId);
const templateClasses = normalizeClassMaskIds(activeTemplate?.classes || []);
const allClasses = [...templateClasses].sort((a, b) => b.zIndex - a.zIndex);
const selectedMask = masks.find((mask) => selectedMaskIds.includes(mask.id)) || null;
const selectedMaskLabel = selectedMask?.className || selectedMask?.label || '未选择';
const currentFrame = frames[currentFrameIndex] || null;
const classButtonRefs = useRef(new Map<string, HTMLButtonElement>());
const skipNextAutoAnalysisRef = useRef(false);
const analysisRequestIdRef = useRef(0);
const smoothingPreviewRef = useRef<{
maskId: string;
baseMask: NonNullable<typeof selectedMask>;
strength: number;
result: SmoothMaskGeometryResult | null;
applied: boolean;
requestId: number;
} | null>(null);
const smoothingRequestIdRef = useRef(0);
const smoothingPreviewTimerRef = useRef<number | null>(null);
const clearSmoothingPreviewTimer = React.useCallback(() => {
if (smoothingPreviewTimerRef.current === null) return;
window.clearTimeout(smoothingPreviewTimerRef.current);
smoothingPreviewTimerRef.current = null;
}, []);
const selectedMaskClass = useMemo(() => {
if (!selectedMask) return null;
const allTemplateClasses = templates.flatMap((template) => (
template.classes.map((templateClass) => ({ template, templateClass }))
));
const selectedName = selectedMask.className || selectedMask.label;
return allTemplateClasses.find(({ templateClass }) => selectedMask.classId && templateClass.id === selectedMask.classId)
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName && templateClass.color === selectedMask.color)
|| allTemplateClasses.find(({ templateClass }) => templateClass.name === selectedName)
|| null;
}, [selectedMask?.classId, selectedMask?.className, selectedMask?.color, selectedMask?.id, selectedMask?.label, templates]);
useEffect(() => {
if (!selectedMaskClass) return;
if (activeTemplateId !== selectedMaskClass.template.id) {
setActiveTemplateId(selectedMaskClass.template.id);
}
setActiveClass(selectedMaskClass.templateClass);
const timer = window.setTimeout(() => {
const node = classButtonRefs.current.get(selectedMaskClass.templateClass.id);
node?.scrollIntoView?.({ block: 'nearest' });
node?.focus?.({ preventScroll: true });
}, 0);
return () => window.clearTimeout(timer);
}, [activeTemplateId, selectedMaskClass, setActiveClass, setActiveTemplateId]);
const handleSelectClass = (templateClass: TemplateClass) => {
if (activeTemplate && !activeTemplateId) {
setActiveTemplateId(activeTemplate.id);
}
setActiveClass(templateClass);
const selectedIdSet = new Set(selectedMaskIds);
const hasSelectedMasks = masks.some((mask) => selectedIdSet.has(mask.id));
if (!hasSelectedMasks) return;
const templateId = activeTemplate?.id || activeTemplateId || undefined;
const targetIdSet = new Set<string>();
masks
.filter((mask) => selectedIdSet.has(mask.id))
.forEach((mask) => {
findPropagationChainMaskIds(mask, masks).forEach((maskId) => targetIdSet.add(maskId));
});
const updatedMasks = masks.map((mask) => {
if (!targetIdSet.has(mask.id)) return mask;
return {
...mask,
templateId: templateId || mask.templateId,
classId: templateClass.id,
className: templateClass.name,
classZIndex: templateClass.zIndex,
classMaskId: templateClass.maskId,
label: templateClass.name,
color: templateClass.color,
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
};
});
const selectedMasksOnTop = selectedMaskIds
.map((id) => updatedMasks.find((mask) => mask.id === id))
.filter((mask): mask is (typeof updatedMasks)[number] => Boolean(mask));
setMasks([
...updatedMasks.filter((mask) => !selectedIdSet.has(mask.id)),
...selectedMasksOnTop,
]);
};
const refreshMaskAnalysis = async () => {
const requestId = analysisRequestIdRef.current + 1;
analysisRequestIdRef.current = requestId;
if (!selectedMask || !currentFrame) {
setMaskAnalysis(null);
setAnalysisMessage(selectedMask ? '当前帧信息不可用,无法读取后端属性' : '请选择一个 mask 查看后端属性');
return;
}
setAnalysisMessage('');
try {
const result = await analyzeMask(selectedMask, currentFrame);
if (analysisRequestIdRef.current !== requestId) return;
setMaskAnalysis(result);
setAnalysisMessage(result.message);
} catch (err) {
if (analysisRequestIdRef.current !== requestId || isRequestAbortError(err)) return;
console.error('Mask analysis failed:', err);
setMaskAnalysis(null);
setAnalysisMessage('后端属性读取失败');
}
};
const restoreSmoothingPreview = React.useCallback(() => {
const preview = smoothingPreviewRef.current;
if (!preview || preview.applied) {
smoothingPreviewRef.current = null;
return;
}
const state = useStore.getState();
useStore.setState({
masks: state.masks.map((mask) => (mask.id === preview.maskId ? preview.baseMask : mask)),
selectedMaskIds: state.selectedMaskIds,
});
smoothingPreviewRef.current = null;
}, []);
const applyActiveTemplateChange = React.useCallback((templateId: string | null) => {
setActiveTemplateId(templateId);
setActiveClass(null);
}, [setActiveClass, setActiveTemplateId]);
const requestActiveTemplateChange = React.useCallback((templateId: string | null) => {
if (templateId === (activeTemplateId || null)) return;
if (!activeTemplateId && templateId && templateId === activeTemplate?.id) {
applyActiveTemplateChange(templateId);
return;
}
if (masks.length === 0) {
applyActiveTemplateChange(templateId);
return;
}
setPendingTemplateSwitch({ templateId });
}, [activeTemplate?.id, activeTemplateId, applyActiveTemplateChange, masks.length]);
const confirmActiveTemplateChange = React.useCallback(async () => {
if (!pendingTemplateSwitch) return;
const nextTemplateId = pendingTemplateSwitch.templateId;
const annotationIds = Array.from(new Set(
useStore.getState().masks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSwitchingTemplate(true);
setClassSaveMessage(annotationIds.length > 0 ? '正在清空旧模板标注...' : '正在清空旧模板遮罩...');
try {
await Promise.all(annotationIds.map(deleteAnnotationIfExists));
useStore.getState().setMasks([]);
setSelectedMaskIds([]);
applyActiveTemplateChange(nextTemplateId);
setPendingTemplateSwitch(null);
setClassSaveMessage(annotationIds.length > 0
? `已清空 ${annotationIds.length} 个旧模板标注并切换激活模板`
: '已清空旧模板遮罩并切换激活模板');
} catch (err) {
console.error('Switch active template failed:', err);
setClassSaveMessage('切换模板失败,旧标注未清空');
} finally {
setIsSwitchingTemplate(false);
}
}, [applyActiveTemplateChange, pendingTemplateSwitch, setSelectedMaskIds]);
React.useEffect(() => {
return () => {
analysisRequestIdRef.current += 1;
clearSmoothingPreviewTimer();
restoreSmoothingPreview();
};
}, [clearSmoothingPreviewTimer, restoreSmoothingPreview]);
React.useEffect(() => {
const preview = smoothingPreviewRef.current;
if (preview && preview.maskId !== selectedMask?.id) {
restoreSmoothingPreview();
}
}, [restoreSmoothingPreview, selectedMask?.id]);
React.useEffect(() => {
if (skipNextAutoAnalysisRef.current) {
skipNextAutoAnalysisRef.current = false;
return;
}
void refreshMaskAnalysis();
// selectedMask is intentionally tracked by id and geometry fields to avoid
// re-running analysis for unrelated store changes.
}, [selectedMask?.id, selectedMask?.segmentation, selectedMask?.points, currentFrame?.id]);
React.useEffect(() => {
const smoothing = selectedMask?.metadata?.geometry_smoothing;
const strength = smoothing && typeof smoothing === 'object'
? Number((smoothing as Record<string, unknown>).strength)
: 0;
setSmoothingStrength(Number.isFinite(strength) ? Math.min(Math.max(strength, 0), 100) : 0);
}, [selectedMask?.id]);
const applySmoothingResultToMask = React.useCallback((
mask: Mask,
result: SmoothMaskGeometryResult,
options: { commit: boolean },
): Mask => {
const metadata = { ...(mask.metadata || {}) };
delete metadata.geometry_smoothing_preview;
if (options.commit) {
delete metadata.geometry_smoothing;
} else {
metadata.geometry_smoothing_preview = result.smoothing;
}
return {
...mask,
pathData: result.pathData,
segmentation: result.segmentation,
bbox: result.bbox,
area: result.area,
metadata,
...(options.commit
? {
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
}
: {}),
};
}, []);
const updateMaskWithSmoothingResult = React.useCallback((
maskId: string,
result: SmoothMaskGeometryResult,
options: { commit: boolean },
) => {
const state = useStore.getState();
const nextMasks = state.masks.map((mask) => (
mask.id === maskId ? applySmoothingResultToMask(mask, result, options) : mask
));
if (options.commit) {
setMasks(nextMasks);
} else {
useStore.setState({ masks: nextMasks });
}
}, [applySmoothingResultToMask, setMasks]);
const applySmoothingResultToAnalysis = React.useCallback((
result: SmoothMaskGeometryResult,
sourceMask: NonNullable<typeof selectedMask>,
suffix: string,
) => {
setMaskAnalysis({
confidence: null,
confidence_source: 'manual_or_imported',
topology_anchor_count: result.topology_anchor_count,
topology_anchors: result.topology_anchors,
area: result.area,
bbox: result.bbox,
source: sourceMask.metadata?.source as string | undefined,
message: result.message,
});
setAnalysisMessage(`${result.message}${suffix}`);
}, []);
const runSmoothingPreview = React.useCallback(async (nextStrength: number) => {
if (!selectedMask || !currentFrame) return;
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
? smoothingPreviewRef.current
: null;
const baseMask = existingPreview?.baseMask || selectedMask;
const requestId = smoothingRequestIdRef.current + 1;
smoothingRequestIdRef.current = requestId;
if (nextStrength <= 0) {
clearSmoothingPreviewTimer();
smoothingPreviewRef.current = {
maskId: selectedMask.id,
baseMask,
strength: 0,
result: null,
applied: false,
requestId,
};
skipNextAutoAnalysisRef.current = true;
useStore.setState({
masks: useStore.getState().masks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask)),
});
setAnalysisMessage('已预览恢复原始边缘,点击应用后写入当前 mask。');
setIsPreviewingSmoothing(false);
return;
}
setAnalysisMessage('正在生成边缘平滑预览...');
try {
const result = await smoothMaskGeometry(baseMask, currentFrame, nextStrength);
if (smoothingRequestIdRef.current !== requestId) return;
smoothingPreviewRef.current = {
maskId: selectedMask.id,
baseMask,
strength: nextStrength,
result,
applied: false,
requestId,
};
skipNextAutoAnalysisRef.current = true;
updateMaskWithSmoothingResult(selectedMask.id, result, { commit: false });
applySmoothingResultToAnalysis(result, baseMask, ',预览中,点击应用后写入当前 mask。');
} catch (err) {
if (smoothingRequestIdRef.current !== requestId) return;
console.error('Mask smoothing preview failed:', err);
setAnalysisMessage('边缘平滑预览失败,请检查后端服务');
} finally {
if (smoothingRequestIdRef.current === requestId) {
setIsPreviewingSmoothing(false);
}
}
}, [applySmoothingResultToAnalysis, clearSmoothingPreviewTimer, currentFrame, selectedMask, updateMaskWithSmoothingResult]);
const previewSmoothing = React.useCallback((nextStrength: number) => {
setSmoothingStrength(nextStrength);
clearSmoothingPreviewTimer();
if (!selectedMask || !currentFrame) return;
if (nextStrength <= 0) {
void runSmoothingPreview(nextStrength);
return;
}
setIsPreviewingSmoothing(true);
setAnalysisMessage('正在等待停止拖动后生成边缘平滑预览...');
smoothingPreviewTimerRef.current = window.setTimeout(() => {
smoothingPreviewTimerRef.current = null;
void runSmoothingPreview(nextStrength);
}, SMOOTHING_PREVIEW_DEBOUNCE_MS);
}, [clearSmoothingPreviewTimer, currentFrame, runSmoothingPreview, selectedMask]);
const handleApplySmoothing = async () => {
if (!selectedMask || !currentFrame) {
setAnalysisMessage('请选择一个 mask 后再应用边缘平滑');
return;
}
clearSmoothingPreviewTimer();
smoothingRequestIdRef.current += 1;
setIsSmoothingMask(true);
setAnalysisMessage('');
try {
const existingPreview = smoothingPreviewRef.current?.maskId === selectedMask.id
&& smoothingPreviewRef.current.strength === smoothingStrength
? smoothingPreviewRef.current
: null;
const baseMask = existingPreview?.baseMask || selectedMask;
if (smoothingStrength <= 0) {
smoothingPreviewRef.current = null;
setSmoothingStrength(0);
setAnalysisMessage('边缘平滑强度为 0当前 mask 保持原始边缘。');
return;
}
const state = useStore.getState();
const frameById = new Map(state.frames.map((frame) => [String(frame.id), frame]));
const chainMaskIds = findPropagationChainMaskIds(baseMask, state.masks);
chainMaskIds.add(selectedMask.id);
const selectedResult = existingPreview?.result || await smoothMaskGeometry(baseMask, currentFrame, smoothingStrength);
const resultEntries = new Map<string, SmoothMaskGeometryResult>();
resultEntries.set(selectedMask.id, selectedResult);
await Promise.all(
Array.from(chainMaskIds)
.filter((maskId) => maskId !== selectedMask.id)
.map(async (maskId) => {
const mask = state.masks.find((item) => item.id === maskId);
const frame = mask ? frameById.get(String(mask.frameId)) : null;
if (!mask || !frame) return;
resultEntries.set(maskId, await smoothMaskGeometry(mask, frame, smoothingStrength));
}),
);
const latestMasks = useStore.getState().masks;
const historyBaseMasks = latestMasks.map((mask) => (mask.id === selectedMask.id ? baseMask : mask));
useStore.setState({ masks: historyBaseMasks });
const nextMasks = historyBaseMasks.map((mask) => {
const result = resultEntries.get(mask.id);
if (!result) return mask;
return applySmoothingResultToMask(mask, result, { commit: true });
});
skipNextAutoAnalysisRef.current = true;
setMasks(nextMasks);
if (smoothingPreviewRef.current) {
smoothingPreviewRef.current.applied = true;
}
smoothingPreviewRef.current = null;
setSmoothingStrength(0);
applySmoothingResultToAnalysis(
selectedResult,
baseMask,
resultEntries.size > 1
? `,已同步应用到传播链 ${resultEntries.size} 个对应 mask强度已重置为 0请保存后生效`
: ',已变为新的 mask强度已重置为 0请保存后生效',
);
} catch (err) {
console.error('Mask smoothing failed:', err);
setAnalysisMessage('边缘平滑失败,请检查后端服务');
} finally {
setIsSmoothingMask(false);
}
};
const handleAddCustom = async () => {
if (!newClassName.trim()) return;
if (!activeTemplate) {
setClassSaveMessage('请先选择一个模板');
return;
}
const activeClasses = templateClasses.filter((templateClass) => !isReservedUnclassifiedClass(templateClass));
const maxZ = activeClasses.length > 0 ? Math.max(...activeClasses.map((c) => c.zIndex)) : 0;
const newClass: TemplateClass = {
id: `custom-${Date.now()}`,
name: newClassName.trim(),
color: newClassColor,
zIndex: maxZ + 10,
maskId: nextClassMaskId(templateClasses),
category: '自定义',
};
setIsSavingClass(true);
setClassSaveMessage('');
try {
const updated = await updateTemplate(activeTemplate.id, {
name: activeTemplate.name,
description: activeTemplate.description,
classes: normalizeClassMaskIds([...templateClasses, newClass]),
rules: activeTemplate.rules || [],
});
updateTemplateStore(updated);
setActiveTemplateId(updated.id);
handleSelectClass(newClass);
setNewClassName('');
setShowAddForm(false);
setClassSaveMessage('自定义分类已保存到后端模板');
} catch (err) {
console.error('Save custom class failed:', err);
setClassSaveMessage('自定义分类保存失败');
} finally {
setIsSavingClass(false);
}
};
const handleReorderClass = async (sourceClassId: string, targetClassId: string) => {
if (!activeTemplate || sourceClassId === targetClassId) {
setDragClassId(null);
return;
}
const sourceIndex = allClasses.findIndex((item) => item.id === sourceClassId);
const targetIndex = allClasses.findIndex((item) => item.id === targetClassId);
if (sourceIndex < 0 || targetIndex < 0) {
setDragClassId(null);
return;
}
if (isReservedUnclassifiedClass(allClasses[sourceIndex]) || isReservedUnclassifiedClass(allClasses[targetIndex])) {
setDragClassId(null);
return;
}
const reordered = [...allClasses];
const [source] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, source);
const nextClasses = normalizeClassMaskIds(
reordered
.filter((item) => !isReservedUnclassifiedClass(item))
.map((item, index, activeItems) => ({
...item,
zIndex: (activeItems.length - index) * 10,
})),
);
setIsSavingClass(true);
setClassSaveMessage('正在保存分类覆盖顺序...');
try {
const updated = await updateTemplate(activeTemplate.id, {
name: activeTemplate.name,
description: activeTemplate.description,
classes: nextClasses,
rules: activeTemplate.rules || [],
});
updateTemplateStore(updated);
setActiveTemplateId(updated.id);
const zIndexByClassId = new Map(nextClasses.map((item) => [item.id, item.zIndex]));
setMasks(useStore.getState().masks.map((mask) => (
mask.classId && zIndexByClassId.has(mask.classId)
? {
...mask,
classZIndex: zIndexByClassId.get(mask.classId),
saveStatus: mask.annotationId ? 'dirty' as const : mask.saveStatus,
saved: mask.annotationId ? false : mask.saved,
}
: mask
)));
const nextActiveClass = nextClasses.find((item) => item.id === activeClassId);
if (nextActiveClass) setActiveClass(nextActiveClass);
setClassSaveMessage('分类覆盖顺序已保存');
} catch (err) {
console.error('Reorder class failed:', err);
setClassSaveMessage('分类覆盖顺序保存失败');
} finally {
setIsSavingClass(false);
setDragClassId(null);
}
};
return (
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
<div className="flex-1 overflow-y-auto seg-scrollbar p-4 flex flex-col gap-6">
{/* Template Selector */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2"></h3>
<div className="relative">
<select
value={activeTemplate?.id || ''}
onChange={(e) => {
requestActiveTemplateChange(e.target.value || null);
}}
disabled={isSwitchingTemplate}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
>
<option value="">-- --</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<ChevronDown size={12} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
</div>
{activeTemplate && (
<div className="mt-2 text-[10px] text-gray-600">
{activeTemplate.classes?.length ?? 0}
</div>
)}
</div>
{/* Workspace Mask Opacity */}
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="workspace-mask-opacity" className="text-[10px] font-bold text-gray-500 uppercase tracking-widest">
</label>
<span className="text-[10px] font-mono text-cyan-400">{maskPreviewOpacity}%</span>
</div>
<input
id="workspace-mask-opacity"
aria-label="遮罩透明度"
type="range"
min={10}
max={100}
step={5}
value={maskPreviewOpacity}
onChange={(event) => setMaskPreviewOpacity(Number(event.target.value))}
className="w-full accent-cyan-500"
/>
</div>
{/* Semantic Classification Tree */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
<span></span>
</h3>
<div className="space-y-2">
{allClasses.map(cls => (
<div key={cls.id} className="flex flex-col gap-1">
<button
type="button"
draggable={Boolean(activeTemplate) && !isSavingClass && !isReservedUnclassifiedClass(cls)}
ref={(node) => {
if (node) {
classButtonRefs.current.set(cls.id, node);
} else {
classButtonRefs.current.delete(cls.id);
}
}}
onClick={() => handleSelectClass(cls)}
onDragStart={(event) => {
if (isReservedUnclassifiedClass(cls)) return;
setDragClassId(cls.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', cls.id);
}}
onDragOver={(event) => {
if (!dragClassId || dragClassId === cls.id || isReservedUnclassifiedClass(cls)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}}
onDrop={(event) => {
event.preventDefault();
const sourceId = event.dataTransfer.getData('text/plain') || dragClassId;
if (sourceId) void handleReorderClass(sourceId, cls.id);
}}
onDragEnd={() => setDragClassId(null)}
aria-current={activeClassId === cls.id ? 'true' : undefined}
className={cn(
'flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors text-left border',
activeClassId === cls.id ? 'border-cyan-500/50 bg-cyan-500/10' : 'border-transparent',
dragClassId === cls.id && 'opacity-50',
isReservedUnclassifiedClass(cls) && 'cursor-default',
)}
>
<div className="flex items-center gap-2">
<GripVertical size={13} className={cn("text-gray-600 group-hover:text-gray-400", isReservedUnclassifiedClass(cls) && "text-gray-800 group-hover:text-gray-800")} aria-hidden="true" />
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500 font-mono">maskid:{cls.maskId}</span>
<Eye size={14} className="text-gray-500 group-hover:text-gray-300" />
</div>
</button>
</div>
))}
{allClasses.length === 0 && (
<div className="text-xs text-gray-600 text-center py-4"></div>
)}
</div>
</div>
{/* Add Custom Class */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest"></h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="text-cyan-400 hover:text-cyan-300 transition-colors"
>
<Plus size={12} />
</button>
</div>
{showAddForm && (
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={newClassColor}
onChange={(e) => setNewClassColor(e.target.value)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
/>
<input
type="text"
value={newClassName}
onChange={(e) => setNewClassName(e.target.value)}
placeholder="分类名称"
className="flex-1 bg-[#111] border border-white/10 rounded px-2 py-1 text-xs text-white"
onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()}
/>
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
{isSavingClass ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />}
</button>
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} />
</button>
</div>
</div>
)}
{classSaveMessage && (
<div className="mt-2 text-[10px] text-gray-500">{classSaveMessage}</div>
)}
</div>
{/* Current Active Object Properties */}
<div className="mt-4 pt-4 border-t border-[#222]">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="bg-white/5 rounded-lg p-3">
<div className="flex items-center gap-2 mb-3">
<Tag size={12} className="text-cyan-400" />
<span className="text-xs font-semibold text-gray-200">
{selectedMaskLabel}
</span>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-gray-500 uppercase">:</span>
<span className="text-xs font-mono text-gray-300">{maskAnalysis?.topology_anchor_count ?? 0} </span>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<label htmlFor="mask-edge-smoothing" className="text-[10px] text-gray-500 uppercase">:</label>
<span className="text-xs font-mono text-gray-300">{smoothingStrength}%</span>
</div>
<input
id="mask-edge-smoothing"
aria-label="边缘平滑强度"
type="range"
min={0}
max={100}
step={5}
value={smoothingStrength}
onChange={(event) => void previewSmoothing(Number(event.target.value))}
disabled={!selectedMask || isSmoothingMask}
className="w-full accent-cyan-500 disabled:opacity-40"
/>
<button
onClick={handleApplySmoothing}
disabled={!selectedMask || !currentFrame || isSmoothingMask || isPreviewingSmoothing}
className="mt-2 w-full bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/20 text-xs text-cyan-100 py-1.5 rounded transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isSmoothingMask ? '平滑中...' : isPreviewingSmoothing ? '预览中...' : '应用边缘平滑'}
</button>
</div>
{analysisMessage && (
<div className="text-[10px] leading-relaxed text-gray-500">{analysisMessage}</div>
)}
</div>
</div>
</div>
</div>
{pendingTemplateSwitch && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-md rounded-lg border border-amber-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{masks.length} mask mask
</p>
<p className="mt-2 text-xs leading-relaxed text-amber-200/70">
mask
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setPendingTemplateSwitch(null)}
disabled={isSwitchingTemplate}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void confirmActiveTemplateChange()}
disabled={isSwitchingTemplate}
className="rounded bg-amber-500 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400 disabled:opacity-60"
>
{isSwitchingTemplate ? '正在切换...' : '清空并切换'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,916 @@
import React, { useState, useEffect, useRef } from 'react';
import { UploadCloud, Film, Settings2, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getProjects, createProject, updateProject, copyProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject, getTask } from '../lib/api';
import type { UploadProgress } from '../lib/api';
import type { Project } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
const naturalFilenameCompare = (left: File, right: File) => left.name.localeCompare(
right.name,
undefined,
{ numeric: true, sensitivity: 'base' },
);
interface ProjectLibraryProps {
onProjectSelect: () => void;
}
interface ImportProgressState {
kind: 'video' | 'dicom';
phase: 'preparing' | 'uploading' | 'queueing' | 'parsing' | 'done' | 'error';
title: string;
detail: string;
percent?: number;
fileCount?: number;
}
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const projects = useStore((state) => state.projects);
const setProjects = useStore((state) => state.setProjects);
const currentProject = useStore((state) => state.currentProject);
const setCurrentProject = useStore((state) => state.setCurrentProject);
const setFrames = useStore((state) => state.setFrames);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [isLoading, setIsLoading] = useState(false);
const [showImportMenu, setShowImportMenu] = useState(false);
const [showVideoConfig, setShowVideoConfig] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [frameProject, setFrameProject] = useState<Project | null>(null);
const [showFrameConfig, setShowFrameConfig] = useState(false);
const [frameParseFps, setFrameParseFps] = useState(30);
const [isGeneratingFrames, setIsGeneratingFrames] = useState(false);
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(null);
const [deleteProjectTarget, setDeleteProjectTarget] = useState<Project | null>(null);
const [copyingProjectId, setCopyingProjectId] = useState<string | null>(null);
const [copyProjectTarget, setCopyProjectTarget] = useState<Project | null>(null);
const [editingProjectId, setEditingProjectId] = useState<string | null>(null);
const [editingProjectName, setEditingProjectName] = useState('');
const [renamingProjectId, setRenamingProjectId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null);
const [importProgress, setImportProgress] = useState<ImportProgressState | null>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const dicomInputRef = useRef<HTMLInputElement>(null);
const showNotice = (message: string, tone: NoticeTone = 'info') => {
setNotice({ id: Date.now(), message, tone });
};
const formatUploadBytes = (value: number) => {
if (!Number.isFinite(value) || value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
const amount = value / (1024 ** index);
return `${amount >= 10 || index === 0 ? amount.toFixed(0) : amount.toFixed(1)} ${units[index]}`;
};
const scheduleProgressDismiss = () => {
window.setTimeout(() => setImportProgress((current) => (
current?.phase === 'done' ? null : current
)), 1400);
};
const uploadProgressDetail = (progress: UploadProgress, fallback: string) => {
if (progress.total) {
return `${formatUploadBytes(progress.loaded)} / ${formatUploadBytes(progress.total)}`;
}
return `${fallback},已上传 ${formatUploadBytes(progress.loaded)}`;
};
const waitForTaskDone = async (
taskId: string | number,
onProgress: (progress: { progress?: number; message?: string | null; status?: string }) => void,
) => {
for (;;) {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
const task = await getTask(taskId);
onProgress(task);
if (['success', 'failed', 'cancelled'].includes(task.status)) return task;
}
};
const refreshProjects = async () => {
const data = await getProjects();
setProjects(data);
if (currentProject?.id) {
const refreshedCurrentProject = data.find((project) => project.id === currentProject.id);
if (refreshedCurrentProject) {
setCurrentProject(refreshedCurrentProject);
}
}
return data;
};
const ImportProgressPanel = () => {
if (!importProgress) return null;
const percent = typeof importProgress.percent === 'number'
? Math.min(100, Math.max(0, importProgress.percent))
: undefined;
const toneClass = importProgress.phase === 'error'
? 'border-red-500/25 bg-red-950/20'
: importProgress.kind === 'dicom'
? 'border-emerald-500/25 bg-emerald-950/15'
: 'border-cyan-500/25 bg-cyan-950/15';
const barClass = importProgress.phase === 'error'
? 'bg-red-400'
: importProgress.kind === 'dicom'
? 'bg-emerald-400'
: 'bg-cyan-400';
return (
<div
aria-live="polite"
aria-label="导入进度"
className={cn('mb-6 rounded-lg border px-4 py-3 shadow-lg shadow-black/20', toneClass)}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-medium text-gray-100">
{importProgress.phase === 'done' ? (
<Check size={16} className="text-emerald-300" />
) : importProgress.phase === 'error' ? (
<X size={16} className="text-red-300" />
) : (
<Loader2 size={16} className="animate-spin text-cyan-300" />
)}
<span>{importProgress.title}</span>
{importProgress.fileCount && (
<span className="rounded border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] font-mono text-gray-400">
{importProgress.fileCount}
</span>
)}
</div>
<div className="mt-1 truncate text-xs text-gray-400">{importProgress.detail}</div>
</div>
{percent !== undefined && (
<div className="shrink-0 font-mono text-sm text-gray-200">{percent}%</div>
)}
</div>
<div
role="progressbar"
aria-label="导入进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-3 h-2 overflow-hidden rounded-full bg-black/35"
>
<div
className={cn(
'h-full rounded-full transition-all duration-200',
barClass,
percent === undefined && 'w-1/3 animate-pulse',
)}
style={percent !== undefined ? { width: `${percent}%` } : undefined}
/>
</div>
</div>
);
};
const frameSequenceLabel = (project: Project) => {
if (project.source_type === 'dicom') return 'DICOM';
if (project.video_path && (project.frames ?? 0) === 0 && project.status !== 'parsing') return '待生成帧';
if (project.parse_fps && project.parse_fps > 0) {
const rounded = Math.round(project.parse_fps * 10) / 10;
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}FPS`;
}
return project.fps || '30FPS';
};
const canGenerateFrames = (project: Project) => (
project.source_type !== 'dicom'
&& Boolean(project.video_path)
&& project.status !== 'parsing'
&& editingProjectId !== project.id
);
const frameGenerationLabel = (project: Project) => (
(project.frames ?? 0) > 0 ? '重新生成帧' : '生成帧'
);
useEffect(() => {
setIsLoading(true);
getProjects()
.then((data) => setProjects(data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, [setProjects]);
const handleSelect = (project: Project) => {
setCurrentProject(project);
onProjectSelect();
};
const openDeleteProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
if (deletingProjectId) return;
setDeleteProjectTarget(project);
};
const handleDeleteProject = async () => {
const project = deleteProjectTarget;
if (!project || deletingProjectId) return;
setDeletingProjectId(project.id);
try {
await deleteProject(project.id);
setProjects(projects.filter((item) => item.id !== project.id));
if (currentProject?.id === project.id) {
setCurrentProject(null);
setFrames([]);
setMasks([]);
setSelectedMaskIds([]);
}
setDeleteProjectTarget(null);
} catch (err) {
console.error('Delete project failed:', err);
showNotice('删除项目失败,请检查后端服务', 'error');
} finally {
setDeletingProjectId(null);
}
};
const openCopyProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setCopyProjectTarget(project);
};
const handleCopyProject = async (mode: 'reset' | 'full') => {
if (!copyProjectTarget || copyingProjectId) return;
setCopyingProjectId(copyProjectTarget.id);
try {
const copied = await copyProject(copyProjectTarget.id, { mode });
const data = await getProjects();
setProjects(data);
setCopyProjectTarget(null);
showNotice(mode === 'full'
? `已全内容复制项目:${copied.name}`
: `已复制为重置项目:${copied.name}`, 'success');
} catch (err) {
console.error('Copy project failed:', err);
showNotice('复制项目失败,请检查后端服务', 'error');
} finally {
setCopyingProjectId(null);
}
};
const beginRenameProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setEditingProjectId(project.id);
setEditingProjectName(project.name);
};
const cancelRenameProject = (event: React.MouseEvent) => {
event.stopPropagation();
setEditingProjectId(null);
setEditingProjectName('');
};
const commitRenameProject = async (project: Project, event?: React.SyntheticEvent) => {
event?.preventDefault();
event?.stopPropagation();
const nextName = editingProjectName.trim();
if (!nextName) {
showNotice('项目名称不能为空', 'error');
return;
}
if (nextName === project.name) {
setEditingProjectId(null);
setEditingProjectName('');
return;
}
setRenamingProjectId(project.id);
try {
const updated = await updateProject(project.id, { name: nextName });
setProjects(projects.map((item) => (item.id === updated.id ? updated : item)));
if (currentProject?.id === updated.id) {
setCurrentProject(updated);
}
setEditingProjectId(null);
setEditingProjectName('');
showNotice('项目名称已更新', 'success');
} catch (err) {
console.error('Rename project failed:', err);
showNotice('项目名称修改失败,请检查后端服务', 'error');
} finally {
setRenamingProjectId(null);
}
};
const handleVideoSelect = (file: File) => {
setPendingFile(file);
setShowVideoConfig(true);
};
const handleVideoUpload = async () => {
if (!pendingFile) return;
setShowVideoConfig(false);
setIsLoading(true);
setImportProgress({
kind: 'video',
phase: 'preparing',
title: '正在准备视频导入',
detail: `创建项目:${pendingFile.name}`,
percent: 2,
});
try {
const newProject = await createProject({
name: pendingFile.name,
description: `导入于 ${new Date().toLocaleString()}`,
});
setImportProgress({
kind: 'video',
phase: 'uploading',
title: '正在上传视频文件',
detail: pendingFile.name,
percent: 5,
});
const result = await uploadMedia(pendingFile, String(newProject.id), {
onProgress: (progress) => setImportProgress({
kind: 'video',
phase: 'uploading',
title: '正在上传视频文件',
detail: uploadProgressDetail(progress, pendingFile.name),
percent: progress.percent,
}),
});
setImportProgress({
kind: 'video',
phase: 'done',
title: '视频导入完成',
detail: pendingFile.name,
percent: 100,
});
showNotice(`视频导入成功: ${pendingFile.name}\n已保存至: ${result.url}\n需要生成帧时请在项目卡片点击“生成帧”。`, 'success');
const data = await getProjects();
setProjects(data);
scheduleProgressDismiss();
} catch (err) {
console.error('Upload failed:', err);
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频导入失败',
detail: pendingFile.name,
percent: 100,
});
showNotice('上传失败,请检查后端服务', 'error');
} finally {
setIsLoading(false);
setPendingFile(null);
if (videoInputRef.current) videoInputRef.current.value = '';
}
};
const openFrameConfig = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
setFrameProject(project);
setFrameParseFps(Math.round(project.parse_fps || 30));
setShowFrameConfig(true);
};
const handleGenerateFrames = async () => {
if (!frameProject?.id) return;
const targetProject = frameProject;
setIsGeneratingFrames(true);
try {
const task = await parseMedia(targetProject.id, { parseFps: frameParseFps });
showNotice(`生成帧任务已入队 #${task.id}\n帧率: ${frameParseFps} FPS\n可在 Dashboard 查看进度。`, 'success');
if (currentProject?.id === targetProject.id) {
setFrames([]);
setMasks([]);
setSelectedMaskIds([]);
}
await refreshProjects();
setShowFrameConfig(false);
setFrameProject(null);
if (task.id) {
setImportProgress({
kind: 'video',
phase: 'parsing',
title: '正在生成视频帧',
detail: task.message || `项目:${targetProject.name}`,
percent: task.progress ?? 0,
});
void waitForTaskDone(task.id, (progress) => {
setImportProgress({
kind: 'video',
phase: 'parsing',
title: '正在生成视频帧',
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
percent: progress.progress,
});
}).then(async (completed) => {
if (completed.status === 'failed') {
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成失败',
detail: completed.error || completed.message || targetProject.name,
percent: 100,
});
showNotice('视频帧生成失败,请检查后端服务或任务日志', 'error');
return;
}
if (completed.status === 'cancelled') {
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成已取消',
detail: targetProject.name,
percent: 100,
});
showNotice('视频帧生成任务已取消', 'error');
return;
}
await refreshProjects();
setImportProgress({
kind: 'video',
phase: 'done',
title: '视频帧生成完成',
detail: '项目封面已自动更新',
percent: 100,
});
showNotice('视频帧生成完成,项目封面已自动更新', 'success');
scheduleProgressDismiss();
}).catch((err) => {
console.error('Frame generation polling failed:', err);
setImportProgress({
kind: 'video',
phase: 'error',
title: '视频帧生成进度同步失败',
detail: targetProject.name,
percent: 100,
});
showNotice('视频帧生成进度同步失败,请刷新项目库重试', 'error');
});
}
} catch (err) {
console.error('Frame generation failed:', err);
showNotice('生成帧失败,请检查后端服务或项目源文件', 'error');
} finally {
setIsGeneratingFrames(false);
}
};
const handleDicomUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const dcmFiles = Array.from(files)
.filter((f) => f.name.toLowerCase().endsWith('.dcm'))
.sort(naturalFilenameCompare);
if (dcmFiles.length === 0) {
showNotice('未选择有效的 .dcm 文件', 'error');
return;
}
setIsLoading(true);
setImportProgress({
kind: 'dicom',
phase: 'uploading',
title: '正在上传 DICOM 序列',
detail: `${dcmFiles.length} 个文件,按文件名自然顺序上传`,
percent: 0,
fileCount: dcmFiles.length,
});
try {
const result = await uploadDicomBatch(dcmFiles, undefined, {
onProgress: (progress) => setImportProgress({
kind: 'dicom',
phase: 'uploading',
title: '正在上传 DICOM 序列',
detail: uploadProgressDetail(progress, `${dcmFiles.length} 个文件`),
percent: progress.percent,
fileCount: dcmFiles.length,
}),
});
setImportProgress({
kind: 'dicom',
phase: 'queueing',
title: 'DICOM 上传完成,正在创建解析任务',
detail: `${result.uploaded_count} 个文件已上传`,
percent: 92,
fileCount: result.uploaded_count,
});
const task = await parseMedia(String(result.project_id));
setImportProgress({
kind: 'dicom',
phase: 'parsing',
title: '正在解析 DICOM 序列',
detail: task.message || '解析任务已入队',
percent: task.progress ?? 0,
fileCount: result.uploaded_count,
});
if (task.id) {
const completed = await waitForTaskDone(task.id, (progress) => {
setImportProgress({
kind: 'dicom',
phase: 'parsing',
title: '正在解析 DICOM 序列',
detail: progress.message || `任务状态: ${progress.status || 'running'}`,
percent: progress.progress,
fileCount: result.uploaded_count,
});
});
if (completed.status === 'failed') {
throw new Error(completed.error || completed.message || 'DICOM 解析失败');
}
if (completed.status === 'cancelled') {
throw new Error('DICOM 解析任务已取消');
}
}
setImportProgress({
kind: 'dicom',
phase: 'done',
title: 'DICOM 导入完成',
detail: `${result.uploaded_count} 个文件已上传并完成解析`,
percent: 100,
fileCount: result.uploaded_count,
});
showNotice(`DICOM 导入完成: ${result.uploaded_count} 个文件`, 'success');
const data = await getProjects();
setProjects(data);
scheduleProgressDismiss();
} catch (err) {
console.error('DICOM upload failed:', err);
setImportProgress({
kind: 'dicom',
phase: 'error',
title: 'DICOM 导入失败',
detail: `${dcmFiles.length} 个文件`,
percent: 100,
fileCount: dcmFiles.length,
});
showNotice('DICOM 上传失败,请检查后端服务', 'error');
} finally {
setIsLoading(false);
if (dicomInputRef.current) dicomInputRef.current.value = '';
}
};
const SkeletonCard = () => (
<div className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden animate-pulse">
<div className="w-full aspect-[16/9] bg-[#1a1a1a]" />
<div className="p-4 flex flex-col gap-2">
<div className="h-4 bg-[#1a1a1a] rounded w-3/4" />
<div className="h-3 bg-[#1a1a1a] rounded w-1/2 mt-2" />
</div>
</div>
);
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
<div className="flex justify-between items-end mb-8 border-b border-white/5 pb-6">
<div>
<h1 className="text-3xl font-medium tracking-tight text-white mb-2"></h1>
<p className="text-gray-400 text-sm">DICOM序列文件</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<button
onClick={() => setShowImportMenu(!showImportMenu)}
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20"
>
<UploadCloud size={18} />
<span></span>
</button>
{showImportMenu && (
<div className="absolute right-0 top-full mt-2 w-56 bg-[#111] border border-white/10 rounded-lg shadow-2xl z-50 overflow-hidden">
<button
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors"
onClick={() => { setShowImportMenu(false); videoInputRef.current?.click(); }}
>
<Film size={16} className="text-cyan-400" />
</button>
<button
className="w-full text-left px-4 py-3 text-sm text-gray-200 hover:bg-white/5 flex items-center gap-3 transition-colors border-t border-white/5"
onClick={() => { setShowImportMenu(false); dicomInputRef.current?.click(); }}
>
<Activity size={16} className="text-emerald-400" />
DICOM
</button>
</div>
)}
</div>
<input
type="file"
ref={videoInputRef}
className="hidden"
accept="video/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleVideoSelect(file);
}}
/>
<input
type="file"
ref={dicomInputRef}
className="hidden"
accept=".dcm"
multiple
onChange={(e) => handleDicomUpload(e.target.files)}
/>
</div>
</div>
<ImportProgressPanel />
{isLoading && projects.length === 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<SkeletonCard key={i} />
))}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{projects.map((proj) => (
<div
key={proj.id}
className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-500/50 transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.15)]"
onClick={() => handleSelect(proj)}
>
<div className={cn("w-full aspect-[16/9] relative flex items-center justify-center overflow-hidden bg-[#0d0d0d]")}>
{proj.thumbnail_url ? (
<img
src={proj.thumbnail_url}
alt={proj.name}
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
/>
) : (
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
)}
<div className="absolute top-2 right-2 flex gap-2">
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
{frameSequenceLabel(proj)}
</span>
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
{proj.status === 'ready' ? (
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> </>
) : proj.status === 'parsing' ? (
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> </>
) : proj.status === 'error' ? (
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> </>
) : (
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> </>
)}
</span>
</div>
</div>
<div className="p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
{editingProjectId === proj.id ? (
<form
className="flex min-w-0 flex-1 items-center gap-1 pr-2"
onClick={(event) => event.stopPropagation()}
onSubmit={(event) => void commitRenameProject(proj, event)}
>
<input
value={editingProjectName}
onChange={(event) => setEditingProjectName(event.target.value)}
autoFocus
className="min-w-0 flex-1 rounded border border-cyan-400/40 bg-black/30 px-2 py-1 text-sm text-gray-100 outline-none focus:border-cyan-300"
/>
<button
type="button"
aria-label={`保存项目名称 ${proj.name}`}
title="保存名称"
disabled={renamingProjectId === proj.id}
onClick={(event) => void commitRenameProject(proj, event)}
className="text-cyan-300 hover:text-cyan-100 disabled:cursor-wait disabled:opacity-50"
>
{renamingProjectId === proj.id ? <Loader2 size={15} className="animate-spin" /> : <Check size={15} />}
</button>
<button
type="button"
aria-label={`取消修改项目名称 ${proj.name}`}
title="取消"
onClick={cancelRenameProject}
disabled={renamingProjectId === proj.id}
className="text-gray-500 hover:text-gray-200 disabled:opacity-50"
>
<X size={15} />
</button>
</form>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2 pr-2">
<h3 className="truncate text-sm font-medium text-gray-200" title={proj.name}>{proj.name}</h3>
<button
type="button"
aria-label={`修改项目名称 ${proj.name}`}
title="修改项目名称"
onClick={(event) => beginRenameProject(proj, event)}
className="shrink-0 text-gray-500 opacity-0 transition-colors hover:text-cyan-300 group-hover:opacity-100 focus:opacity-100"
>
<Pencil size={14} />
</button>
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`复制项目 ${proj.name}`}
title="复制项目"
disabled={copyingProjectId === proj.id || deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openCopyProject(proj, event)}
className="text-gray-500 hover:text-emerald-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{copyingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Copy size={16} />}
</button>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id || renamingProjectId === proj.id}
onClick={(event) => openDeleteProject(proj, event)}
className="text-gray-500 hover:text-red-400 disabled:opacity-50 disabled:cursor-wait transition-colors"
>
{deletingProjectId === proj.id ? <Loader2 size={16} className="animate-spin" /> : <Trash2 size={16} />}
</button>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono mt-2">
<span className="flex items-center gap-1.5"><Settings2 size={12} /> {proj.frames ?? 0} </span>
{proj.original_fps && (
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> {proj.original_fps.toFixed(1)}fps</span>
)}
</div>
{canGenerateFrames(proj) && (
<button
onClick={(event) => openFrameConfig(proj, event)}
className="mt-3 inline-flex items-center justify-center gap-2 rounded-md border border-cyan-500/30 bg-cyan-500/10 px-3 py-2 text-xs font-medium text-cyan-200 hover:bg-cyan-500/20 transition-colors"
>
<Images size={14} />
{frameGenerationLabel(proj)}
</button>
)}
</div>
</div>
))}
</div>
)}
{/* Delete project confirmation */}
{deleteProjectTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-3 text-sm leading-6 text-gray-400">
<span className="text-gray-100">{deleteProjectTarget.name}</span>
mask
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteProjectTarget(null)}
disabled={deletingProjectId === deleteProjectTarget.id}
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleDeleteProject()}
disabled={deletingProjectId === deleteProjectTarget.id}
className="inline-flex items-center gap-2 rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400 disabled:cursor-wait disabled:opacity-60"
>
{deletingProjectId === deleteProjectTarget.id && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{/* Copy project modal */}
{copyProjectTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div
className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<h2 className="text-lg font-semibold text-white mb-2"></h2>
<p className="text-sm text-gray-400 mb-5">
{copyProjectTarget.name}
</p>
<div className="space-y-3">
<button
type="button"
onClick={() => void handleCopyProject('reset')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-cyan-500/25 bg-cyan-500/10 px-4 py-3 text-left transition-colors hover:bg-cyan-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-cyan-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-cyan-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
<button
type="button"
onClick={() => void handleCopyProject('full')}
disabled={copyingProjectId === copyProjectTarget.id}
className="w-full rounded-lg border border-emerald-500/25 bg-emerald-500/10 px-4 py-3 text-left transition-colors hover:bg-emerald-500/20 disabled:cursor-wait disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-emerald-100"></span>
{copyingProjectId === copyProjectTarget.id && <Loader2 size={16} className="animate-spin text-emerald-200" />}
</div>
<p className="mt-1 text-xs leading-5 text-gray-500"> mask </p>
</button>
</div>
<div className="flex justify-end mt-5">
<button
type="button"
onClick={() => setCopyProjectTarget(null)}
disabled={copyingProjectId === copyProjectTarget.id}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* Video parse FPS config modal */}
{showVideoConfig && pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-4">
<div className="text-sm text-gray-400">: <span className="text-gray-200">{pendingFile.name}</span></div>
<p className="text-xs leading-5 text-gray-500"> FPS</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => { setShowVideoConfig(false); setPendingFile(null); }}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleVideoUpload}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
>
</button>
</div>
</div>
</div>
)}
{/* Frame generation FPS config modal */}
{showFrameConfig && frameProject && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4">{frameGenerationLabel(frameProject)}</h2>
<div className="space-y-4">
<div className="text-sm text-gray-400">: <span className="text-gray-200">{frameProject.name}</span></div>
{(frameProject.frames ?? 0) > 0 && (
<div className="rounded-lg border border-amber-500/25 bg-amber-950/20 px-3 py-2 text-xs leading-5 text-amber-100">
mask FPS
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"> (FPS)</label>
<div className="flex items-center gap-3">
<input
type="range"
min="1"
max="60"
value={frameParseFps}
onChange={(e) => setFrameParseFps(parseInt(e.target.value))}
className="flex-1 accent-cyan-500"
/>
<span className="text-sm font-mono text-cyan-400 w-12 text-right">{frameParseFps}</span>
</div>
<p className="text-[10px] text-gray-600 mt-1"></p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => { setShowFrameConfig(false); setFrameProject(null); }}
disabled={isGeneratingFrames}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleGenerateFrames}
disabled={isGeneratingFrames}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all disabled:opacity-60"
>
{isGeneratingFrames ? '入队中...' : '开始生成帧'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { Home, FolderOpen, Edit3, LayoutTemplate, LogOut, UserCircle } from 'lucide-react';
import { cn } from '../lib/utils';
import type { ActiveModule } from '../App';
import { ModelStatusBadge } from './ModelStatusBadge';
import { useStore } from '../store/useStore';
import { AiSegmentationIcon } from './AiSegmentationIcon';
interface SidebarProps {
activeModule: ActiveModule;
setActiveModule: (m: ActiveModule) => void;
}
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
const currentUser = useStore((state) => state.currentUser);
const logout = useStore((state) => state.logout);
const navItems = [
{ id: 'dashboard', icon: Home, label: '总体概况' },
{ id: 'projects', icon: FolderOpen, label: '项目库' },
{ id: 'workspace', icon: Edit3, label: '分割工作区' },
{ id: 'ai', icon: AiSegmentationIcon, label: 'AI智能分割' },
{ id: 'templates', icon: LayoutTemplate, label: '模板库' },
...(currentUser?.role === 'admin' ? [{ id: 'admin', icon: UserCircle, label: '用户管理' }] : []),
] as const;
return (
<aside className="w-16 flex flex-col items-center py-6 bg-[#0d0d0d] border-r border-white/10 z-50 gap-8">
<div className="w-10 h-10 rounded-lg overflow-hidden flex items-center justify-center bg-white">
<img src="/logo.png" alt="Logo" className="h-full w-full object-contain" />
</div>
<nav className="flex flex-col gap-6 w-full px-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = activeModule === item.id;
return (
<button
key={item.id}
onClick={() => setActiveModule(item.id as ActiveModule)}
className={cn(
"relative group flex items-center justify-center w-full aspect-square rounded-xl transition-all duration-200",
isActive ? "text-cyan-400 border-l-2 border-cyan-400 bg-cyan-400/5 rounded-none aspect-auto py-2" : "text-gray-500 hover:text-white"
)}
title={item.label}
>
{/* Indicator removed in favor of border-l-2 added above */}
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
<div className="absolute left-full ml-2 px-2 py-1 bg-[#222] border border-[#333] text-xs rounded opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 shadow-xl">
{item.label}
</div>
</button>
);
})}
</nav>
<div className="mt-auto mb-4 flex flex-col gap-4">
<ModelStatusBadge compact />
<button
type="button"
title={currentUser ? `当前用户:${currentUser.username},点击退出` : '退出登录'}
onClick={logout}
className="group relative flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-gray-400 transition-colors hover:border-red-400/40 hover:bg-red-500/10 hover:text-red-200"
>
<LogOut size={20} />
<span className="pointer-events-none invisible absolute left-full ml-2 whitespace-nowrap rounded border border-[#333] bg-[#222] px-2 py-1 text-xs text-gray-200 opacity-0 shadow-xl transition-all group-hover:visible group-hover:opacity-100">
{currentUser ? `${currentUser.username} / 退出` : '退出登录'}
</span>
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,812 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
import { RESERVED_UNCLASSIFIED_CLASS, isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
import type { Template, TemplateClass } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
// HSL to Hex color generator
function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
function generateColor(index: number, total: number): string {
const hue = (index * 360 / Math.max(total, 1)) % 360;
return hslToHex(hue, 75, 55);
}
const extractImportJsonText = (raw: string): string => {
let text = raw.trim();
const fenceMatch = text.match(/^```(?:json|javascript|js)?\s*([\s\S]*?)\s*```$/i);
if (fenceMatch) text = fenceMatch[1].trim();
const firstObject = text.indexOf('{');
const firstArray = text.indexOf('[');
const starts = [firstObject, firstArray].filter((index) => index >= 0);
if (starts.length > 0) {
const start = Math.min(...starts);
const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'));
if (end >= start) text = text.slice(start, end + 1);
}
return text;
};
const parseImportJson = (raw: string): any => {
const text = extractImportJsonText(raw);
const attempts = [
text,
text
.replace(/[]/g, ',')
.replace(/[]/g, ':')
.replace(/([{,]\s*)(colors|names|color|name)(\s*:)/gi, '$1"$2"$3')
.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner) => `"${inner.replace(/"/g, '\\"')}"`)
.replace(/,\s*([}\]])/g, '$1'),
];
for (const candidate of attempts) {
try {
return JSON.parse(candidate);
} catch {
// Try the next normalized candidate.
}
}
throw new Error('JSON 解析失败');
};
const normalizeImportRgb = (value: unknown, index: number): [number, number, number] => {
if (value === undefined) return [100, 100, 100];
if (!Array.isArray(value) || value.length < 3) {
throw new Error(`${index + 1} 个颜色不是 [R,G,B] 三元组`);
}
const rgb = value.slice(0, 3).map((part) => Number(part));
if (rgb.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) {
throw new Error(`${index + 1} 个颜色值必须在 0-255 之间`);
}
return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])];
};
export function TemplateRegistry() {
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
const addTemplate = useStore((state) => state.addTemplate);
const updateTemplateStore = useStore((state) => state.updateTemplate);
const removeTemplateStore = useStore((state) => state.removeTemplate);
const setMasks = useStore((state) => state.setMasks);
const [isLoading, setIsLoading] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [showModal, setShowModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isSavingOrder, setIsSavingOrder] = useState(false);
const [copyingTemplateId, setCopyingTemplateId] = useState<string | null>(null);
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
const [showImport, setShowImport] = useState(false);
const [importText, setImportText] = useState('');
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
const [editingClassId, setEditingClassId] = useState<string | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [detailDragClassId, setDetailDragClassId] = useState<string | null>(null);
const [detailDragOverClassId, setDetailDragOverClassId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null);
const showNotice = (message: string, tone: NoticeTone = 'info') => {
setNotice({ id: Date.now(), message, tone });
};
useEffect(() => {
setIsLoading(true);
getTemplates()
.then((data) => setTemplates(data))
.catch(console.error)
.finally(() => setIsLoading(false));
}, [setTemplates]);
const openCreate = () => {
setSelectedTemplate(null);
setEditName('');
setEditDesc('');
setEditClasses(normalizeClassMaskIds([]));
setShowModal(true);
};
const openEdit = (template: Template) => {
setSelectedTemplate(template);
setEditName(template.name);
setEditDesc(template.description || '');
setEditClasses(normalizeClassMaskIds(template.classes ? [...template.classes] : []));
setShowModal(true);
};
const buildNewClass = (classes: TemplateClass[]): TemplateClass => ({
id: `cls-${Date.now()}`,
name: '新类别',
color: generateColor(classes.length, Math.max(classes.length + 1, 8)),
zIndex: classes.length > 0 ? Math.max(...classes.map((c) => c.zIndex)) + 10 : 10,
maskId: nextClassMaskId(classes),
category: '未分类',
});
const buildTemplatePayload = (template: Template | null, classes: TemplateClass[]) => ({
name: template ? template.name : editName.trim(),
description: template ? template.description || undefined : editDesc.trim() || undefined,
classes: normalizeClassMaskIds(classes),
rules: template ? template.rules || [] : [],
color: template ? template.color || '#06b6d4' : '#06b6d4',
z_index: template ? template.z_index ?? 0 : 0,
});
const nextCopyName = (name: string) => {
const baseName = `${name} 副本`;
const existingNames = new Set(templates.map((template) => template.name));
if (!existingNames.has(baseName)) return baseName;
let suffix = 2;
while (existingNames.has(`${baseName} ${suffix}`)) {
suffix += 1;
}
return `${baseName} ${suffix}`;
};
const copyTemplateClasses = (template: Template) => {
const timestamp = Date.now();
return normalizeClassMaskIds(template.classes || []).map((templateClass, index) => ({
...templateClass,
id: isReservedUnclassifiedClass(templateClass) ? RESERVED_UNCLASSIFIED_CLASS.id : `cls-copy-${timestamp}-${index}`,
}));
};
const recalculateClassOrder = (classes: TemplateClass[]) => (
normalizeClassMaskIds(classes)
.filter((templateClass) => !isReservedUnclassifiedClass(templateClass))
.map((templateClass, index, activeClasses) => ({ ...templateClass, zIndex: (activeClasses.length - index) * 10 }))
.concat(normalizeClassMaskIds(classes).filter(isReservedUnclassifiedClass))
);
const syncMaskClassOrder = (classes: TemplateClass[]) => {
const zIndexByClassId = new Map(classes.map((templateClass) => [templateClass.id, templateClass.zIndex]));
setMasks(useStore.getState().masks.map((mask) => (
mask.classId && zIndexByClassId.has(mask.classId)
? {
...mask,
classZIndex: zIndexByClassId.get(mask.classId),
saveStatus: mask.annotationId ? 'dirty' as const : 'draft' as const,
saved: mask.annotationId ? false : mask.saved,
}
: mask
)));
};
const handleSave = async () => {
if (!editName.trim()) return;
setIsSaving(true);
try {
const basePayload = {
name: editName.trim(),
description: editDesc.trim() || undefined,
classes: normalizeClassMaskIds(editClasses),
rules: [],
color: selectedTemplate ? selectedTemplate.color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? selectedTemplate.z_index ?? 0 : 0,
};
if (selectedTemplate) {
const updated = await updateTemplate(selectedTemplate.id, basePayload);
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(normalizeClassMaskIds(updated.classes || []));
} else {
const created = await createTemplate(basePayload);
addTemplate(created);
setSelectedTemplate(created);
}
setShowModal(false);
} catch (err) {
console.error('Failed to save template:', err);
showNotice('保存失败,请查看控制台', 'error');
} finally {
setIsSaving(false);
}
};
const handleCopy = async (template: Template) => {
setCopyingTemplateId(template.id);
try {
const created = await createTemplate({
name: nextCopyName(template.name),
description: template.description || undefined,
classes: copyTemplateClasses(template),
rules: template.rules || [],
color: template.color || '#06b6d4',
z_index: template.z_index ?? 0,
});
addTemplate(created);
setSelectedTemplate(created);
showNotice(`已复制模板:${created.name}`, 'success');
} catch (err) {
console.error('Failed to copy template:', err);
showNotice('复制失败,请检查后端服务', 'error');
} finally {
setCopyingTemplateId(null);
}
};
const handleDelete = async () => {
const target = deleteTemplateTarget;
if (!target) return;
try {
await deleteTemplate(target.id);
removeTemplateStore(target.id);
if (selectedTemplate?.id === target.id) {
setSelectedTemplate(null);
}
setDeleteTemplateTarget(null);
} catch (err) {
console.error('Failed to delete template:', err);
showNotice('删除失败,请检查后端服务', 'error');
}
};
const addClass = () => {
const newClass = buildNewClass(editClasses);
setEditClasses(recalculateClassOrder([...editClasses, newClass]));
setEditingClassId(newClass.id);
};
const updateClass = (id: string, updates: Partial<TemplateClass>) => {
setEditClasses(editClasses.map((c) => (c.id === id ? { ...c, ...updates } : c)));
};
const removeClass = (id: string) => {
setEditClasses(editClasses.filter((c) => c.id !== id || isReservedUnclassifiedClass(c)));
};
const reorderClasses = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
const items = [...editClasses];
if (isReservedUnclassifiedClass(items[fromIndex]) || isReservedUnclassifiedClass(items[toIndex])) return;
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
setEditClasses(recalculateClassOrder(items));
};
const saveDetailClassOrder = async (sourceId: string, targetId: string) => {
if (!activeTemplate || sourceId === targetId || isSavingOrder) return;
const classes = normalizeClassMaskIds(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex);
if (classes.some((templateClass) => (
(templateClass.id === sourceId || templateClass.id === targetId) && isReservedUnclassifiedClass(templateClass)
))) return;
const sourceIndex = classes.findIndex((templateClass) => templateClass.id === sourceId);
const targetIndex = classes.findIndex((templateClass) => templateClass.id === targetId);
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) return;
const reordered = [...classes];
const [source] = reordered.splice(sourceIndex, 1);
reordered.splice(targetIndex, 0, source);
const nextClasses = recalculateClassOrder(reordered);
setIsSavingOrder(true);
try {
const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(nextClasses);
} catch (err) {
console.error('Failed to save template class order:', err);
showNotice('层级顺序保存失败,请检查后端服务', 'error');
} finally {
setIsSavingOrder(false);
setDetailDragClassId(null);
setDetailDragOverClassId(null);
}
};
const deleteDetailClass = async (classId: string) => {
if (!activeTemplate || isSavingOrder) return;
const currentClasses = normalizeClassMaskIds(activeTemplate.classes || []);
const targetClass = currentClasses.find((templateClass) => templateClass.id === classId);
if (!targetClass || isReservedUnclassifiedClass(targetClass)) return;
const nextClasses = recalculateClassOrder(
currentClasses
.filter((templateClass) => templateClass.id !== classId)
.sort((a, b) => b.zIndex - a.zIndex),
);
if (nextClasses.length === currentClasses.length) return;
setIsSavingOrder(true);
try {
const updated = await updateTemplate(activeTemplate.id, buildTemplatePayload(activeTemplate, nextClasses));
updateTemplateStore(updated);
setSelectedTemplate(updated);
syncMaskClassOrder(nextClasses);
showNotice('分类已删除', 'success');
} catch (err) {
console.error('Failed to delete template class:', err);
showNotice('分类删除失败,请检查后端服务', 'error');
} finally {
setIsSavingOrder(false);
}
};
const parseImportClasses = () => {
const data = parseImportJson(importText);
let colors: unknown[] = [];
let names: unknown[] = [];
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
colors = data[0];
names = data[1];
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
colors = data.colors;
names = data.names;
} else if (Array.isArray(data.color) && Array.isArray(data.name)) {
colors = data.color;
names = data.name;
} else {
throw new Error('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
}
if (names.length === 0) {
throw new Error('names 至少需要包含 1 个分类名称');
}
const firstMaskId = nextClassMaskId(editClasses);
const classes: TemplateClass[] = names.map((name, i: number) => {
const normalizedName = String(name ?? '').trim();
if (!normalizedName) {
throw new Error(`${i + 1} 个分类名称为空`);
}
const rgb = normalizeImportRgb(colors[i], i);
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-import-${Date.now()}-${i}`,
name: normalizedName,
color: hex,
zIndex: (names.length - i) * 10,
maskId: firstMaskId + i,
category: '批量导入',
};
});
return {
classes,
firstMaskId,
missingColorCount: Math.max(0, names.length - colors.length),
};
};
const importPreview = useMemo(() => {
if (!showImport || !importText.trim()) return null;
try {
const parsed = parseImportClasses();
return { status: 'ready' as const, ...parsed };
} catch (err: any) {
return { status: 'error' as const, message: err?.message || 'JSON 解析失败' };
}
}, [showImport, importText, editClasses]);
const handleImport = () => {
try {
const imported = parseImportClasses();
setEditClasses(recalculateClassOrder([...editClasses, ...imported.classes]));
setShowImport(false);
setImportText('');
} catch (err: any) {
showNotice(err?.message || 'JSON 解析失败', 'error');
}
};
const activeTemplate = selectedTemplate
? templates.find((template) => template.id === selectedTemplate.id) || selectedTemplate
: templates[0] || null;
const activeTemplateClasses = normalizeClassMaskIds(activeTemplate?.classes || []).sort((a, b) => b.zIndex - a.zIndex);
return (
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
<div className="mb-8 border-b border-white/5 pb-6">
<h1 className="text-3xl font-medium tracking-tight text-white mb-2"></h1>
<p className="text-gray-400 text-sm">(Z-Index裁决权重)(Dict Translation)</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 border-r border-white/5 pr-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-sm font-bold text-gray-500 uppercase tracking-widest"></h2>
<button
onClick={openCreate}
className="text-cyan-400 hover:text-cyan-300 text-sm transition-colors flex items-center gap-1"
>
<Plus size={14} />
</button>
</div>
{isLoading && templates.length === 0 ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-[#111] border border-white/5 p-4 rounded-xl animate-pulse">
<div className="h-4 bg-[#1a1a1a] rounded w-2/3 mb-2" />
<div className="h-3 bg-[#1a1a1a] rounded w-1/2" />
</div>
))}
</div>
) : (
<div className="space-y-3">
{templates.map((t) => (
<div
key={t.id}
className={cn(
"bg-[#111] border p-4 rounded-xl cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-900/10 group",
activeTemplate?.id === t.id ? "border-cyan-500/50" : "border-white/5 hover:border-cyan-500/50"
)}
onClick={() => setSelectedTemplate(t)}
>
<div className="flex justify-between items-start">
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
title="复制模板"
aria-label={`复制模板 ${t.name}`}
disabled={copyingTemplateId === t.id}
onClick={(e) => { e.stopPropagation(); void handleCopy(t); }}
className={cn(
"p-1 rounded text-gray-500 hover:text-emerald-400 transition-colors disabled:cursor-wait disabled:text-gray-600",
)}
>
{copyingTemplateId === t.id ? <Loader2 size={14} className="animate-spin" /> : <Copy size={14} />}
</button>
<button
title="编辑模板"
aria-label={`编辑模板 ${t.name}`}
onClick={(e) => { e.stopPropagation(); openEdit(t); }}
className="p-1 rounded text-gray-500 hover:text-cyan-400 transition-colors"
>
<Edit3 size={14} />
</button>
<button
title="删除模板"
aria-label={`删除模板 ${t.name}`}
onClick={(e) => { e.stopPropagation(); setDeleteTemplateTarget(t); }}
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono">
<span> {t.classes?.length ?? 0} </span>
<span> {t.rules?.length ?? 0} </span>
</div>
</div>
))}
</div>
)}
</div>
<div className="xl:col-span-2 space-y-6">
<div className="bg-[#111] border border-white/5 rounded-xl p-6">
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-4">
<h2 className="text-lg font-medium text-gray-200 flex items-center gap-2">
<Database size={18} />
{activeTemplate ? activeTemplate.name : '未选择模板'}
</h2>
{activeTemplate && (
<button
onClick={() => openEdit(activeTemplate)}
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
>
<Edit3 size={14} />
</button>
)}
</div>
{activeTemplate ? (
<div className="space-y-6">
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
</h3>
<div className="space-y-2">
{activeTemplateClasses.map((cls) => (
<div
key={cls.id}
draggable={!isSavingOrder && !isReservedUnclassifiedClass(cls)}
onDragStart={(event) => {
if (isReservedUnclassifiedClass(cls)) return;
setDetailDragClassId(cls.id);
event.dataTransfer.setData('text/plain', cls.id);
event.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(event) => {
if (!detailDragClassId || detailDragClassId === cls.id || isSavingOrder || isReservedUnclassifiedClass(cls)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setDetailDragOverClassId(cls.id);
}}
onDragLeave={() => setDetailDragOverClassId(null)}
onDrop={(event) => {
event.preventDefault();
const sourceId = event.dataTransfer.getData('text/plain') || detailDragClassId;
if (sourceId) void saveDetailClassOrder(sourceId, cls.id);
}}
onDragEnd={() => {
setDetailDragClassId(null);
setDetailDragOverClassId(null);
}}
className={cn(
"grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border rounded items-center transition-all",
detailDragOverClassId === cls.id ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5",
detailDragClassId === cls.id && "opacity-50",
isSavingOrder ? "cursor-wait" : isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-grab active:cursor-grabbing",
)}
>
<div className="col-span-1 flex items-center gap-2 min-w-0">
<GripVertical size={14} className={cn("shrink-0", isReservedUnclassifiedClass(cls) ? "text-gray-800" : "text-gray-600")} />
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div>
<span className="font-medium text-sm text-gray-300 truncate">{cls.name}</span>
</div>
<div className="col-span-1 font-mono text-xs text-gray-500">maskid: {cls.maskId}</div>
<div className="col-span-2 flex justify-end">
<button
type="button"
aria-label={`删除分类 ${cls.name}`}
title="删除分类"
disabled={isSavingOrder || isReservedUnclassifiedClass(cls)}
onClick={(event) => {
event.stopPropagation();
void deleteDetailClass(cls.id);
}}
className="rounded p-1 text-gray-500 transition-colors hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
{(activeTemplate.classes || []).length === 0 && (
<div className="text-sm text-gray-500 text-center py-8"></div>
)}
</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500 text-center py-12"></div>
)}
</div>
</div>
</div>
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto shadow-2xl">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-white">{selectedTemplate ? '编辑模板' : '新建模板'}</h2>
<button onClick={() => setShowModal(false)} className="text-gray-500 hover:text-white transition-colors">
<X size={18} />
</button>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2"></label>
<input
type="text"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
/>
</div>
</div>
<div className="mb-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest"> ({editClasses.length} )</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setShowImport(true)}
className="text-gray-400 hover:text-white text-xs transition-colors flex items-center gap-1 bg-white/5 px-2 py-1 rounded"
>
<Import size={12} />
</button>
<button
onClick={addClass}
className="text-cyan-400 hover:text-cyan-300 text-xs transition-colors flex items-center gap-1"
>
<Plus size={12} />
</button>
</div>
</div>
<div className="space-y-2">
{editClasses.map((cls, idx) => (
<div
key={cls.id}
draggable={!isReservedUnclassifiedClass(cls)}
onDragStart={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
if (isReservedUnclassifiedClass(cls)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(idx);
}}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (isReservedUnclassifiedClass(cls) || Number.isNaN(fromIndex)) return;
reorderClasses(fromIndex, idx);
setDragOverIndex(null);
}}
onDragEnd={() => setDragOverIndex(null)}
className={cn(
"flex items-center gap-2 bg-[#0d0d0d] border rounded-lg p-2 transition-all",
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
)}
>
<div className={cn("text-gray-600 shrink-0", isReservedUnclassifiedClass(cls) ? "cursor-default opacity-30" : "cursor-grab active:cursor-grabbing")}>
<GripVertical size={14} />
</div>
<input
type="color"
value={cls.color}
onChange={(e) => updateClass(cls.id, { color: e.target.value })}
disabled={isReservedUnclassifiedClass(cls)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0 disabled:cursor-not-allowed disabled:opacity-50"
/>
{editingClassId === cls.id ? (
<input
type="text"
value={cls.name}
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
onBlur={() => setEditingClassId(null)}
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
autoFocus
readOnly={isReservedUnclassifiedClass(cls)}
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
/>
) : (
<>
<span
className={cn("flex-1 text-sm text-gray-300", isReservedUnclassifiedClass(cls) ? "cursor-default" : "cursor-pointer")}
onClick={() => {
if (!isReservedUnclassifiedClass(cls)) setEditingClassId(cls.id);
}}
>
{cls.name}
</span>
<span className="w-24 text-sm text-gray-500 font-mono text-right">maskid:{cls.maskId}</span>
</>
)}
<button
onClick={() => removeClass(cls.id)}
disabled={isReservedUnclassifiedClass(cls)}
className="text-gray-500 hover:text-red-400 transition-colors disabled:cursor-not-allowed disabled:opacity-30"
>
<Trash2 size={14} />
</button>
</div>
))}
{editClasses.length === 0 && (
<div className="text-sm text-gray-500 text-center py-4"></div>
)}
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={isSaving || !editName.trim()}
className={cn(
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
isSaving || !editName.trim()
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
: "bg-cyan-500 hover:bg-cyan-400 text-black"
)}
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{showImport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-lg shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<p className="text-xs text-gray-500 mb-2">支持格式: JSON [[colors], [names]] {'{'}&quot;colors&quot;: [...], &quot;names&quot;: [...]{'}'}</p>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'
className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none"
/>
{importPreview?.status === 'ready' && (
<div className="mt-3 rounded-lg border border-cyan-500/20 bg-cyan-950/15 px-3 py-2 text-xs text-cyan-100">
{importPreview.classes.length} maskid {importPreview.firstMaskId}
{importPreview.missingColorCount > 0 && (
<span className="ml-1 text-amber-200">{importPreview.missingColorCount} 使</span>
)}
</div>
)}
{importPreview?.status === 'error' && (
<div className="mt-3 rounded-lg border border-red-500/20 bg-red-950/20 px-3 py-2 text-xs text-red-100">
{importPreview.message}
</div>
)}
<div className="flex justify-end items-center mt-4">
<div className="flex gap-3">
<button
onClick={() => { setShowImport(false); setImportText(''); }}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleImport}
disabled={importPreview?.status === 'error'}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
</div>
</div>
</div>
</div>
)}
{deleteTemplateTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-[#111] p-6 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-3 text-sm leading-6 text-gray-400">
<span className="text-gray-100">{deleteTemplateTarget.name}</span> mask
</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setDeleteTemplateTarget(null)}
className="rounded-lg px-4 py-2 text-sm text-gray-400 transition-colors hover:text-white"
>
</button>
<button
type="button"
onClick={() => void handleDelete()}
className="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-400"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,199 @@
import React from 'react';
import { MousePointer2, CircleOff, PencilLine, Hexagon, Square, Circle, Brush, Eraser, Combine, Scissors, FileUp, Trash2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { AiAutoInferenceIcon } from './AiAutoInferenceIcon';
import { AiSegmentationIcon } from './AiSegmentationIcon';
import { useStore } from '../store/useStore';
interface ToolsPaletteProps {
activeTool: string;
setActiveTool: (tool: string) => void;
onTriggerAI?: () => void;
onAutoPropagate?: () => void;
onImportGtMask?: () => void;
onClearSelection?: () => void;
onDeleteMasks?: () => void;
onClearMasks?: () => void;
canAutoPropagate?: boolean;
isPropagating?: boolean;
canImportGtMask?: boolean;
isImportingGtMask?: boolean;
}
export function ToolsPalette({
activeTool,
setActiveTool,
onTriggerAI,
onAutoPropagate,
onImportGtMask,
onClearSelection,
onDeleteMasks,
onClearMasks,
canAutoPropagate = false,
isPropagating = false,
canImportGtMask = false,
isImportingGtMask = false,
}: ToolsPaletteProps) {
const brushSize = useStore((state) => state.brushSize);
const eraserSize = useStore((state) => state.eraserSize);
const setBrushSize = useStore((state) => state.setBrushSize);
const setEraserSize = useStore((state) => state.setEraserSize);
const sizeControl = activeTool === 'brush'
? {
label: '画笔大小',
value: brushSize,
min: 4,
max: 96,
onChange: setBrushSize,
}
: activeTool === 'eraser'
? {
label: '橡皮擦大小',
value: eraserSize,
min: 4,
max: 128,
onChange: setEraserSize,
}
: null;
const tools = [
{ id: 'move', icon: MousePointer2, label: '拖拽 / 选择 (V)' },
{ id: 'edit_polygon', icon: PencilLine, label: '调整多边形 (E)' },
{ id: 'create_polygon', icon: Hexagon, label: '创建多边形 (P)' },
{ id: 'create_rectangle', icon: Square, label: '创建矩形 (R)' },
{ id: 'create_circle', icon: Circle, label: '创建圆 (O)' },
{ id: 'brush', icon: Brush, label: '画笔 (B)' },
{ id: 'eraser', icon: Eraser, label: '橡皮擦 (X)' },
{ id: 'area_merge', icon: Combine, label: '区域合并 (+)' },
{ id: 'area_remove', icon: Scissors, label: '重叠区域去除 (-)' },
];
return (
<div className="h-full w-14 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-start py-2 shrink-0 z-10 overflow-y-auto overflow-x-hidden overscroll-contain seg-scrollbar">
<div className="flex flex-col gap-1.5 w-12 shrink-0 px-1.5">
{tools.map(tool => {
const Icon = tool.icon;
const isActive = activeTool === tool.id;
return (
<React.Fragment key={tool.id}>
<button
onClick={() => setActiveTool(tool.id)}
title={tool.label}
className={cn(
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5",
isActive
? (tool.id.includes('remove') ? "bg-red-500/10 text-red-500"
: tool.id.includes('merge') ? "bg-green-500/10 text-green-500"
: "bg-white/10 text-white")
: "text-gray-500 hover:bg-white/5 hover:text-white"
)}
>
<Icon size={16} strokeWidth={isActive ? 2.5 : 2} />
</button>
{tool.id === 'move' && (
<button
type="button"
onClick={onClearSelection}
disabled={!onClearSelection}
title="取消选中 (Esc)"
aria-label="取消选中"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-gray-400 hover:bg-white/5 hover:text-white disabled:opacity-35 disabled:cursor-not-allowed"
>
<CircleOff size={16} strokeWidth={2.1} />
</button>
)}
{tool.id === 'eraser' && sizeControl && (
<div className="w-9 rounded-md border border-white/10 bg-white/[0.03] px-1 py-2 text-center">
<label htmlFor={`${activeTool}-size`} className="sr-only">{sizeControl.label}</label>
<input
id={`${activeTool}-size`}
aria-label={sizeControl.label}
type="range"
min={sizeControl.min}
max={sizeControl.max}
value={sizeControl.value}
onChange={(event) => sizeControl.onChange(Number(event.target.value))}
className="h-20 w-7 accent-cyan-400 [writing-mode:vertical-rl]"
/>
<div className="mt-1 text-[10px] leading-none text-gray-400">{sizeControl.value}</div>
</div>
)}
{tool.id === 'eraser' && (
<>
<button
type="button"
onClick={() => {
setActiveTool('auto_propagate');
onAutoPropagate?.();
}}
disabled={!canAutoPropagate || isPropagating}
aria-label="AI自动推理"
title={isPropagating ? 'AI自动推理中...' : 'AI自动推理'}
className={cn(
"w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border",
activeTool === 'auto_propagate'
? "border-cyan-300/50 bg-cyan-500/20 text-white shadow-lg shadow-cyan-900/30"
: "border-cyan-500/25 bg-cyan-500/10 text-cyan-200 hover:bg-cyan-500/20 hover:text-white",
(!canAutoPropagate || isPropagating) && "opacity-35 cursor-not-allowed hover:bg-cyan-500/10 hover:text-cyan-200",
)}
>
<AiAutoInferenceIcon size={17} strokeWidth={2.2} />
</button>
<div className="my-1 h-px w-9 bg-white/15" />
</>
)}
{tool.id === 'create_circle' && (
<div className="my-1 h-px w-9 bg-white/15" />
)}
</React.Fragment>
)
})}
<button
onClick={onDeleteMasks}
disabled={!onDeleteMasks}
title="删除选中遮罩 (Del)"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-200 hover:bg-red-500/10 hover:text-red-100 disabled:opacity-35 disabled:cursor-not-allowed"
>
<span className="text-[10px] font-bold leading-none">DEL</span>
</button>
<button
onClick={onClearMasks}
disabled={!onClearMasks}
title="清空遮罩"
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-35 disabled:cursor-not-allowed"
>
<Trash2 size={16} strokeWidth={2.1} />
</button>
<div data-testid="tool-group-separator" className="my-1 h-px w-9 bg-white/15" />
<button
onClick={onImportGtMask}
disabled={!canImportGtMask || isImportingGtMask}
title={isImportingGtMask ? '正在导入 GT Mask' : '导入 GT Mask'}
className="w-9 h-9 rounded-md flex items-center justify-center transition-all p-1.5 border border-violet-500/30 bg-violet-500/10 text-violet-200 hover:bg-violet-500/20 hover:text-white disabled:opacity-35 disabled:hover:bg-violet-500/10 disabled:hover:text-violet-200 disabled:cursor-not-allowed"
>
<FileUp size={16} strokeWidth={2.2} />
</button>
<button
onClick={() => {
setActiveTool('sam_trigger');
if (onTriggerAI) onTriggerAI();
}}
title="打开 AI 智能分割"
className={cn(
"w-9 h-9 rounded-md flex items-center justify-center transition-all",
activeTool === 'sam_trigger'
? "bg-cyan-600 text-white shadow-lg shadow-cyan-900/20"
: "text-gray-500 hover:bg-white/5"
)}
>
<AiSegmentationIcon size={17} strokeWidth={2.1} />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React, { useEffect } from 'react';
import { cn } from '../lib/utils';
export type NoticeTone = 'info' | 'success' | 'error';
export interface NoticeState {
id: number;
message: string;
tone?: NoticeTone;
}
interface TransientNoticeProps {
notice: NoticeState | null;
onDismiss: () => void;
durationMs?: number;
}
const toneClasses: Record<NoticeTone, string> = {
info: 'border-cyan-400/30 bg-cyan-950/85 text-cyan-100 shadow-cyan-950/30',
success: 'border-emerald-400/30 bg-emerald-950/85 text-emerald-100 shadow-emerald-950/30',
error: 'border-red-400/30 bg-red-950/85 text-red-100 shadow-red-950/30',
};
export function TransientNotice({ notice, onDismiss, durationMs = 3600 }: TransientNoticeProps) {
useEffect(() => {
if (!notice) return undefined;
const timer = window.setTimeout(onDismiss, durationMs);
return () => window.clearTimeout(timer);
}, [durationMs, notice, onDismiss]);
if (!notice) return null;
const tone = notice.tone || 'info';
return (
<div className="pointer-events-none fixed right-6 top-6 z-[80] max-w-sm" aria-live="polite">
<div
role="status"
className={cn(
'rounded-md border px-4 py-3 text-xs font-medium leading-relaxed shadow-2xl backdrop-blur whitespace-pre-line',
toneClasses[tone],
)}
>
{notice.message}
</div>
</div>
);
}

View File

@@ -0,0 +1,473 @@
import React, { useEffect, useMemo, useState } from 'react';
import { KeyRound, Loader2, Plus, ShieldCheck, Trash2, UserCog } from 'lucide-react';
import {
createAdminUser,
deleteAdminUser,
getAdminUsers,
getAuditLogs,
resetDemoFactory,
updateAdminUser,
type AdminUser,
type AuditLog,
} from '../lib/api';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { TransientNotice, type NoticeState, type NoticeTone } from './TransientNotice';
const roleLabels: Record<string, string> = {
admin: '管理员',
annotator: '标注员',
};
function formatTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function UserAdmin() {
const currentUser = useStore((state) => state.currentUser);
const setProjects = useStore((state) => state.setProjects);
const setCurrentProject = useStore((state) => state.setCurrentProject);
const setFrames = useStore((state) => state.setFrames);
const setCurrentFrame = useStore((state) => state.setCurrentFrame);
const setMasks = useStore((state) => state.setMasks);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [users, setUsers] = useState<AdminUser[]>([]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [notice, setNotice] = useState<NoticeState | null>(null);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
const [nextPassword, setNextPassword] = useState('');
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false);
const [factoryResetText, setFactoryResetText] = useState('');
const activeCount = useMemo(() => users.filter((user) => user.is_active).length, [users]);
const showNotice = (message: string, tone: NoticeTone = 'info') => {
setNotice({ id: Date.now(), message, tone });
};
const loadAdminData = async () => {
setIsLoading(true);
try {
const [nextUsers, nextLogs] = await Promise.all([getAdminUsers(), getAuditLogs(100)]);
setUsers(nextUsers);
setAuditLogs(nextLogs);
} catch (err) {
console.error('Failed to load admin data:', err);
showNotice('用户管理数据加载失败', 'error');
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadAdminData();
}, []);
const handleCreateUser = async (event: React.FormEvent) => {
event.preventDefault();
if (!newUsername.trim() || newPassword.length < 6) {
showNotice('请输入用户名,并设置至少 6 位密码', 'error');
return;
}
setIsSaving(true);
try {
const created = await createAdminUser({
username: newUsername.trim(),
password: newPassword,
role: 'annotator',
is_active: true,
});
setUsers((prev) => [...prev, created]);
setNewUsername('');
setNewPassword('');
showNotice('用户已创建', 'success');
setAuditLogs(await getAuditLogs(100));
} catch (err: any) {
showNotice(err?.response?.data?.detail || '创建用户失败', 'error');
} finally {
setIsSaving(false);
}
};
const handlePatchUser = async (user: AdminUser, patch: Parameters<typeof updateAdminUser>[1]) => {
setIsSaving(true);
try {
const updated = await updateAdminUser(user.id, patch);
setUsers((prev) => prev.map((item) => (item.id === user.id ? updated : item)));
showNotice('用户已更新', 'success');
setAuditLogs(await getAuditLogs(100));
return true;
} catch (err: any) {
showNotice(err?.response?.data?.detail || '更新用户失败', 'error');
return false;
} finally {
setIsSaving(false);
}
};
const handleChangePassword = (user: AdminUser) => {
setPasswordTarget(user);
setNextPassword('');
};
const submitPasswordChange = async () => {
if (!passwordTarget) return;
if (nextPassword.length < 6) {
showNotice('新密码至少需要 6 位', 'error');
return;
}
const updated = await handlePatchUser(passwordTarget, { password: nextPassword });
if (updated) {
setPasswordTarget(null);
setNextPassword('');
}
};
const handleDeleteUser = async () => {
const user = deleteUserTarget;
if (!user) return;
setIsSaving(true);
try {
await deleteAdminUser(user.id);
setUsers((prev) => prev.filter((item) => item.id !== user.id));
setDeleteUserTarget(null);
showNotice('用户已删除', 'success');
setAuditLogs(await getAuditLogs(100));
} catch (err: any) {
showNotice(err?.response?.data?.detail || '删除用户失败', 'error');
} finally {
setIsSaving(false);
}
};
const handleFactoryReset = async () => {
if (factoryResetText !== 'RESET_DEMO_FACTORY') {
showNotice('确认文本不匹配,未执行恢复出厂设置', 'error');
return;
}
setIsResetting(true);
try {
const result = await resetDemoFactory(factoryResetText);
setUsers([result.admin_user]);
setProjects(result.projects?.length ? result.projects : [result.project]);
setCurrentProject(null);
setFrames([]);
setCurrentFrame(0);
setMasks([]);
setSelectedMaskIds([]);
setAuditLogs(await getAuditLogs(100));
setShowFactoryResetConfirm(false);
setFactoryResetText('');
showNotice(result.message || '演示环境已恢复出厂设置', 'success');
} catch (err: any) {
showNotice(err?.response?.data?.detail || '恢复演示出厂设置失败', 'error');
} finally {
setIsResetting(false);
}
};
return (
<div className="flex h-full flex-col overflow-hidden bg-[#0a0a0a] text-gray-200">
<TransientNotice notice={notice} onDismiss={() => setNotice(null)} />
<header className="border-b border-white/10 bg-[#0d0d0d] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-white"></h1>
<p className="mt-1 text-xs text-gray-500"></p>
</div>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
{currentUser?.username || 'admin'}
</span>
<span className="rounded border border-white/10 bg-white/5 px-3 py-1"> {activeCount}</span>
</div>
</div>
</header>
<main className="grid min-h-0 flex-1 grid-cols-[minmax(0,1.15fr)_minmax(360px,0.85fr)] gap-4 overflow-hidden p-4">
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-white/10 bg-[#111]">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="flex items-center gap-2 text-sm font-medium text-white">
<UserCog size={18} className="text-cyan-300" />
</div>
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
</div>
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_auto] gap-2 border-b border-white/10 p-4">
<input
value={newUsername}
onChange={(event) => setNewUsername(event.target.value)}
placeholder="用户名"
autoComplete="off"
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
/>
<input
value={newPassword}
type="password"
onChange={(event) => setNewPassword(event.target.value)}
placeholder="初始密码"
autoComplete="new-password"
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
/>
<button
type="submit"
disabled={isSaving}
className="inline-flex items-center gap-2 rounded bg-cyan-500 px-4 py-2 text-sm font-semibold text-black transition-colors hover:bg-cyan-400 disabled:opacity-50"
>
<Plus size={16} />
</button>
</form>
<div className="min-h-0 flex-1 overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[#151515] text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{users.map((user) => (
<tr key={user.id} className="hover:bg-white/[0.03]">
<td className="px-4 py-3">
<div className="font-medium text-white">{user.username}</div>
<div className="text-xs text-gray-500">ID {user.id}</div>
</td>
<td className="px-4 py-3">
<span
className={cn(
'inline-flex rounded border px-2 py-1 text-xs',
user.role === 'admin'
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-100'
: 'border-white/10 bg-white/5 text-gray-200',
)}
>
{roleLabels[user.role] || '标注员'}
</span>
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => void handlePatchUser(user, { is_active: !user.is_active })}
disabled={isSaving}
className={cn(
'rounded-full border px-3 py-1 text-xs',
user.is_active
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-200'
: 'border-gray-500/30 bg-gray-500/10 text-gray-300',
)}
>
{user.is_active ? '启用' : '停用'}
</button>
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => handleChangePassword(user)}
className="rounded border border-white/10 p-2 text-gray-300 hover:border-cyan-400/40 hover:text-cyan-200"
title="修改密码"
>
<KeyRound size={15} />
</button>
<button
type="button"
onClick={() => setDeleteUserTarget(user)}
disabled={user.id === currentUser?.id}
className="rounded border border-white/10 p-2 text-gray-300 hover:border-red-400/40 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-40"
title="删除用户"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-white/10 bg-[#111]">
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3 text-sm font-medium text-white">
<ShieldCheck size={18} className="text-emerald-300" />
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
<div className="space-y-2">
{auditLogs.map((log) => (
<div key={log.id} className="rounded border border-white/10 bg-black/20 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-cyan-100">{log.action}</span>
<span className="text-[10px] text-gray-500">{formatTime(log.created_at)}</span>
</div>
<div className="mt-1 text-[11px] text-gray-400">
actor #{log.actor_user_id ?? 'system'} {'->'} {log.target_type || 'target'} #{log.target_id || '-'}
</div>
{log.detail && Object.keys(log.detail).length > 0 && (
<pre className="mt-2 max-h-24 overflow-auto rounded bg-black/30 p-2 text-[10px] leading-relaxed text-gray-500">
{JSON.stringify(log.detail, null, 2)}
</pre>
)}
</div>
))}
{!auditLogs.length && !isLoading && (
<div className="py-10 text-center text-sm text-gray-500"></div>
)}
</div>
</div>
<div className="border-t border-red-400/20 bg-red-950/10 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-red-100"></div>
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
adminLC视频序列DICOM序列
</p>
</div>
<button
type="button"
onClick={() => {
setFactoryResetText('');
setShowFactoryResetConfirm(true);
}}
disabled={isResetting || isSaving}
className="shrink-0 rounded border border-red-400/40 bg-red-500/15 px-3 py-2 text-xs font-semibold text-red-100 transition-colors hover:bg-red-500/25 disabled:cursor-wait disabled:opacity-50"
>
{isResetting ? '恢复中...' : '恢复演示出厂设置'}
</button>
</div>
</div>
</section>
</main>
{passwordTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-400">
<span className="text-gray-100">{passwordTarget.username}</span>
</p>
<input
type="password"
value={nextPassword}
onChange={(event) => setNextPassword(event.target.value)}
autoComplete="new-password"
placeholder="至少 6 位"
className="mt-4 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPasswordTarget(null);
setNextPassword('');
}}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void submitPasswordChange()}
disabled={isSaving || nextPassword.length < 6}
className="inline-flex items-center gap-2 rounded bg-cyan-500 px-3 py-2 text-xs font-semibold text-black hover:bg-cyan-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{deleteUserTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 px-4">
<div className="w-full max-w-sm rounded-lg border border-red-400/20 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-gray-400">
<span className="text-gray-100">{deleteUserTarget.username}</span>
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setDeleteUserTarget(null)}
disabled={isSaving}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleDeleteUser()}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
{showFactoryResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4">
<div className="w-full max-w-lg rounded-lg border border-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white"></h2>
<p className="mt-2 text-sm leading-relaxed text-red-100/80">
admin LC视频序列DICOM序列
</p>
<label className="mt-4 block text-xs text-gray-400" htmlFor="factory-reset-confirm">
RESET_DEMO_FACTORY
</label>
<input
id="factory-reset-confirm"
value={factoryResetText}
onChange={(event) => setFactoryResetText(event.target.value)}
className="mt-2 w-full rounded border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:border-red-400/50"
/>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowFactoryResetConfirm(false);
setFactoryResetText('');
}}
disabled={isResetting}
className="rounded border border-white/10 px-3 py-2 text-xs text-gray-300 hover:bg-white/5 disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => void handleFactoryReset()}
disabled={isResetting || factoryResetText !== 'RESET_DEMO_FACTORY'}
className="inline-flex items-center gap-2 rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-not-allowed disabled:opacity-50"
>
{isResetting && <Loader2 size={14} className="animate-spin" />}
</button>
</div>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

46
src/index.css Normal file
View File

@@ -0,0 +1,46 @@
@import "tailwindcss";
@layer utilities {
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.seg-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(113, 113, 122, 0.22) transparent;
}
.seg-scrollbar:hover,
.seg-scrollbar:focus-within {
scrollbar-color: rgba(34, 211, 238, 0.42) transparent;
}
.seg-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.seg-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.seg-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(113, 113, 122, 0.18);
border: 2px solid transparent;
border-radius: 9999px;
background-clip: content-box;
}
.seg-scrollbar:hover::-webkit-scrollbar-thumb,
.seg-scrollbar:focus-within::-webkit-scrollbar-thumb {
background-color: rgba(34, 211, 238, 0.42);
}
.seg-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
}

1101
src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

29
src/lib/config.ts Normal file
View File

@@ -0,0 +1,29 @@
const DEFAULT_API_BASE_URL = 'http://192.168.3.11:8000';
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function inferApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL;
if (envUrl) return trimTrailingSlash(envUrl);
if (typeof window !== 'undefined' && window.location.hostname) {
return `${window.location.protocol}//${window.location.hostname}:8000`;
}
return DEFAULT_API_BASE_URL;
}
export const API_BASE_URL = inferApiBaseUrl();
function inferWsProgressUrl(): string {
const envUrl = import.meta.env.VITE_WS_PROGRESS_URL;
if (envUrl) return envUrl;
const url = new URL('/ws/progress', API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
}
export const WS_PROGRESS_URL = inferWsProgressUrl();

View File

@@ -0,0 +1,25 @@
export type UndoRedoShortcut = 'undo' | 'redo';
export function isEditableShortcutTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tagName = target.tagName.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
function isKey(event: KeyboardEvent, key: 'z' | 'y'): boolean {
return event.key.toLowerCase() === key || event.code === `Key${key.toUpperCase()}`;
}
export function getUndoRedoShortcut(event: KeyboardEvent): UndoRedoShortcut | null {
if (isEditableShortcutTarget(event.target)) return null;
if (!event.metaKey && !event.ctrlKey) return null;
if (event.altKey) return null;
if (isKey(event, 'z')) {
return event.shiftKey ? 'redo' : 'undo';
}
if (isKey(event, 'y')) {
return 'redo';
}
return null;
}

70
src/lib/maskIds.ts Normal file
View File

@@ -0,0 +1,70 @@
import type { TemplateClass } from '../store/useStore';
export const RESERVED_UNCLASSIFIED_CLASS: TemplateClass = {
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
category: '系统保留',
};
export function isReservedUnclassifiedClass(templateClass: Pick<TemplateClass, 'id' | 'maskId' | 'name'>): boolean {
return Number(templateClass.maskId) === 0 || templateClass.id === RESERVED_UNCLASSIFIED_CLASS.id || templateClass.name === RESERVED_UNCLASSIFIED_CLASS.name;
}
function reservedUnclassifiedClass(source?: Partial<TemplateClass>): TemplateClass {
return {
...RESERVED_UNCLASSIFIED_CLASS,
...source,
id: RESERVED_UNCLASSIFIED_CLASS.id,
name: RESERVED_UNCLASSIFIED_CLASS.name,
color: RESERVED_UNCLASSIFIED_CLASS.color,
zIndex: RESERVED_UNCLASSIFIED_CLASS.zIndex,
maskId: RESERVED_UNCLASSIFIED_CLASS.maskId,
category: RESERVED_UNCLASSIFIED_CLASS.category,
};
}
export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateClass[] {
const used = new Set<number>();
let nextMaskId = 1;
let reservedClass: TemplateClass | undefined;
const nextAvailableMaskId = () => {
while (used.has(nextMaskId)) nextMaskId += 1;
const value = nextMaskId;
used.add(value);
nextMaskId += 1;
return value;
};
const normalized = classes
.filter((templateClass) => {
if (isReservedUnclassifiedClass(templateClass)) {
reservedClass ??= reservedUnclassifiedClass(templateClass);
return false;
}
return true;
})
.map((templateClass) => {
const parsed = Number(templateClass.maskId);
if (Number.isInteger(parsed) && parsed > 0 && !used.has(parsed)) {
used.add(parsed);
return { ...templateClass, maskId: parsed };
}
return { ...templateClass, maskId: nextAvailableMaskId() };
});
return [...normalized, reservedClass || reservedUnclassifiedClass()];
}
export function nextClassMaskId(classes: TemplateClass[] = []): number {
const used = new Set(
classes
.map((templateClass) => Number(templateClass.maskId))
.filter((value) => Number.isInteger(value) && value > 0),
);
let value = 1;
while (used.has(value)) value += 1;
return value;
}

View File

@@ -0,0 +1,15 @@
import type { Template, TemplateClass } from '../store/useStore';
export function getActiveTemplate(templates: Template[], activeTemplateId: string | null): Template | null {
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
}
export function getActiveClass(
templates: Template[],
activeTemplateId: string | null,
activeClassId: string | null,
): TemplateClass | null {
const template = getActiveTemplate(templates, activeTemplateId);
if (!template) return null;
return template.classes.find((templateClass) => templateClass.id === activeClassId) || null;
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

182
src/lib/websocket.ts Normal file
View File

@@ -0,0 +1,182 @@
import { WS_PROGRESS_URL } from './config';
type ProgressCallback = (data: ProgressMessage) => void;
type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
type StatusCallback = (status: ConnectionStatus) => void;
interface ProgressMessage {
type: 'progress' | 'status' | 'error' | 'complete' | 'cancelled';
taskId?: string;
task_id?: number;
project_id?: number;
projectName?: string;
filename?: string;
progress?: number;
status?: string;
message?: string;
error?: string;
timestamp?: string;
}
class ProgressWebSocket {
private ws: WebSocket | null = null;
private url: string;
private callbacks: Set<ProgressCallback> = new Set();
private statusCallbacks: Set<StatusCallback> = new Set();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectInterval = 3000;
private maxReconnectInterval = 30000;
private heartbeatInterval = 15000;
private shouldReconnect = false;
private shouldCloseAfterOpen = false;
private manualDisconnect = false;
private currentInterval = 3000;
constructor(url = WS_PROGRESS_URL) {
this.url = url;
}
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
this.shouldCloseAfterOpen = false;
this.manualDisconnect = false;
this.notifyStatus('connecting');
try {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
// 如果连接前被要求关闭React StrictMode 快速卸载),则打开后立即关闭
if (this.shouldCloseAfterOpen) {
this.ws?.close();
this.ws = null;
return;
}
this.currentInterval = this.reconnectInterval;
this.startHeartbeat();
this.notifyStatus('connected');
console.log('[WebSocket] Connected to progress stream');
};
this.ws.onmessage = (event) => {
try {
const data: ProgressMessage = JSON.parse(event.data);
this.callbacks.forEach((cb) => cb(data));
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err);
}
};
this.ws.onclose = () => {
if (!this.manualDisconnect) {
console.log('[WebSocket] Connection closed');
}
this.stopHeartbeat();
this.ws = null;
this.notifyStatus('disconnected');
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// 静默处理错误,避免在 CONNECTING 状态时 close 触发浏览器报错
this.stopHeartbeat();
this.ws = null;
this.notifyStatus('disconnected');
if (this.shouldReconnect) {
this.scheduleReconnect();
}
};
} catch (err) {
console.error('[WebSocket] Failed to connect:', err);
this.scheduleReconnect();
}
}
disconnect() {
this.shouldReconnect = false;
this.manualDisconnect = true;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (!this.ws) return;
// 如果还在连接中,设置标志位让 onopen 打开后立即关闭
if (this.ws.readyState === WebSocket.CONNECTING) {
this.shouldCloseAfterOpen = true;
return;
}
// 只有已打开的连接才调用 close()
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close();
}
this.ws = null;
this.notifyStatus('disconnected');
}
onProgress(callback: ProgressCallback) {
this.callbacks.add(callback);
return () => {
this.callbacks.delete(callback);
};
}
onStatus(callback: StatusCallback) {
this.statusCallbacks.add(callback);
callback(this.isConnected() ? 'connected' : 'disconnected');
return () => {
this.statusCallbacks.delete(callback);
};
}
private scheduleReconnect() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.notifyStatus('reconnecting');
this.reconnectTimer = setTimeout(() => {
console.log('[WebSocket] Reconnecting to progress stream...');
this.connect();
this.currentInterval = Math.min(this.currentInterval * 1.5, this.maxReconnectInterval);
}, this.currentInterval);
}
private startHeartbeat() {
this.stopHeartbeat();
this.sendHeartbeat();
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private sendHeartbeat() {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send('ping');
}
}
private notifyStatus(status: ConnectionStatus) {
this.statusCallbacks.forEach((cb) => cb(status));
}
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}
export const progressWS = new ProgressWebSocket();
export type { ConnectionStatus, ProgressMessage };

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

349
src/store/useStore.ts Normal file
View File

@@ -0,0 +1,349 @@
import { create } from 'zustand';
export interface Project {
id: string;
name: string;
description?: string;
status: 'pending' | 'parsing' | 'ready' | 'error';
fps?: string;
frames?: number;
thumbnail?: string;
thumbnail_url?: string;
video_path?: string;
source_type?: string;
original_fps?: number;
parse_fps?: number;
createdAt?: string;
updatedAt?: string;
}
export type AiModelId =
| 'sam2.1_hiera_tiny'
| 'sam2.1_hiera_small'
| 'sam2.1_hiera_base_plus'
| 'sam2.1_hiera_large';
export const DEFAULT_AI_MODEL_ID: AiModelId = 'sam2.1_hiera_tiny';
export const DEFAULT_BRUSH_SIZE = 24;
export const DEFAULT_ERASER_SIZE = 28;
export const SAM2_MODEL_OPTIONS: Array<{ id: AiModelId; label: string; shortLabel: string }> = [
{ id: 'sam2.1_hiera_tiny', label: 'SAM 2.1 Tiny', shortLabel: 'tiny' },
{ id: 'sam2.1_hiera_small', label: 'SAM 2.1 Small', shortLabel: 'small' },
{ id: 'sam2.1_hiera_base_plus', label: 'SAM 2.1 Base+', shortLabel: 'base+' },
{ id: 'sam2.1_hiera_large', label: 'SAM 2.1 Large', shortLabel: 'large' },
];
export interface Frame {
id: string;
projectId: string;
index: number;
url: string;
width: number;
height: number;
timestamp?: string;
timestampMs?: number;
sourceFrameNumber?: number;
}
export interface Annotation {
id: string;
frameId: string;
type: 'polygon' | 'rectangle' | 'circle' | 'point' | 'mask';
points: number[];
label: string;
color: string;
zIndex?: number;
confidence?: number;
metadata?: Record<string, unknown>;
}
export interface Mask {
id: string;
frameId: string;
annotationId?: string;
templateId?: string;
classId?: string;
className?: string;
classZIndex?: number;
classMaskId?: number;
saveStatus?: 'draft' | 'saved' | 'dirty' | 'saving' | 'error';
saved?: boolean;
pathData: string;
label: string;
color: string;
opacity?: number;
segmentation?: number[][];
points?: number[][];
bbox?: [number, number, number, number];
area?: number;
metadata?: Record<string, unknown>;
}
export interface Template {
id: string;
name: string;
description?: string;
color?: string;
z_index?: number;
classes: TemplateClass[];
rules?: TemplateRule[];
createdAt?: string;
updatedAt?: string;
}
export interface TemplateClass {
id: string;
name: string;
color: string;
zIndex: number;
maskId?: number;
category?: string;
description?: string;
}
export interface TemplateRule {
id: string;
name: string;
sourceKey: string;
targetKey: string;
operation: string;
}
export interface UserProfile {
id: number;
username: string;
role: string;
is_active?: number;
}
export interface AppState {
// Auth
isAuthenticated: boolean;
token: string | null;
currentUser: UserProfile | null;
login: (token: string, user?: UserProfile | null) => void;
setCurrentUser: (user: UserProfile | null) => void;
logout: () => void;
// Projects
projects: Project[];
currentProject: Project | null;
setProjects: (projects: Project[]) => void;
setCurrentProject: (project: Project | null) => void;
addProject: (project: Project) => void;
updateProject: (project: Project) => void;
// Workspace
activeModule: string;
activeTool: string;
aiModel: AiModelId;
frames: Frame[];
currentFrameIndex: number;
annotations: Annotation[];
masks: Mask[];
selectedMaskIds: string[];
maskPreviewOpacity: number;
brushSize: number;
eraserSize: number;
maskHistory: Mask[][];
maskFuture: Mask[][];
setActiveModule: (module: string) => void;
setActiveTool: (tool: string) => void;
setAiModel: (model: AiModelId) => void;
setFrames: (frames: Frame[]) => void;
setCurrentFrame: (index: number) => void;
addAnnotation: (annotation: Annotation) => void;
addMask: (mask: Mask) => void;
updateMask: (id: string, updates: Partial<Mask>) => void;
setMasks: (masks: Mask[]) => void;
setSelectedMaskIds: (ids: string[]) => void;
setMaskPreviewOpacity: (opacity: number) => void;
setBrushSize: (size: number) => void;
setEraserSize: (size: number) => void;
clearMasks: () => void;
undoMasks: () => void;
redoMasks: () => void;
removeAnnotation: (id: string) => void;
// Templates
templates: Template[];
activeTemplateId: string | null;
activeClassId: string | null;
activeClass: TemplateClass | null;
setTemplates: (templates: Template[]) => void;
setActiveTemplateId: (id: string | null) => void;
setActiveClassId: (id: string | null) => void;
setActiveClass: (templateClass: TemplateClass | null) => void;
addTemplate: (template: Template) => void;
updateTemplate: (template: Template) => void;
removeTemplate: (id: string) => void;
// UI
isLoading: boolean;
error: string | null;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useStore = create<AppState>((set) => ({
// Auth
isAuthenticated: Boolean(localStorage.getItem('token')),
token: localStorage.getItem('token'),
currentUser: null,
login: (token: string, user: UserProfile | null = null) => {
localStorage.setItem('token', token);
set({ isAuthenticated: true, token, currentUser: user });
},
setCurrentUser: (currentUser: UserProfile | null) => set({ currentUser }),
logout: () => {
localStorage.removeItem('token');
set({
isAuthenticated: false,
token: null,
currentUser: null,
currentProject: null,
projects: [],
templates: [],
frames: [],
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
activeTemplateId: null,
activeClassId: null,
activeClass: null,
});
},
// Projects
projects: [],
currentProject: null,
setProjects: (projects: Project[]) => set({ projects }),
setCurrentProject: (currentProject: Project | null) => set({ currentProject }),
addProject: (project: Project) =>
set((state) => ({ projects: [project, ...state.projects] })),
updateProject: (project: Project) =>
set((state) => ({
projects: state.projects.map((p) => (p.id === project.id ? project : p)),
})),
// Workspace
activeModule: 'dashboard',
activeTool: 'move',
aiModel: DEFAULT_AI_MODEL_ID,
frames: [],
currentFrameIndex: 0,
annotations: [],
masks: [],
selectedMaskIds: [],
maskPreviewOpacity: 50,
brushSize: DEFAULT_BRUSH_SIZE,
eraserSize: DEFAULT_ERASER_SIZE,
maskHistory: [],
maskFuture: [],
setActiveModule: (activeModule: string) => set({ activeModule }),
setActiveTool: (activeTool: string) => set({ activeTool }),
setAiModel: (aiModel: AiModelId) => set({ aiModel }),
setFrames: (frames: Frame[]) => set({ frames }),
setCurrentFrame: (currentFrameIndex: number) => set({ currentFrameIndex }),
addAnnotation: (annotation: Annotation) =>
set((state) => ({ annotations: [...state.annotations, annotation] })),
addMask: (mask: Mask) =>
set((state) => ({
masks: [...state.masks, mask],
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
updateMask: (id: string, updates: Partial<Mask>) =>
set((state) => ({
masks: state.masks.map((mask) => (mask.id === id ? { ...mask, ...updates } : mask)),
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
setMasks: (masks: Mask[]) =>
set((state) => {
const isInitialHydration = state.masks.length === 0
&& state.maskHistory.length === 0
&& state.maskFuture.length === 0;
return {
masks,
maskHistory: isInitialHydration ? [] : [...state.maskHistory, state.masks],
maskFuture: [],
};
}),
setSelectedMaskIds: (selectedMaskIds: string[]) => set({ selectedMaskIds }),
setMaskPreviewOpacity: (maskPreviewOpacity: number) => set({
maskPreviewOpacity: Math.min(Math.max(maskPreviewOpacity, 10), 100),
}),
setBrushSize: (brushSize: number) => set({
brushSize: Math.round(Math.min(Math.max(brushSize, 4), 96)),
}),
setEraserSize: (eraserSize: number) => set({
eraserSize: Math.round(Math.min(Math.max(eraserSize, 4), 128)),
}),
clearMasks: () =>
set((state) => ({
masks: [],
selectedMaskIds: [],
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
undoMasks: () =>
set((state) => {
if (state.maskHistory.length === 0) return state;
const previous = state.maskHistory[state.maskHistory.length - 1];
return {
masks: previous,
maskHistory: state.maskHistory.slice(0, -1),
maskFuture: [state.masks, ...state.maskFuture],
};
}),
redoMasks: () =>
set((state) => {
if (state.maskFuture.length === 0) return state;
const [next, ...rest] = state.maskFuture;
return {
masks: next,
maskHistory: [...state.maskHistory, state.masks],
maskFuture: rest,
};
}),
removeAnnotation: (id: string) =>
set((state) => ({
annotations: state.annotations.filter((a) => a.id !== id),
})),
// Templates
templates: [],
activeTemplateId: null,
activeClassId: null,
activeClass: null,
setTemplates: (templates: Template[]) => set({ templates }),
setActiveTemplateId: (activeTemplateId: string | null) => set({ activeTemplateId }),
setActiveClassId: (activeClassId: string | null) => set({ activeClassId }),
setActiveClass: (activeClass: TemplateClass | null) => set({
activeClass,
activeClassId: activeClass?.id || null,
}),
addTemplate: (template: Template) =>
set((state) => ({ templates: [...state.templates, template] })),
updateTemplate: (template: Template) =>
set((state) => ({
templates: state.templates.map((t) => (t.id === template.id ? template : t)),
})),
removeTemplate: (id: string) =>
set((state) => ({
templates: state.templates.filter((t) => t.id !== id),
})),
// UI
isLoading: false,
error: null,
setLoading: (isLoading: boolean) => set({ isLoading }),
setError: (error: string | null) => set({ error }),
}));

6
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
readonly VITE_WS_PROGRESS_URL?: string;
}