- 将软著说明书占位图替换为线上系统实拍高清截图,并加入分模块 MP4 演示视频链接 - 新增登录、总体概况、项目库、分割工作区、AI 推理、GT Mask、导出、模板库、用户管理和退出登录截图素材 - 新增 4 段 1920x1080 系统使用演示视频,同时保留 Playwright 原始 WebM 录制文件 - 新增功能验证与素材清单,记录验证地址、截图文件、视频文件和非破坏性验证说明 - 新增可复用 Playwright 采集脚本,便于后续重新录制软著素材
1024 lines
35 KiB
Markdown
1024 lines
35 KiB
Markdown
```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();
|
||
```
|