完善项目导入、模板与分割工作区交互
- 增强 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:
@@ -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', () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
z_index?: number;
|
||||
classes: TemplateClass[];
|
||||
rules?: TemplateRule[];
|
||||
createdAt?: string;
|
||||
|
||||
Reference in New Issue
Block a user