2026-04-29-21-51-19 - 全栈系统改造:FastAPI后端+SAM2+PostgreSQL+Redis+MinIO+前端Zustand重构
This commit is contained in:
26
src/App.tsx
26
src/App.tsx
@@ -1,4 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { useStore } from './store/useStore';
|
||||
import { getProjects } from './lib/api';
|
||||
import { Sidebar } from './components/Sidebar';
|
||||
import { Dashboard } from './components/Dashboard';
|
||||
import { ProjectLibrary } from './components/ProjectLibrary';
|
||||
@@ -10,16 +12,30 @@ import { Login } from './components/Login';
|
||||
export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates';
|
||||
|
||||
export default function App() {
|
||||
const [activeModule, setActiveModule] = useState<ActiveModule>('workspace');
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isAuthenticated = useStore((state) => state.isAuthenticated);
|
||||
const activeModule = useStore((state) => state.activeModule);
|
||||
const setActiveModule = useStore((state) => state.setActiveModule);
|
||||
const setProjects = useStore((state) => state.setProjects);
|
||||
const setError = useStore((state) => state.setError);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
getProjects()
|
||||
.then((data) => setProjects(data))
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch projects:', err);
|
||||
setError('获取项目列表失败');
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, setProjects, setError]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Login onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-[#0a0a0a] text-gray-200 overflow-hidden font-sans">
|
||||
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} />
|
||||
<Sidebar activeModule={activeModule as ActiveModule} setActiveModule={setActiveModule} />
|
||||
<main className="flex-1 flex flex-col min-w-0 h-full relative">
|
||||
{activeModule === 'dashboard' && <Dashboard />}
|
||||
{activeModule === 'projects' && <ProjectLibrary onProjectSelect={() => setActiveModule('workspace')} />}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, Settings2, Cpu, Image as ImageIcon, SendToBack, Tags, Undo, Redo } from 'lucide-react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Target, PlusCircle, MinusCircle, SquareDashed, Sparkles, SendToBack, Image as ImageIcon, Undo, Redo, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Path, Group } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { predictMask } from '../lib/api';
|
||||
|
||||
interface AISegmentationProps {
|
||||
onSendToWorkspace: () => void;
|
||||
}
|
||||
|
||||
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const [activeTool, setActiveTool] = useState('point_pos');
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
const clearMasks = useStore((state) => state.clearMasks);
|
||||
|
||||
const [modelSize, setModelSize] = useState('vit_l');
|
||||
const [semanticText, setSemanticText] = useState('');
|
||||
const [autoDeleteBg, setAutoDeleteBg] = useState(true);
|
||||
const [cropMode, setCropMode] = useState(false);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
|
||||
// Canvas state
|
||||
const [scale, setScale] = useState(1);
|
||||
@@ -23,6 +31,8 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
const [image] = useImage('https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop');
|
||||
|
||||
const effectiveTool = storeActiveTool;
|
||||
|
||||
const handleWheel = (e: any) => {
|
||||
e.evt.preventDefault();
|
||||
const scaleBy = 1.1;
|
||||
@@ -51,13 +61,43 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const runInference = useCallback(async () => {
|
||||
if (points.length === 0 && !semanticText.trim()) return;
|
||||
setIsInferencing(true);
|
||||
try {
|
||||
const result = await predictMask({
|
||||
imageUrl: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop',
|
||||
points: points.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
||||
text: semanticText.trim() || undefined,
|
||||
modelSize,
|
||||
});
|
||||
|
||||
result.masks.forEach((m) => {
|
||||
addMask({
|
||||
id: m.id,
|
||||
frameId: 'frame-ai-1',
|
||||
pathData: m.pathData,
|
||||
label: m.label,
|
||||
color: m.color,
|
||||
segmentation: m.segmentation,
|
||||
bbox: m.bbox,
|
||||
area: m.area,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('AI inference failed:', err);
|
||||
} finally {
|
||||
setIsInferencing(false);
|
||||
}
|
||||
}, [points, semanticText, modelSize, addMask]);
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (activeTool === 'move') return;
|
||||
if (activeTool === 'point_pos' || activeTool === 'point_neg') {
|
||||
if (effectiveTool === 'move') return;
|
||||
if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
if (pos) {
|
||||
setPoints([...points, { x: pos.x, y: pos.y, type: activeTool === 'point_pos' ? 'pos' : 'neg' }]);
|
||||
setPoints([...points, { x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' }]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -68,7 +108,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<aside className="w-80 bg-[#0d0d0d] flex flex-col border-r border-white/5 shrink-0 z-10 overflow-hidden">
|
||||
<div className="h-16 border-b border-white/5 flex items-center px-6 shrink-0 justify-between">
|
||||
<div className="flex items-center font-medium text-[11px] uppercase tracking-widest text-cyan-400">
|
||||
<Cpu size={16} className="mr-3 text-cyan-400" />
|
||||
<Sparkles size={16} className="mr-3 text-cyan-400" />
|
||||
AI智能分割引擎
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +136,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={() => setActiveTool('point_pos')}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", activeTool === 'point_pos' ? "bg-green-500/10 border-green-500/30 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'point_pos' ? "bg-green-500/10 border-green-500/30 text-green-400 shadow-[0_0_15px_rgba(34,197,94,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
>
|
||||
<PlusCircle size={22} className="mb-3" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold">正向选点</span>
|
||||
@@ -104,15 +144,15 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTool('point_neg')}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", activeTool === 'point_neg' ? "bg-red-500/10 border-red-500/30 text-red-500 shadow-[0_0_15px_rgba(239,68,68,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'point_neg' ? "bg-red-500/10 border-red-500/30 text-red-500 shadow-[0_0_15px_rgba(239,68,68,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
>
|
||||
<MinusCircle size={22} className="mb-3" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold">反向选点</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTool('box')}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", activeTool === 'box' ? "bg-blue-500/10 border-blue-500/30 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
onClick={() => setActiveTool('box_select')}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'box_select' ? "bg-blue-500/10 border-blue-500/30 text-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.1)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
>
|
||||
<SquareDashed size={22} className="mb-3" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold">边界框选</span>
|
||||
@@ -120,7 +160,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTool('move')}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", activeTool === 'move' ? "bg-white/10 border-white/20 text-white shadow-[0_0_15px_rgba(255,255,255,0.05)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
className={cn("flex flex-col items-center justify-center p-4 rounded-lg border transition-all", effectiveTool === 'move' ? "bg-white/10 border-white/20 text-white shadow-[0_0_15px_rgba(255,255,255,0.05)]" : "bg-[#111] border-white/5 text-gray-400 hover:bg-white/5 hover:text-gray-200")}
|
||||
>
|
||||
<Target size={22} className="mb-3" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-semibold">视口控制</span>
|
||||
@@ -165,9 +205,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
|
||||
<div className="p-6 bg-[#0a0a0a] border-t border-white/5 shrink-0 flex flex-col gap-3">
|
||||
<button
|
||||
className="w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all shadow-lg font-medium tracking-wide text-xs uppercase bg-cyan-500 hover:bg-cyan-400 text-black shadow-cyan-500/20 hover:shadow-cyan-500/40"
|
||||
onClick={runInference}
|
||||
disabled={isInferencing}
|
||||
className={cn(
|
||||
"w-full py-3.5 rounded-lg flex items-center justify-center gap-2 transition-all shadow-lg font-medium tracking-wide text-xs uppercase",
|
||||
isInferencing
|
||||
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
|
||||
: "bg-cyan-500 hover:bg-cyan-400 text-black shadow-cyan-500/20 hover:shadow-cyan-500/40"
|
||||
)}
|
||||
>
|
||||
<Sparkles size={16} /> 执行高精度语义分割
|
||||
{isInferencing ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
{isInferencing ? '推理中...' : '执行高精度语义分割'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSendToWorkspace}
|
||||
@@ -196,7 +244,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<button className="flex items-center gap-2 text-xs text-gray-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5">
|
||||
<ImageIcon size={14} /> 上传替换底图
|
||||
</button>
|
||||
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={() => setPoints([])}>
|
||||
<button className="text-xs text-gray-400 hover:text-white transition-colors px-3 py-1.5" onClick={() => { setPoints([]); clearMasks(); }}>
|
||||
清空全体锚点
|
||||
</button>
|
||||
</div>
|
||||
@@ -205,7 +253,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<div className="flex-1 relative p-8">
|
||||
<div className="w-full h-full relative border border-white/5 rounded shadow-2xl bg-[#1e1e1e] overflow-hidden cursor-crosshair">
|
||||
<Stage
|
||||
width={window.innerWidth - 320 - 64} // approx sizing, uses window to avoid ResizeObserver for simplicity here
|
||||
width={window.innerWidth - 320 - 64}
|
||||
height={window.innerHeight - 64 - 64}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -214,7 +262,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
scaleY={scale}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
draggable={activeTool === 'move'}
|
||||
draggable={effectiveTool === 'move'}
|
||||
>
|
||||
<Layer>
|
||||
{/* Background Image */}
|
||||
@@ -227,13 +275,17 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mock Instance Mask from SAM3 */}
|
||||
<Group opacity={0.4}>
|
||||
<Path
|
||||
data="M 300 200 Q 400 150 450 250 T 400 350 Q 250 350 280 250 Z"
|
||||
fill="#06b6d4" // cyan-500
|
||||
/>
|
||||
</Group>
|
||||
{/* AI Returned Masks */}
|
||||
{masks.map((mask) => (
|
||||
<Group key={mask.id} opacity={0.45}>
|
||||
<Path
|
||||
data={mask.pathData}
|
||||
fill={mask.color}
|
||||
stroke={mask.color}
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* Points */}
|
||||
{points.map((p, i) => (
|
||||
@@ -257,6 +309,7 @@ export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
|
||||
<div className="absolute bottom-4 left-4 flex gap-4 text-[10px] font-mono text-gray-500 pointer-events-none">
|
||||
<span>光标坐标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
|
||||
<span>缩放比率: {(scale * 100).toFixed(0)}%</span>
|
||||
<span>遮罩数: {masks.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Stage, Layer, Image as KonvaImage, Circle, Rect, Path, Group } from 'react-konva';
|
||||
import useImage from 'use-image';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { predictMask } from '../lib/api';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface CanvasAreaProps {
|
||||
activeTool: string;
|
||||
@@ -13,6 +16,17 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [points, setPoints] = useState<{ x: number, y: number, type: 'pos'|'neg' }[]>([]);
|
||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
||||
const [boxStart, setBoxStart] = useState<{ x: number, y: number } | null>(null);
|
||||
const [boxCurrent, setBoxCurrent] = useState<{ x: number, y: number } | null>(null);
|
||||
const [isInferencing, setIsInferencing] = useState(false);
|
||||
|
||||
const masks = useStore((state) => state.masks);
|
||||
const addMask = useStore((state) => state.addMask);
|
||||
const clearMasks = useStore((state) => state.clearMasks);
|
||||
const storeActiveTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
|
||||
const effectiveTool = activeTool || storeActiveTool;
|
||||
|
||||
// We load a mock image representing a frame
|
||||
const [image] = useImage('https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop');
|
||||
@@ -56,35 +70,120 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
if (!stage) return;
|
||||
const pos = stage.getPointerPosition();
|
||||
if (pos) {
|
||||
// Convert to image coordinates
|
||||
const imageX = (pos.x - position.x) / scale;
|
||||
const imageY = (pos.y - position.y) / scale;
|
||||
setCursorPos({ x: imageX, y: imageY });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (activeTool === 'move') return;
|
||||
|
||||
if (activeTool === 'point_pos' || activeTool === 'point_neg') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
setPoints([...points, { x: pos.x, y: pos.y, type: activeTool === 'point_pos' ? 'pos' : 'neg' }]);
|
||||
if (boxStart && effectiveTool === 'box_select') {
|
||||
const relPos = stage.getRelativePointerPosition();
|
||||
if (relPos) {
|
||||
setBoxCurrent({ x: relPos.x, y: relPos.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runInference = useCallback(async (promptPoints?: typeof points, promptBox?: { x1: number, y1: number, x2: number, y2: number }) => {
|
||||
setIsInferencing(true);
|
||||
try {
|
||||
const result = await predictMask({
|
||||
imageUrl: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0be2?q=80&w=2070&auto=format&fit=crop',
|
||||
points: promptPoints?.map((p) => ({ x: p.x, y: p.y, type: p.type })),
|
||||
box: promptBox,
|
||||
});
|
||||
|
||||
result.masks.forEach((m) => {
|
||||
addMask({
|
||||
id: m.id,
|
||||
frameId: 'frame-1',
|
||||
pathData: m.pathData,
|
||||
label: m.label,
|
||||
color: m.color,
|
||||
segmentation: m.segmentation,
|
||||
bbox: m.bbox,
|
||||
area: m.area,
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Inference failed:', err);
|
||||
} finally {
|
||||
setIsInferencing(false);
|
||||
}
|
||||
}, [addMask]);
|
||||
|
||||
const handleStageMouseDown = (e: any) => {
|
||||
if (effectiveTool === 'box_select') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
if (pos) {
|
||||
setBoxStart({ x: pos.x, y: pos.y });
|
||||
setBoxCurrent({ x: pos.x, y: pos.y });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageMouseUp = (e: any) => {
|
||||
if (effectiveTool === 'box_select' && boxStart && boxCurrent) {
|
||||
const x1 = Math.min(boxStart.x, boxCurrent.x);
|
||||
const y1 = Math.min(boxStart.y, boxCurrent.y);
|
||||
const x2 = Math.max(boxStart.x, boxCurrent.x);
|
||||
const y2 = Math.max(boxStart.y, boxCurrent.y);
|
||||
|
||||
if (Math.abs(x2 - x1) > 5 && Math.abs(y2 - y1) > 5) {
|
||||
runInference(undefined, { x1, y1, x2, y2 });
|
||||
}
|
||||
|
||||
setBoxStart(null);
|
||||
setBoxCurrent(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStageClick = (e: any) => {
|
||||
if (effectiveTool === 'move') return;
|
||||
if (effectiveTool === 'box_select') return; // handled by mouseup
|
||||
|
||||
if (effectiveTool === 'point_pos' || effectiveTool === 'point_neg') {
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getRelativePointerPosition();
|
||||
if (pos) {
|
||||
const newPoints = [...points, { x: pos.x, y: pos.y, type: effectiveTool === 'point_pos' ? 'pos' : 'neg' as 'pos'|'neg' }];
|
||||
setPoints(newPoints);
|
||||
// Auto-trigger inference after point selection
|
||||
runInference(newPoints);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const boxRect = React.useMemo(() => {
|
||||
if (!boxStart || !boxCurrent) return null;
|
||||
const x = Math.min(boxStart.x, boxCurrent.x);
|
||||
const y = Math.min(boxStart.y, boxCurrent.y);
|
||||
const width = Math.abs(boxCurrent.x - boxStart.x);
|
||||
const height = Math.abs(boxCurrent.y - boxStart.y);
|
||||
return { x, y, width, height };
|
||||
}, [boxStart, boxCurrent]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative cursor-crosshair overflow-hidden rounded-sm">
|
||||
{isInferencing && (
|
||||
<div className="absolute top-4 right-4 z-20 flex items-center gap-2 bg-[#111] border border-white/10 px-3 py-2 rounded-lg shadow-xl">
|
||||
<div className="w-3 h-3 border-2 border-cyan-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-xs text-cyan-400 font-mono">AI 推理中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stage
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
onWheel={handleWheel}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleStageMouseDown}
|
||||
onMouseUp={handleStageMouseUp}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
draggable={activeTool === 'move'}
|
||||
draggable={effectiveTool === 'move'}
|
||||
onClick={handleStageClick}
|
||||
>
|
||||
<Layer>
|
||||
@@ -98,24 +197,38 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mock Instance Mask overlapping */}
|
||||
<Group opacity={0.4}>
|
||||
<Path
|
||||
data="M 300 200 Q 400 150 450 250 T 400 350 Q 250 350 280 250 Z"
|
||||
fill="#06b6d4" // cyan-500
|
||||
{/* AI Returned Masks */}
|
||||
{masks.map((mask) => (
|
||||
<Group key={mask.id} opacity={0.5}>
|
||||
<Path
|
||||
data={mask.pathData}
|
||||
fill={mask.color}
|
||||
stroke={mask.color}
|
||||
strokeWidth={1 / scale}
|
||||
/>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{/* Box selection preview */}
|
||||
{boxRect && effectiveTool === 'box_select' && (
|
||||
<Rect
|
||||
x={boxRect.x}
|
||||
y={boxRect.y}
|
||||
width={boxRect.width}
|
||||
height={boxRect.height}
|
||||
fill="rgba(6, 182, 212, 0.1)"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2 / scale}
|
||||
dash={[4 / scale, 4 / scale]}
|
||||
/>
|
||||
<Path
|
||||
data="M 600 400 Q 700 350 750 450 T 650 550 Q 550 550 580 450 Z"
|
||||
fill="#a855f7" // purple-500
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* AI Prompts Point Regions */}
|
||||
{points.map((p, i) => (
|
||||
<Group key={i} x={p.x} y={p.y}>
|
||||
<Circle
|
||||
radius={6 / scale}
|
||||
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'} // green or red
|
||||
fill={p.type === 'pos' ? '#22c55e' : '#ef4444'}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={2 / scale}
|
||||
shadowColor="black"
|
||||
@@ -134,7 +247,17 @@ export function CanvasArea({ activeTool }: CanvasAreaProps) {
|
||||
<span>光标: {cursorPos.x.toFixed(2)}, {cursorPos.y.toFixed(2)}</span>
|
||||
<span>当前图层树: OBJECT_VEHICLE_01</span>
|
||||
<span>缩放比: {(scale * 100).toFixed(0)}%</span>
|
||||
<span>遮罩数: {masks.length}</span>
|
||||
</div>
|
||||
|
||||
{masks.length > 0 && (
|
||||
<button
|
||||
onClick={clearMasks}
|
||||
className="absolute bottom-4 right-4 text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 border border-red-500/20 px-3 py-1.5 rounded transition-colors"
|
||||
>
|
||||
清空遮罩
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Activity, Clock, Folders, CheckCircle2 } from 'lucide-react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Activity, Clock, Folders, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { progressWS, type ProgressMessage } from '../lib/websocket';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface QueueTask {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [tasks, setTasks] = useState<QueueTask[]>([
|
||||
{ id: '1', name: 'City_Driving_Dataset_004.mp4', progress: 85, status: '正在截取帧 (30fps)' },
|
||||
{ id: '2', name: 'Pedestrian_Night_Vision_02.mkv', progress: 32, status: '正在截取帧 (60fps)' },
|
||||
{ id: '3', name: 'Drone_Mapping_Sector_7.avi', progress: 0, status: '队列排队等待中' },
|
||||
]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [activityLog, setActivityLog] = useState<Array<{ time: string; message: string; project?: string }>>([
|
||||
{ time: '10 分钟前', message: '语义归档完成 54 帧', project: 'Highway_Data' },
|
||||
{ time: '25 分钟前', message: '项目解析开始', project: 'City_Driving_Dataset_004' },
|
||||
{ time: '1 小时前', message: '模板库更新: Cityscapes_v2', project: '系统' },
|
||||
{ time: '2 小时前', message: 'AI 推理完成 12 个实例', project: 'Nav_Cam_Left' },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
progressWS.connect();
|
||||
|
||||
const unsubscribe = progressWS.onProgress((data: ProgressMessage) => {
|
||||
setIsConnected(progressWS.isConnected());
|
||||
|
||||
if (data.type === 'progress' && data.taskId && data.filename) {
|
||||
setTasks((prev) => {
|
||||
const exists = prev.find((t) => t.id === data.taskId);
|
||||
if (exists) {
|
||||
return prev.map((t) =>
|
||||
t.id === data.taskId
|
||||
? { ...t, progress: data.progress ?? t.progress, status: data.status ?? t.status }
|
||||
: t
|
||||
);
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: data.taskId!,
|
||||
name: data.filename!,
|
||||
progress: data.progress ?? 0,
|
||||
status: data.status ?? '处理中',
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if (data.type === 'complete' && data.taskId) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === data.taskId ? { ...t, progress: 100, status: '已完成' } : t
|
||||
)
|
||||
);
|
||||
setActivityLog((prev) => [
|
||||
{ time: '刚刚', message: `解析完成: ${data.filename || data.taskId}`, project: '系统' },
|
||||
...prev.slice(0, 9),
|
||||
]);
|
||||
}
|
||||
|
||||
if (data.type === 'error' && data.taskId) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === data.taskId ? { ...t, status: `错误: ${data.message || '未知错误'}` } : t
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (data.type === 'status') {
|
||||
setActivityLog((prev) => [
|
||||
{ time: '刚刚', message: data.message || '状态更新', project: '系统' },
|
||||
...prev.slice(0, 9),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const checkConnection = setInterval(() => {
|
||||
setIsConnected(progressWS.isConnected());
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
clearInterval(checkConnection);
|
||||
progressWS.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stats = [
|
||||
{ label: '运行中项目', value: '14', icon: Folders, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '排队处理任务', value: '3,291', icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ label: '排队处理任务', value: tasks.length.toString(), icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
{ label: '已归档批次', value: '128', icon: CheckCircle2, color: 'text-emerald-400', bg: 'bg-emerald-400/10' },
|
||||
{ label: '系统负载', value: '78%', icon: Activity, color: 'text-cyan-400', bg: 'bg-cyan-400/10' },
|
||||
];
|
||||
@@ -12,7 +101,18 @@ export function Dashboard() {
|
||||
return (
|
||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-medium tracking-tight text-white">系统整体概况</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-medium tracking-tight text-white">系统整体概况</h1>
|
||||
<div className={cn(
|
||||
"flex items-center gap-1.5 text-[10px] uppercase font-mono px-2 py-1 rounded border",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-400 border-amber-500/20"
|
||||
)}>
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", isConnected ? "bg-emerald-500" : "bg-amber-500 animate-pulse")} />
|
||||
{isConnected ? 'WebSocket 已连接' : 'WebSocket 断开'}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">系统全局数据吞吐状态与所有接入项目进度实时洞察驾驶舱。</p>
|
||||
</header>
|
||||
|
||||
@@ -37,36 +137,43 @@ export function Dashboard() {
|
||||
<div className="lg:col-span-2 bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6">解析队列 (FFmpeg 挂起任务)</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ name: 'City_Driving_Dataset_004.mp4', progress: 85, status: '正在截取帧 (30fps)' },
|
||||
{ name: 'Pedestrian_Night_Vision_02.mkv', progress: 32, status: '正在截取帧 (60fps)' },
|
||||
{ name: 'Drone_Mapping_Sector_7.avi', progress: 0, status: '队列排队等待中' }
|
||||
].map((task, i) => (
|
||||
<div key={i} className="bg-[#0d0d0d] border border-white/5 p-4 rounded-lg">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="bg-[#0d0d0d] border border-white/5 p-4 rounded-lg">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-mono text-sm text-gray-200">{task.name}</span>
|
||||
<span className="text-xs text-cyan-400 font-mono">{task.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden mb-2">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 rounded-full" style={{ width: `${task.progress}%` }} />
|
||||
<div className="h-full bg-gradient-to-r from-cyan-600 to-cyan-400 rounded-full transition-all duration-500" style={{ width: `${task.progress}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
{task.status === '已完成' ? (
|
||||
<CheckCircle2 size={12} className="text-emerald-400" />
|
||||
) : task.status.includes('错误') ? (
|
||||
<span className="text-red-400">●</span>
|
||||
) : (
|
||||
<Loader2 size={12} className="text-cyan-400 animate-spin" />
|
||||
)}
|
||||
{task.status}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{task.status}</div>
|
||||
</div>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-12">当前无处理任务</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111] border border-white/5 rounded-xl p-6 min-h-[400px]">
|
||||
<h2 className="text-sm font-medium text-gray-400 uppercase tracking-widest mb-6">近期实时流转记录</h2>
|
||||
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-[11px] before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-white/10 before:to-transparent">
|
||||
{/* Activity log mockup */}
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
{activityLog.map((log, i) => (
|
||||
<div key={i} className="relative flex items-center justify-between md:justify-normal md:odd:flex-row-reverse group is-active">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full border border-white/10 bg-[#111] group-[.is-active]:bg-cyan-500 group-[.is-active]:border-cyan-400 text-slate-500 group-[.is-active]:text-black shadow shrink-0 md:order-1 md:group-odd:-translate-x-1/2 md:group-even:translate-x-1/2 z-10" />
|
||||
<div className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] bg-[#0d0d0d] p-3 rounded border border-white/5">
|
||||
<div className="text-xs text-gray-400 mb-1">10 分钟前</div>
|
||||
<div className="text-sm font-medium text-gray-200">语义归档完成 54 帧</div>
|
||||
<div className="text-xs text-gray-500">归属项目: Highway_Data</div>
|
||||
<div className="text-xs text-gray-400 mb-1">{log.time}</div>
|
||||
<div className="text-sm font-medium text-gray-200">{log.message}</div>
|
||||
<div className="text-xs text-gray-500">归属项目: {log.project}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { BrainCircuit } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { login as loginApi } from '../lib/api';
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: (token: string) => void;
|
||||
}
|
||||
|
||||
export function Login({ onLoginSuccess }: LoginProps) {
|
||||
export function Login() {
|
||||
const storeLogin = useStore((state) => state.login);
|
||||
const [username, setUsername] = useState('admin');
|
||||
const [password, setPassword] = useState('123456');
|
||||
const [error, setError] = useState('');
|
||||
@@ -18,21 +17,11 @@ export function Login({ onLoginSuccess }: LoginProps) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
onLoginSuccess(data.token);
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
setError(errData.error || '登录失败');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('网络异常,无法连接到后端验证');
|
||||
const data = await loginApi(username, password);
|
||||
storeLogin(data.token);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.detail || err?.response?.data?.error || '登录失败,请检查网络或凭证';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -45,7 +34,7 @@ export function Login({ onLoginSuccess }: LoginProps) {
|
||||
<div className="relative z-10 w-full max-w-md p-8 bg-[#111] border border-white/5 rounded-2xl shadow-2xl scale-in shadow-black/50">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-16 h-16 bg-white rounded-2xl flex items-center justify-center text-cyan-500 shadow-lg shadow-cyan-500/20 mb-4 overflow-hidden border border-white/10">
|
||||
<img src="/Logo.png" alt="Logo" className="w-full h-full object-cover" />
|
||||
<BrainCircuit size={32} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-wider mb-2">欢迎登录协同工作站</h1>
|
||||
<p className="text-sm text-gray-500">AI 智能切分与多模态数据标注系统</p>
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal } from 'lucide-react';
|
||||
import { UploadCloud, Film, Settings2, MoreHorizontal, Plus, Loader2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getProjects, createProject } from '../lib/api';
|
||||
import type { Project } from '../store/useStore';
|
||||
|
||||
interface ProjectLibraryProps {
|
||||
onProjectSelect: () => void;
|
||||
}
|
||||
|
||||
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const projects = useStore((state) => state.projects);
|
||||
const setProjects = useStore((state) => state.setProjects);
|
||||
const setCurrentProject = useStore((state) => state.setCurrentProject);
|
||||
const addProject = useStore((state) => state.addProject);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/projects')
|
||||
.then(res => res.json())
|
||||
.then(data => setProjects(data))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
setIsLoading(true);
|
||||
getProjects()
|
||||
.then((data) => setProjects(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [setProjects]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const project = await createProject({ name: newName.trim(), description: newDesc.trim() || undefined });
|
||||
addProject(project);
|
||||
setShowModal(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
} catch (err) {
|
||||
console.error('Failed to create project:', err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (project: Project) => {
|
||||
setCurrentProject(project);
|
||||
onProjectSelect();
|
||||
};
|
||||
|
||||
const SkeletonCard = () => (
|
||||
<div className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden animate-pulse">
|
||||
<div className="w-full aspect-[16/9] bg-[#1a1a1a]" />
|
||||
<div className="p-4 flex flex-col gap-2">
|
||||
<div className="h-4 bg-[#1a1a1a] rounded w-3/4" />
|
||||
<div className="h-3 bg-[#1a1a1a] rounded w-1/2 mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||
@@ -22,47 +66,116 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
|
||||
<h1 className="text-3xl font-medium tracking-tight text-white mb-2">视频与连续帧项目库</h1>
|
||||
<p className="text-gray-400 text-sm">上传源文件、按帧解析配置,并结构化管理多媒体资产实体。</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20">
|
||||
<UploadCloud size={18} />
|
||||
<span>导入多媒体资源</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="flex items-center gap-2 bg-white/5 hover:bg-white/10 border border-white/10 text-gray-200 px-5 py-2.5 rounded-lg font-medium text-sm transition-colors"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>新建项目</span>
|
||||
</button>
|
||||
<button className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 text-white px-5 py-2.5 rounded-lg font-medium text-sm transition-colors border border-cyan-500 shadow-lg shadow-cyan-900/20">
|
||||
<UploadCloud size={18} />
|
||||
<span>导入多媒体资源</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((proj) => (
|
||||
<div
|
||||
key={proj.id}
|
||||
className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-500/50 transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.15)]"
|
||||
onClick={onProjectSelect}
|
||||
>
|
||||
<div className={`w-full aspect-[16/9] ${proj.thumbnail} relative flex items-center justify-center overflow-hidden`}>
|
||||
{/* Stand-in for actual video frame thumbnail */}
|
||||
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||
{proj.fps}
|
||||
</span>
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||
{proj.status === 'Ready' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> 已就绪</>
|
||||
) : (
|
||||
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> 解析拆帧中</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading && projects.length === 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{projects.map((proj) => (
|
||||
<div
|
||||
key={proj.id}
|
||||
className="group flex flex-col bg-[#111] border border-white/5 rounded-xl overflow-hidden cursor-pointer hover:border-cyan-500/50 transition-all hover:shadow-[0_0_20px_rgba(6,182,212,0.15)]"
|
||||
onClick={() => handleSelect(proj)}
|
||||
>
|
||||
<div className={cn("w-full aspect-[16/9] relative flex items-center justify-center overflow-hidden", proj.thumbnail || 'bg-[#0d0d0d]')}>
|
||||
<Film className="w-12 h-12 text-[#2a2a2a] group-hover:text-[#333] transition-colors" />
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] font-mono px-2 py-1 rounded border border-white/10 uppercase tracking-widest">
|
||||
{proj.fps || '30FPS'}
|
||||
</span>
|
||||
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
|
||||
{proj.status === 'Ready' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> 已就绪</>
|
||||
) : proj.status === 'Parsing' ? (
|
||||
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> 解析拆帧中</>
|
||||
) : (
|
||||
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> 异常</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</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 className="text-gray-500 hover:text-gray-300"><MoreHorizontal size={16} /></button>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</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 className="text-gray-500 hover:text-gray-300"><MoreHorizontal size={16} /></button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">新建项目</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">项目名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
||||
placeholder="输入项目名称"
|
||||
/>
|
||||
</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} 帧节点</span>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">描述(可选)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
||||
placeholder="输入项目描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setNewName(''); setNewDesc(''); }}
|
||||
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !newName.trim()}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
|
||||
isCreating || !newName.trim()
|
||||
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
|
||||
: "bg-cyan-500 hover:bg-cyan-400 text-black"
|
||||
)}
|
||||
>
|
||||
{isCreating && <Loader2 size={14} className="animate-spin" />}
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,112 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Settings, FileJson, ArrowRightLeft, Database } from 'lucide-react';
|
||||
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
|
||||
import type { Template, TemplateClass } from '../store/useStore';
|
||||
|
||||
export function TemplateRegistry() {
|
||||
const [templates, setTemplates] = useState<any[]>([]);
|
||||
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 [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
|
||||
const [editingClassId, setEditingClassId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/templates')
|
||||
.then(res => res.json())
|
||||
.then(data => setTemplates(data))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
setIsLoading(true);
|
||||
getTemplates()
|
||||
.then((data) => setTemplates(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [setTemplates]);
|
||||
|
||||
const openCreate = () => {
|
||||
setSelectedTemplate(null);
|
||||
setEditName('');
|
||||
setEditDesc('');
|
||||
setEditClasses([]);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const openEdit = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setEditName(template.name);
|
||||
setEditDesc(template.description || '');
|
||||
setEditClasses(template.classes ? [...template.classes] : []);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editName.trim()) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (selectedTemplate) {
|
||||
const updated = await updateTemplate(selectedTemplate.id, {
|
||||
name: editName.trim(),
|
||||
description: editDesc.trim() || undefined,
|
||||
classes: editClasses,
|
||||
});
|
||||
updateTemplateStore(updated);
|
||||
} else {
|
||||
const created = await createTemplate({
|
||||
name: editName.trim(),
|
||||
description: editDesc.trim() || undefined,
|
||||
classes: editClasses,
|
||||
});
|
||||
addTemplate(created);
|
||||
}
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save template:', err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除此模板吗?')) return;
|
||||
try {
|
||||
await deleteTemplate(id);
|
||||
removeTemplateStore(id);
|
||||
if (selectedTemplate?.id === id) {
|
||||
setSelectedTemplate(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete template:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const addClass = () => {
|
||||
const newClass: TemplateClass = {
|
||||
id: `cls-${Date.now()}`,
|
||||
name: '新类别',
|
||||
color: '#06b6d4',
|
||||
zIndex: editClasses.length > 0 ? Math.max(...editClasses.map((c) => c.zIndex)) + 10 : 10,
|
||||
category: '未分类',
|
||||
};
|
||||
setEditClasses([...editClasses, newClass]);
|
||||
setEditingClassId(newClass.id);
|
||||
};
|
||||
|
||||
const updateClass = (id: string, updates: Partial<TemplateClass>) => {
|
||||
setEditClasses(editClasses.map((c) => (c.id === id ? { ...c, ...updates } : c)));
|
||||
};
|
||||
|
||||
const removeClass = (id: string) => {
|
||||
setEditClasses(editClasses.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const activeTemplate = selectedTemplate || templates[0] || null;
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full h-full overflow-y-auto bg-[#0a0a0a]">
|
||||
@@ -22,90 +119,224 @@ export function TemplateRegistry() {
|
||||
<div className="xl:col-span-1 border-r border-white/5 pr-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-sm font-bold text-gray-500 uppercase tracking-widest">生效中模板架构清单</h2>
|
||||
<button className="text-cyan-400 hover:text-cyan-300 text-sm transition-colors">+ 新建方案</button>
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="text-cyan-400 hover:text-cyan-300 text-sm transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Plus size={14} /> 新建方案
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{templates.map(t => (
|
||||
<div key={t.id} className="bg-[#111] border border-white/5 hover:border-cyan-500/50 p-4 rounded-xl cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-900/10">
|
||||
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono">
|
||||
<span>涵盖 {t.classes} 个字典大类</span>
|
||||
<span>挂载 {t.rules} 项解析规则</span>
|
||||
|
||||
{isLoading && templates.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-[#111] border border-white/5 p-4 rounded-xl animate-pulse">
|
||||
<div className="h-4 bg-[#1a1a1a] rounded w-2/3 mb-2" />
|
||||
<div className="h-3 bg-[#1a1a1a] rounded w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{templates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"bg-[#111] border p-4 rounded-xl cursor-pointer transition-all hover:shadow-lg hover:shadow-cyan-900/10 group",
|
||||
activeTemplate?.id === t.id ? "border-cyan-500/50" : "border-white/5 hover:border-cyan-500/50"
|
||||
)}
|
||||
onClick={() => setSelectedTemplate(t)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-medium text-gray-200 mb-1">{t.name}</h3>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
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); }}
|
||||
className="p-1 rounded text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 font-mono">
|
||||
<span>涵盖 {t.classes?.length ?? 0} 个字典大类</span>
|
||||
<span>挂载 {t.rules?.length ?? 0} 项解析规则</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<div className="bg-[#111] border border-white/5 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-4">
|
||||
<h2 className="text-lg font-medium text-gray-200 flex items-center gap-2"><Database size={18} /> Cityscapes_v2_Mapping</h2>
|
||||
<button 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">修改库视图结构 (Schema)</button>
|
||||
<h2 className="text-lg font-medium text-gray-200 flex items-center gap-2">
|
||||
<Database size={18} />
|
||||
{activeTemplate ? activeTemplate.name : '未选择模板'}
|
||||
</h2>
|
||||
{activeTemplate && (
|
||||
<button
|
||||
onClick={() => openEdit(activeTemplate)}
|
||||
className="bg-white/5 hover:bg-white/10 border border-white/10 px-4 py-1.5 rounded text-sm text-gray-300 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Settings size={14} /> 修改库视图结构 (Schema)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{[
|
||||
{ l: 'pedestrian', z: 90, c: '#ec4899', t: '运动中物理特型 (Dynamic Entity)' },
|
||||
{ l: 'bicycle', z: 85, c: '#f59e0b', t: '运动中物理特型 (Dynamic Entity)' },
|
||||
{ l: 'vehicle_car', z: 80, c: '#6366f1', t: '运动中物理特型 (Dynamic Entity)' },
|
||||
{ l: 'traffic_sign', z: 60, c: '#eab308', t: '交通属性静态特型 (Static Entity)' },
|
||||
{ l: 'road_surface', z: 10, c: '#71717a', t: '全局视野底板 (Background / Floor)' },
|
||||
].map(cls => (
|
||||
<div key={cls.l} className="flex 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">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.c }}></div>
|
||||
<span className="font-medium text-sm text-gray-300">{cls.l}</span>
|
||||
{activeTemplate ? (
|
||||
<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">
|
||||
{(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">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: cls.color }}></div>
|
||||
<span className="font-medium text-sm text-gray-300">{cls.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 font-mono text-xs text-gray-500">优先级 Z-Level: {cls.zIndex}</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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 font-mono text-xs text-gray-500">优先级 Z-Level: {cls.z}</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.t}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
{(activeTemplate.classes || []).length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-8">暂无分类定义</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-4 flex items-center gap-2">
|
||||
<ArrowRightLeft size={14} /> 强兼容真实标签 (GT Source) 闭环降维转置拓扑结构约束表
|
||||
</h3>
|
||||
<div className="bg-[#0d0d0d] border border-white/5 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-gray-400">
|
||||
<thead className="bg-[#111] border-b border-white/5 text-xs uppercase text-gray-500 font-mono">
|
||||
<tr>
|
||||
<th className="px-4 py-3">原始 JSON 键 (Legacy Key)</th>
|
||||
<th className="px-4 py-3">映射降维引挚解析路径</th>
|
||||
<th className="px-4 py-3">并轨至标准分类</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-gray-300">"car_sedan"</td>
|
||||
<td className="px-4 py-3 font-mono text-cyan-400">布尔合并聚类覆盖 (Logical OR)</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-300">vehicle_car</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-gray-300">"car_suv"</td>
|
||||
<td className="px-4 py-3 font-mono text-cyan-400">布尔合并聚类覆盖 (Logical OR)</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-300">vehicle_car</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 font-mono text-gray-300">"sidewalk_curb"</td>
|
||||
<td className="px-4 py-3 font-mono text-cyan-400">形态学极限分离-内切骨架法 (Skeletonization)</td>
|
||||
<td className="px-4 py-3 font-medium text-gray-300">road_curb</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 text-center py-12">请从左侧选择一个模板或创建新模板</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto shadow-2xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">{selectedTemplate ? '编辑模板' : '新建模板'}</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-gray-500 hover:text-white transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">模板名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-widest mb-2">描述</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-4 py-3 text-sm focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">分类定义</h3>
|
||||
<button
|
||||
onClick={addClass}
|
||||
className="text-cyan-400 hover:text-cyan-300 text-xs transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Plus size={12} /> 添加分类
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{editClasses.map((cls) => (
|
||||
<div key={cls.id} className="flex items-center gap-3 bg-[#0d0d0d] border border-white/5 rounded-lg p-3">
|
||||
<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"
|
||||
/>
|
||||
{editingClassId === cls.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={cls.name}
|
||||
onChange={(e) => updateClass(cls.id, { name: e.target.value })}
|
||||
onBlur={() => setEditingClassId(null)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setEditingClassId(null)}
|
||||
autoFocus
|
||||
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={cls.zIndex}
|
||||
onChange={(e) => updateClass(cls.id, { zIndex: parseInt(e.target.value) || 0 })}
|
||||
className="w-20 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white font-mono"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="flex-1 text-sm text-gray-300 cursor-pointer"
|
||||
onClick={() => setEditingClassId(cls.id)}
|
||||
>
|
||||
{cls.name}
|
||||
</span>
|
||||
<span className="w-20 text-sm text-gray-500 font-mono text-right">z:{cls.zIndex}</span>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => removeClass(cls.id)} className="text-gray-500 hover:text-red-400 transition-colors">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{editClasses.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4">暂无分类,请点击上方按钮添加</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !editName.trim()}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-all",
|
||||
isSaving || !editName.trim()
|
||||
? "bg-cyan-500/50 text-black/70 cursor-not-allowed"
|
||||
: "bg-cyan-500 hover:bg-cyan-400 text-black"
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MousePointer2, Hexagon, Square, Circle, Minus, Combine, Scissors, Wand2, Undo, Redo, Crosshair } from 'lucide-react';
|
||||
import { MousePointer2, Hexagon, Square, Circle, Minus, Combine, Scissors, Wand2, Undo, Redo, Crosshair, PlusCircle, MinusCircle, SquareDashed } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ToolsPaletteProps {
|
||||
@@ -20,6 +20,12 @@ export function ToolsPalette({ activeTool, setActiveTool, onTriggerAI }: ToolsPa
|
||||
{ id: 'area_remove', icon: Scissors, label: '重叠区域去除 (-)' },
|
||||
];
|
||||
|
||||
const aiTools = [
|
||||
{ id: 'point_pos', icon: PlusCircle, label: '正向选点 (SAM)', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30' },
|
||||
{ id: 'point_neg', icon: MinusCircle, label: '反向选点 (SAM)', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30' },
|
||||
{ id: 'box_select', icon: SquareDashed, label: '边界框选 (SAM)', color: 'text-blue-400', bg: 'bg-blue-500/10', border: 'border-blue-500/30' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-12 bg-[#0d0d0d] border-r border-white/5 flex flex-col items-center py-4 shrink-0 z-10">
|
||||
<div className="flex flex-col gap-4 w-full px-2">
|
||||
@@ -47,6 +53,26 @@ export function ToolsPalette({ activeTool, setActiveTool, onTriggerAI }: ToolsPa
|
||||
|
||||
<div className="w-full h-px bg-white/10 my-1" />
|
||||
|
||||
{aiTools.map(tool => {
|
||||
const Icon = tool.icon;
|
||||
const isActive = activeTool === tool.id;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => setActiveTool(tool.id)}
|
||||
title={tool.label}
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center transition-all p-2 border",
|
||||
isActive
|
||||
? `${tool.bg} ${tool.color} ${tool.border} shadow-[0_0_10px_rgba(255,255,255,0.05)]`
|
||||
: "text-gray-500 hover:bg-white/5 hover:text-white border-transparent"
|
||||
)}
|
||||
>
|
||||
<Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTool('sam_trigger');
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { useStore } from '../store/useStore';
|
||||
import { CanvasArea } from './CanvasArea';
|
||||
import { ToolsPalette } from './ToolsPalette';
|
||||
import { OntologyInspector } from './OntologyInspector';
|
||||
import { FrameTimeline } from './FrameTimeline';
|
||||
|
||||
export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void }) {
|
||||
const [activeTool, setActiveTool] = useState<string>('move');
|
||||
const activeTool = useStore((state) => state.activeTool);
|
||||
const setActiveTool = useStore((state) => state.setActiveTool);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-[#0a0a0a]">
|
||||
|
||||
135
src/lib/api.ts
Normal file
135
src/lib/api.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import type { Project, Template } from '../store/useStore';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: 'http://localhost:8000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Request interceptor: attach token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor: handle errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.reload();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth
|
||||
export async function login(username: string, password: string): Promise<{ token: string }> {
|
||||
const response = await apiClient.post('/api/auth/login', { username, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Projects
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
const response = await apiClient.get('/api/projects');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createProject(payload: {
|
||||
name: string;
|
||||
description?: string;
|
||||
}): Promise<Project> {
|
||||
const response = await apiClient.post('/api/projects', payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, payload: Partial<Project>): Promise<Project> {
|
||||
const response = await apiClient.put(`/api/projects/${id}`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
// Templates
|
||||
export async function getTemplates(): Promise<Template[]> {
|
||||
const response = await apiClient.get('/api/templates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createTemplate(payload: {
|
||||
name: string;
|
||||
description?: string;
|
||||
classes?: { name: string; color: string; zIndex: number; category?: string }[];
|
||||
}): Promise<Template> {
|
||||
const response = await apiClient.post('/api/templates', payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateTemplate(id: string, payload: Partial<Template>): Promise<Template> {
|
||||
const response = await apiClient.put(`/api/templates/${id}`, payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<void> {
|
||||
await apiClient.delete(`/api/templates/${id}`);
|
||||
}
|
||||
|
||||
// Media
|
||||
export async function uploadMedia(file: File, projectId?: string): Promise<{ url: string; id: string }> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (projectId) {
|
||||
formData.append('project_id', projectId);
|
||||
}
|
||||
const response = await apiClient.post('/api/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// AI Prediction
|
||||
export async function predictMask(payload: {
|
||||
imageUrl: string;
|
||||
points?: { x: number; y: number; type: 'pos' | 'neg' }[];
|
||||
box?: { x1: number; y1: number; x2: number; y2: number };
|
||||
text?: string;
|
||||
modelSize?: string;
|
||||
}): Promise<{
|
||||
masks: Array<{
|
||||
id: string;
|
||||
pathData: string;
|
||||
label: string;
|
||||
color: string;
|
||||
segmentation: number[][];
|
||||
bbox: [number, number, number, number];
|
||||
area: number;
|
||||
confidence: number;
|
||||
}>;
|
||||
}> {
|
||||
const response = await apiClient.post('/api/ai/predict', payload);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Export
|
||||
export async function exportCoco(projectId: string): Promise<Blob> {
|
||||
const response = await apiClient.get(`/api/export/coco/${projectId}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
104
src/lib/websocket.ts
Normal file
104
src/lib/websocket.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
type ProgressCallback = (data: ProgressMessage) => void;
|
||||
|
||||
interface ProgressMessage {
|
||||
type: 'progress' | 'status' | 'error' | 'complete';
|
||||
taskId?: string;
|
||||
filename?: string;
|
||||
progress?: number;
|
||||
status?: string;
|
||||
message?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
class ProgressWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private callbacks: Set<ProgressCallback> = new Set();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectInterval = 3000;
|
||||
private maxReconnectInterval = 30000;
|
||||
private shouldReconnect = false;
|
||||
private currentInterval = 3000;
|
||||
|
||||
constructor(url = 'ws://localhost:8000/ws/progress') {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.shouldReconnect = true;
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.currentInterval = this.reconnectInterval;
|
||||
console.log('[WebSocket] Connected to progress stream');
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: ProgressMessage = JSON.parse(event.data);
|
||||
this.callbacks.forEach((cb) => cb(data));
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to parse message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('[WebSocket] Connection closed');
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
console.error('[WebSocket] Error:', err);
|
||||
this.ws?.close();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[WebSocket] Failed to connect:', err);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(callback: ProgressCallback) {
|
||||
this.callbacks.add(callback);
|
||||
return () => {
|
||||
this.callbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`[WebSocket] Reconnecting in ${this.currentInterval}ms...`);
|
||||
this.connect();
|
||||
this.currentInterval = Math.min(this.currentInterval * 1.5, this.maxReconnectInterval);
|
||||
}, this.currentInterval);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
|
||||
export const progressWS = new ProgressWebSocket();
|
||||
export type { ProgressMessage };
|
||||
195
src/store/useStore.ts
Normal file
195
src/store/useStore.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'Ready' | 'Parsing' | 'Error';
|
||||
fps?: string;
|
||||
frames?: number;
|
||||
thumbnail?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
id: string;
|
||||
projectId: string;
|
||||
index: number;
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string;
|
||||
frameId: string;
|
||||
type: 'polygon' | 'rectangle' | 'circle' | 'point' | 'mask';
|
||||
points: number[];
|
||||
label: string;
|
||||
color: string;
|
||||
zIndex?: number;
|
||||
confidence?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Mask {
|
||||
id: string;
|
||||
frameId: string;
|
||||
pathData: string;
|
||||
label: string;
|
||||
color: string;
|
||||
opacity?: number;
|
||||
segmentation?: number[][];
|
||||
bbox?: [number, number, number, number];
|
||||
area?: number;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
classes: TemplateClass[];
|
||||
rules?: TemplateRule[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface TemplateClass {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
zIndex: number;
|
||||
category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TemplateRule {
|
||||
id: string;
|
||||
name: string;
|
||||
sourceKey: string;
|
||||
targetKey: string;
|
||||
operation: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// Auth
|
||||
isAuthenticated: boolean;
|
||||
token: string | null;
|
||||
login: (token: string) => void;
|
||||
logout: () => void;
|
||||
|
||||
// Projects
|
||||
projects: Project[];
|
||||
currentProject: Project | null;
|
||||
setProjects: (projects: Project[]) => void;
|
||||
setCurrentProject: (project: Project | null) => void;
|
||||
addProject: (project: Project) => void;
|
||||
updateProject: (project: Project) => void;
|
||||
|
||||
// Workspace
|
||||
activeModule: string;
|
||||
activeTool: string;
|
||||
frames: Frame[];
|
||||
currentFrameIndex: number;
|
||||
annotations: Annotation[];
|
||||
masks: Mask[];
|
||||
setActiveModule: (module: string) => void;
|
||||
setActiveTool: (tool: string) => void;
|
||||
setFrames: (frames: Frame[]) => void;
|
||||
setCurrentFrame: (index: number) => void;
|
||||
addAnnotation: (annotation: Annotation) => void;
|
||||
addMask: (mask: Mask) => void;
|
||||
clearMasks: () => void;
|
||||
removeAnnotation: (id: string) => void;
|
||||
|
||||
// Templates
|
||||
templates: Template[];
|
||||
setTemplates: (templates: Template[]) => void;
|
||||
addTemplate: (template: Template) => void;
|
||||
updateTemplate: (template: Template) => void;
|
||||
removeTemplate: (id: string) => void;
|
||||
|
||||
// UI
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set) => ({
|
||||
// Auth
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
login: (token: string) => {
|
||||
localStorage.setItem('token', token);
|
||||
set({ isAuthenticated: true, token });
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
token: null,
|
||||
currentProject: null,
|
||||
projects: [],
|
||||
templates: [],
|
||||
frames: [],
|
||||
annotations: [],
|
||||
masks: [],
|
||||
});
|
||||
},
|
||||
|
||||
// Projects
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
setProjects: (projects: Project[]) => set({ projects }),
|
||||
setCurrentProject: (currentProject: Project | null) => set({ currentProject }),
|
||||
addProject: (project: Project) =>
|
||||
set((state) => ({ projects: [project, ...state.projects] })),
|
||||
updateProject: (project: Project) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((p) => (p.id === project.id ? project : p)),
|
||||
})),
|
||||
|
||||
// Workspace
|
||||
activeModule: 'workspace',
|
||||
activeTool: 'move',
|
||||
frames: [],
|
||||
currentFrameIndex: 0,
|
||||
annotations: [],
|
||||
masks: [],
|
||||
setActiveModule: (activeModule: string) => set({ activeModule }),
|
||||
setActiveTool: (activeTool: string) => set({ activeTool }),
|
||||
setFrames: (frames: Frame[]) => set({ frames }),
|
||||
setCurrentFrame: (currentFrameIndex: number) => set({ currentFrameIndex }),
|
||||
addAnnotation: (annotation: Annotation) =>
|
||||
set((state) => ({ annotations: [...state.annotations, annotation] })),
|
||||
addMask: (mask: Mask) =>
|
||||
set((state) => ({ masks: [...state.masks, mask] })),
|
||||
clearMasks: () => set({ masks: [] }),
|
||||
removeAnnotation: (id: string) =>
|
||||
set((state) => ({
|
||||
annotations: state.annotations.filter((a) => a.id !== id),
|
||||
})),
|
||||
|
||||
// Templates
|
||||
templates: [],
|
||||
setTemplates: (templates: Template[]) => set({ templates }),
|
||||
addTemplate: (template: Template) =>
|
||||
set((state) => ({ templates: [...state.templates, template] })),
|
||||
updateTemplate: (template: Template) =>
|
||||
set((state) => ({
|
||||
templates: state.templates.map((t) => (t.id === template.id ? template : t)),
|
||||
})),
|
||||
removeTemplate: (id: string) =>
|
||||
set((state) => ({
|
||||
templates: state.templates.filter((t) => t.id !== id),
|
||||
})),
|
||||
|
||||
// UI
|
||||
isLoading: false,
|
||||
error: null,
|
||||
setLoading: (isLoading: boolean) => set({ isLoading }),
|
||||
setError: (error: string | null) => set({ error }),
|
||||
}));
|
||||
Reference in New Issue
Block a user