- 将软著说明书占位图替换为线上系统实拍高清截图,并加入分模块 MP4 演示视频链接 - 新增登录、总体概况、项目库、分割工作区、AI 推理、GT Mask、导出、模板库、用户管理和退出登录截图素材 - 新增 4 段 1920x1080 系统使用演示视频,同时保留 Playwright 原始 WebM 录制文件 - 新增功能验证与素材清单,记录验证地址、截图文件、视频文件和非破坏性验证说明 - 新增可复用 Playwright 采集脚本,便于后续重新录制软著素材
35 KiB
35 KiB
// src/main.tsx
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
// src/App.tsx
export type ActiveModule = 'dashboard' | 'projects' | 'ai' | 'workspace' | 'templates' | 'admin';
export default function App() {
const isAuthenticated = useStore((state) => state.isAuthenticated);
const activeModule = useStore((state) => state.activeModule);
const setActiveModule = useStore((state) => state.setActiveModule);
const setProjects = useStore((state) => state.setProjects);
const setCurrentUser = useStore((state) => state.setCurrentUser);
const logout = useStore((state) => state.logout);
const currentUser = useStore((state) => state.currentUser);
useEffect(() => {
if (!isAuthenticated) return;
Promise.all([getCurrentUser(), getProjects()])
.then(([user, projects]) => {
setCurrentUser(user);
setProjects(projects);
})
.catch((err) => {
if (err?.response?.status === 401) logout();
});
}, [isAuthenticated]);
if (!isAuthenticated) return <Login />;
return (
<div className="app-shell">
<Sidebar activeModule={activeModule as ActiveModule} setActiveModule={setActiveModule} />
<main>
{activeModule === 'dashboard' && <Dashboard />}
{activeModule === 'projects' && <ProjectLibrary onProjectSelect={() => setActiveModule('workspace')} />}
{activeModule === 'ai' && <AISegmentation onSendToWorkspace={() => setActiveModule('workspace')} />}
{activeModule === 'workspace' && <VideoWorkspace onNavigateToAI={() => setActiveModule('ai')} />}
{activeModule === 'templates' && <TemplateRegistry />}
{activeModule === 'admin' && currentUser?.role === 'admin' && <UserAdmin />}
</main>
</div>
);
}
// src/store/useStore.ts
export interface Project {
id: string;
name: string;
description?: string;
status: 'pending' | 'parsing' | 'ready' | 'error';
fps?: string;
frames?: number;
thumbnail_url?: string;
video_path?: string;
source_type?: string;
original_fps?: number;
parse_fps?: number;
}
export type AiModelId =
| 'sam2.1_hiera_tiny'
| 'sam2.1_hiera_small'
| 'sam2.1_hiera_base_plus'
| 'sam2.1_hiera_large';
export interface Frame {
id: string;
projectId: string;
index: number;
url: string;
width: number;
height: number;
timestampMs?: number;
sourceFrameNumber?: number;
}
export interface Mask {
id: string;
frameId: string;
annotationId?: string;
templateId?: string;
classId?: string;
className?: string;
classMaskId?: number;
saveStatus?: 'draft' | 'saved' | 'dirty' | 'saving' | 'error';
pathData: string;
label: string;
color: string;
segmentation?: number[][];
points?: number[][];
bbox?: [number, number, number, number];
metadata?: Record<string, unknown>;
}
export interface TemplateClass {
id: string;
name: string;
color: string;
zIndex: number;
maskId?: number;
description?: string;
}
export interface Template {
id: string;
name: string;
description?: string;
color?: string;
z_index?: number;
classes: TemplateClass[];
rules?: TemplateRule[];
}
export interface UserProfile {
id: number;
username: string;
role: string;
is_active?: number;
}
export const useStore = create<AppState>((set) => ({
isAuthenticated: Boolean(localStorage.getItem('token')),
token: localStorage.getItem('token'),
currentUser: null,
login: (token, user = null) => {
localStorage.setItem('token', token);
set({ isAuthenticated: true, token, currentUser: user });
},
logout: () => {
localStorage.removeItem('token');
set({
isAuthenticated: false,
token: null,
currentUser: null,
currentProject: null,
projects: [],
templates: [],
frames: [],
annotations: [],
masks: [],
selectedMaskIds: [],
activeTemplateId: null,
activeClassId: null,
activeClass: null,
});
},
projects: [],
currentProject: null,
setProjects: (projects) => set({ projects }),
setCurrentProject: (currentProject) => set({ currentProject }),
updateProject: (project) => set((state) => ({
projects: state.projects.map((item) => (item.id === project.id ? project : item)),
})),
activeModule: 'dashboard',
activeTool: 'move',
aiModel: 'sam2.1_hiera_tiny',
frames: [],
currentFrameIndex: 0,
annotations: [],
masks: [],
selectedMaskIds: [],
maskHistory: [],
maskFuture: [],
setActiveModule: (activeModule) => set({ activeModule }),
setActiveTool: (activeTool) => set({ activeTool }),
setFrames: (frames) => set({ frames }),
setCurrentFrame: (currentFrameIndex) => set({ currentFrameIndex }),
addMask: (mask) => set((state) => ({
masks: [...state.masks, mask],
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
updateMask: (id, updates) => set((state) => ({
masks: state.masks.map((mask) => (mask.id === id ? { ...mask, ...updates } : mask)),
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
setMasks: (masks) => set((state) => ({
masks,
maskHistory: [...state.maskHistory, state.masks],
maskFuture: [],
})),
setSelectedMaskIds: (selectedMaskIds) => set({ selectedMaskIds }),
undoMasks: () => set((state) => {
const previous = state.maskHistory[state.maskHistory.length - 1];
if (!previous) return state;
return {
masks: previous,
maskHistory: state.maskHistory.slice(0, -1),
maskFuture: [state.masks, ...state.maskFuture],
};
}),
redoMasks: () => set((state) => {
const next = state.maskFuture[0];
if (!next) return state;
return {
masks: next,
maskHistory: [...state.maskHistory, state.masks],
maskFuture: state.maskFuture.slice(1),
};
}),
templates: [],
activeTemplateId: null,
activeClassId: null,
activeClass: null,
setTemplates: (templates) => set({ templates }),
setActiveTemplateId: (activeTemplateId) => set({ activeTemplateId }),
setActiveClassId: (activeClassId) => set({ activeClassId }),
setActiveClass: (activeClass) => set({ activeClass }),
}));
// src/lib/config.ts
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
function inferApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL;
if (envUrl) return trimTrailingSlash(envUrl);
if (typeof window !== 'undefined' && window.location.hostname) {
return `${window.location.protocol}//${window.location.hostname}:8000`;
}
return 'http://192.168.3.11:8000';
}
export const API_BASE_URL = inferApiBaseUrl();
function inferWsProgressUrl(): string {
const envUrl = import.meta.env.VITE_WS_PROGRESS_URL;
if (envUrl) return envUrl;
const url = new URL('/ws/progress', API_BASE_URL);
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
return url.toString();
}
export const WS_PROGRESS_URL = inferWsProgressUrl();
// src/lib/api.ts
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: { 'Content-Type': 'application/json' },
timeout: 30000,
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
if (!error.config?.url?.includes('/api/auth/login')) window.location.reload();
}
return Promise.reject(error);
},
);
export async function login(username: string, password: string) {
const response = await apiClient.post('/api/auth/login', { username, password });
return response.data;
}
export async function getCurrentUser() {
const response = await apiClient.get('/api/auth/me');
return response.data;
}
export async function getProjects() {
const response = await apiClient.get('/api/projects');
return response.data.map(mapProject);
}
export async function createProject(payload: { name: string; description?: string; parse_fps?: number }) {
const response = await apiClient.post('/api/projects', payload);
return mapProject(response.data);
}
export async function updateProject(id: string, payload: Partial<Project>) {
const response = await apiClient.patch(`/api/projects/${id}`, payload);
return mapProject(response.data);
}
export async function copyProject(id: string, payload: { mode: 'reset' | 'full'; name?: string }) {
const response = await apiClient.post(`/api/projects/${id}/copy`, payload);
return mapProject(response.data);
}
export async function deleteProject(id: string) {
await apiClient.delete(`/api/projects/${id}`);
}
export async function uploadMedia(projectId: string, file: File, onProgress?: (progress: UploadProgress) => void) {
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', projectId);
const response = await apiClient.post('/api/media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => onProgress?.({ loaded: event.loaded, total: event.total }),
});
return response.data;
}
export async function uploadDicomBatch(files: File[], onProgress?: (progress: UploadProgress) => void) {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
const response = await apiClient.post('/api/media/upload/dicom', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => onProgress?.({ loaded: event.loaded, total: event.total }),
});
return response.data;
}
export async function parseMedia(projectId: string, options: { parse_fps?: number; max_frames?: number }) {
const response = await apiClient.post('/api/media/parse', { project_id: Number(projectId), ...options });
return response.data;
}
export async function getFrames(projectId: string): Promise<Frame[]> {
const response = await apiClient.get(`/api/projects/${projectId}/frames`);
return response.data.map(mapFrame);
}
export async function getTemplates(): Promise<Template[]> {
const response = await apiClient.get('/api/templates');
return response.data.map(mapTemplate);
}
export async function createTemplate(payload: Partial<Template>) {
const response = await apiClient.post('/api/templates', payload);
return mapTemplate(response.data);
}
export async function updateTemplate(id: string, payload: Partial<Template>) {
const response = await apiClient.patch(`/api/templates/${id}`, payload);
return mapTemplate(response.data);
}
export async function deleteTemplate(id: string) {
await apiClient.delete(`/api/templates/${id}`);
}
export async function predictMask(payload: AiPredictPayload) {
const response = await apiClient.post('/api/ai/predict', payload);
return response.data;
}
export async function queuePropagationTask(payload: PropagationTaskPayload) {
const response = await apiClient.post('/api/ai/propagate/task', payload);
return response.data;
}
export async function importGtMask(payload: FormData) {
const response = await apiClient.post('/api/ai/import-gt-mask', payload, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return response.data;
}
export async function exportSegmentationResults(projectId: string, payload: SegmentationExportPayload) {
const response = await apiClient.post(`/api/export/${projectId}/segmentation-results`, payload, {
responseType: 'blob',
});
return response.data;
}
export async function getAdminUsers() {
const response = await apiClient.get('/api/admin/users');
return response.data;
}
export async function createAdminUser(payload: { username: string; password: string; role: string; is_active: boolean }) {
const response = await apiClient.post('/api/admin/users', payload);
return response.data;
}
export async function resetDemoFactory(confirmation: string) {
const response = await apiClient.post('/api/admin/demo-factory-reset', { confirmation });
return response.data;
}
// src/components/Login.tsx
export function Login() {
const storeLogin = useStore((state) => state.login);
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
setIsLoading(true);
try {
const data = await loginApi(username, password);
storeLogin(data.token, data.user);
} catch (err: any) {
setError(err?.response?.data?.detail || '登录失败,请检查网络或凭证');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input value={username} onChange={(event) => setUsername(event.target.value)} />
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
{error && <div>{error}</div>}
<button disabled={isLoading}>{isLoading ? '验证中...' : '安全登录'}</button>
</form>
);
}
// src/components/Sidebar.tsx
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
const currentUser = useStore((state) => state.currentUser);
const logout = useStore((state) => state.logout);
const navItems = [
{ id: 'dashboard', label: '总体概况' },
{ id: 'projects', label: '项目库' },
{ id: 'workspace', label: '分割工作区' },
{ id: 'ai', label: 'AI智能分割' },
{ id: 'templates', label: '模板库' },
...(currentUser?.role === 'admin' ? [{ id: 'admin', label: '用户管理' }] : []),
] as const;
return (
<aside>
{navItems.map((item) => (
<button key={item.id} onClick={() => setActiveModule(item.id as ActiveModule)}>
{item.label}
</button>
))}
<ModelStatusBadge compact />
<button onClick={logout}>{currentUser ? `${currentUser.username} / 退出` : '退出登录'}</button>
</aside>
);
}
// src/components/Dashboard.tsx
export function Dashboard() {
const [summary, setSummary] = useState(emptySummary);
const [tasks, setTasks] = useState<DashboardTask[]>([]);
const [activityLog, setActivityLog] = useState<DashboardActivity[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [selectedTask, setSelectedTask] = useState<ProcessingTask | null>(null);
useEffect(() => {
let cancelled = false;
const loadOverview = () => {
getDashboardOverview()
.then((overview) => {
if (cancelled) return;
setSummary(overview.summary);
setTasks(overview.tasks);
setActivityLog(overview.activity);
});
};
loadOverview();
const timer = setInterval(loadOverview, 5000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
useEffect(() => {
progressWS.connect();
const unsubscribe = progressWS.onProgress((data) => {
setIsConnected(progressWS.isConnected());
if (data.type === 'progress' && data.taskId) {
setTasks((prev) => mergeProgressTask(prev, data));
}
if (data.type === 'complete' && data.taskId) {
setTasks((prev) => markTaskDone(prev, data.taskId));
}
});
return () => {
unsubscribe();
progressWS.disconnect();
};
}, []);
const handleCancelTask = async (task: DashboardTask) => {
if (!task.task_id) return;
const updated = await cancelTask(task.task_id);
setTasks((prev) => prev.map((item) => item.id === task.id ? taskFromProcessingTask(updated, task.name) : item));
};
const handleRetryTask = async (task: DashboardTask) => {
if (!task.task_id) return;
const retried = await retryTask(task.task_id);
setTasks((prev) => [taskFromProcessingTask(retried, task.name), ...prev]);
};
return <section data-connected={isConnected} data-task-count={tasks.length} />;
}
// src/components/ProjectLibrary.tsx
export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
const projects = useStore((state) => state.projects);
const setProjects = useStore((state) => state.setProjects);
const currentProject = useStore((state) => state.currentProject);
const setCurrentProject = useStore((state) => state.setCurrentProject);
const setFrames = useStore((state) => state.setFrames);
const setMasks = useStore((state) => state.setMasks);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [frameProject, setFrameProject] = useState<Project | null>(null);
const [frameParseFps, setFrameParseFps] = useState(30);
const [importProgress, setImportProgress] = useState<ImportProgressState | null>(null);
useEffect(() => {
getProjects().then(setProjects);
}, []);
const handleSelect = (project: Project) => {
setCurrentProject(project);
onProjectSelect();
};
const handleVideoUpload = async () => {
if (!pendingFile) return;
setImportProgress({ kind: 'video', phase: 'preparing', title: '正在准备视频导入', detail: pendingFile.name });
const project = await createProject({ name: pendingFile.name, description: `导入于 ${new Date().toLocaleString()}` });
await uploadMedia(project.id, pendingFile, (progress) => {
setImportProgress({ kind: 'video', phase: 'uploading', title: '正在上传视频', detail: String(progress.loaded) });
});
await refreshProjects();
};
const handleDicomFiles = async (fileList: FileList | null) => {
const files = Array.from(fileList || []).sort(naturalFilenameCompare);
if (files.length === 0) return;
setImportProgress({ kind: 'dicom', phase: 'uploading', title: '正在导入DICOM序列', detail: `${files.length} 文件` });
const task = await uploadDicomBatch(files);
await waitForTaskDone(task.id, (progress) => {
setImportProgress({ kind: 'dicom', phase: 'parsing', title: '正在解析DICOM序列', detail: progress.message || '' });
});
await refreshProjects();
};
const handleGenerateFrames = async () => {
if (!frameProject) return;
const task = await parseMedia(frameProject.id, { parse_fps: frameParseFps });
await waitForTaskDone(task.id, () => {});
await refreshProjects();
};
const commitRenameProject = async (project: Project) => {
const updated = await updateProject(project.id, { name: editingProjectName.trim() });
setProjects(projects.map((item) => (item.id === updated.id ? updated : item)));
};
const handleCopyProject = async (mode: 'reset' | 'full') => {
if (!copyProjectTarget) return;
await copyProject(copyProjectTarget.id, { mode });
setProjects(await getProjects());
};
const handleDeleteProject = async () => {
if (!deleteProjectTarget) return;
await deleteProject(deleteProjectTarget.id);
setProjects(projects.filter((item) => item.id !== deleteProjectTarget.id));
if (currentProject?.id === deleteProjectTarget.id) {
setCurrentProject(null);
setFrames([]);
setMasks([]);
}
};
return <section data-projects={projects.length} onClick={() => undefined} />;
}
// src/components/AISegmentation.tsx
export function AISegmentation({ onSendToWorkspace }: AISegmentationProps) {
const aiModel = useStore((state) => state.aiModel);
const setAiModel = useStore((state) => state.setAiModel);
const currentFrame = useStore((state) => state.frames[state.currentFrameIndex]);
const addMask = useStore((state) => state.addMask);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [positivePoints, setPositivePoints] = useState<PointPrompt[]>([]);
const [negativePoints, setNegativePoints] = useState<PointPrompt[]>([]);
const [boxPrompt, setBoxPrompt] = useState<BoxPrompt | null>(null);
const [candidateMask, setCandidateMask] = useState<Mask | null>(null);
const [modelStatus, setModelStatus] = useState<AiRuntimeStatus | null>(null);
useEffect(() => {
getAiModelStatus(aiModel).then(setModelStatus);
}, [aiModel]);
const modelCanInfer = Boolean(modelStatus?.models.find((model) => model.id === aiModel)?.available);
const handleCanvasClick = (point: PointPrompt) => {
if (promptMode === 'positive') setPositivePoints((prev) => [...prev, point]);
if (promptMode === 'negative') setNegativePoints((prev) => [...prev, point]);
};
const runInference = async () => {
if (!currentFrame || !modelCanInfer) return;
const result = await predictMask({
image_id: currentFrame.id,
model: aiModel,
prompt_type: boxPrompt ? 'interactive' : 'point',
prompt_data: {
points: [...positivePoints, ...negativePoints].map((item) => [item.x, item.y]),
labels: [...positivePoints.map(() => 1), ...negativePoints.map(() => 0)],
box: boxPrompt,
},
});
const mask = buildMaskFromPrediction(result, currentFrame);
setCandidateMask(mask);
};
const sendToWorkspace = () => {
if (!candidateMask) return;
addMask(candidateMask);
setSelectedMaskIds([candidateMask.id]);
onSendToWorkspace();
};
return <section data-model={aiModel} data-ready={modelCanInfer} />;
}
// src/components/VideoWorkspace.tsx
export function VideoWorkspace({ onNavigateToAI }: VideoWorkspaceProps) {
const currentProject = useStore((state) => state.currentProject);
const frames = useStore((state) => state.frames);
const masks = useStore((state) => state.masks);
const setFrames = useStore((state) => state.setFrames);
const setMasks = useStore((state) => state.setMasks);
const currentFrameIndex = useStore((state) => state.currentFrameIndex);
const currentFrame = frames[currentFrameIndex];
const [isSaving, setIsSaving] = useState(false);
const [isPropagating, setIsPropagating] = useState(false);
const [propagationStartFrame, setPropagationStartFrame] = useState(1);
const [propagationEndFrame, setPropagationEndFrame] = useState(1);
const [propagationWeight, setPropagationWeight] = useState<AiModelId>('sam2.1_hiera_tiny');
const [statusMessage, setStatusMessage] = useState('');
useEffect(() => {
if (!currentProject?.id) return;
getFrames(currentProject.id).then(setFrames);
getSavedAnnotations(currentProject.id).then((items) => setMasks(mapAnnotationsToMasks(items)));
}, [currentProject?.id]);
const savePendingAnnotations = async () => {
if (!currentProject?.id) return;
setIsSaving(true);
try {
const pending = masks.filter((mask) => mask.saveStatus === 'draft' || mask.saveStatus === 'dirty');
await Promise.all(pending.map((mask) => saveMaskAnnotation(currentProject.id, mask)));
setStatusMessage('标注已保存');
} finally {
setIsSaving(false);
}
};
const ensurePropagationModelAvailable = async () => {
const status = await getAiModelStatus(propagationWeight);
return Boolean(status.models.find((model) => model.id === propagationWeight)?.available);
};
const runAutoPropagate = async () => {
if (!currentProject?.id || !currentFrame?.id) return;
if (!await ensurePropagationModelAvailable()) {
setStatusMessage('AI自动推理不可用,请检查模型状态');
return;
}
const seedMasks = masks.filter((mask) => String(mask.frameId) === String(currentFrame.id));
if (seedMasks.length === 0) {
setStatusMessage('当前参考帧无遮罩');
return;
}
await savePendingAnnotations();
setIsPropagating(true);
try {
const steps = buildPropagationSteps(seedMasks, propagationStartFrame, propagationEndFrame, currentFrameIndex);
const task = await queuePropagationTask({
project_id: Number(currentProject.id),
frame_id: Number(currentFrame.id),
model: propagationWeight,
steps,
include_source: false,
save_annotations: true,
});
let currentTask = task;
while (!['success', 'failed', 'cancelled'].includes(currentTask.status)) {
await sleep(PROPAGATION_POLL_INTERVAL_MS);
currentTask = await getTask(task.id);
setStatusMessage(currentTask.message || '自动传播运行中');
}
await hydrateSavedAnnotations(currentProject.id, frames);
setStatusMessage(currentTask.status === 'success' ? '自动传播完成' : '自动传播未完成');
} finally {
setIsPropagating(false);
}
};
const handleImportGtMask = async (file: File, unknownPolicy: 'discard' | 'undefined') => {
if (!currentProject?.id || !currentFrame?.id) return;
const formData = new FormData();
formData.append('file', file);
formData.append('project_id', currentProject.id);
formData.append('frame_id', currentFrame.id);
formData.append('unknown_color_policy', unknownPolicy);
const result = await importGtMask(formData);
setMasks([...masks, ...mapImportedMasks(result)]);
};
const handleExportSegmentation = async (payload: SegmentationExportPayload) => {
if (!currentProject?.id) return;
await savePendingAnnotations();
const blob = await exportSegmentationResults(currentProject.id, payload);
downloadBlob(blob, buildExportFilename(currentProject, payload));
};
return (
<section>
<CanvasArea frame={currentFrame} masks={masks} onNavigateToAI={onNavigateToAI} />
<FrameTimeline frames={frames} currentIndex={currentFrameIndex} />
</section>
);
}
// src/components/CanvasArea.tsx
export function CanvasArea({ frame, masks }: CanvasAreaProps) {
const activeTool = useStore((state) => state.activeTool);
const activeClass = useStore((state) => state.activeClass);
const selectedMaskIds = useStore((state) => state.selectedMaskIds);
const addMask = useStore((state) => state.addMask);
const updateMask = useStore((state) => state.updateMask);
const setSelectedMaskIds = useStore((state) => state.setSelectedMaskIds);
const [draftPoints, setDraftPoints] = useState<Point[]>([]);
const [isDrawing, setIsDrawing] = useState(false);
const createMask = (points: Point[]) => {
const mask = {
id: crypto.randomUUID(),
frameId: frame.id,
pathData: pointsToPath(points),
label: activeClass?.name || '待分类',
color: activeClass?.color || '#000000',
classId: activeClass?.id,
classMaskId: activeClass?.maskId ?? 0,
saveStatus: 'draft',
points: points.map((point) => [point.x, point.y]),
};
addMask(mask);
setSelectedMaskIds([mask.id]);
};
const handleStageClick = (point: Point) => {
if (activeTool === 'polygon') setDraftPoints((prev) => [...prev, point]);
if (activeTool === 'rectangle') startRectangle(point);
if (activeTool === 'circle') startCircle(point);
};
const handleBrushEnd = (points: Point[]) => {
if (selectedMaskIds.length > 0) {
mergeBrushIntoSelectedMask(points);
} else {
createMask(points);
}
};
const handleEraserEnd = (points: Point[]) => {
const selectedId = selectedMaskIds[0];
if (!selectedId) return;
const nextGeometry = subtractStrokeFromMask(selectedId, points);
updateMask(selectedId, { ...nextGeometry, saveStatus: 'dirty' });
};
useKeyboardShortcuts({
Escape: () => setSelectedMaskIds([]),
Delete: () => deleteSelectedMasks(),
Backspace: () => deleteSelectedMasks(),
});
return <Stage data-tool={activeTool} data-selected={selectedMaskIds.join(',')} />;
}
// src/components/ToolsPalette.tsx
export function ToolsPalette(props: ToolsPaletteProps) {
const activeTool = useStore((state) => state.activeTool);
const setActiveTool = useStore((state) => state.setActiveTool);
const brushSize = useStore((state) => state.brushSize);
const eraserSize = useStore((state) => state.eraserSize);
const setBrushSize = useStore((state) => state.setBrushSize);
const setEraserSize = useStore((state) => state.setEraserSize);
const toolGroups = [
['move', 'polygon', 'rectangle', 'circle'],
['brush', 'eraser', 'auto-propagate'],
['merge', 'subtract-overlap', 'delete-mask', 'clear-mask'],
['import-gt', 'ai-segmentation'],
];
const selectTool = (tool: string) => {
if (tool === 'auto-propagate') props.onAutoPropagate();
else if (tool === 'import-gt') props.onImportGtMask();
else if (tool === 'ai-segmentation') props.onNavigateToAI();
else setActiveTool(tool);
};
return (
<aside>
{toolGroups.flat().map((tool) => (
<button key={tool} data-active={activeTool === tool} onClick={() => selectTool(tool)} />
))}
<input value={brushSize} onChange={(event) => setBrushSize(Number(event.target.value))} />
<input value={eraserSize} onChange={(event) => setEraserSize(Number(event.target.value))} />
</aside>
);
}
// src/components/OntologyInspector.tsx
export function OntologyInspector({ templates }: OntologyInspectorProps) {
const activeTemplateId = useStore((state) => state.activeTemplateId);
const activeClassId = useStore((state) => state.activeClassId);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
const setActiveClassId = useStore((state) => state.setActiveClassId);
const setActiveClass = useStore((state) => state.setActiveClass);
const activeTemplate = templates.find((template) => template.id === activeTemplateId);
const selectTemplate = (template: Template) => {
setActiveTemplateId(template.id);
const firstClass = template.classes[0] || null;
setActiveClassId(firstClass?.id || null);
setActiveClass(firstClass);
};
const selectClass = (templateClass: TemplateClass) => {
setActiveClassId(templateClass.id);
setActiveClass(templateClass);
};
return (
<section>
{templates.map((template) => <button onClick={() => selectTemplate(template)}>{template.name}</button>)}
{activeTemplate?.classes.map((templateClass) => (
<button data-active={activeClassId === templateClass.id} onClick={() => selectClass(templateClass)}>
{templateClass.name}
</button>
))}
</section>
);
}
// src/components/TemplateRegistry.tsx
export function TemplateRegistry() {
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [bulkImportText, setBulkImportText] = useState('');
useEffect(() => {
getTemplates().then((items) => {
setTemplates(items);
setSelectedTemplate(items[0] || null);
});
}, []);
const createNewTemplate = async () => {
const template = await createTemplate({
name: '新建模板',
description: '',
color: '#00bcd4',
z_index: 0,
classes: [buildUndefinedClass()],
});
setTemplates([template, ...templates]);
setSelectedTemplate(template);
};
const saveTemplateClasses = async (classes: TemplateClass[]) => {
if (!selectedTemplate) return;
const normalized = normalizeClassMaskIds(ensureUndefinedClass(classes));
const updated = await updateTemplate(selectedTemplate.id, { ...selectedTemplate, classes: normalized });
setTemplates(templates.map((template) => template.id === updated.id ? updated : template));
setSelectedTemplate(updated);
};
const deleteClass = async (classId: string) => {
if (!selectedTemplate) return;
const nextClasses = selectedTemplate.classes.filter((item) => item.id !== classId || item.maskId === 0);
await saveTemplateClasses(nextClasses);
};
const applyBulkImport = async () => {
const parsed = parseBulkClasses(bulkImportText);
const nextClasses = [...(selectedTemplate?.classes || []), ...parsed.classes];
await saveTemplateClasses(nextClasses);
setBulkImportText('');
};
const duplicateTemplate = async (template: Template) => {
const copied = await createTemplate({
name: `${template.name} 副本`,
description: template.description,
color: template.color || '#00bcd4',
z_index: template.z_index || 0,
classes: template.classes,
rules: template.rules,
});
setTemplates([copied, ...templates]);
};
return <section data-template-count={templates.length} />;
}
// src/components/UserAdmin.tsx
export function UserAdmin() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [factoryResetText, setFactoryResetText] = useState('');
const loadAdminData = async () => {
const [nextUsers, nextLogs] = await Promise.all([getAdminUsers(), getAuditLogs(100)]);
setUsers(nextUsers);
setAuditLogs(nextLogs);
};
useEffect(() => {
void loadAdminData();
}, []);
const handleCreateUser = async (event: React.FormEvent) => {
event.preventDefault();
if (!newUsername.trim() || newPassword.length < 6) return;
const created = await createAdminUser({
username: newUsername.trim(),
password: newPassword,
role: 'annotator',
is_active: true,
});
setUsers((prev) => [...prev, created]);
setAuditLogs(await getAuditLogs(100));
};
const submitPasswordChange = async (user: AdminUser, password: string) => {
if (password.length < 6) return;
const updated = await updateAdminUser(user.id, { password });
setUsers((prev) => prev.map((item) => item.id === user.id ? updated : item));
};
const handleDeleteUser = async (user: AdminUser) => {
await deleteAdminUser(user.id);
setUsers((prev) => prev.filter((item) => item.id !== user.id));
setAuditLogs(await getAuditLogs(100));
};
const handleFactoryReset = async () => {
if (factoryResetText !== 'RESET_DEMO_FACTORY') return;
const result = await resetDemoFactory(factoryResetText);
setUsers([result.admin_user]);
setProjects(result.projects?.length ? result.projects : [result.project]);
setCurrentProject(null);
setFrames([]);
setMasks([]);
setSelectedMaskIds([]);
setAuditLogs(await getAuditLogs(100));
};
return <section data-users={users.length} data-logs={auditLogs.length} />;
}
// src/lib/websocket.ts
export class ProgressWebSocket {
private ws: WebSocket | null = null;
private progressHandlers = new Set<(data: ProgressMessage) => void>();
private statusHandlers = new Set<(status: ConnectionStatus) => void>();
constructor(private url = WS_PROGRESS_URL) {}
connect() {
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
this.ws = new WebSocket(this.url);
this.ws.onopen = () => this.emitStatus('connected');
this.ws.onclose = () => this.emitStatus('disconnected');
this.ws.onerror = () => this.emitStatus('error');
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data) as ProgressMessage;
this.progressHandlers.forEach((handler) => handler(data));
};
}
disconnect() {
this.ws?.close();
this.ws = null;
}
onProgress(handler: (data: ProgressMessage) => void) {
this.progressHandlers.add(handler);
return () => this.progressHandlers.delete(handler);
}
onStatus(handler: (status: ConnectionStatus) => void) {
this.statusHandlers.add(handler);
return () => this.statusHandlers.delete(handler);
}
isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private emitStatus(status: ConnectionStatus) {
this.statusHandlers.forEach((handler) => handler(status));
}
}
export const progressWS = new ProgressWebSocket();