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

35 KiB
Raw Blame History

// 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();