Files
Pre_Seg_Server/新撰写软著文档/3. 代码汇总.md
admin 09f6137a8f 补充软著图文说明书与演示素材
- 将软著说明书占位图替换为线上系统实拍高清截图,并加入分模块 MP4 演示视频链接
- 新增登录、总体概况、项目库、分割工作区、AI 推理、GT Mask、导出、模板库、用户管理和退出登录截图素材
- 新增 4 段 1920x1080 系统使用演示视频,同时保留 Playwright 原始 WebM 录制文件
- 新增功能验证与素材清单,记录验证地址、截图文件、视频文件和非破坏性验证说明
- 新增可复用 Playwright 采集脚本,便于后续重新录制软著素材
2026-05-08 01:42:08 +08:00

1024 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
```typescript
// 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();
```