完善项目导入、模板与分割工作区交互

- 增强 DICOM/视频项目导入与演示数据:DICOM 按文件名自然顺序处理,导入后展示上传与解析任务进度,恢复演示出厂设置保留演示视频和演示 DICOM 项目,并补充 demo media seed 逻辑。

- 完善项目管理:项目支持重命名、删除、复制,删除使用站内确认弹窗,复制支持新项目重置和全内容复制,DICOM 项目不显示生成帧入口。

- 完善 GT Mask 与导出链路:只支持 8-bit maskid 图导入,非法/全背景图明确拒绝,尺寸自动适配,高精度 polygon 回显;统一导出默认当前帧,GT_label 使用 uint8 和真实 maskid,待分类 maskid 0 与背景一致。

- 完善分割工作区交互:新增画笔和橡皮擦并支持尺寸控制,移除创建点/线段入口,工具栏按类别分隔,AI 智能分割使用明确 AI 图标,取消黄色 seed point,清空/删除传播 mask 后同步清理空帧时间轴状态。

- 完善传播与时间轴:自动传播使用 SAM 2.1 权重任务,参考帧无遮罩时提示,传播历史按同一蓝色系递进变暗,删除/清空传播链时保留人工或独立 AI 标注来源。

- 完善模板库:新增头颈部 CT 分割默认模板,所有模板保留 maskid 0 待分类,支持鼠标复制模板、拖拽层级、JSON 批量导入预览、删除 label 和站内删除确认。

- 完善用户与高风险确认:用户改密码、删除用户、恢复演示出厂设置和清空人工/AI 标注帧均改为站内确认交互,避免浏览器原生 prompt/confirm。

- 补充前后端测试与文档:更新项目、模板、GT 导入、导出、传播、DICOM、用户管理等测试,并同步 README、AGENTS 和 doc 下实现/契约/测试计划文档。
This commit is contained in:
2026-05-03 17:11:59 +08:00
parent afcddfaeb9
commit 481ffa5b67
47 changed files with 3650 additions and 676 deletions

View File

@@ -466,7 +466,7 @@ describe('CanvasArea', () => {
expect(screen.getByText('当前图层: 胆囊 #21')).toBeInTheDocument();
});
it('renders imported GT seed points for editable point regions', () => {
it('does not render stored GT seed points as visible editable handles', () => {
useStore.setState({
masks: [
{
@@ -482,7 +482,28 @@ describe('CanvasArea', () => {
render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.getAllByTestId('konva-circle')).toHaveLength(2);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('does not derive visible seed points for ordinary polygon masks', () => {
useStore.setState({
masks: [
{
id: 'manual-1',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Manual',
color: '#06b6d4',
segmentation: [[10, 10, 90, 10, 90, 40]],
},
],
});
render(<CanvasArea activeTool="move" frame={frame} />);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('selects a polygon mask and drags a vertex into dirty saved state', () => {
@@ -668,6 +689,79 @@ describe('CanvasArea', () => {
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['99']);
});
it('deletes linked propagated masks while keeping independent AI inference masks', () => {
const onDeleteMaskAnnotations = vi.fn();
const propagatedFrame = { ...frame, id: 'frame-2', index: 1, url: '/frame-2.jpg' };
useStore.setState({
masks: [
{
id: 'annotation-99',
annotationId: '99',
frameId: 'frame-1',
pathData: 'M 10 10 L 90 10 L 90 40 Z',
label: 'Seed',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[10, 10, 90, 10, 90, 40]],
},
{
id: 'annotation-100',
annotationId: '100',
frameId: 'frame-2',
pathData: 'M 12 10 L 92 10 L 92 40 Z',
label: 'Propagated A',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[12, 10, 92, 10, 92, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-101',
annotationId: '101',
frameId: 'frame-3',
pathData: 'M 14 10 L 94 10 L 94 40 Z',
label: 'Propagated B',
color: '#06b6d4',
saveStatus: 'saved',
saved: true,
segmentation: [[14, 10, 94, 10, 94, 40]],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
source_annotation_id: 99,
source_mask_id: 'annotation-99',
propagation_seed_key: 'annotation:99',
},
},
{
id: 'annotation-102',
annotationId: '102',
frameId: 'frame-3',
pathData: 'M 200 10 L 260 10 L 260 40 Z',
label: 'AI Candidate',
color: '#22c55e',
saveStatus: 'saved',
saved: true,
segmentation: [[200, 10, 260, 10, 260, 40]],
metadata: { source: 'ai_segmentation' },
},
],
});
render(<CanvasArea activeTool="move" frame={propagatedFrame} onDeleteMaskAnnotations={onDeleteMaskAnnotations} />);
fireEvent.click(screen.getByTestId('konva-path'));
fireEvent.keyDown(window, { key: 'Delete' });
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99', 'annotation-102']);
expect(onDeleteMaskAnnotations).toHaveBeenCalledWith(['100', '101']);
});
it('inserts a polygon vertex from an edge midpoint handle', () => {
useStore.setState({
masks: [
@@ -784,7 +878,8 @@ describe('CanvasArea', () => {
const paths = screen.getAllByTestId('konva-path');
fireEvent.click(paths[0]);
expect(screen.getByText('已选 1')).toBeInTheDocument();
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(0);
fireEvent.click(paths[1]);
expect(screen.getByText('已选 2')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '合并选中' }));
@@ -1069,7 +1164,8 @@ describe('CanvasArea', () => {
shape: '多边形',
}),
}));
expect(screen.queryAllByTestId('konva-circle')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('shows contextual guidance for boolean selection ordering', () => {

View File

@@ -121,6 +121,16 @@ function findPropagationChainMaskIds(selectedIds: string[], allMasks: Mask[]): S
);
}
function expandedPropagationDeletionMaskIds(selectedIds: string[], allMasks: Mask[]): Set<string> {
const selectedIdSet = new Set(selectedIds);
const chainIds = findPropagationChainMaskIds(selectedIds, allMasks);
return new Set(
allMasks
.filter((mask) => selectedIdSet.has(mask.id) || (chainIds.has(mask.id) && isPropagationMask(mask)))
.map((mask) => mask.id),
);
}
function maskLayerPriority(mask: Mask): number {
const parsed = Number(mask.classZIndex ?? mask.metadata?.classZIndex ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
@@ -958,7 +968,7 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const deleteMasksById = useCallback((maskIds: string[]) => {
if (maskIds.length === 0) return;
const idSet = new Set(maskIds);
const idSet = expandedPropagationDeletionMaskIds(maskIds, masks);
const deletingMasks = masks.filter((mask) => idSet.has(mask.id));
if (deletingMasks.length === 0) return;
setMasks(masks.filter((mask) => !idSet.has(mask.id)));
@@ -1278,18 +1288,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
return null;
}, [cursorPos, effectiveTool, manualCurrent, manualStart, polygonPoints]);
const handleSeedPointDragEnd = (mask: Mask, pointIndex: number, event: any) => {
const x = event.target.x();
const y = event.target.y();
const nextPoints = [...(mask.points || [])];
nextPoints[pointIndex] = [x, y];
updateMask(mask.id, {
points: nextPoints,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
});
};
const handleMaskSelect = (mask: Mask, event: any, polygonIndex = 0) => {
if (!isPolygonEditTool && !isBooleanTool) return;
event.cancelBubble = true;
@@ -1390,8 +1388,12 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) {
const deleteIds = primary.annotationId ? [primary.annotationId] : [];
setMasks(masks.filter((mask) => mask.id !== primary.id));
const deleteMaskIds = expandedPropagationDeletionMaskIds([primary.id], masks);
const deleteIds = masks
.filter((mask) => deleteMaskIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId));
setMasks(masks.filter((mask) => !deleteMaskIds.has(mask.id)));
if (deleteIds.length > 0) await onDeleteMaskAnnotations?.(deleteIds);
setSelectedMaskId(null);
setSelectedMaskIds([]);
@@ -1404,11 +1406,11 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
hasHoles: multiPolygonHasHoles(resultGeometry),
});
const secondaryIds = effectiveTool === 'area_merge'
? new Set(booleanSelectedMasks.slice(1).map((mask) => mask.id))
? expandedPropagationDeletionMaskIds(booleanSelectedMasks.slice(1).map((mask) => mask.id), masks)
: new Set<string>();
const secondaryAnnotationIds = effectiveTool === 'area_merge'
? booleanSelectedMasks
.slice(1)
? masks
.filter((mask) => secondaryIds.has(mask.id))
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId))
: [];
@@ -1584,21 +1586,6 @@ export function CanvasArea({ activeTool, frame, onClearMasks, onDeleteMaskAnnota
/>
))}
{/* Imported GT seed points / editable point regions */}
{frameMasks.flatMap((mask) => (mask.points || []).map(([x, y], index) => (
<Group key={`${mask.id}-seed-${index}`} x={x} y={y}>
<Circle
radius={5 / scale}
fill="#facc15"
stroke="#111827"
strokeWidth={2 / scale}
draggable
onDragEnd={(event: any) => handleSeedPointDragEnd(mask, index, event)}
/>
<Circle radius={1.5 / scale} fill="#111827" />
</Group>
)))}
{/* Polygon edge insertion handles */}
{isPolygonEditTool && selectedMask && selectedMaskPoints.map((point, index) => {
const next = selectedMaskPoints[(index + 1) % selectedMaskPoints.length];

View File

@@ -98,6 +98,14 @@ describe('FrameTimeline', () => {
{ id: 'f6', projectId: 'p1', index: 5, url: '/6.jpg', width: 640, height: 360 },
{ id: 'f7', projectId: 'p1', index: 6, url: '/7.jpg', width: 640, height: 360 },
],
masks: Array.from({ length: 7 }, (_, index) => ({
id: `tracked-${index + 1}`,
frameId: `f${index + 1}`,
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
})),
});
render(
@@ -133,6 +141,73 @@ describe('FrameTimeline', () => {
expect(segments[6].style.backgroundColor).not.toBe(segments[0].style.backgroundColor);
});
it('does not color propagation history frames after all masks on those frames are gone', () => {
useStore.setState({
frames: [
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
],
masks: [],
});
render(
<FrameTimeline
propagationHistory={[
{ id: 'history-empty', startFrame: 1, endFrame: 3, colorIndex: 0, label: '已删除传播' },
]}
/>,
);
expect(screen.queryByTestId('propagation-history-segment')).not.toBeInTheDocument();
expect(screen.queryByTestId('propagated-frame-segment')).not.toBeInTheDocument();
expect(screen.queryByTestId('annotated-frame-marker')).not.toBeInTheDocument();
expect(screen.getByText('人工/AI 0 帧 · 自动传播 0 帧')).toBeInTheDocument();
});
it('splits propagation history around frames that no longer have propagated masks', () => {
useStore.setState({
frames: [
{ id: 'f1', projectId: 'p1', index: 0, url: '/1.jpg', width: 640, height: 360 },
{ id: 'f2', projectId: 'p1', index: 1, url: '/2.jpg', width: 640, height: 360 },
{ id: 'f3', projectId: 'p1', index: 2, url: '/3.jpg', width: 640, height: 360 },
{ id: 'f4', projectId: 'p1', index: 3, url: '/4.jpg', width: 640, height: 360 },
{ id: 'f5', projectId: 'p1', index: 4, url: '/5.jpg', width: 640, height: 360 },
],
masks: [
{
id: 'tracked-2',
frameId: 'f2',
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
},
{
id: 'tracked-4',
frameId: 'f4',
pathData: 'M 0 0 Z',
label: 'Tracked',
color: '#3b82f6',
metadata: { source: 'sam2.1_hiera_tiny_propagation' },
},
],
});
render(
<FrameTimeline
propagationHistory={[
{ id: 'history-sparse', startFrame: 1, endFrame: 5, colorIndex: 0, label: '稀疏传播' },
]}
/>,
);
const segments = screen.getAllByTestId('propagation-history-segment');
expect(segments).toHaveLength(2);
expect(segments[0]).toHaveStyle({ left: '20%', width: '20%' });
expect(segments[1]).toHaveStyle({ left: '60%', width: '20%' });
});
it('jumps from the processing progress bar and frame status markers', () => {
useStore.setState({
frames: [

View File

@@ -71,6 +71,10 @@ export function FrameTimeline({
() => 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(
@@ -128,13 +132,44 @@ export function FrameTimeline({
};
const visiblePropagationHistory = useMemo(() => (
propagationHistory
.map((segment, order) => {
.flatMap((segment, order) => {
const range = normalizeRange(segment.startFrame, segment.endFrame);
const ageFromNewest = Math.min(Math.max(propagationHistory.length - 1 - order, 0), 4);
return { ...segment, ...range, order, ageFromNewest };
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)
), [propagationHistory, totalFrames]);
), [propagatedFrameNumbers, propagationHistory, totalFrames]);
const frameFromPointerEvent = (event: React.PointerEvent<HTMLElement>) => {
const rect = event.currentTarget.getBoundingClientRect();

View File

@@ -307,6 +307,7 @@ describe('OntologyInspector', () => {
classes: [
expect.objectContaining({ id: 'c2', zIndex: 20, maskId: 2 }),
expect.objectContaining({ id: 'c1', zIndex: 10, maskId: 1 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
})));
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({

View File

@@ -5,7 +5,7 @@ import type { Mask, TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { getActiveTemplate } from '../lib/templateSelection';
import { analyzeMask, smoothMaskGeometry, updateTemplate, type MaskAnalysisResult, type SmoothMaskGeometryResult } from '../lib/api';
import { nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
import { isReservedUnclassifiedClass, nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
const SMOOTHING_PREVIEW_DEBOUNCE_MS = 220;
@@ -458,7 +458,8 @@ export function OntologyInspector() {
setClassSaveMessage('请先选择一个模板');
return;
}
const maxZ = templateClasses.length > 0 ? Math.max(...templateClasses.map((c) => c.zIndex)) : 0;
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(),
@@ -501,14 +502,20 @@ export function OntologyInspector() {
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.map((item, index) => ({
reordered
.filter((item) => !isReservedUnclassifiedClass(item))
.map((item, index, activeItems) => ({
...item,
zIndex: (reordered.length - index) * 10,
zIndex: (activeItems.length - index) * 10,
})),
);
@@ -606,7 +613,7 @@ export function OntologyInspector() {
<div key={cls.id} className="flex flex-col gap-1">
<button
type="button"
draggable={Boolean(activeTemplate) && !isSavingClass}
draggable={Boolean(activeTemplate) && !isSavingClass && !isReservedUnclassifiedClass(cls)}
ref={(node) => {
if (node) {
classButtonRefs.current.set(cls.id, node);
@@ -616,12 +623,13 @@ export function OntologyInspector() {
}}
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) return;
if (!dragClassId || dragClassId === cls.id || isReservedUnclassifiedClass(cls)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}}
@@ -636,10 +644,11 @@ export function OntologyInspector() {
'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="text-gray-600 group-hover:text-gray-400" aria-hidden="true" />
<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>

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, 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';
@@ -7,19 +7,25 @@ import { ProjectLibrary } from './ProjectLibrary';
const apiMock = vi.hoisted(() => ({
getProjects: vi.fn(),
createProject: vi.fn(),
updateProject: vi.fn(),
copyProject: vi.fn(),
uploadMedia: vi.fn(),
parseMedia: vi.fn(),
uploadDicomBatch: vi.fn(),
deleteProject: vi.fn(),
getTask: vi.fn(),
}));
vi.mock('../lib/api', () => ({
getProjects: apiMock.getProjects,
createProject: apiMock.createProject,
updateProject: apiMock.updateProject,
copyProject: apiMock.copyProject,
uploadMedia: apiMock.uploadMedia,
parseMedia: apiMock.parseMedia,
uploadDicomBatch: apiMock.uploadDicomBatch,
deleteProject: apiMock.deleteProject,
getTask: apiMock.getTask,
}));
describe('ProjectLibrary', () => {
@@ -93,11 +99,38 @@ describe('ProjectLibrary', () => {
await waitFor(() => expect(apiMock.createProject).toHaveBeenCalledWith(expect.objectContaining({
name: 'clip.mp4',
})));
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3');
expect(apiMock.uploadMedia).toHaveBeenCalledWith(file, 'p3', expect.objectContaining({
onProgress: expect.any(Function),
}));
expect(apiMock.parseMedia).not.toHaveBeenCalled();
expect(await screen.findByRole('status')).toHaveTextContent('视频导入成功');
});
it('visualizes video upload progress while importing media', async () => {
let resolveUpload: ((value: { url: string; id: string }) => void) | undefined;
apiMock.createProject.mockResolvedValueOnce({ id: 'p-progress', name: 'large.mp4', status: 'pending' });
apiMock.uploadMedia.mockImplementationOnce((_file, _projectId, options) => {
options.onProgress({ loaded: 50, total: 100, percent: 50 });
return new Promise((resolve) => {
resolveUpload = resolve;
});
});
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept="video/*"]') as HTMLInputElement;
const file = new File(['video'], 'large.mp4', { type: 'video/mp4' });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.click(await screen.findByRole('button', { name: '开始导入' }));
expect(await screen.findByText('正在上传视频文件')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '50');
await act(async () => {
resolveUpload?.({ url: 'http://file', id: 'object' });
});
expect(await screen.findByText('视频导入完成')).toBeInTheDocument();
});
it('generates frames from an imported video with the selected FPS', async () => {
apiMock.getProjects
.mockResolvedValueOnce([{ id: 'p4', name: 'clip.mp4', status: 'pending', frames: 0, video_path: 'uploads/clip.mp4', parse_fps: 30 }])
@@ -115,6 +148,31 @@ describe('ProjectLibrary', () => {
expect(await screen.findByText('12FPS')).toBeInTheDocument();
});
it('hides frame generation while editing a project name', async () => {
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p-edit', name: 'Editable Clip', status: 'pending', frames: 0, video_path: 'uploads/editable.mp4', parse_fps: 30, source_type: 'video' },
]);
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
expect(await screen.findByRole('button', { name: '生成帧' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '修改项目名称 Editable Clip' }));
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
});
it('does not show frame generation for DICOM projects', async () => {
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p-dicom', name: 'DICOM Series', status: 'ready', frames: 0, video_path: 'uploads/dicom', source_type: 'dicom' },
]);
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
expect(await screen.findByText('DICOM Series')).toBeInTheDocument();
expect(screen.getByText('DICOM')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '生成帧' })).not.toBeInTheDocument();
});
it('deletes a project from the project card without entering the workspace', async () => {
const onProjectSelect = vi.fn();
apiMock.getProjects.mockResolvedValueOnce([
@@ -131,6 +189,7 @@ describe('ProjectLibrary', () => {
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '删除项目 Delete Me' }));
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteProject).toHaveBeenCalledWith('p5'));
expect(onProjectSelect).not.toHaveBeenCalled();
@@ -141,18 +200,129 @@ describe('ProjectLibrary', () => {
expect(useStore.getState().selectedMaskIds).toEqual([]);
});
it('imports only valid DICOM files and parses the returned project', async () => {
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 1, message: 'ok' });
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 1 });
it('renames a project from the project card without entering the workspace', async () => {
const onProjectSelect = vi.fn();
apiMock.getProjects.mockResolvedValueOnce([
{ id: 'p7', name: 'Old Name', status: 'ready', frames: 3, fps: '30FPS' },
]);
apiMock.updateProject.mockResolvedValueOnce({ id: 'p7', name: 'New Name', status: 'ready', frames: 3, fps: '30FPS' });
useStore.setState({
currentProject: { id: 'p7', name: 'Old Name', status: 'ready' },
});
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '修改项目名称 Old Name' }));
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: '保存项目名称 Old Name' }));
await waitFor(() => expect(apiMock.updateProject).toHaveBeenCalledWith('p7', { name: 'New Name' }));
expect(onProjectSelect).not.toHaveBeenCalled();
expect(useStore.getState().projects[0]).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
expect(useStore.getState().currentProject).toEqual(expect.objectContaining({ id: 'p7', name: 'New Name' }));
expect(await screen.findByRole('status')).toHaveTextContent('项目名称已更新');
});
it('copies a project as a reset project from the project card', async () => {
const onProjectSelect = vi.fn();
apiMock.getProjects
.mockResolvedValueOnce([
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
])
.mockResolvedValueOnce([
{ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' },
{ id: 'p8', name: 'Source Project', status: 'ready', frames: 3, fps: '30FPS' },
]);
apiMock.copyProject.mockResolvedValueOnce({ id: 'p9', name: 'Source Project 副本', status: 'ready', frames: 3, fps: '30FPS' });
render(<ProjectLibrary onProjectSelect={onProjectSelect} />);
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Source Project' }));
fireEvent.click(screen.getByRole('button', { name: /新项目重置/ }));
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p8', { mode: 'reset' }));
expect(onProjectSelect).not.toHaveBeenCalled();
expect(useStore.getState().projects.map((project) => project.id)).toEqual(['p9', 'p8']);
expect(await screen.findByRole('status')).toHaveTextContent('已复制为重置项目Source Project 副本');
});
it('copies a project with all content from the project card', async () => {
apiMock.getProjects
.mockResolvedValueOnce([
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
])
.mockResolvedValueOnce([
{ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' },
{ id: 'p10', name: 'Annotated Project', status: 'ready', frames: 2, fps: '30FPS' },
]);
apiMock.copyProject.mockResolvedValueOnce({ id: 'p11', name: 'Annotated Project 副本', status: 'ready', frames: 2, fps: '30FPS' });
render(<ProjectLibrary onProjectSelect={vi.fn()} />);
fireEvent.click(await screen.findByRole('button', { name: '复制项目 Annotated Project' }));
fireEvent.click(screen.getByRole('button', { name: /全内容复制/ }));
await waitFor(() => expect(apiMock.copyProject).toHaveBeenCalledWith('p10', { mode: 'full' }));
expect(await screen.findByRole('status')).toHaveTextContent('已全内容复制项目Annotated Project 副本');
});
it('imports valid DICOM files in natural filename order and parses the returned project', async () => {
apiMock.uploadDicomBatch.mockResolvedValueOnce({ project_id: 77, uploaded_count: 3, message: 'ok' });
apiMock.parseMedia.mockResolvedValueOnce({ frames_extracted: 3 });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
const dcm = new File(['dcm'], 'scan.dcm', { type: 'application/dicom' });
const ten = new File(['dcm10'], '10.dcm', { type: 'application/dicom' });
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
const ignored = new File(['txt'], 'notes.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [dcm, ignored] } });
fireEvent.change(input, { target: { files: [ten, ignored, two, one] } });
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([dcm]));
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalledWith([one, two, ten], undefined, expect.objectContaining({
onProgress: expect.any(Function),
})));
expect(apiMock.parseMedia).toHaveBeenCalledWith('77');
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 上传成功: 1 个文件');
expect(await screen.findByRole('status')).toHaveTextContent('DICOM 导入完成: 3 个文件');
});
it('visualizes DICOM upload progress and parsing queue handoff', async () => {
let resolveDicomUpload: ((value: { project_id: number; uploaded_count: number; message: string }) => void) | undefined;
apiMock.uploadDicomBatch.mockImplementationOnce(() => new Promise((resolve) => {
resolveDicomUpload = resolve;
}));
apiMock.parseMedia.mockResolvedValueOnce({ id: 44, status: 'queued', progress: 0 });
apiMock.getTask
.mockResolvedValueOnce({ id: 44, status: 'running', progress: 55, message: '正在写入帧索引' })
.mockResolvedValueOnce({ id: 44, status: 'success', progress: 100, message: '解析完成' });
const { container } = render(<ProjectLibrary onProjectSelect={vi.fn()} />);
const input = container.querySelector('input[accept=".dcm"]') as HTMLInputElement;
const one = new File(['dcm1'], '1.dcm', { type: 'application/dicom' });
const two = new File(['dcm2'], '2.dcm', { type: 'application/dicom' });
fireEvent.change(input, { target: { files: [two, one] } });
await waitFor(() => expect(apiMock.uploadDicomBatch).toHaveBeenCalled());
const progressOptions = apiMock.uploadDicomBatch.mock.calls[0][2];
await act(async () => {
progressOptions.onProgress({ loaded: 80, total: 100, percent: 80 });
});
expect(await screen.findByText('正在上传 DICOM 序列')).toBeInTheDocument();
expect(screen.getByText('2 文件')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '80');
vi.useFakeTimers();
await act(async () => {
resolveDicomUpload?.({ project_id: 78, uploaded_count: 2, message: 'ok' });
});
expect(screen.getByText('正在解析 DICOM 序列')).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: '导入进度' })).toHaveAttribute('aria-valuenow', '0');
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(apiMock.getTask).toHaveBeenCalledWith(44);
expect(screen.getByText('正在写入帧索引')).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(1200);
});
expect(screen.getByText('DICOM 导入完成')).toBeInTheDocument();
vi.useRealTimers();
});
});

View File

@@ -1,15 +1,31 @@
import React, { useState, useEffect, useRef } from 'react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2 } from 'lucide-react';
import { UploadCloud, Film, Settings2, Plus, Loader2, Activity, Images, Trash2, Pencil, Check, X, Copy } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getProjects, createProject, uploadMedia, parseMedia, uploadDicomBatch, deleteProject } from '../lib/api';
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);
@@ -32,7 +48,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
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);
@@ -40,6 +63,105 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
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 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 '待生成帧';
@@ -50,6 +172,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
return project.fps || '30FPS';
};
const canGenerateFrames = (project: Project) => (
project.source_type !== 'dicom'
&& Boolean(project.video_path)
&& (project.frames ?? 0) === 0
&& project.status !== 'parsing'
&& editingProjectId !== project.id
);
useEffect(() => {
setIsLoading(true);
getProjects()
@@ -79,12 +209,15 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
onProjectSelect();
};
const handleDeleteProject = async (project: Project, event: React.MouseEvent) => {
const openDeleteProject = (project: Project, event: React.MouseEvent) => {
event.stopPropagation();
if (deletingProjectId) return;
const confirmed = window.confirm(`确认删除项目“${project.name}”?\n该操作会删除项目帧、标注、任务记录和相关 mask 元数据,无法撤销。`);
if (!confirmed) return;
setDeleteProjectTarget(project);
};
const handleDeleteProject = async () => {
const project = deleteProjectTarget;
if (!project || deletingProjectId) return;
setDeletingProjectId(project.id);
try {
await deleteProject(project.id);
@@ -95,6 +228,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
setMasks([]);
setSelectedMaskIds([]);
}
setDeleteProjectTarget(null);
} catch (err) {
console.error('Delete project failed:', err);
showNotice('删除项目失败,请检查后端服务', 'error');
@@ -103,6 +237,73 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
}
};
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);
@@ -112,17 +313,54 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
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()}`,
});
const result = await uploadMedia(pendingFile, String(newProject.id));
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);
@@ -158,20 +396,90 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const handleDicomUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const dcmFiles = Array.from(files).filter((f) => f.name.toLowerCase().endsWith('.dcm'));
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);
await parseMedia(String(result.project_id));
showNotice(`DICOM 上传成功: ${result.uploaded_count} 个文件`, 'success');
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);
@@ -253,6 +561,8 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</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) => (
@@ -297,17 +607,75 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</div>
<div className="p-4 flex flex-col gap-1">
<div className="flex justify-between items-start">
<h3 className="text-sm font-medium text-gray-200 truncate pr-4" title={proj.name}>{proj.name}</h3>
<button
type="button"
aria-label={`删除项目 ${proj.name}`}
title="删除项目"
disabled={deletingProjectId === proj.id}
onClick={(event) => handleDeleteProject(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>
{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>
@@ -315,7 +683,7 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
<span className="flex items-center gap-1.5 text-cyan-400/80"><Activity size={12} /> {proj.original_fps.toFixed(1)}fps</span>
)}
</div>
{proj.video_path && (proj.frames ?? 0) === 0 && proj.status !== 'parsing' && (
{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"
@@ -330,6 +698,92 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
</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">

View File

@@ -19,6 +19,16 @@ vi.mock('../lib/api', () => ({
}));
describe('TemplateRegistry', () => {
const makeDataTransfer = () => {
const store = new Map<string, string>();
return {
effectAllowed: '',
dropEffect: '',
setData: vi.fn((key: string, value: string) => store.set(key, value)),
getData: vi.fn((key: string) => store.get(key) || ''),
};
};
beforeEach(() => {
resetStore();
vi.clearAllMocks();
@@ -40,6 +50,8 @@ describe('TemplateRegistry', () => {
expect(await screen.findAllByText('腹腔镜胆囊切除术')).toHaveLength(2);
expect(screen.getByText('胆囊')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?1/).length).toBeGreaterThan(0);
expect(screen.getByText('待分类')).toBeInTheDocument();
expect(screen.getAllByText(/maskid: ?0/).length).toBeGreaterThan(0);
expect(screen.queryByText(/Z-Level/)).not.toBeInTheDocument();
});
@@ -49,7 +61,7 @@ describe('TemplateRegistry', () => {
id: 't2',
name: 'New Template',
description: 'desc',
classes: [],
classes: [expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' })],
rules: [],
});
@@ -62,7 +74,15 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: 'New Template',
description: 'desc',
classes: [],
classes: [
expect.objectContaining({
id: 'reserved-unclassified',
name: '待分类',
color: '#000000',
zIndex: 0,
maskId: 0,
}),
],
rules: [],
color: '#06b6d4',
z_index: 0,
@@ -77,15 +97,17 @@ describe('TemplateRegistry', () => {
fireEvent.click(screen.getByText('新建方案'));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: 'With Classes' } });
fireEvent.click(screen.getByText('批量导入'));
expect(screen.queryByText('📋 载入腹腔镜胆囊切除术模板')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{"colors":[[255,0,0]],"names":["分类A"]}' },
});
expect(screen.getByText(/将导入 1 个分类maskid 从 1 开始分配/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(screen.getByText('分类A')).toBeInTheDocument();
});
it('shows JSON import errors as transient notices instead of blocking alerts', async () => {
it('shows JSON import errors inline instead of blocking alerts', async () => {
apiMock.getTemplates.mockResolvedValueOnce([]);
render(<TemplateRegistry />);
@@ -94,9 +116,9 @@ describe('TemplateRegistry', () => {
fireEvent.change(screen.getByPlaceholderText('[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'), {
target: { value: '{broken-json' },
});
fireEvent.click(screen.getByRole('button', { name: '导入' }));
expect(await screen.findByRole('status')).toHaveTextContent('JSON 解析失败');
expect(screen.getByText('JSON 解析失败')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '导入' })).toBeDisabled();
});
it('shows template save errors as transient notices', async () => {
@@ -132,7 +154,7 @@ describe('TemplateRegistry', () => {
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: /修改库视图结构/ }));
fireEvent.click(await screen.findByRole('button', { name: '编辑模板 旧模板' }));
fireEvent.change(screen.getAllByRole('textbox')[0], { target: { value: '新模板' } });
fireEvent.change(screen.getAllByRole('textbox')[1], { target: { value: 'new desc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
@@ -140,7 +162,10 @@ describe('TemplateRegistry', () => {
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
name: '新模板',
description: 'new desc',
classes: [expect.objectContaining({ id: 'c1', name: '胆囊' })],
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
],
rules: [],
color: '#06b6d4',
z_index: 3,
@@ -149,6 +174,205 @@ describe('TemplateRegistry', () => {
id: 't1',
name: '新模板',
}));
expect(await screen.findAllByText('新模板')).toHaveLength(2);
});
it('shows the semantic tree title and opens the add-class modal from the detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' }],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'new-class', name: '新类别', color: '#00ff00', zIndex: 20, maskId: 2, category: '未分类' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
expect(await screen.findByText('语义分类树(拖拽调层级)')).toBeInTheDocument();
expect(screen.queryByText(/Painter's Algorithm Weight/)).not.toBeInTheDocument();
expect(screen.queryByText('器官')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /新建分类/ }));
expect(screen.getByDisplayValue('新类别')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', maskId: 1 }),
expect.objectContaining({ name: '新类别', maskId: 2, category: '未分类' }),
expect.objectContaining({ name: '待分类', maskId: 0, color: '#000000' }),
],
})));
});
it('deletes a class directly from the semantic tree', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
{ id: 'reserved-unclassified', name: '待分类', color: '#000000', zIndex: 0, maskId: 0, category: '系统保留' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '删除分类 肝脏' }));
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', name: '胆囊', zIndex: 10, maskId: 1 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes).toEqual([
expect.objectContaining({ id: 'c1', name: '胆囊' }),
expect.objectContaining({ name: '待分类', maskId: 0 }),
]));
expect(await screen.findByRole('status')).toHaveTextContent('分类已删除');
});
it('copies a template from the active template list into a new editable template', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '头颈部CT分割',
description: 'desc',
classes: [
{ id: 'c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
},
{
id: 't2',
name: '头颈部CT分割 副本',
description: 'existing copy',
classes: [],
rules: [],
},
]);
apiMock.createTemplate.mockResolvedValueOnce({
id: 't3',
name: '头颈部CT分割 副本 2',
description: 'desc',
classes: [
{ id: 'copy-c1', name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'copy-c2', name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' },
],
rules: [{ id: 'r1', name: 'rule', sourceKey: 'a', targetKey: 'b', operation: 'copy' }],
color: '#ef4444',
z_index: 10,
});
render(<TemplateRegistry />);
fireEvent.click(await screen.findByRole('button', { name: '复制模板 头颈部CT分割' }));
await waitFor(() => expect(apiMock.createTemplate).toHaveBeenCalledWith(expect.objectContaining({
name: '头颈部CT分割 副本 2',
description: 'desc',
color: '#ef4444',
z_index: 10,
rules: [expect.objectContaining({ id: 'r1' })],
classes: [
expect.objectContaining({ name: '肿瘤', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' }),
expect.objectContaining({ name: '气管', color: '#00ff00', zIndex: 10, maskId: 4, category: '器官' }),
expect.objectContaining({ name: '待分类', color: '#000000', zIndex: 0, maskId: 0 }),
],
})));
const payload = apiMock.createTemplate.mock.calls[0][0];
expect(payload.classes[0].id).toMatch(/^cls-copy-/);
expect(payload.classes[0].id).not.toBe('c1');
expect(useStore.getState().templates.some((template) => template.id === 't3')).toBe(true);
expect(await screen.findByText('已复制模板头颈部CT分割 副本 2')).toBeInTheDocument();
});
it('persists dragged class layer order directly from the template detail view', async () => {
apiMock.getTemplates.mockResolvedValueOnce([
{
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 20, maskId: 2, category: '器官' },
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 10, maskId: 1, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
},
]);
apiMock.updateTemplate.mockResolvedValueOnce({
id: 't1',
name: '模板',
description: 'desc',
classes: [
{ id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1, category: '器官' },
{ id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 10, maskId: 2, category: '器官' },
],
rules: [],
color: '#06b6d4',
z_index: 3,
});
render(<TemplateRegistry />);
const gallbladderRow = (await screen.findByText('胆囊')).closest('[draggable="true"]') as HTMLElement;
const liverRow = screen.getByText('肝脏').closest('[draggable="true"]') as HTMLElement;
const dataTransfer = makeDataTransfer();
fireEvent.dragStart(gallbladderRow, { dataTransfer });
fireEvent.dragOver(liverRow, { dataTransfer });
fireEvent.drop(liverRow, { dataTransfer });
await waitFor(() => expect(apiMock.updateTemplate).toHaveBeenCalledWith('t1', expect.objectContaining({
classes: [
expect.objectContaining({ id: 'c1', zIndex: 20, maskId: 1 }),
expect.objectContaining({ id: 'c2', zIndex: 10, maskId: 2 }),
expect.objectContaining({ name: '待分类', zIndex: 0, maskId: 0 }),
],
color: '#06b6d4',
z_index: 3,
})));
await waitFor(() => expect(useStore.getState().templates[0].classes[0]).toEqual(
expect.objectContaining({ id: 'c1', zIndex: 20 }),
));
});
it('deletes an existing template after confirmation', async () => {
@@ -165,8 +389,8 @@ describe('TemplateRegistry', () => {
const { container } = render(<TemplateRegistry />);
await screen.findAllByText('待删除模板');
const buttons = Array.from(container.querySelectorAll('button'));
fireEvent.click(buttons[2]);
fireEvent.click(container.querySelector('button[title="删除模板"]') as HTMLElement);
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteTemplate).toHaveBeenCalledWith('t1'));
expect(useStore.getState().templates).toEqual([]);

View File

@@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react';
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 { nextClassMaskId, normalizeClassMaskIds } from '../lib/maskIds';
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';
@@ -24,35 +24,21 @@ function generateColor(index: number, total: number): string {
return hslToHex(hue, 75, 55);
}
const LAPAROSCOPIC_COLORS = [
[134, 124, 118], [0, 157, 142], [245, 161, 0], [255, 172, 159], [146, 175, 236], [155, 62, 0],
[255, 91, 0], [255, 234, 0], [85, 111, 181], [155, 132, 0], [181, 227, 14], [72, 0, 255],
[255, 0, 255], [29, 32, 136], [240, 16, 116], [160, 15, 95], [0, 155, 33], [0, 160, 233],
[52, 184, 178], [66, 115, 82], [90, 120, 41], [255, 0, 0], [117, 0, 0], [167, 24, 233],
[42, 8, 66], [112, 113, 150], [0, 255, 0], [255, 255, 255], [0, 255, 255], [181, 85, 105],
[113, 102, 140], [202, 202, 200], [197, 83, 181], [136, 162, 196], [138, 251, 213],
];
const LAPAROSCOPIC_NAMES = [
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
];
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('');
@@ -61,6 +47,8 @@ export function TemplateRegistry() {
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') => {
@@ -79,7 +67,7 @@ export function TemplateRegistry() {
setSelectedTemplate(null);
setEditName('');
setEditDesc('');
setEditClasses([]);
setEditClasses(normalizeClassMaskIds([]));
setShowModal(true);
};
@@ -91,6 +79,76 @@ export function TemplateRegistry() {
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 openAddClass = (template: Template) => {
const classes = normalizeClassMaskIds(template.classes ? [...template.classes] : []);
const newClass = buildNewClass(classes);
setSelectedTemplate(template);
setEditName(template.name);
setEditDesc(template.description || '');
setEditClasses([...classes, newClass]);
setEditingClassId(newClass.id);
setShowModal(true);
};
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);
@@ -100,15 +158,18 @@ export function TemplateRegistry() {
description: editDesc.trim() || undefined,
classes: normalizeClassMaskIds(editClasses),
rules: [],
color: selectedTemplate ? (selectedTemplate as any).color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? (selectedTemplate as any).z_index ?? 0 : 0,
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) {
@@ -119,14 +180,38 @@ export function TemplateRegistry() {
}
};
const handleDelete = async (id: string) => {
if (!confirm('确定要删除此模板吗?')) return;
const handleCopy = async (template: Template) => {
setCopyingTemplateId(template.id);
try {
await deleteTemplate(id);
removeTemplateStore(id);
if (selectedTemplate?.id === id) {
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');
@@ -134,15 +219,8 @@ export function TemplateRegistry() {
};
const addClass = () => {
const newClass: TemplateClass = {
id: `cls-${Date.now()}`,
name: '新类别',
color: generateColor(editClasses.length, Math.max(editClasses.length + 1, 8)),
zIndex: editClasses.length > 0 ? Math.max(...editClasses.map((c) => c.zIndex)) + 10 : 10,
maskId: nextClassMaskId(editClasses),
category: '未分类',
};
setEditClasses([...editClasses, newClass]);
const newClass = buildNewClass(editClasses);
setEditClasses(recalculateClassOrder([...editClasses, newClass]));
setEditingClassId(newClass.id);
};
@@ -151,76 +229,141 @@ export function TemplateRegistry() {
};
const removeClass = (id: string) => {
setEditClasses(editClasses.filter((c) => c.id !== id));
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);
// Recalculate z-index based on new order (top = highest)
const recalculated = items.map((c, i) => ({ ...c, zIndex: (items.length - i) * 10 }));
setEditClasses(recalculated);
setEditClasses(recalculateClassOrder(items));
};
const handleImport = () => {
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 data = JSON.parse(importText);
let colors: number[][] = [];
let names: string[] = [];
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 {
showNotice('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}', 'error');
return;
}
const firstMaskId = nextClassMaskId(editClasses);
const imported: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
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,
color: hex,
zIndex: (names.length - i) * 10,
maskId: firstMaskId + i,
category: '批量导入',
};
});
setEditClasses([...editClasses, ...imported]);
setShowImport(false);
setImportText('');
} catch (e) {
showNotice('JSON 解析失败', 'error');
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 loadLaparoscopic = () => {
const imported: TemplateClass[] = LAPAROSCOPIC_NAMES.map((name, i) => {
const rgb = LAPAROSCOPIC_COLORS[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-lap-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (LAPAROSCOPIC_NAMES.length - i) * 10,
maskId: i + 1,
category: '腹腔镜胆囊切除术',
};
});
setEditClasses(imported);
setShowImport(false);
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 activeTemplate = selectedTemplate || templates[0] || null;
const parseImportClasses = () => {
let data: any;
try {
data = JSON.parse(importText);
} catch {
throw new Error('JSON 解析失败');
}
let colors: number[][] = [];
let names: string[] = [];
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 {
throw new Error('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
}
const firstMaskId = nextClassMaskId(editClasses);
const classes: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
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,
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]">
@@ -266,13 +409,28 @@ export function TemplateRegistry() {
<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
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
<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} />
@@ -298,10 +456,10 @@ export function TemplateRegistry() {
</h2>
{activeTemplate && (
<button
onClick={() => openEdit(activeTemplate)}
onClick={() => openAddClass(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"
>
<Settings size={14} /> (Schema)
<Plus size={14} />
</button>
)}
</div>
@@ -310,18 +468,62 @@ export function TemplateRegistry() {
<div className="space-y-6">
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4">
(Painter's Algorithm Weight)
</h3>
<div className="space-y-2">
{normalizeClassMaskIds(activeTemplate.classes || []).sort((a, b) => b.zIndex - a.zIndex).map((cls) => (
<div key={cls.id} className="grid grid-cols-4 gap-4 p-3 bg-[#0d0d0d] border border-white/5 rounded items-center">
<div className="col-span-1 flex items-center gap-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">{cls.name}</span>
<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">
<span className="bg-white/5 text-gray-400 text-xs px-2 py-1 rounded border border-white/10">{cls.category || ''}</span>
<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>
))}
@@ -391,12 +593,14 @@ export function TemplateRegistry() {
{editClasses.map((cls, idx) => (
<div
key={cls.id}
draggable
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);
@@ -405,6 +609,7 @@ export function TemplateRegistry() {
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);
}}
@@ -414,14 +619,15 @@ export function TemplateRegistry() {
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
)}
>
<div className="text-gray-600 cursor-grab active:cursor-grabbing shrink-0">
<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 })}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0"
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 ? (
<>
@@ -432,6 +638,7 @@ export function TemplateRegistry() {
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"
/>
<input
@@ -445,15 +652,21 @@ export function TemplateRegistry() {
) : (
<>
<span
className="flex-1 text-sm text-gray-300 cursor-pointer"
onClick={() => setEditingClassId(cls.id)}
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)} className="text-gray-500 hover:text-red-400 transition-colors">
<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>
@@ -500,13 +713,20 @@ export function TemplateRegistry() {
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"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={loadLaparoscopic}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
📋 载入腹腔镜胆囊切除术模板
</button>
{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(''); }}
@@ -516,7 +736,8 @@ export function TemplateRegistry() {
</button>
<button
onClick={handleImport}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
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>
@@ -525,6 +746,32 @@ export function TemplateRegistry() {
</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

@@ -81,8 +81,6 @@ describe('UserAdmin', () => {
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
vi.spyOn(window, 'prompt').mockReturnValueOnce('nextsecret');
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
render(<UserAdmin />);
await screen.findByText('doctor');
@@ -95,9 +93,12 @@ describe('UserAdmin', () => {
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
fireEvent.click(screen.getAllByTitle('修改密码')[1]);
fireEvent.change(screen.getByPlaceholderText('至少 6 位'), { target: { value: 'nextsecret' } });
fireEvent.click(screen.getByRole('button', { name: '确认修改' }));
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { password: 'nextsecret' }));
fireEvent.click(screen.getAllByTitle('删除用户')[1]);
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
await waitFor(() => expect(apiMock.deleteAdminUser).toHaveBeenCalledWith(2));
});
@@ -106,12 +107,33 @@ describe('UserAdmin', () => {
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
project: {
id: '8',
name: 'Data_MyVideo_1',
status: 'pending',
frames: 0,
name: '演示DICOM序列',
status: 'ready',
frames: 300,
fps: '30FPS',
video_path: 'uploads/8/Data_MyVideo_1.mp4',
source_type: 'dicom',
video_path: 'uploads/8/dicom',
},
projects: [
{
id: '7',
name: 'Data_MyVideo_1',
status: 'pending',
frames: 0,
fps: '30FPS',
source_type: 'video',
video_path: 'uploads/7/Data_MyVideo_1.mp4',
},
{
id: '8',
name: '演示DICOM序列',
status: 'ready',
frames: 300,
fps: '30FPS',
source_type: 'dicom',
video_path: 'uploads/8/dicom',
},
],
deleted_counts: { users: 1 },
message: '演示环境已恢复出厂设置',
});
@@ -126,29 +148,34 @@ describe('UserAdmin', () => {
created_at: '2026-05-02T00:00:00Z',
},
]);
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
vi.spyOn(window, 'prompt').mockReturnValueOnce('RESET_DEMO_FACTORY');
render(<UserAdmin />);
await screen.findByText('doctor');
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
target: { value: 'RESET_DEMO_FACTORY' },
});
fireEvent.click(screen.getByRole('button', { name: '确认恢复' }));
await waitFor(() => expect(apiMock.resetDemoFactory).toHaveBeenCalledWith('RESET_DEMO_FACTORY'));
expect(await screen.findByText('演示环境已恢复出厂设置')).toBeInTheDocument();
expect(useStore.getState().projects).toEqual([expect.objectContaining({ name: 'Data_MyVideo_1' })]);
expect(useStore.getState().projects).toEqual([
expect.objectContaining({ name: 'Data_MyVideo_1', source_type: 'video' }),
expect.objectContaining({ name: '演示DICOM序列', source_type: 'dicom' }),
]);
expect(useStore.getState().frames).toEqual([]);
expect(useStore.getState().masks).toEqual([]);
});
it('does not reset demo data when confirmation text does not match', async () => {
vi.spyOn(window, 'confirm').mockReturnValueOnce(true);
vi.spyOn(window, 'prompt').mockReturnValueOnce('wrong');
render(<UserAdmin />);
await screen.findByText('doctor');
fireEvent.click(screen.getByRole('button', { name: '恢复演示出厂设置' }));
fireEvent.change(screen.getByLabelText('输入 RESET_DEMO_FACTORY 确认'), {
target: { value: 'wrong' },
});
expect(apiMock.resetDemoFactory).not.toHaveBeenCalled();
expect(await screen.findByText('确认文本不匹配,未执行恢复出厂设置')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '确认恢复' })).toBeDisabled();
});
});

View File

@@ -48,6 +48,11 @@ export function UserAdmin() {
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newRole, setNewRole] = useState('annotator');
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') => {
@@ -106,25 +111,41 @@ export function UserAdmin() {
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 = async (user: AdminUser) => {
const password = window.prompt(`${user.username} 设置新密码(至少 6 位)`);
if (password === null) return;
await handlePatchUser(user, { password });
const handleChangePassword = (user: AdminUser) => {
setPasswordTarget(user);
setNextPassword('');
};
const handleDeleteUser = async (user: AdminUser) => {
if (!window.confirm(`确定删除用户 ${user.username} 吗?已有项目的用户建议先停用。`)) return;
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) {
@@ -135,27 +156,23 @@ export function UserAdmin() {
};
const handleFactoryReset = async () => {
const firstConfirmed = window.confirm(
'恢复演示出厂设置会删除除默认 admin 外的所有用户、项目帧、标注、任务和私有模板,只保留一个未生成帧的演示视频项目。确定继续吗?',
);
if (!firstConfirmed) return;
const typed = window.prompt('请输入 RESET_DEMO_FACTORY 以确认恢复演示出厂设置');
if (typed === null) return;
if (typed !== 'RESET_DEMO_FACTORY') {
if (factoryResetText !== 'RESET_DEMO_FACTORY') {
showNotice('确认文本不匹配,未执行恢复出厂设置', 'error');
return;
}
setIsResetting(true);
try {
const result = await resetDemoFactory(typed);
const result = await resetDemoFactory(factoryResetText);
setUsers([result.admin_user]);
setProjects([result.project]);
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');
@@ -275,7 +292,7 @@ export function UserAdmin() {
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => void handleChangePassword(user)}
onClick={() => handleChangePassword(user)}
className="rounded border border-white/10 p-2 text-gray-300 hover:border-cyan-400/40 hover:text-cyan-200"
title="修改密码"
>
@@ -283,7 +300,7 @@ export function UserAdmin() {
</button>
<button
type="button"
onClick={() => void handleDeleteUser(user)}
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="删除用户"
@@ -332,12 +349,15 @@ export function UserAdmin() {
<div>
<div className="text-sm font-semibold text-red-100"></div>
<p className="mt-1 text-xs leading-relaxed text-red-200/70">
admin
admin DICOM
</p>
</div>
<button
type="button"
onClick={() => void handleFactoryReset()}
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"
>
@@ -347,6 +367,119 @@ export function UserAdmin() {
</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 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>
);
}

View File

@@ -200,6 +200,56 @@ describe('VideoWorkspace', () => {
]));
});
it('downgrades masks whose saved class no longer exists in the template to maskid 0 pending classification', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations.mockResolvedValueOnce([{ id: 100, frame_id: 10, template_id: 2 }]);
apiMock.annotationToMask.mockReturnValueOnce({
id: 'annotation-100',
annotationId: '100',
frameId: '10',
templateId: '2',
classId: 'deleted-class',
className: '已删除类别',
classMaskId: 7,
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
label: '已删除类别',
color: '#ff0000',
segmentation: [[0, 0, 10, 0, 10, 10]],
});
useStore.setState({
templates: [{
id: '2',
name: '当前模板',
classes: [{ id: 'c1', name: '胆囊', color: '#00ff00', zIndex: 10, maskId: 1 }],
rules: [],
}],
});
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().masks).toEqual([
expect.objectContaining({
id: 'annotation-100',
label: '待分类',
className: '待分类',
classMaskId: 0,
classId: undefined,
color: '#9ca3af',
saved: false,
saveStatus: 'dirty',
metadata: expect.objectContaining({
needs_classification: true,
stale_class: expect.objectContaining({ id: 'deleted-class', maskId: 7 }),
}),
}),
]));
expect(screen.getByRole('button', { name: '保存 1 个改动' })).toBeInTheDocument();
});
it('preserves unsaved AI masks when hydrating saved annotations after entering the workspace', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
@@ -361,9 +411,13 @@ describe('VideoWorkspace', () => {
segmentation: [[0, 0, 10, 0, 10, 10]],
bbox: [0, 0, 10, 10],
metadata: {
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
geometry_smoothing_preview: { strength: 35, method: 'chaikin' },
},
}],
});
@@ -377,12 +431,17 @@ describe('VideoWorkspace', () => {
mask_data: {
polygons: [],
label: '胆囊',
source: 'sam2.1_hiera_tiny_propagation',
propagated_from_frame_id: 10,
propagation_seed_key: 'annotation:7',
source_annotation_id: 7,
source_mask_id: 'annotation-7',
propagation_seed_signature: 'old-signature',
},
points: undefined,
bbox: undefined,
}));
expect(apiMock.updateAnnotation.mock.calls[0][1].mask_data).not.toHaveProperty('geometry_smoothing_preview');
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
});
@@ -425,7 +484,6 @@ describe('VideoWorkspace', () => {
});
it('clears masks across the selected frame range', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -470,18 +528,82 @@ describe('VideoWorkspace', () => {
expect(screen.getByLabelText('传播结束帧')).toHaveValue(2);
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('是否清除“人工/AI标注帧”'));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('100');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-100']);
expect(useStore.getState().selectedMaskIds).not.toContain('draft-1');
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
confirmSpy.mockRestore();
});
it('clears a range after undo restores a mask whose backend annotation was already deleted', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation.mockRejectedValueOnce({ response: { status: 404 } });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
const restoredMask = {
id: 'annotation-99',
annotationId: '99',
frameId: '10',
pathData: 'M 0 0 Z',
label: 'Restored',
color: '#06b6d4',
saved: true,
saveStatus: 'saved' as const,
};
act(() => {
useStore.setState({ masks: [restoredMask], selectedMaskIds: ['annotation-99'] });
useStore.getState().setMasks([]);
useStore.getState().undoMasks();
});
expect(useStore.getState().masks).toEqual([restoredMask]);
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 1 个遮罩,其中后端标注 1 个')).toBeInTheDocument();
});
it('continues clearing a range when one of several annotation deletes returns 404', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.deleteAnnotation
.mockRejectedValueOnce({ status: 404 })
.mockResolvedValueOnce(undefined);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{ id: 'annotation-10149', annotationId: '10149', frameId: '10', pathData: 'M 0 0 Z', label: 'Missing', color: '#06b6d4', saved: true, saveStatus: 'saved' },
{ id: 'annotation-10150', annotationId: '10150', frameId: '11', pathData: 'M 1 1 Z', label: 'Saved', color: '#22c55e', saved: true, saveStatus: 'saved' },
],
});
});
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
fireEvent.click(screen.getByRole('button', { name: '确认清除人工/AI 标注' }));
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10149'));
expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('10150');
expect(useStore.getState().masks).toEqual([]);
expect(screen.getByText('已清空第 1-2 帧的 2 个遮罩,其中后端标注 2 个')).toBeInTheDocument();
});
it('can clear only propagated masks while preserving manual or AI annotated frames', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -515,17 +637,15 @@ describe('VideoWorkspace', () => {
expect(screen.getByRole('button', { name: '保留人工/AI' })).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(confirmSpy).not.toHaveBeenCalled();
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalledWith('98');
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['manual-1']);
expect(useStore.getState().selectedMaskIds).toEqual(['manual-1']);
expect(screen.getByText('已清空第 1-2 帧的 1 个自动传播遮罩,其中后端标注 1 个,人工/AI 标注帧已保留')).toBeInTheDocument();
confirmSpy.mockRestore();
});
it('cancels range clearing when manual or AI annotated frames are not confirmed', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -543,16 +663,16 @@ describe('VideoWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(screen.getByText('清除人工/AI 标注帧')).toBeInTheDocument();
const modal = screen.getByText('清除人工/AI 标注帧').closest('.fixed') as HTMLElement;
fireEvent.click(within(modal).getByRole('button', { name: '取消' }));
expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('是否清除“人工/AI标注帧”'));
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(useStore.getState().masks.map((mask) => mask.id)).toEqual(['annotation-99']);
expect(screen.getByText('已取消清空片段遮罩')).toBeInTheDocument();
confirmSpy.mockRestore();
});
it('does not ask for manual-frame confirmation when clearing propagated-only frames', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame-0.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
@@ -582,9 +702,8 @@ describe('VideoWorkspace', () => {
fireEvent.click(screen.getByRole('button', { name: '清空片段遮罩' }));
fireEvent.click(screen.getByRole('button', { name: '确认清空' }));
expect(confirmSpy).not.toHaveBeenCalled();
expect(screen.queryByText('清除人工/AI 标注帧')).not.toBeInTheDocument();
await waitFor(() => expect(apiMock.deleteAnnotation).toHaveBeenCalledWith('99'));
confirmSpy.mockRestore();
});
it('auto-saves pending masks before exporting segmentation results', async () => {
@@ -832,6 +951,111 @@ describe('VideoWorkspace', () => {
}));
});
it('blocks propagation with a clear message when the current reference frame has no masks', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 0 0 Z',
label: '旧帧遮罩',
color: '#ff0000',
saveStatus: 'dirty',
}],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
expect(await screen.findByText('当前参考帧无遮罩')).toBeInTheDocument();
expect(apiMock.saveAnnotation).not.toHaveBeenCalled();
expect(apiMock.updateAnnotation).not.toHaveBeenCalled();
expect(apiMock.deleteAnnotation).not.toHaveBeenCalled();
expect(apiMock.queuePropagationTask).not.toHaveBeenCalled();
});
it('only saves masks on the current reference frame before propagation', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
{ id: 11, project_id: 1, frame_index: 1, image_url: '/frame-1.jpg', width: 640, height: 360 },
]);
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ id: 8, frame_id: 10 }])
.mockResolvedValue([{ id: 8, frame_id: 10 }]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
}));
apiMock.buildAnnotationPayload.mockReturnValue({
project_id: 1,
frame_id: 10,
mask_data: {
polygons: [[[0.1, 0.1], [0.3, 0.1], [0.3, 0.3]]],
label: '胆囊',
color: '#ff0000',
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.updateAnnotation.mockResolvedValueOnce({ id: 8 });
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
act(() => {
useStore.setState({
masks: [
{
id: 'annotation-8',
annotationId: '8',
frameId: '10',
pathData: 'M 0 0 Z',
label: '胆囊',
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
saveStatus: 'dirty',
},
{
id: 'stale-other-frame',
annotationId: '10369',
frameId: '11',
pathData: 'M 1 1 Z',
label: '旧帧遮罩',
color: '#00ff00',
segmentation: [[10, 10, 20, 10, 20, 20]],
bbox: [10, 10, 10, 10],
saveStatus: 'dirty',
},
],
});
});
fireEvent.click(screen.getByRole('button', { name: '自动传播' }));
fireEvent.click(screen.getByRole('button', { name: '开始传播' }));
await waitFor(() => expect(apiMock.updateAnnotation).toHaveBeenCalledTimes(1));
expect(apiMock.updateAnnotation).toHaveBeenCalledWith('8', expect.any(Object));
expect(apiMock.updateAnnotation).not.toHaveBeenCalledWith('10369', expect.any(Object));
await waitFor(() => expect(apiMock.queuePropagationTask).toHaveBeenCalledTimes(1));
});
it('auto-propagates reference-frame masks through the configured frame range', async () => {
apiMock.getProjectFrames.mockResolvedValueOnce([
{ id: 10, project_id: 1, frame_index: 0, image_url: '/frame.jpg', width: 640, height: 360 },
@@ -852,13 +1076,17 @@ describe('VideoWorkspace', () => {
};
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValue([{ id: 5, frame_id: 10 }]);
.mockResolvedValueOnce([{ id: 5, frame_id: 10 }])
.mockResolvedValue([
{ id: 5, frame_id: 10 },
{ id: 6, frame_id: 11 },
]);
apiMock.buildAnnotationPayload.mockReturnValue(seedPayload);
apiMock.saveAnnotation.mockResolvedValueOnce({ id: 5 });
apiMock.annotationToMask.mockReturnValue({
id: 'annotation-5',
annotationId: '5',
frameId: '10',
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
saved: true,
saveStatus: 'saved',
pathData: 'M 0 0 Z',
@@ -866,7 +1094,10 @@ describe('VideoWorkspace', () => {
color: '#ff0000',
segmentation: [[64, 36, 192, 36, 192, 108]],
bbox: [64, 36, 128, 72],
});
metadata: annotation.frame_id === 11
? { source: 'sam2.1_hiera_tiny_propagation', propagated_from_frame_id: 10, source_annotation_id: 5 }
: undefined,
}));
render(<VideoWorkspace />);
await waitFor(() => expect(useStore.getState().frames).toHaveLength(2));
@@ -1119,6 +1350,24 @@ describe('VideoWorkspace', () => {
},
bbox: [0.1, 0.1, 0.2, 0.2],
});
apiMock.getProjectAnnotations
.mockResolvedValueOnce([])
.mockResolvedValue([
{ id: 101, frame_id: 11 },
{ id: 102, frame_id: 12 },
]);
apiMock.annotationToMask.mockImplementation((annotation) => ({
id: `annotation-${annotation.id}`,
annotationId: String(annotation.id),
frameId: String(annotation.frame_id),
pathData: 'M 0 0 Z',
label: 'Propagated',
color: '#ff0000',
saved: true,
saveStatus: 'saved',
segmentation: [[64, 36, 192, 36, 192, 108]],
metadata: { source: 'sam2_propagation', propagated_from_frame_id: 10 },
}));
apiMock.deleteAnnotation.mockResolvedValue(undefined);
render(<VideoWorkspace />);

View File

@@ -22,7 +22,7 @@ import { ToolsPalette } from './ToolsPalette';
import { OntologyInspector } from './OntologyInspector';
import { FrameTimeline } from './FrameTimeline';
import { ModelStatusBadge } from './ModelStatusBadge';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type TemplateClass } from '../store/useStore';
import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, type Mask, type Template, type TemplateClass } from '../store/useStore';
import { cn } from '../lib/utils';
import { normalizeClassMaskIds } from '../lib/maskIds';
@@ -44,6 +44,14 @@ type PropagationHistorySegment = {
};
type RangeSelectionMode = 'propagation' | 'clear' | 'export' | null;
type ClearRangeMode = 'all' | 'propagated_only';
type ClearRangeConfirmState = {
frameIdsToClear: string[];
annotationIds: string[];
maskCount: number;
rangeStartIndex: number;
rangeEndIndex: number;
mode: ClearRangeMode;
};
type GtUnknownPolicy = 'discard' | 'undefined';
type ExportScope = 'all' | 'range' | 'current';
type ExportPreviewPolygon = {
@@ -66,7 +74,7 @@ type GtMaskPreviewState = {
validationSkipped?: boolean;
};
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传灰度图,或 RGB 三通道完全相同的 maskid 图(背景 0像素值为 maskid。';
const GT_MASK_REQUIREMENT_MESSAGE = 'GT Mask 图片不符合要求:请上传 8-bit 灰度图,或 8-bit RGB 三通道完全相同的 maskid 图(背景 0像素值为 1-255 的 maskid。';
const flatPolygonToSvgPoints = (polygon: number[]) => {
const points: string[] = [];
@@ -115,6 +123,66 @@ const classByMaskId = (classes: TemplateClass[]) => new Map(
normalizeClassMaskIds(classes).map((templateClass) => [Number(templateClass.maskId), templateClass]),
);
const UNCLASSIFIED_MASK_LABEL = '待分类';
const UNCLASSIFIED_MASK_COLOR = '#9ca3af';
const normalizeMaskAgainstTemplates = (mask: Mask, templates: Template[]): Mask => {
const hasClassReference = Boolean(mask.classId || mask.className || mask.classMaskId !== undefined);
if (!hasClassReference || mask.classMaskId === 0) return mask;
const template = mask.templateId
? templates.find((item) => String(item.id) === String(mask.templateId))
: null;
if (!template) return mask;
const classes = normalizeClassMaskIds(template.classes || []);
let matchedClass: TemplateClass | undefined;
if (mask.classId) {
matchedClass = classes.find((templateClass) => templateClass.id === mask.classId);
} else if (mask.classMaskId !== undefined) {
matchedClass = classes.find((templateClass) => Number(templateClass.maskId) === Number(mask.classMaskId));
} else if (mask.className) {
matchedClass = classes.find((templateClass) => (
templateClass.name === mask.className
&& (!mask.color || templateClass.color.toLowerCase() === mask.color.toLowerCase())
)) || classes.find((templateClass) => templateClass.name === mask.className);
}
if (matchedClass) {
return {
...mask,
classId: matchedClass.id,
className: matchedClass.name,
classZIndex: matchedClass.zIndex,
classMaskId: matchedClass.maskId,
label: matchedClass.name,
color: matchedClass.color,
};
}
return {
...mask,
classId: undefined,
className: UNCLASSIFIED_MASK_LABEL,
classZIndex: undefined,
classMaskId: 0,
label: UNCLASSIFIED_MASK_LABEL,
color: UNCLASSIFIED_MASK_COLOR,
saveStatus: mask.annotationId ? 'dirty' : 'draft',
saved: mask.annotationId ? false : mask.saved,
metadata: {
...(mask.metadata || {}),
needs_classification: true,
stale_class: {
id: mask.classId,
name: mask.className || mask.label,
maskId: mask.classMaskId,
color: mask.color,
},
},
};
};
const trimPropagationHistoryByClearedRange = (
segments: PropagationHistorySegment[],
clearStartFrame: number,
@@ -147,6 +215,62 @@ const trimPropagationHistoryByClearedRange = (
});
};
const prunePropagationHistoryByActiveFrames = (
segments: PropagationHistorySegment[],
activeFrameNumbers: Set<number>,
totalFrames: number,
): PropagationHistorySegment[] => (
segments.flatMap((segment) => {
const start = Math.max(1, Math.min(segment.startFrame, segment.endFrame));
const end = Math.min(totalFrames, Math.max(segment.startFrame, segment.endFrame));
const chunks: PropagationHistorySegment[] = [];
let chunkStart: number | null = null;
for (let frameNumber = start; frameNumber <= end; frameNumber += 1) {
if (activeFrameNumbers.has(frameNumber)) {
chunkStart ??= frameNumber;
continue;
}
if (chunkStart !== null) {
const chunkEnd = frameNumber - 1;
chunks.push({
...segment,
id: chunkStart === start && chunkEnd === end ? segment.id : `${segment.id}-${chunkStart}-${chunkEnd}`,
startFrame: chunkStart,
endFrame: chunkEnd,
});
chunkStart = null;
}
}
if (chunkStart !== null) {
chunks.push({
...segment,
id: chunkStart === start ? segment.id : `${segment.id}-${chunkStart}-${end}`,
startFrame: chunkStart,
endFrame: end,
});
}
return chunks;
})
);
const propagationHistoryEqual = (
left: PropagationHistorySegment[],
right: PropagationHistorySegment[],
) => (
left.length === right.length
&& left.every((segment, index) => {
const other = right[index];
return other
&& segment.id === other.id
&& segment.startFrame === other.startFrame
&& segment.endFrame === other.endFrame
&& segment.colorIndex === other.colorIndex
&& segment.label === other.label;
})
);
const isPropagatedMask = (mask: Mask) => {
const source = typeof mask.metadata?.source === 'string' ? mask.metadata.source : '';
return source.includes('_propagation')
@@ -156,6 +280,43 @@ const isPropagatedMask = (mask: Mask) => {
|| mask.metadata?.propagation_seed_key !== undefined;
};
const persistentMaskMetadata = (metadata?: Record<string, unknown>) => {
if (!metadata) return {};
const {
geometry_smoothing: _geometrySmoothing,
geometry_smoothing_preview: _geometrySmoothingPreview,
...rest
} = metadata;
return rest;
};
const isNotFoundError = (error: unknown) => (
typeof error === 'object'
&& error !== null
&& (
('response' in error
&& typeof (error as { response?: { status?: unknown } }).response === 'object'
&& (error as { response?: { status?: unknown } }).response?.status === 404)
|| ('status' in error && (error as { status?: unknown }).status === 404)
)
);
const deleteAnnotationIfExists = async (annotationId: string) => {
try {
await deleteAnnotation(annotationId);
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
};
const deleteAnnotationsIfExist = async (annotationIds: string[]) => {
const results = await Promise.allSettled(annotationIds.map((annotationId) => deleteAnnotationIfExists(annotationId)));
const firstFailure = results.find((result): result is PromiseRejectedResult => (
result.status === 'rejected' && !isNotFoundError(result.reason)
));
if (firstFailure) throw firstFailure.reason;
};
const PROPAGATION_POLL_INTERVAL_MS = 250;
const STATUS_MESSAGE_TTL_MS = 3600;
@@ -272,6 +433,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const [isPropagationRangeSelecting, setIsPropagationRangeSelecting] = useState(false);
const [rangeSelectionMode, setRangeSelectionMode] = useState<RangeSelectionMode>(null);
const [clearRangeMode, setClearRangeMode] = useState<ClearRangeMode>('all');
const [pendingClearRangeConfirm, setPendingClearRangeConfirm] = useState<ClearRangeConfirmState | null>(null);
const [hasExplicitPropagationRange, setHasExplicitPropagationRange] = useState(false);
const [propagationProgress, setPropagationProgress] = useState<PropagationProgress>(null);
const [propagationTaskId, setPropagationTaskId] = useState<number | null>(null);
@@ -317,6 +479,9 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => window.removeEventListener('keydown', handleWorkspaceShortcuts);
}, [redoMasks, undoMasks]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
const hydrateSavedAnnotations = useCallback(async (
projectId: string,
projectFrames: Frame[],
@@ -326,11 +491,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frameById = new Map(projectFrames.map((frame) => [frame.id, frame]));
const projectFrameIds = new Set(projectFrames.map((frame) => frame.id));
const excludedDraftIds = new Set(excludeUnsavedMaskIds);
let latestTemplates = useStore.getState().templates;
if (latestTemplates.length === 0) {
latestTemplates = await getTemplates();
setTemplates(latestTemplates);
}
const annotations = await getProjectAnnotations(projectId);
const savedMasks = annotations
.map((annotation) => {
const frame = annotation.frame_id ? frameById.get(String(annotation.frame_id)) : null;
return frame ? annotationToMask(annotation, frame) : null;
const mask = frame ? annotationToMask(annotation, frame) : null;
return mask ? normalizeMaskAgainstTemplates(mask, latestTemplates) : null;
})
.filter((mask): mask is NonNullable<typeof mask> => Boolean(mask));
const currentMasks = useStore.getState().masks;
@@ -346,7 +517,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setSelectedMaskIds(nextSelectedIds);
}
}
}, [setMasks, setSelectedMaskIds]);
}, [setMasks, setSelectedMaskIds, setTemplates]);
useEffect(() => {
if (!currentProject?.id) return;
@@ -408,9 +579,6 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => { cancelled = true; };
}, [currentProject?.id, currentProject?.video_path, hydrateSavedAnnotations, setFrames, setCurrentFrame]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
useEffect(() => {
if (templates.length === 0) {
getTemplates().then((data) => setTemplates(data)).catch(console.error);
@@ -451,6 +619,25 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
})));
}, [exportPreviewFrame, masks]);
useEffect(() => {
if (propagationHistory.length === 0 || frames.length === 0) return;
const frameNumberById = new Map(frames.map((frame, index) => [String(frame.id), index + 1]));
const activePropagatedFrameNumbers = new Set<number>();
masks.forEach((mask) => {
if (!isPropagatedMask(mask)) return;
const frameNumber = frameNumberById.get(String(mask.frameId));
if (frameNumber) activePropagatedFrameNumbers.add(frameNumber);
});
const nextHistory = prunePropagationHistoryByActiveFrames(
propagationHistory,
activePropagatedFrameNumbers,
totalFrames,
);
if (!propagationHistoryEqual(propagationHistory, nextHistory)) {
setPropagationHistory(nextHistory);
}
}, [frames, masks, propagationHistory, totalFrames]);
useEffect(() => {
if (!statusMessage || isWorkspaceBusy || totalFrames === 0) return undefined;
const timer = window.setTimeout(() => setStatusMessage(''), STATUS_MESSAGE_TTL_MS);
@@ -474,9 +661,13 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setHasExplicitPropagationRange(false);
}, [currentFrameNumber, totalFrames]);
const savePendingAnnotations = useCallback(async ({ silent = false } = {}) => {
const savePendingAnnotations = useCallback(async ({ silent = false, frameId }: { silent?: boolean; frameId?: string } = {}) => {
if (!currentProject?.id) return 0;
const projectMasks = masks.filter((mask) => projectFrameIds.has(mask.frameId));
const latestMasks = useStore.getState().masks;
const projectMasks = latestMasks.filter((mask) => (
projectFrameIds.has(mask.frameId)
&& (!frameId || String(mask.frameId) === String(frameId))
));
const pendingMasks = projectMasks.filter((mask) => !mask.annotationId);
const dirtyMasks = projectMasks.filter((mask) => mask.annotationId && mask.saveStatus === 'dirty');
if (pendingMasks.length === 0 && dirtyMasks.length === 0) {
@@ -500,13 +691,10 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const frame = frameById.get(mask.frameId);
const payload = frame ? buildAnnotationPayload(currentProject.id, mask, frame, activeTemplateId) : null;
if (!payload || !mask.annotationId) return null;
const propagationLineage = {
...(mask.metadata?.source_annotation_id !== undefined ? { source_annotation_id: mask.metadata.source_annotation_id } : {}),
...(mask.metadata?.source_mask_id !== undefined ? { source_mask_id: mask.metadata.source_mask_id } : {}),
};
const savedMetadata = persistentMaskMetadata(mask.metadata);
const updatePayload = {
template_id: payload.template_id,
mask_data: { ...payload.mask_data, ...propagationLineage },
mask_data: { ...savedMetadata, ...payload.mask_data },
points: payload.points,
bbox: payload.bbox,
};
@@ -539,7 +727,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
} finally {
setIsSaving(false);
}
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, masks, projectFrameIds]);
}, [activeTemplateId, currentProject?.id, frameById, frames, hydrateSavedAnnotations, projectFrameIds]);
const handleClearCurrentFrameMasks = useCallback(async () => {
if (!currentFrame) return;
@@ -551,7 +739,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
setIsSaving(true);
setStatusMessage(annotationIds.length > 0 ? '正在删除已保存标注...' : '正在清空本帧遮罩...');
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
await deleteAnnotationsIfExist(annotationIds);
setMasks(masks.filter((mask) => mask.frameId !== currentFrame.id));
setStatusMessage(annotationIds.length > 0
? `已删除 ${annotationIds.length} 个后端标注`
@@ -564,6 +752,39 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
}
}, [currentFrame, masks, setMasks]);
const executeClearFrameRange = useCallback(async (request: ClearRangeConfirmState) => {
const frameIdsToClear = new Set(request.frameIdsToClear);
setIsSaving(true);
setStatusMessage(request.annotationIds.length > 0
? `正在删除第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的已保存标注...`
: `正在清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的本地遮罩...`);
try {
await deleteAnnotationsIfExist(request.annotationIds);
const latestMasks = useStore.getState().masks;
const clearedMaskIds = new Set(
latestMasks
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
.filter((mask) => request.mode === 'all' || isPropagatedMask(mask))
.map((mask) => mask.id),
);
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, request.rangeStartIndex + 1, request.rangeEndIndex + 1));
setStatusMessage(request.mode === 'propagated_only'
? `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个自动传播遮罩,其中后端标注 ${request.annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${request.rangeStartIndex + 1}-${request.rangeEndIndex + 1} 帧的 ${request.maskCount} 个遮罩,其中后端标注 ${request.annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
setPendingClearRangeConfirm(null);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
}
}, [setMasks, setSelectedMaskIds]);
const handleClearFrameRangeMasks = useCallback(async () => {
if (rangeSelectionMode !== 'clear') {
setIsPropagationRangeSelecting(true);
@@ -595,57 +816,34 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return;
}
const hasManualOrAiAnnotatedFrames = clearRangeMode === 'all' && rangeMasks.some((mask) => !isPropagatedMask(mask));
if (hasManualOrAiAnnotatedFrames) {
const confirmed = window.confirm('是否清除“人工/AI标注帧”\n该范围包含人工绘制或 AI 智能分割生成的 mask确认后这些 mask 也会被删除。');
if (!confirmed) {
setStatusMessage('已取消清空片段遮罩');
return;
}
}
const annotationIds = Array.from(new Set(
rangeMasks
.map((mask) => mask.annotationId)
.filter((annotationId): annotationId is string => Boolean(annotationId)),
));
setIsSaving(true);
setStatusMessage(annotationIds.length > 0
? `正在删除第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的已保存标注...`
: `正在清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的本地遮罩...`);
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
const latestMasks = useStore.getState().masks;
const clearedMaskIds = new Set(
latestMasks
.filter((mask) => frameIdsToClear.has(String(mask.frameId)))
.filter((mask) => clearRangeMode === 'all' || isPropagatedMask(mask))
.map((mask) => mask.id),
);
setMasks(latestMasks.filter((mask) => !clearedMaskIds.has(mask.id)));
setSelectedMaskIds(useStore.getState().selectedMaskIds.filter((id) => !clearedMaskIds.has(id)));
setPropagationHistory((previous) => trimPropagationHistoryByClearedRange(previous, rangeStartIndex + 1, rangeEndIndex + 1));
setStatusMessage(clearRangeMode === 'propagated_only'
? `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个自动传播遮罩,其中后端标注 ${annotationIds.length} 个,人工/AI 标注帧已保留`
: `已清空第 ${rangeStartIndex + 1}-${rangeEndIndex + 1} 帧的 ${rangeMasks.length} 个遮罩,其中后端标注 ${annotationIds.length}`);
setIsPropagationRangeSelecting(false);
setRangeSelectionMode(null);
setHasExplicitPropagationRange(false);
} catch (err) {
console.error('Delete range annotations failed:', err);
setStatusMessage('批量清空失败,请检查后端服务');
} finally {
setIsSaving(false);
const request = {
frameIdsToClear: Array.from(frameIdsToClear),
annotationIds,
maskCount: rangeMasks.length,
rangeStartIndex,
rangeEndIndex,
mode: clearRangeMode,
};
if (hasManualOrAiAnnotatedFrames) {
setPendingClearRangeConfirm(request);
return;
}
}, [clearRangeMode, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, setMasks, setSelectedMaskIds, totalFrames]);
await executeClearFrameRange(request);
}, [clearRangeMode, executeClearFrameRange, frames, masks, propagationEndFrame, propagationStartFrame, rangeSelectionMode, totalFrames]);
const handleDeleteMaskAnnotations = useCallback(async (annotationIds: string[]) => {
if (annotationIds.length === 0) return;
try {
await Promise.all(annotationIds.map((annotationId) => deleteAnnotation(annotationId)));
setStatusMessage(`已删除 ${annotationIds.length}被合并标注`);
await deleteAnnotationsIfExist(annotationIds);
setStatusMessage(`已删除 ${annotationIds.length} 个标注`);
} catch (err) {
console.error('Delete merged annotations failed:', err);
setStatusMessage('合并后删除标注失败,请检查后端服务');
console.error('Delete annotations failed:', err);
setStatusMessage('删除标注失败,请检查后端服务');
throw err;
}
}, []);
@@ -990,21 +1188,21 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
const runAutoPropagate = async () => {
if (!currentProject?.id || !currentFrame?.id) return;
const initialSeedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
const initialSeedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (initialSeedMasks.length === 0) {
setStatusMessage('请先在当前参考帧创建或保存至少一个 mask');
setStatusMessage('当前参考帧无遮罩');
return;
}
const hasUnstableSeedMasks = initialSeedMasks.some((mask) => !mask.annotationId || mask.saveStatus === 'dirty');
if (hasUnstableSeedMasks) {
setStatusMessage('正在先保存参考帧 mask确保二次传播可以替换旧结果...');
await savePendingAnnotations({ silent: true });
await savePendingAnnotations({ silent: true, frameId: currentFrame.id });
}
const seedMasks = useStore.getState().masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (seedMasks.length === 0) {
setStatusMessage('参考帧 mask 保存后未能回显,请先检查归档保存是否成功');
setStatusMessage('当前参考帧无遮罩');
return;
}
@@ -1285,7 +1483,7 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<div className="w-full max-w-xl rounded-md border border-white/10 bg-[#151515] p-4 shadow-2xl shadow-black/60">
<div className="text-sm font-semibold text-white"> GT Mask</div>
<div className="mt-2 text-xs leading-5 text-gray-400">
GT maskid RGB [X,X,X] maskid 0 X maskid
GT 8-bit maskid 8-bit RGB [X,X,X] maskid 0 X 1-255 maskid
</div>
<div className="mt-3 rounded border border-white/10 bg-black/20 px-3 py-2 text-[11px] text-gray-500">
{pendingGtImportFile.name}
@@ -1663,6 +1861,42 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
<OntologyInspector />
</div>
{pendingClearRangeConfirm && (
<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-red-400/25 bg-[#151515] p-5 shadow-2xl">
<h2 className="text-lg font-semibold text-white">/AI </h2>
<p className="mt-2 text-sm leading-relaxed text-gray-300">
{pendingClearRangeConfirm.rangeStartIndex + 1}-{pendingClearRangeConfirm.rangeEndIndex + 1} AI mask
{pendingClearRangeConfirm.maskCount}
</p>
<p className="mt-2 text-xs leading-relaxed text-red-200/70">
/AI
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPendingClearRangeConfirm(null);
setStatusMessage('已取消清空片段遮罩');
}}
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 executeClearFrameRange(pendingClearRangeConfirm)}
disabled={isSaving}
className="rounded bg-red-500 px-3 py-2 text-xs font-semibold text-white hover:bg-red-400 disabled:cursor-wait disabled:opacity-50"
>
/AI
</button>
</div>
</div>
</div>
)}
{/* Bottom Timeline */}
<FrameTimeline
propagationRange={visibleTimelineRange}

View File

@@ -75,6 +75,19 @@ describe('api client contracts', () => {
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/projects/3', { name: 'Renamed' });
});
it('copies projects through the copy endpoint', async () => {
const { copyProject } = await import('./api');
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 4, name: 'Copied', status: 'ready', frame_count: 3 } });
await expect(copyProject('3', { mode: 'full' })).resolves.toEqual(expect.objectContaining({
id: '4',
name: 'Copied',
frames: 3,
}));
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/projects/3/copy', { mode: 'full' });
});
it('deletes projects through DELETE', async () => {
const { deleteProject } = await import('./api');
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
@@ -84,6 +97,47 @@ describe('api client contracts', () => {
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/projects/3');
});
it('reports upload progress for video media imports', async () => {
const { uploadMedia } = await import('./api');
const onProgress = vi.fn();
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
axiosMock.client.post.mockResolvedValueOnce({ data: { file_url: 'http://file', object_name: 'uploads/clip.mp4' } });
await expect(uploadMedia(file, '9', { onProgress })).resolves.toEqual({
url: 'http://file',
id: 'uploads/clip.mp4',
});
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload', expect.any(FormData), expect.objectContaining({
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: expect.any(Function),
}));
config.onUploadProgress({ loaded: 25, total: 100 });
expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 });
});
it('reports upload progress for DICOM batch imports', async () => {
const { uploadDicomBatch } = await import('./api');
const onProgress = vi.fn();
const file = new File(['dcm'], '1.dcm', { type: 'application/dicom' });
axiosMock.client.post.mockResolvedValueOnce({ data: { project_id: 10, uploaded_count: 1, message: 'ok' } });
await expect(uploadDicomBatch([file], undefined, { onProgress })).resolves.toEqual({
project_id: 10,
uploaded_count: 1,
message: 'ok',
});
const [, , config] = axiosMock.client.post.mock.calls.at(-1);
expect(axiosMock.client.post).toHaveBeenCalledWith('/api/media/upload/dicom', expect.any(FormData), expect.objectContaining({
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: expect.any(Function),
}));
config.onUploadProgress({ loaded: 10, total: 20 });
expect(onProgress).toHaveBeenCalledWith({ loaded: 10, total: 20, percent: 50 });
});
it('normalizes missing template class maskids without using priority as the public id', async () => {
const { getTemplates } = await import('./api');
axiosMock.client.get.mockResolvedValueOnce({
@@ -107,6 +161,7 @@ describe('api client contracts', () => {
expect.objectContaining({ id: 'c1', maskId: 1, zIndex: 100 }),
expect.objectContaining({ id: 'c2', maskId: 7, zIndex: 10 }),
expect.objectContaining({ id: 'c3', maskId: 2, zIndex: 50 }),
expect.objectContaining({ id: 'reserved-unclassified', name: '待分类', maskId: 0, color: '#000000', zIndex: 0 }),
],
}),
]);
@@ -148,14 +203,22 @@ describe('api client contracts', () => {
axiosMock.client.post.mockResolvedValueOnce({
data: {
admin_user: { id: 1, username: 'admin', role: 'admin', is_active: 1 },
project: { id: 8, name: 'Data_MyVideo_1', status: 'pending', frame_count: 0, video_path: 'uploads/8/Data_MyVideo_1.mp4' },
project: { id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
projects: [
{ id: 7, name: 'Data_MyVideo_1', status: 'pending', source_type: 'video', frame_count: 0, video_path: 'uploads/7/Data_MyVideo_1.mp4' },
{ id: 8, name: '演示DICOM序列', status: 'ready', source_type: 'dicom', frame_count: 300, video_path: 'uploads/8/dicom' },
],
deleted_counts: { users: 1 },
message: '演示环境已恢复出厂设置',
},
});
await expect(resetDemoFactory('RESET_DEMO_FACTORY')).resolves.toEqual(expect.objectContaining({
admin_user: expect.objectContaining({ username: 'admin' }),
project: expect.objectContaining({ id: '8', name: 'Data_MyVideo_1', frames: 0 }),
project: expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
projects: [
expect.objectContaining({ id: '7', name: 'Data_MyVideo_1', frames: 0, source_type: 'video' }),
expect.objectContaining({ id: '8', name: '演示DICOM序列', frames: 300, source_type: 'dicom' }),
],
}));
expect(axiosMock.client.post).toHaveBeenLastCalledWith('/api/admin/demo-factory-reset', {
confirmation: 'RESET_DEMO_FACTORY',
@@ -338,6 +401,10 @@ describe('api client contracts', () => {
await expect(deleteAnnotation('1')).resolves.toBeUndefined();
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/1');
axiosMock.client.delete.mockRejectedValueOnce({ response: { status: 404 } });
await expect(deleteAnnotation('missing')).resolves.toBeUndefined();
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/ai/annotations/missing');
axiosMock.client.post.mockResolvedValueOnce({
data: {
model: 'sam2.1_hiera_tiny',
@@ -468,6 +535,7 @@ describe('api client contracts', () => {
geometry_smoothing: { strength: 35, method: 'chaikin' },
},
bbox: [0.1, 0.2, 0.8, 0.6],
points: [[0.6333333333333333, 0.4]],
});
expect(annotationToMask({

View File

@@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios';
import axios, { AxiosError, type AxiosProgressEvent } from 'axios';
import { DEFAULT_AI_MODEL_ID, type AiModelId, type Frame, type Mask, type Project, type Template, type UserProfile } from '../store/useStore';
import { API_BASE_URL } from './config';
import { normalizeClassMaskIds } from './maskIds';
@@ -65,6 +65,7 @@ export interface AuditLog {
export interface DemoFactoryResetResult {
admin_user: AdminUser;
project: Project;
projects?: Project[];
deleted_counts: Record<string, number>;
message: string;
}
@@ -108,6 +109,7 @@ export async function resetDemoFactory(confirmation: string): Promise<DemoFactor
return {
...response.data,
project: mapProject(response.data.project),
projects: Array.isArray(response.data.projects) ? response.data.projects.map(mapProject) : undefined,
};
}
@@ -163,6 +165,14 @@ export async function updateProject(id: string, payload: Partial<Project>): Prom
return mapProject(response.data);
}
export async function copyProject(
id: string,
payload: { mode: 'reset' | 'full'; name?: string },
): Promise<Project> {
const response = await apiClient.post(`/api/projects/${id}/copy`, payload);
return mapProject(response.data);
}
export async function deleteProject(id: string): Promise<void> {
await apiClient.delete(`/api/projects/${id}`);
}
@@ -174,6 +184,8 @@ function _mapTemplate(t: any): Template {
id: String(t.id),
name: t.name,
description: t.description,
color: t.color,
z_index: t.z_index,
classes: normalizeClassMaskIds(mapping.classes || []),
rules: mapping.rules || [],
createdAt: t.created_at,
@@ -191,7 +203,7 @@ export async function createTemplate(payload: {
description?: string;
color: string;
z_index: number;
classes?: { name: string; color: string; zIndex: number; maskId?: number; category?: string }[];
classes?: Template['classes'];
rules?: any[];
}): Promise<Template> {
const response = await apiClient.post('/api/templates', payload);
@@ -208,7 +220,26 @@ export async function deleteTemplate(id: string): Promise<void> {
}
// Media
export async function uploadMedia(file: File, projectId?: string): Promise<{ url: string; id: string }> {
export interface UploadProgress {
loaded: number;
total?: number;
percent?: number;
}
export interface UploadOptions {
onProgress?: (progress: UploadProgress) => void;
}
const toUploadProgress = (event: AxiosProgressEvent): UploadProgress => {
const total = typeof event.total === 'number' && event.total > 0 ? event.total : undefined;
return {
loaded: event.loaded,
total,
percent: total ? Math.min(100, Math.max(0, Math.round((event.loaded / total) * 100))) : undefined,
};
};
export async function uploadMedia(file: File, projectId?: string, options: UploadOptions = {}): Promise<{ url: string; id: string }> {
const formData = new FormData();
formData.append('file', file);
if (projectId) {
@@ -218,6 +249,7 @@ export async function uploadMedia(file: File, projectId?: string): Promise<{ url
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
});
const { file_url, object_name } = response.data;
return { url: file_url, id: object_name };
@@ -237,12 +269,13 @@ export async function getProjectFrames(projectId: string): Promise<Array<{
return response.data;
}
export async function uploadDicomBatch(files: File[], projectId?: string): Promise<{ project_id: number; uploaded_count: number; message: string }> {
export async function uploadDicomBatch(files: File[], projectId?: string, options: UploadOptions = {}): Promise<{ project_id: number; uploaded_count: number; message: string }> {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
if (projectId) formData.append('project_id', projectId);
const response = await apiClient.post('/api/media/upload/dicom', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: options.onProgress ? (event) => options.onProgress?.(toUploadProgress(event)) : undefined,
});
return response.data;
}
@@ -567,6 +600,44 @@ function polygonAreaPixels(points: number[][], width: number, height: number): n
return Math.abs(total) / 2;
}
function polygonRepresentativePointPixels(polygon: number[] | undefined): [number, number] | null {
if (!polygon || polygon.length < 6) return null;
const points: number[][] = [];
for (let index = 0; index < polygon.length - 1; index += 2) {
points.push([polygon[index], polygon[index + 1]]);
}
if (points.length < 3) return null;
let twiceArea = 0;
let centroidX = 0;
let centroidY = 0;
points.forEach(([x, y], index) => {
const [nextX, nextY] = points[(index + 1) % points.length];
const cross = x * nextY - nextX * y;
twiceArea += cross;
centroidX += (x + nextX) * cross;
centroidY += (y + nextY) * cross;
});
if (Math.abs(twiceArea) > 1e-6) {
return [centroidX / (3 * twiceArea), centroidY / (3 * twiceArea)];
}
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
return [
(Math.min(...xs) + Math.max(...xs)) / 2,
(Math.min(...ys) + Math.max(...ys)) / 2,
];
}
function maskSeedPointsPixels(mask: Mask): number[][] {
if (mask.points && mask.points.length > 0) return mask.points;
return (mask.segmentation || [])
.map(polygonRepresentativePointPixels)
.filter((point): point is [number, number] => Boolean(point));
}
function normalizeGeometrySmoothing(value: unknown): GeometrySmoothingOptions | undefined {
if (!value || typeof value !== 'object') return undefined;
const source = value as Record<string, unknown>;
@@ -651,8 +722,9 @@ export function buildAnnotationPayload(
: undefined,
};
if (mask.points) {
payload.points = mask.points.map(([x, y]) => [
const seedPoints = maskSeedPointsPixels(mask);
if (seedPoints.length > 0) {
payload.points = seedPoints.map(([x, y]) => [
clamp01(x / Math.max(frame.width, 1)),
clamp01(y / Math.max(frame.height, 1)),
]);
@@ -864,7 +936,12 @@ export async function updateAnnotation(annotationId: string, payload: UpdateAnno
}
export async function deleteAnnotation(annotationId: string): Promise<void> {
await apiClient.delete(`/api/ai/annotations/${annotationId}`);
try {
await apiClient.delete(`/api/ai/annotations/${annotationId}`);
} catch (error) {
if ((error as AxiosError).response?.status === 404) return;
throw error;
}
}
export async function importGtMask(

View File

@@ -1,8 +1,35 @@
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;
@@ -12,7 +39,15 @@ export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateCl
return value;
};
return classes.map((templateClass) => {
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);
@@ -20,6 +55,7 @@ export function normalizeClassMaskIds(classes: TemplateClass[] = []): TemplateCl
}
return { ...templateClass, maskId: nextAvailableMaskId() };
});
return [...normalized, reservedClass || reservedUnclassifiedClass()];
}
export function nextClassMaskId(classes: TemplateClass[] = []): number {

View File

@@ -84,6 +84,8 @@ export interface Template {
id: string;
name: string;
description?: string;
color?: string;
z_index?: number;
classes: TemplateClass[];
rules?: TemplateRule[];
createdAt?: string;