2026-04-29-21-51-19 - 全栈系统改造:FastAPI后端+SAM2+PostgreSQL+Redis+MinIO+前端Zustand重构

This commit is contained in:
2026-04-29 22:17:25 +08:00
parent c8f8686097
commit fd4b5e5b3d
39 changed files with 3816 additions and 211 deletions

View File

@@ -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')} />}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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');

View File

@@ -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
View 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
View 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
View 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 }),
}));