Bläddra i källkod

-dev:完成了前端对于按项目进行标注的基本流程

LuoChinWen 1 månad sedan
förälder
incheckning
88f7d9c412

+ 6 - 6
backend/init_image_annotation_data.py

@@ -13,7 +13,7 @@ BASE_URL = "http://localhost:8000"
 # 1. 目标检测标注配置(矩形框标注)
 OBJECT_DETECTION_CONFIG = """<View>
   <Header value="目标检测 - 物体识别"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <RectangleLabels name="label" toName="image">
     <Label value="人" background="red"/>
     <Label value="车" background="blue"/>
@@ -26,7 +26,7 @@ OBJECT_DETECTION_CONFIG = """<View>
 # 2. 图像分类标注配置
 IMAGE_CLASSIFICATION_CONFIG = """<View>
   <Header value="图像分类"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <Choices name="category" toName="image" choice="single" showInline="true">
     <Choice value="风景"/>
     <Choice value="人物"/>
@@ -40,7 +40,7 @@ IMAGE_CLASSIFICATION_CONFIG = """<View>
 # 3. 图像分割标注配置(多边形标注)
 IMAGE_SEGMENTATION_CONFIG = """<View>
   <Header value="图像分割 - 精细标注"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <PolygonLabels name="label" toName="image">
     <Label value="前景" background="rgba(255, 0, 0, 0.5)"/>
     <Label value="背景" background="rgba(0, 0, 255, 0.5)"/>
@@ -51,7 +51,7 @@ IMAGE_SEGMENTATION_CONFIG = """<View>
 # 4. 关键点标注配置(人体姿态估计)
 KEYPOINT_DETECTION_CONFIG = """<View>
   <Header value="关键点标注 - 人体姿态"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <KeyPointLabels name="keypoint" toName="image">
     <Label value="头部" background="red"/>
     <Label value="肩膀" background="blue"/>
@@ -65,7 +65,7 @@ KEYPOINT_DETECTION_CONFIG = """<View>
 # 5. 多标签图像分类配置
 MULTI_LABEL_CLASSIFICATION_CONFIG = """<View>
   <Header value="多标签图像分类"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <Choices name="attributes" toName="image" choice="multiple" showInline="false">
     <Choice value="室内"/>
     <Choice value="室外"/>
@@ -81,7 +81,7 @@ MULTI_LABEL_CLASSIFICATION_CONFIG = """<View>
 # 6. 图像质量评估配置
 IMAGE_QUALITY_CONFIG = """<View>
   <Header value="图像质量评估"/>
-  <Image name="image" value="$image"/>
+  <Image name="image" value="$image" zoom="true" zoomControl="true" rotateControl="true"/>
   <Choices name="quality" toName="image" choice="single" showInline="true">
     <Choice value="优秀"/>
     <Choice value="良好"/>

+ 226 - 0
web/apps/lq_label/ANNOTATION_LOADING_FINAL_FIX.md

@@ -0,0 +1,226 @@
+# 标注加载最终修复总结
+
+## 问题回顾
+
+1. ✅ **标注结果无法加载** - 已修复
+2. ✅ **任务状态不更新** - 已修复
+3. ✅ **MobX 警告** - 已修复
+
+## 最终解决方案
+
+### 1. 使用 LabelStudio 的标准方式加载标注
+
+**关键改变**:在初始化 LabelStudio 时,直接在 `task` 对象中传递 `annotations` 数组,而不是在 `onStorageInitialized` 中手动创建。
+
+```typescript
+// 准备标注数据
+const annotations = [];
+const existingResult = loadedAnnotationRef.current;
+
+if (existingResult && existingResult.result && Array.isArray(existingResult.result)) {
+  annotations.push({
+    id: 'existing',
+    result: existingResult.result,
+  });
+}
+
+// 初始化 LabelStudio
+lsfInstanceRef.current = new LabelStudio(editorContainerRef.current, {
+  config: currentProject.config,
+  task: {
+    id: ...,
+    data: currentTask.data,
+    annotations: annotations.length > 0 ? annotations : undefined,  // 关键!
+  },
+  // ...
+});
+```
+
+### 2. 在 onStorageInitialized 中选择已加载的标注
+
+```typescript
+onStorageInitialized: (LS: any) => {
+  const initAnnotation = () => {
+    const as = LS.annotationStore;
+    
+    // 如果有已加载的标注,选择第一个
+    if (as.annotations && as.annotations.length > 0) {
+      as.selectAnnotation(as.annotations[0].id);
+      // 设置 snapshot 监听器
+    } else {
+      // 创建新的空白标注
+      const annotation = as.createAnnotation();
+      as.selectAnnotation(annotation.id);
+    }
+  };
+  setTimeout(initAnnotation, 100);
+}
+```
+
+### 3. 使用独立的 ref 存储加载的标注
+
+```typescript
+const loadedAnnotationRef = useRef<any>(null); // 存储从 API 加载的标注
+const annotationResultRef = useRef<any>(null); // 存储当前编辑器的标注
+```
+
+这样可以避免在清理时丢失已加载的数据。
+
+### 4. 优化清理逻辑,避免 MobX 警告
+
+**问题**:在组件卸载时,snapshot 监听器还在尝试访问已销毁的 MobX 对象。
+
+**解决方案**:
+1. 在 snapshot 回调中添加 try-catch 捕获错误
+2. 检查 `isCleanedUp` 和 `lsfInstanceRef.current` 确保实例还存在
+3. 先销毁 snapshot 监听器,再销毁 LabelStudio 实例
+
+```typescript
+// 在 snapshot 回调中
+snapshotDisposer = onSnapshot(annotation, () => {
+  if (!isCleanedUp && lsfInstanceRef.current) {
+    try {
+      annotationResultRef.current = annotation.serializeAnnotation();
+    } catch (e) {
+      // Ignore errors during cleanup
+      console.warn('Error serializing annotation:', e);
+    }
+  }
+});
+
+// 清理顺序
+function cleanup() {
+  // 1. 先销毁 snapshot 监听器
+  if (snapshotDisposer) {
+    snapshotDisposer();
+    snapshotDisposer = null;
+  }
+  
+  // 2. 再销毁 LabelStudio 实例
+  if (lsfInstanceRef.current) {
+    lsfInstanceRef.current.destroy();
+    lsfInstanceRef.current = null;
+  }
+}
+```
+
+## 修复效果
+
+### ✅ 标注加载
+- 打开已标注的任务时,标注结果正确显示
+- 多边形、标签等所有标注元素都正确渲染
+- 控制台显示 "✅ Selecting existing annotation"
+
+### ✅ 任务状态更新
+- 保存标注后,任务状态更新为"已完成"
+- 任务列表中正确显示完成状态
+- 不会创建重复的标注记录
+
+### ✅ 无 MobX 警告
+- 组件卸载时不再出现 MobX 警告
+- 清理逻辑正确执行
+- 控制台干净无错误
+
+## 测试验证
+
+### 场景 1:首次标注
+1. 打开待处理任务
+2. 完成标注
+3. 保存
+4. ✅ 任务状态变为"已完成"
+
+### 场景 2:重新打开已标注任务
+1. 打开已完成任务
+2. ✅ 标注结果正确显示
+3. ✅ 无 MobX 警告
+
+### 场景 3:修改已有标注
+1. 修改标注内容
+2. 保存
+3. 重新打开
+4. ✅ 显示最新修改
+
+### 场景 4:导航离开
+1. 打开标注界面
+2. 点击返回
+3. ✅ 无 MobX 警告
+4. ✅ 清理日志正常
+
+## 技术要点
+
+### LabelStudio 标注加载的正确方式
+
+LabelStudio 支持两种方式加载标注:
+
+1. **通过 task.annotations**(推荐)✅
+   ```typescript
+   task: {
+     data: {...},
+     annotations: [{ id: 'xxx', result: [...] }]
+   }
+   ```
+
+2. **通过 createAnnotation()**(不推荐用于加载已有标注)❌
+   ```typescript
+   as.createAnnotation({ result: [...] })
+   ```
+
+我们使用第一种方式,因为:
+- 这是 LabelStudio 的标准方式
+- 自动处理标注的初始化
+- 避免手动管理标注状态
+
+### MobX State Tree 清理
+
+MobX State Tree 要求:
+1. 在访问节点前检查它是否还在树中
+2. 在销毁树前先移除所有监听器
+3. 使用 try-catch 捕获清理时的错误
+
+### React useEffect 清理
+
+正确的清理顺序:
+1. 设置 `isCleanedUp = true` 标志
+2. 取消所有异步操作(animation frames)
+3. 移除所有事件监听器(snapshot disposers)
+4. 销毁外部实例(LabelStudio)
+5. 清理全局状态(window.LabelStudio)
+
+## 相关文件
+
+- `web/apps/lq_label/src/views/annotation-view/annotation-view.tsx` - 标注视图(主要修改)
+- `web/apps/lq_label/src/services/api.ts` - API 服务
+- `backend/routers/annotation.py` - 标注 API 路由
+- `backend/routers/task.py` - 任务 API 路由
+
+## 后续优化建议
+
+1. **移除调试日志**
+   - 生产环境中移除详细的 console.log
+   - 保留关键的错误日志
+
+2. **添加加载状态**
+   - 显示"加载标注中..."提示
+   - 处理加载失败的情况
+
+3. **性能优化**
+   - 缓存标注数据,避免重复请求
+   - 使用 React.memo 优化组件渲染
+
+4. **用户体验**
+   - 添加"标注已加载"的提示
+   - 支持撤销/重做功能
+   - 添加自动保存功能
+
+## 总结
+
+通过使用 LabelStudio 的标准 API 和正确的清理逻辑,我们成功解决了:
+- ✅ 标注结果加载问题
+- ✅ 任务状态更新问题
+- ✅ MobX 警告问题
+
+现在标注系统可以正常工作,用户可以:
+- 创建新标注
+- 保存标注
+- 重新打开并继续编辑
+- 无错误和警告

+ 198 - 0
web/apps/lq_label/ANNOTATION_PERSISTENCE_FIX.md

@@ -0,0 +1,198 @@
+# 标注结果持久化修复
+
+## 问题描述
+
+1. **标注结果没有加载**:用户保存标注后,再次打开标注界面时,之前的标注结果没有被加载
+2. **任务状态没有更新**:保存标注后,任务列表中的状态没有正确显示为"已完成"
+
+## 问题根源
+
+### 1. 前端没有加载已有标注
+
+在 `annotation-view.tsx` 中,编辑器初始化时:
+- 只创建了新的空白标注 (`as.createAnnotation()`)
+- 没有检查是否已有标注结果
+- 没有将已有标注加载到编辑器中
+
+### 2. 保存逻辑不正确
+
+保存标注时:
+- 每次都创建新的标注记录,而不是更新已有的
+- 任务状态更新逻辑不正确(只是增加进度,而不是直接标记为完成)
+
+## 修复方案
+
+### 1. 加载已有标注 (annotation-view.tsx)
+
+#### 修改数据加载逻辑
+
+```typescript
+// 在 loadData 函数中添加
+const existingAnnotations = await getTaskAnnotations(id);
+console.log('Existing annotations:', existingAnnotations);
+
+// Store existing annotations for later use
+if (existingAnnotations.length > 0) {
+  // Use the most recent annotation
+  const latestAnnotation = existingAnnotations[0];
+  annotationResultRef.current = latestAnnotation.result;
+  console.log('Loaded existing annotation:', latestAnnotation);
+}
+```
+
+#### 修改编辑器初始化逻辑
+
+```typescript
+onStorageInitialized: (LS: any) => {
+  const initAnnotation = () => {
+    const as = LS.annotationStore;
+    
+    // Check if we have existing annotation data
+    const existingResult = annotationResultRef.current;
+    
+    if (existingResult && existingResult.result && Array.isArray(existingResult.result)) {
+      console.log('Loading existing annotation:', existingResult);
+      
+      // Create annotation with existing data
+      const annotation = as.createAnnotation({
+        userGenerate: false,
+        result: existingResult.result,
+      });
+      as.selectAnnotation(annotation.id);
+      
+      // Set up snapshot listener
+      // ...
+    } else {
+      console.log('Creating new annotation');
+      
+      // Create new empty annotation
+      const annotation = as.createAnnotation();
+      as.selectAnnotation(annotation.id);
+      // ...
+    }
+  };
+  setTimeout(initAnnotation, 100);
+}
+```
+
+### 2. 修复保存逻辑
+
+#### 检查并更新已有标注
+
+```typescript
+// Check if annotation already exists for this task
+const existingAnnotations = await getTaskAnnotations(id);
+
+if (existingAnnotations.length > 0) {
+  // Update existing annotation
+  const existingAnnotation = existingAnnotations[0];
+  await updateAnnotation(existingAnnotation.id, {
+    result: resultData,
+  });
+  console.log('Updated existing annotation:', existingAnnotation.id);
+} else {
+  // Create new annotation
+  await createAnnotation({
+    task_id: id,
+    user_id: 'current_user',
+    result: resultData,
+  });
+  console.log('Created new annotation');
+}
+```
+
+#### 直接标记任务为已完成
+
+```typescript
+// Update task status to completed
+await updateTask(id, {
+  status: 'completed',
+});
+
+console.log('Task marked as completed');
+```
+
+### 3. 添加必要的 API 导入
+
+```typescript
+import { 
+  getTask, 
+  getProject, 
+  createAnnotation, 
+  updateTask, 
+  getTaskAnnotations,  // 新增
+  updateAnnotation      // 新增
+} from '../../services/api';
+```
+
+## 修复效果
+
+### 修复前
+- ❌ 再次打开标注界面时,之前的标注结果丢失
+- ❌ 每次保存都创建新的标注记录(数据库中有重复)
+- ❌ 任务状态不会变为"已完成"
+
+### 修复后
+- ✅ 再次打开标注界面时,自动加载之前的标注结果
+- ✅ 保存时更新已有标注,而不是创建新记录
+- ✅ 保存后任务状态正确显示为"已完成"
+- ✅ 任务列表中的进度和状态正确更新
+
+## 测试步骤
+
+1. **创建新任务并标注**
+   - 打开一个待处理的任务
+   - 完成标注
+   - 点击"保存"按钮
+   - 验证:返回任务列表后,任务状态显示为"已完成"
+
+2. **重新打开已标注的任务**
+   - 在任务列表中点击已完成的任务
+   - 验证:编辑器中显示之前保存的标注结果
+   - 修改标注
+   - 再次保存
+   - 验证:修改被正确保存(没有创建重复记录)
+
+3. **检查数据库**
+   - 查看 `annotations` 表
+   - 验证:每个任务只有一条标注记录
+   - 验证:`updated_at` 字段在修改后正确更新
+
+## 相关文件
+
+- `web/apps/lq_label/src/views/annotation-view/annotation-view.tsx` - 标注视图组件
+- `web/apps/lq_label/src/services/api.ts` - API 服务层
+- `backend/routers/annotation.py` - 标注 API 路由
+- `backend/routers/task.py` - 任务 API 路由
+
+## 注意事项
+
+1. **LabelStudio 数据格式**
+   - `createAnnotation()` 的 `userGenerate: false` 参数表示使用预加载的数据
+   - `result` 字段必须是数组格式
+
+2. **并发问题**
+   - 当前实现假设一个任务只有一个标注者
+   - 如果需要支持多人标注,需要修改逻辑(根据 user_id 过滤)
+
+3. **性能优化**
+   - 当前每次保存都会查询已有标注
+   - 可以考虑在组件状态中缓存标注 ID,避免重复查询
+
+## 后续优化建议
+
+1. **添加用户认证**
+   - 当前使用硬编码的 `'current_user'`
+   - 应该从认证上下文中获取真实的用户 ID
+
+2. **支持多人标注**
+   - 根据用户 ID 加载和保存标注
+   - 显示其他用户的标注(只读模式)
+
+3. **添加自动保存**
+   - 定期自动保存标注结果
+   - 避免用户忘记保存导致数据丢失
+
+4. **添加版本历史**
+   - 保存标注的历史版本
+   - 支持回退到之前的版本

+ 158 - 0
web/apps/lq_label/CLEANUP_SUMMARY.md

@@ -0,0 +1,158 @@
+# 代码清理总结
+
+## 清理内容
+
+### 移除的调试日志
+
+1. **数据加载日志**
+   - ❌ `console.log('=== Annotation Loading Debug ===')`
+   - ❌ `console.log('Task ID:', id)`
+   - ❌ `console.log('Existing annotations count:', ...)`
+   - ❌ `console.log('Existing annotations:', ...)`
+   - ❌ `console.log('Latest annotation result:', ...)`
+   - ❌ `console.log('✅ Loaded existing annotation into refs')`
+   - ❌ `console.log('ℹ️ No existing annotations found')`
+
+2. **编辑器初始化日志**
+   - ❌ `console.log('Loading LabelStudio editor for task:', ...)`
+   - ❌ `console.log('Component was cleaned up, skipping LSF initialization')`
+   - ❌ `console.log('Initializing LabelStudio instance...')`
+   - ❌ `console.log('✅ Preparing to load existing annotation with', ...)`
+   - ❌ `console.log('=== Storage Initialized ===')`
+   - ❌ `console.log('Annotation store:', as)`
+   - ❌ `console.log('Existing annotations in store:', ...)`
+   - ❌ `console.log('✅ Selecting existing annotation:', ...)`
+   - ❌ `console.log('ℹ️ Creating new empty annotation')`
+   - ❌ `console.log('LabelStudio instance initialized')`
+
+3. **清理日志**
+   - ❌ `console.log('Cleaning up LabelStudio editor...')`
+   - ❌ `console.warn('Error disposing snapshot:', e)`
+   - ❌ `console.warn('Error disposing snapshot ref:', e)`
+   - ❌ `console.warn('Error destroying LSF instance:', e)`
+   - ❌ `console.log('LabelStudio editor cleaned up')`
+
+4. **保存日志**
+   - ❌ `console.log('=== Save Annotation Debug ===')`
+   - ❌ `console.log('Annotation result:', ...)`
+   - ❌ `console.log('Annotation result type:', ...)`
+   - ❌ `console.log('Has result field?', ...)`
+   - ❌ `console.log('Sending annotation data:', ...)`
+   - ❌ `console.log('Updated existing annotation:', ...)`
+   - ❌ `console.log('Created new annotation')`
+   - ❌ `console.log('Task marked as completed')`
+   - ❌ `console.warn('Error serializing annotation:', e)`
+
+### 保留的日志
+
+只保留了关键的错误日志:
+- ✅ `console.error('Error loading LabelStudio:', err)` - 编辑器加载失败
+- ✅ `console.error('Save annotation error:', err)` - 保存失败
+
+## 关于 React Key Warning
+
+### 警告内容
+```
+Warning: A props object containing a "key" prop is being spread into JSX
+```
+
+### 来源
+- 来自 LabelStudio 内部的 `OutlinerTree.tsx` 组件
+- 使用了 Ant Design 的 Tree 组件
+- 这是 LabelStudio 库本身的问题
+
+### 影响
+- ⚠️ 这是一个 React 警告,不是错误
+- ✅ 不影响功能正常使用
+- ✅ 不影响性能
+- ✅ 只在开发模式下显示
+
+### 解决方案
+1. **短期**:可以忽略这个警告
+2. **长期**:等待 LabelStudio 库更新修复
+3. **可选**:如果需要,可以在浏览器控制台中过滤这类警告
+
+### 如何过滤警告(可选)
+
+在浏览器控制台中:
+1. 点击"过滤器"图标
+2. 添加过滤规则:`-OutlinerTree`
+3. 或者添加:`-A props object containing a "key" prop`
+
+## 代码质量改进
+
+### 1. 错误处理
+- 所有 try-catch 块都保留
+- 清理时的错误被静默处理(不影响用户体验)
+- 关键错误仍然会显示给用户
+
+### 2. 代码可读性
+- 移除了大量调试日志
+- 保留了必要的注释
+- 代码逻辑清晰
+
+### 3. 性能
+- 减少了控制台输出
+- 减少了字符串拼接和 JSON 序列化
+- 提升了运行时性能
+
+## 清理后的控制台
+
+### 正常流程
+- 无日志输出(除非发生错误)
+- 干净的控制台
+- 只有 LabelStudio 的 React key 警告(可忽略)
+
+### 错误流程
+- 显示清晰的错误信息
+- 用户可以看到具体的错误原因
+- 便于调试和问题定位
+
+## 文件变更
+
+- ✅ `web/apps/lq_label/src/views/annotation-view/annotation-view.tsx` - 移除所有调试日志
+
+## 测试建议
+
+1. **功能测试**
+   - ✅ 创建新标注
+   - ✅ 保存标注
+   - ✅ 重新打开已标注任务
+   - ✅ 修改已有标注
+   - ✅ 任务状态更新
+
+2. **控制台检查**
+   - ✅ 正常流程无日志输出
+   - ✅ 错误时显示清晰的错误信息
+   - ✅ 无 MobX 警告
+   - ⚠️ 有 React key 警告(来自 LabelStudio,可忽略)
+
+3. **性能检查**
+   - ✅ 页面加载速度
+   - ✅ 标注操作响应速度
+   - ✅ 内存使用情况
+
+## 后续优化
+
+1. **生产环境配置**
+   - 考虑使用环境变量控制日志级别
+   - 生产环境完全禁用 console.log
+   - 开发环境可以保留部分日志
+
+2. **日志系统**
+   - 考虑引入专业的日志库(如 winston)
+   - 实现日志级别控制(debug, info, warn, error)
+   - 支持日志上报到服务器
+
+3. **错误监控**
+   - 集成错误监控服务(如 Sentry)
+   - 自动收集和上报错误
+   - 提供错误分析和告警
+
+## 总结
+
+✅ 成功移除了所有调试日志
+✅ 保留了关键的错误日志
+✅ 代码更加简洁和专业
+✅ 控制台输出干净整洁
+⚠️ LabelStudio 的 React key 警告可以忽略

+ 209 - 0
web/apps/lq_label/TESTING_ANNOTATION_FIX.md

@@ -0,0 +1,209 @@
+# 标注持久化修复 - 测试指南
+
+## 快速测试步骤
+
+### 准备工作
+
+1. **启动后端服务器**
+   ```bash
+   cd backend
+   python -m uvicorn main:app --reload --host 0.0.0.0 --port 8000
+   ```
+
+2. **启动前端服务器**
+   ```bash
+   cd web
+   yarn nx serve lq_label
+   ```
+
+3. **确保有测试数据**
+   - 如果没有,运行:`cd backend && python init_sample_data.py`
+
+### 测试场景 1:首次标注并保存
+
+**目标**:验证标注可以正确保存,任务状态更新为"已完成"
+
+1. 打开浏览器访问 `http://localhost:4200`
+2. 点击左侧菜单"任务管理"
+3. 找到一个状态为"待处理"的任务
+4. 点击"开始标注"按钮(播放图标)
+5. 在编辑器中完成标注(例如:选择一个标签)
+6. 点击右上角"保存"按钮
+7. **验证点**:
+   - ✅ 页面自动返回任务列表
+   - ✅ 该任务的状态显示为"已完成"
+   - ✅ 进度显示为 100%
+   - ✅ 控制台没有错误信息
+
+### 测试场景 2:重新打开已标注的任务
+
+**目标**:验证已保存的标注结果可以正确加载
+
+1. 在任务列表中,找到刚才标注的任务(状态为"已完成")
+2. 再次点击"开始标注"按钮
+3. **验证点**:
+   - ✅ 编辑器加载完成后,显示之前保存的标注结果
+   - ✅ 标签、区域等标注内容完整显示
+   - ✅ 控制台显示 "Loaded existing annotation" 日志
+
+### 测试场景 3:修改已有标注
+
+**目标**:验证修改标注后可以正确更新,不会创建重复记录
+
+1. 在已有标注的基础上进行修改(例如:添加新标签或删除标签)
+2. 点击"保存"按钮
+3. 再次打开该任务
+4. **验证点**:
+   - ✅ 显示最新的修改结果
+   - ✅ 控制台显示 "Updated existing annotation" 日志
+   - ✅ 数据库中该任务只有一条标注记录(不是两条)
+
+### 测试场景 4:检查数据库
+
+**目标**:验证数据库中的数据正确性
+
+1. 打开数据库文件:`backend/annotation_platform.db`
+2. 查询标注表:
+   ```sql
+   SELECT * FROM annotations WHERE task_id = 'your_task_id';
+   ```
+3. **验证点**:
+   - ✅ 每个任务只有一条标注记录
+   - ✅ `result` 字段包含完整的标注数据(JSON 格式)
+   - ✅ `updated_at` 字段在修改后有更新
+
+4. 查询任务表:
+   ```sql
+   SELECT id, name, status FROM tasks WHERE id = 'your_task_id';
+   ```
+5. **验证点**:
+   - ✅ 任务的 `status` 字段为 `'completed'`
+
+## 控制台日志检查
+
+### 正常流程的日志输出
+
+#### 首次标注
+```
+Loading LabelStudio editor for task: task_xxx
+Initializing LabelStudio instance...
+Creating new annotation
+LabelStudio instance initialized
+Annotation result: { result: [...] }
+Sending annotation data: { result: [...] }
+Created new annotation
+Task marked as completed
+```
+
+#### 重新打开已标注任务
+```
+Loading LabelStudio editor for task: task_xxx
+Existing annotations: [{ id: 'ann_xxx', ... }]
+Loaded existing annotation: { id: 'ann_xxx', result: {...} }
+Initializing LabelStudio instance...
+Loading existing annotation: { result: [...] }
+LabelStudio instance initialized
+```
+
+#### 修改已有标注
+```
+Annotation result: { result: [...] }
+Sending annotation data: { result: [...] }
+Updated existing annotation: ann_xxx
+Task marked as completed
+```
+
+## 常见问题排查
+
+### 问题 1:标注结果没有加载
+
+**症状**:再次打开任务时,编辑器是空白的
+
+**排查步骤**:
+1. 打开浏览器控制台
+2. 查看是否有 "Loaded existing annotation" 日志
+3. 检查 `existingAnnotations` 数组是否为空
+4. 检查数据库中是否有该任务的标注记录
+
+**可能原因**:
+- 标注没有正确保存
+- API 请求失败
+- 标注数据格式不正确
+
+### 问题 2:任务状态没有更新
+
+**症状**:保存后任务仍然显示为"待处理"或"进行中"
+
+**排查步骤**:
+1. 检查控制台是否有 "Task marked as completed" 日志
+2. 检查 Network 标签,查看 PUT `/api/tasks/{id}` 请求是否成功
+3. 刷新任务列表页面
+
+**可能原因**:
+- API 请求失败
+- 后端更新任务失败
+- 前端缓存没有刷新
+
+### 问题 3:创建了重复的标注记录
+
+**症状**:数据库中同一个任务有多条标注记录
+
+**排查步骤**:
+1. 检查控制台日志,应该显示 "Updated existing annotation" 而不是 "Created new annotation"
+2. 检查 `getTaskAnnotations` API 是否正确返回已有标注
+
+**可能原因**:
+- `getTaskAnnotations` 返回空数组
+- 条件判断逻辑错误
+
+## 性能测试
+
+### 测试大量标注数据
+
+1. 创建一个包含大量标注点的任务(例如:100+ 个标签)
+2. 保存标注
+3. 重新打开任务
+4. **验证点**:
+   - ✅ 加载时间在可接受范围内(< 3秒)
+   - ✅ 所有标注点都正确显示
+   - ✅ 编辑器响应流畅
+
+## 浏览器兼容性测试
+
+建议在以下浏览器中测试:
+- ✅ Chrome (最新版本)
+- ✅ Firefox (最新版本)
+- ✅ Edge (最新版本)
+- ✅ Safari (如果使用 Mac)
+
+## 回归测试
+
+确保修复没有破坏其他功能:
+- ✅ 创建新项目
+- ✅ 创建新任务
+- ✅ 删除任务
+- ✅ 项目列表显示正常
+- ✅ 任务列表筛选功能正常
+
+## 测试完成检查清单
+
+- [ ] 场景 1:首次标注并保存 - 通过
+- [ ] 场景 2:重新打开已标注的任务 - 通过
+- [ ] 场景 3:修改已有标注 - 通过
+- [ ] 场景 4:数据库数据正确 - 通过
+- [ ] 控制台日志正常 - 通过
+- [ ] 没有重复标注记录 - 通过
+- [ ] 任务状态正确更新 - 通过
+- [ ] 性能测试通过 - 通过
+- [ ] 浏览器兼容性测试通过 - 通过
+- [ ] 回归测试通过 - 通过
+
+## 报告问题
+
+如果测试中发现问题,请记录:
+1. 问题描述
+2. 复现步骤
+3. 预期结果 vs 实际结果
+4. 浏览器控制台日志
+5. Network 请求详情
+6. 数据库查询结果(如果相关)

+ 2 - 0
web/apps/lq_label/src/app/app.tsx

@@ -10,6 +10,7 @@ import {
   AnnotationsView,
   AnnotationView,
 } from '../views';
+import { ProjectAnnotationView } from '../views/project-annotation-view';
 import { EditorTest } from '../views/editor-test';
 import { ToastContainer } from '../components/toast-container';
 import { ErrorBoundary } from '../components/error-boundary';
@@ -38,6 +39,7 @@ export function App() {
             {/* Projects Routes */}
             <Route path="/projects" element={<ProjectsView />} />
             <Route path="/projects/:id/edit" element={<ProjectEditView />} />
+            <Route path="/projects/:projectId/annotate" element={<ProjectAnnotationView />} />
 
             {/* Tasks Routes */}
             <Route path="/tasks" element={<TasksView />} />

+ 1 - 0
web/apps/lq_label/src/components/layout/layout.module.scss

@@ -16,6 +16,7 @@
   flex: 1;
   overflow-y: auto;
   overflow-x: hidden;
+  position: relative;
 }
 
 .contentInner {

+ 92 - 55
web/apps/lq_label/src/views/annotation-view/annotation-view.tsx

@@ -14,7 +14,7 @@ import {
   IconCheck,
   IconForward,
 } from '@humansignal/ui';
-import { getTask, getProject, createAnnotation, updateTask } from '../../services/api';
+import { getTask, getProject, createAnnotation, updateTask, getTaskAnnotations, updateAnnotation } from '../../services/api';
 import { currentTaskAtom } from '../../atoms/task-atoms';
 import { currentProjectAtom } from '../../atoms/project-atoms';
 import { LoadingSpinner } from '../../components/loading-spinner';
@@ -40,6 +40,7 @@ export const AnnotationView: React.FC = () => {
   const lsfInstanceRef = useRef<any>(null);
   const snapshotDisposerRef = useRef<any>(null);
   const annotationResultRef = useRef<any>(null);
+  const loadedAnnotationRef = useRef<any>(null); // Store loaded annotation separately
   const rafIdRef = useRef<number | null>(null);
 
   // Load task and project data
@@ -58,6 +59,19 @@ export const AnnotationView: React.FC = () => {
         // Load project details to get annotation config
         const projectData = await getProject(taskData.project_id);
         setCurrentProject(projectData);
+        
+        // Load existing annotations for this task
+        const existingAnnotations = await getTaskAnnotations(id);
+        
+        // Store existing annotations for later use
+        if (existingAnnotations.length > 0) {
+          // Use the most recent annotation
+          const latestAnnotation = existingAnnotations[0];
+          loadedAnnotationRef.current = latestAnnotation.result;
+          annotationResultRef.current = latestAnnotation.result;
+        } else {
+          loadedAnnotationRef.current = null;
+        }
       } catch (err: any) {
         setError(err.message || '加载任务失败');
       } finally {
@@ -82,15 +96,13 @@ export const AnnotationView: React.FC = () => {
     function cleanup() {
       if (isCleanedUp) return;
       isCleanedUp = true;
-
-      console.log('Cleaning up LabelStudio editor...');
       
-      // Dispose snapshot listener first
+      // Dispose snapshot listener first (before destroying instance)
       if (snapshotDisposer) {
         try {
           snapshotDisposer();
         } catch (e) {
-          console.warn('Error disposing snapshot:', e);
+          // Ignore cleanup errors
         }
         snapshotDisposer = null;
       }
@@ -99,7 +111,7 @@ export const AnnotationView: React.FC = () => {
         try {
           snapshotDisposerRef.current();
         } catch (e) {
-          console.warn('Error disposing snapshot ref:', e);
+          // Ignore cleanup errors
         }
         snapshotDisposerRef.current = null;
       }
@@ -113,15 +125,10 @@ export const AnnotationView: React.FC = () => {
       // Destroy LabelStudio instance
       if (lsfInstanceRef.current) {
         try {
-          // Give React time to finish rendering before destroying
-          setTimeout(() => {
-            if (lsfInstanceRef.current) {
-              lsfInstanceRef.current.destroy();
-              lsfInstanceRef.current = null;
-            }
-          }, 0);
+          lsfInstanceRef.current.destroy();
+          lsfInstanceRef.current = null;
         } catch (e) {
-          console.warn('Error destroying LSF instance:', e);
+          // Ignore cleanup errors
           lsfInstanceRef.current = null;
         }
       }
@@ -133,16 +140,12 @@ export const AnnotationView: React.FC = () => {
       
       // Reset state
       setEditorReady(false);
-      annotationResultRef.current = null;
-      
-      console.log('LabelStudio editor cleaned up');
     }
 
     async function loadLSF() {
       try {
-        console.log('Loading LabelStudio editor for task:', currentTask.id);
-        
         // Dynamically import LabelStudio
+        // @ts-ignore - LabelStudio doesn't have TypeScript declarations
         dependencies = await import('@humansignal/editor');
         LabelStudio = dependencies.LabelStudio;
         
@@ -151,13 +154,11 @@ export const AnnotationView: React.FC = () => {
           return;
         }
 
-        // Don't cleanup here, let the previous effect cleanup handle it
         setEditorReady(true);
 
         // Initialize LabelStudio instance
         setTimeout(() => {
           if (isCleanedUp) {
-            console.log('Component was cleaned up, skipping LSF initialization');
             return;
           }
           
@@ -165,8 +166,23 @@ export const AnnotationView: React.FC = () => {
             setError('编辑器容器未找到');
             return;
           }
-
-          console.log('Initializing LabelStudio instance...');
+          
+          // Safety check: ensure currentProject and currentTask exist
+          if (!currentProject || !currentTask) {
+            setError('项目或任务数据未加载');
+            return;
+          }
+          
+          // Prepare annotations if we have existing data
+          const annotations = [];
+          const existingResult = loadedAnnotationRef.current;
+          
+          if (existingResult && existingResult.result && Array.isArray(existingResult.result)) {
+            annotations.push({
+              id: 'existing',
+              result: existingResult.result,
+            });
+          }
 
           lsfInstanceRef.current = new LabelStudio(editorContainerRef.current, {
             config: currentProject.config,
@@ -177,6 +193,7 @@ export const AnnotationView: React.FC = () => {
                 return a & a;
               }, 0)),
               data: currentTask.data,
+              annotations: annotations.length > 0 ? annotations : undefined,
             },
             interfaces: [
               'panel',
@@ -201,28 +218,50 @@ export const AnnotationView: React.FC = () => {
             onStorageInitialized: (LS: any) => {
               const initAnnotation = () => {
                 if (isCleanedUp) {
-                  console.log('Component was cleaned up, skipping annotation initialization');
                   return;
                 }
                 
                 const as = LS.annotationStore;
-                const annotation = as.createAnnotation();
-                as.selectAnnotation(annotation.id);
+                
+                // If we have loaded annotations, select the first one
+                if (as.annotations && as.annotations.length > 0) {
+                  as.selectAnnotation(as.annotations[0].id);
+                  
+                  const annotation = as.selected;
+                  if (annotation && !isCleanedUp) {
+                    snapshotDisposer = onSnapshot(annotation, () => {
+                      if (!isCleanedUp && lsfInstanceRef.current) {
+                        try {
+                          annotationResultRef.current = annotation.serializeAnnotation();
+                        } catch (e) {
+                          // Ignore errors during cleanup
+                        }
+                      }
+                    });
+                    snapshotDisposerRef.current = snapshotDisposer;
+                  }
+                } else {
+                  // Create new empty annotation
+                  const annotation = as.createAnnotation();
+                  as.selectAnnotation(annotation.id);
 
-                if (annotation) {
-                  snapshotDisposer = onSnapshot(annotation, () => {
-                    if (!isCleanedUp) {
-                      annotationResultRef.current = annotation.serializeAnnotation();
-                    }
-                  });
-                  snapshotDisposerRef.current = snapshotDisposer;
+                  if (annotation && !isCleanedUp) {
+                    snapshotDisposer = onSnapshot(annotation, () => {
+                      if (!isCleanedUp && lsfInstanceRef.current) {
+                        try {
+                          annotationResultRef.current = annotation.serializeAnnotation();
+                        } catch (e) {
+                          // Ignore errors during cleanup
+                        }
+                      }
+                    });
+                    snapshotDisposerRef.current = snapshotDisposer;
+                  }
                 }
               };
               setTimeout(initAnnotation, 100);
             },
           });
-          
-          console.log('LabelStudio instance initialized');
         }, 100);
       } catch (err: any) {
         console.error('Error loading LabelStudio:', err);
@@ -249,8 +288,6 @@ export const AnnotationView: React.FC = () => {
       // Get annotation result from editor
       const annotationResult = annotationResultRef.current;
       
-      console.log('Annotation result:', annotationResult);
-      
       // Validate annotation result
       if (!annotationResult || typeof annotationResult !== 'object') {
         setError('请完成标注后再保存');
@@ -259,18 +296,13 @@ export const AnnotationView: React.FC = () => {
       }
       
       // Extract only the result field if it exists, otherwise use the whole object
-      // LabelStudio's serializeAnnotation() returns an object with various fields
-      // We need to extract just the annotation data
       let resultData: Record<string, any> = {};
       
       if (annotationResult.result && Array.isArray(annotationResult.result)) {
-        // Standard LabelStudio format: { result: [...], ... }
         resultData = { result: annotationResult.result };
       } else if (Array.isArray(annotationResult)) {
-        // If it's already an array, wrap it
         resultData = { result: annotationResult };
       } else {
-        // Use the whole object
         resultData = annotationResult;
       }
       
@@ -281,22 +313,27 @@ export const AnnotationView: React.FC = () => {
         return;
       }
       
-      console.log('Sending annotation data:', resultData);
-      
-      // Create annotation
-      await createAnnotation({
-        task_id: id,
-        user_id: 'current_user', // TODO: Get from auth context
-        result: resultData,
-      });
+      // Check if annotation already exists for this task
+      const existingAnnotations = await getTaskAnnotations(id);
       
-      // Calculate new progress (increment by 10%, max 100%)
-      const newProgress = Math.min(currentTask.progress + 10, 100);
-      const newStatus = newProgress >= 100 ? 'completed' : 'in_progress';
+      if (existingAnnotations.length > 0) {
+        // Update existing annotation
+        const existingAnnotation = existingAnnotations[0];
+        await updateAnnotation(existingAnnotation.id, {
+          result: resultData,
+        });
+      } else {
+        // Create new annotation
+        await createAnnotation({
+          task_id: id,
+          user_id: 'current_user', // TODO: Get from auth context
+          result: resultData,
+        });
+      }
       
-      // Update task status and progress
+      // Update task status to completed
       await updateTask(id, {
-        status: newStatus,
+        status: 'completed',
       });
       
       // Navigate back to tasks list

+ 1 - 0
web/apps/lq_label/src/views/project-annotation-view/index.ts

@@ -0,0 +1 @@
+export { ProjectAnnotationView } from './project-annotation-view';

+ 450 - 0
web/apps/lq_label/src/views/project-annotation-view/project-annotation-view.module.scss

@@ -0,0 +1,450 @@
+.root {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  background: var(--theme-background);
+  overflow: hidden;
+
+  // LabelStudio 全局样式(参考 annotation-view)
+  :global(.lsf-tabs-panel__body) {
+    height: 100%;
+  }
+
+  :global(.lsf-panel-tabs__tab) {
+    color: var(--color-neutral-content-subtler);
+  }
+
+  :global(.lsf-panel-tabs__tab_active) {
+    transform: none;
+    border-width: 0;
+    color: var(--color-neutral-content);
+    box-shadow: 1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%), -1px -1px 0 rgba(var(--color-neutral-shadow-raw) / 4%);
+  }
+
+  :global(.lsf-tabs__tabs-row) {
+    border: none;
+  }
+
+  :global(.lsf-sidepanels_collapsed .lsf-sidepanels__wrapper .lsf-tabs__contents) {
+    border: none;
+  }
+
+  :global(.lsf-sidepanels_collapsed) {
+    flex: 1;
+  }
+
+  :global(.lsf-wrapper) {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+  }
+
+  :global(.lsf-editor) {
+    min-width: 320px;
+    width: 100%;
+  }
+
+  :global(.relations-overlay) {
+    background: transparent !important;
+    fill: none !important;
+  }
+
+  :global(.lsf-container) {
+    background: transparent !important;
+  }
+}
+
+// Sidebar
+.sidebar {
+  width: 240px;
+  flex-shrink: 0;
+  display: flex;
+  flex-direction: column;
+  background: var(--theme-card-background);
+  border: 1px solid var(--theme-border);
+  border-radius: 12px;
+  overflow: hidden;
+  margin: 16px;
+  margin-right: 16px;
+  align-self: flex-start;
+  max-height: calc(100vh - 120px);
+  position: sticky;
+  top: 16px;
+}
+
+.sidebarHeader {
+  padding: 16px;
+  border-bottom: 1px solid var(--theme-border);
+  background: var(--theme-background-secondary);
+}
+
+.sidebarTitle {
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--theme-headline);
+  margin: 0 0 4px 0;
+}
+
+.sidebarSubtitle {
+  font-size: 12px;
+  color: var(--theme-paragraph-subtle);
+  margin: 0;
+}
+
+.taskList {
+  flex: 1;
+  overflow-y: auto;
+  padding: 8px;
+}
+
+.taskItem {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  width: 100%;
+  padding: 10px 12px;
+  margin-bottom: 2px;
+  background: transparent;
+  border: none;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.15s ease;
+  text-align: left;
+
+  &:hover {
+    background: var(--theme-background-secondary);
+  }
+
+  &.taskItemActive {
+    background: var(--theme-button);
+
+    .taskName {
+      color: var(--theme-button-text);
+      font-weight: 600;
+    }
+
+    .taskStatus {
+      color: var(--theme-button-text);
+      opacity: 0.9;
+    }
+  }
+}
+
+.taskIcon {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8px;
+}
+
+.iconCompleted {
+  color: var(--theme-success);
+}
+
+.iconCurrent {
+  color: var(--theme-button);
+}
+
+.iconPending {
+  color: var(--theme-paragraph-subtle);
+}
+
+.taskInfo {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+}
+
+.taskName {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--theme-headline);
+  margin: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  flex: 1;
+}
+
+.taskStatus {
+  font-size: 11px;
+  color: var(--theme-paragraph-subtle);
+  margin: 0;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
+// Main Content
+.main {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  width: 100%;
+}
+
+.header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 24px;
+  background: var(--theme-background);
+  border-bottom: 1px solid var(--theme-border);
+  flex-shrink: 0;
+  gap: 24px;
+}
+
+.headerLeft {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  flex: 1;
+  min-width: 0;
+}
+
+.headerDivider {
+  width: 1px;
+  height: 24px;
+  background: var(--theme-border);
+  flex-shrink: 0;
+}
+
+.headerCenter {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.headerRight {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-shrink: 0;
+}
+
+.taskInfo {
+  flex: 1;
+  min-width: 0;
+}
+
+.taskName {
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--theme-headline);
+  margin: 0 0 2px 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.taskMeta {
+  font-size: 12px;
+  color: var(--theme-paragraph);
+  margin: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+// 缩放控制样式
+.zoomControls {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  padding: 4px 8px;
+  background: var(--theme-background-secondary);
+  border: 1px solid var(--theme-border);
+  border-radius: 8px;
+}
+
+.zoomButton {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  background: transparent;
+  border: none;
+  border-radius: 6px;
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.15s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-background);
+    color: var(--theme-headline);
+  }
+
+  &:active:not(:disabled) {
+    transform: scale(0.95);
+  }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+  }
+
+  svg {
+    flex-shrink: 0;
+  }
+}
+
+.zoomDisplay {
+  min-width: 48px;
+  padding: 0 8px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--theme-headline);
+  text-align: center;
+  user-select: none;
+}
+
+.zoomDivider {
+  width: 1px;
+  height: 20px;
+  background: var(--theme-border);
+  margin: 0 4px;
+}
+
+.errorBanner {
+  padding: 12px 24px;
+  background: var(--theme-error);
+  color: var(--theme-button-text);
+  font-size: 14px;
+  border-bottom: 1px solid var(--theme-border);
+}
+
+.editorContainer {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  width: 100%;
+  overflow: auto;
+
+  // 限制图片容器的最大尺寸,同时保持最小尺寸
+  :global([class*="ImageView_container"]) {
+    max-width: min(800px, 90vw) !important;
+    max-height: min(700px, 70vh) !important;
+    min-width: 400px !important;
+    min-height: 300px !important;
+    margin: 0 auto !important;
+    
+    // 让 canvas 自适应容器
+    canvas {
+      max-width: 100% !important;
+      max-height: 100% !important;
+      width: auto !important;
+      height: auto !important;
+      object-fit: contain !important;
+    }
+  }
+
+  // 同样处理 konvajs-content
+  :global(.konvajs-content) {
+    max-width: min(1200px, 90vw) !important;
+    max-height: min(700px, 70vh) !important;
+    margin: 0 auto !important;
+    
+    canvas {
+      max-width: 100% !important;
+      max-height: 100% !important;
+      width: auto !important;
+      height: auto !important;
+    }
+  }
+}
+
+.loadingContainer {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+// Responsive
+@media (max-width: 1024px) {
+  .root {
+    margin: -16px;
+  }
+
+  .sidebar {
+    width: 200px;
+  }
+  
+  .main {
+    max-width: 100%;
+  }
+
+  .header {
+    padding: 10px 16px;
+    gap: 16px;
+  }
+
+  .headerCenter {
+    display: none; // 在中等屏幕隐藏缩放控制
+  }
+}
+
+@media (max-width: 768px) {
+  .root {
+    flex-direction: column;
+    margin: -16px;
+  }
+
+  .sidebar {
+    width: calc(100% - 32px);
+    max-height: 180px;
+    margin: 16px;
+    position: relative;
+    top: 0;
+  }
+
+  .main {
+    margin: 0 16px 16px 16px;
+  }
+
+  .header {
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 12px;
+    padding: 12px 16px;
+  }
+
+  .headerLeft,
+  .headerRight {
+    width: 100%;
+  }
+
+  .headerRight {
+    justify-content: flex-end;
+  }
+
+  .headerCenter {
+    display: flex; // 在小屏幕显示缩放控制
+    width: 100%;
+    justify-content: center;
+  }
+
+  .headerDivider {
+    display: none;
+  }
+
+  .taskInfo {
+    .taskName {
+      font-size: 14px;
+    }
+
+    .taskMeta {
+      font-size: 11px;
+    }
+  }
+}

+ 642 - 0
web/apps/lq_label/src/views/project-annotation-view/project-annotation-view.tsx

@@ -0,0 +1,642 @@
+/**
+ * ProjectAnnotationView Component
+ * 
+ * Project-based sequential annotation interface with task sidebar.
+ * Allows annotating multiple tasks in a project sequentially.
+ */
+import React, { useEffect, useState, useRef } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useAtom } from 'jotai';
+import { onSnapshot } from 'mobx-state-tree';
+import {
+  Button,
+  IconArrowLeft,
+  IconCheck,
+  IconForward,
+} from '@humansignal/ui';
+import { 
+  getProject, 
+  getProjectTasks, 
+  createAnnotation, 
+  updateTask, 
+  getTaskAnnotations, 
+  updateAnnotation 
+} from '../../services/api';
+import { currentProjectAtom } from '../../atoms/project-atoms';
+import { LoadingSpinner } from '../../components/loading-spinner';
+import { CheckCircle, Circle, PlayCircle, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
+import styles from './project-annotation-view.module.scss';
+import type { Task } from '../../atoms/task-atoms';
+
+// Clear localStorage of any LabelStudio:settings
+if (typeof localStorage !== 'undefined') {
+  localStorage.removeItem('labelStudio:settings');
+}
+
+export const ProjectAnnotationView: React.FC = () => {
+  const { projectId } = useParams<{ projectId: string }>();
+  const navigate = useNavigate();
+  
+  const [currentProject, setCurrentProject] = useAtom(currentProjectAtom);
+  const [tasks, setTasks] = useState<Task[]>([]);
+  const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [isSaving, setIsSaving] = useState(false);
+  const [editorReady, setEditorReady] = useState(false);
+  const [imageScale, setImageScale] = useState(100); // 图片缩放比例(百分比)
+  
+  const editorContainerRef = useRef<HTMLDivElement>(null);
+  const lsfInstanceRef = useRef<any>(null);
+  const snapshotDisposerRef = useRef<any>(null);
+  const annotationResultRef = useRef<any>(null);
+  const loadedAnnotationRef = useRef<any>(null);
+  const rafIdRef = useRef<number | null>(null);
+
+  const currentTask = tasks[currentTaskIndex];
+
+  // Load project and tasks
+  useEffect(() => {
+    if (!projectId) return;
+
+    const loadData = async () => {
+      try {
+        setLoading(true);
+        setError(null);
+        
+        // Load project details
+        const projectData = await getProject(projectId);
+        setCurrentProject(projectData);
+        
+        // Load all tasks for this project
+        const tasksData = await getProjectTasks(projectId);
+        setTasks(tasksData);
+        
+        // Find first incomplete task
+        const firstIncompleteIndex = tasksData.findIndex(
+          (task) => task.status !== 'completed'
+        );
+        setCurrentTaskIndex(firstIncompleteIndex >= 0 ? firstIncompleteIndex : 0);
+      } catch (err: any) {
+        setError(err.message || '加载项目失败');
+      } finally {
+        setLoading(false);
+      }
+    };
+
+    loadData();
+  }, [projectId]);
+
+  // Load annotation for current task
+  useEffect(() => {
+    if (!currentTask) return;
+
+    const loadAnnotation = async () => {
+      try {
+        const existingAnnotations = await getTaskAnnotations(currentTask.id);
+        
+        if (existingAnnotations.length > 0) {
+          const latestAnnotation = existingAnnotations[0];
+          loadedAnnotationRef.current = latestAnnotation.result;
+          annotationResultRef.current = latestAnnotation.result;
+        } else {
+          loadedAnnotationRef.current = null;
+        }
+      } catch (err: any) {
+        console.error('Error loading annotation:', err);
+      }
+    };
+
+    loadAnnotation();
+  }, [currentTask]);
+
+  // Initialize LabelStudio editor
+  useEffect(() => {
+    if (loading || error || !currentTask || !currentProject) {
+      return;
+    }
+
+    let LabelStudio: any;
+    let dependencies: any;
+    let snapshotDisposer: any;
+    let isCleanedUp = false;
+
+    function cleanup() {
+      if (isCleanedUp) return;
+      isCleanedUp = true;
+      
+      if (snapshotDisposer) {
+        try {
+          snapshotDisposer();
+        } catch (e) {
+          // Ignore cleanup errors
+        }
+        snapshotDisposer = null;
+      }
+      
+      if (snapshotDisposerRef.current) {
+        try {
+          snapshotDisposerRef.current();
+        } catch (e) {
+          // Ignore cleanup errors
+        }
+        snapshotDisposerRef.current = null;
+      }
+      
+      if (rafIdRef.current !== null) {
+        cancelAnimationFrame(rafIdRef.current);
+        rafIdRef.current = null;
+      }
+      
+      if (lsfInstanceRef.current) {
+        try {
+          lsfInstanceRef.current.destroy();
+          lsfInstanceRef.current = null;
+        } catch (e) {
+          // Ignore cleanup errors
+          lsfInstanceRef.current = null;
+        }
+      }
+      
+      if (typeof window !== 'undefined' && (window as any).LabelStudio) {
+        delete (window as any).LabelStudio;
+      }
+      
+      setEditorReady(false);
+    }
+
+    async function loadLSF() {
+      try {
+        // @ts-ignore - LabelStudio doesn't have TypeScript declarations
+        dependencies = await import('@humansignal/editor');
+        LabelStudio = dependencies.LabelStudio;
+        
+        if (!LabelStudio) {
+          setError('编辑器加载失败:LabelStudio 未定义');
+          return;
+        }
+
+        setEditorReady(true);
+
+        setTimeout(() => {
+          if (isCleanedUp) {
+            return;
+          }
+          
+          if (!editorContainerRef.current) {
+            setError('编辑器容器未找到');
+            return;
+          }
+          
+          if (!currentProject || !currentTask) {
+            setError('项目或任务数据未加载');
+            return;
+          }
+          
+          const annotations = [];
+          const existingResult = loadedAnnotationRef.current;
+          
+          if (existingResult && existingResult.result && Array.isArray(existingResult.result)) {
+            annotations.push({
+              id: 'existing',
+              result: existingResult.result,
+            });
+          }
+
+          lsfInstanceRef.current = new LabelStudio(editorContainerRef.current, {
+            config: currentProject.config,
+            task: {
+              id: Math.abs(currentTask.id.split('').reduce((a, b) => {
+                a = ((a << 5) - a) + b.charCodeAt(0);
+                return a & a;
+              }, 0)),
+              data: currentTask.data,
+              annotations: annotations.length > 0 ? annotations : undefined,
+            },
+            interfaces: [
+              'panel',
+              'update',
+              'submit',
+              'controls',
+              'side-column',
+              'annotations:menu',
+              'annotations:add-new',
+              'annotations:delete',
+              'predictions:menu',
+            ],
+            instanceOptions: {
+              reactVersion: 'v18',
+            },
+            settings: {
+              forceBottomPanel: true,
+              collapsibleBottomPanel: true,
+              defaultCollapsedBottomPanel: false,
+              fullscreen: false,
+            },
+            onStorageInitialized: (LS: any) => {
+              const initAnnotation = () => {
+                if (isCleanedUp) {
+                  return;
+                }
+                
+                const as = LS.annotationStore;
+                
+                if (as.annotations && as.annotations.length > 0) {
+                  as.selectAnnotation(as.annotations[0].id);
+                  
+                  const annotation = as.selected;
+                  if (annotation && !isCleanedUp) {
+                    snapshotDisposer = onSnapshot(annotation, () => {
+                      if (!isCleanedUp && lsfInstanceRef.current) {
+                        try {
+                          annotationResultRef.current = annotation.serializeAnnotation();
+                        } catch (e) {
+                          // Ignore errors during cleanup
+                        }
+                      }
+                    });
+                    snapshotDisposerRef.current = snapshotDisposer;
+                  }
+                } else {
+                  const annotation = as.createAnnotation();
+                  as.selectAnnotation(annotation.id);
+
+                  if (annotation && !isCleanedUp) {
+                    snapshotDisposer = onSnapshot(annotation, () => {
+                      if (!isCleanedUp && lsfInstanceRef.current) {
+                        try {
+                          annotationResultRef.current = annotation.serializeAnnotation();
+                        } catch (e) {
+                          // Ignore errors during cleanup
+                        }
+                      }
+                    });
+                    snapshotDisposerRef.current = snapshotDisposer;
+                  }
+                }
+              };
+              setTimeout(initAnnotation, 100);
+            },
+          });
+        }, 100);
+      } catch (err: any) {
+        console.error('Error loading LabelStudio:', err);
+        setError(err.message || '初始化编辑器失败');
+      }
+    }
+
+    rafIdRef.current = requestAnimationFrame(() => {
+      loadLSF();
+    });
+
+    return () => {
+      cleanup();
+    };
+  }, [currentTask, currentProject, loading, error]);
+
+  const saveAnnotation = async () => {
+    if (!currentTask) return false;
+
+    try {
+      const annotationResult = annotationResultRef.current;
+      
+      if (!annotationResult || typeof annotationResult !== 'object') {
+        setError('请完成标注后再保存');
+        return false;
+      }
+      
+      let resultData: Record<string, any> = {};
+      
+      if (annotationResult.result && Array.isArray(annotationResult.result)) {
+        resultData = { result: annotationResult.result };
+      } else if (Array.isArray(annotationResult)) {
+        resultData = { result: annotationResult };
+      } else {
+        resultData = annotationResult;
+      }
+      
+      if (!resultData.result || (Array.isArray(resultData.result) && resultData.result.length === 0)) {
+        setError('请完成标注后再保存(标注结果为空)');
+        return false;
+      }
+      
+      const existingAnnotations = await getTaskAnnotations(currentTask.id);
+      
+      if (existingAnnotations.length > 0) {
+        const existingAnnotation = existingAnnotations[0];
+        await updateAnnotation(existingAnnotation.id, {
+          result: resultData,
+        });
+      } else {
+        await createAnnotation({
+          task_id: currentTask.id,
+          user_id: 'current_user',
+          result: resultData,
+        });
+      }
+      
+      await updateTask(currentTask.id, {
+        status: 'completed',
+      });
+      
+      // Update local task status
+      setTasks(prevTasks => 
+        prevTasks.map(task => 
+          task.id === currentTask.id 
+            ? { ...task, status: 'completed' as const }
+            : task
+        )
+      );
+      
+      return true;
+    } catch (err: any) {
+      console.error('Save annotation error:', err);
+      setError(err.message || '保存标注失败');
+      return false;
+    }
+  };
+
+  const handleSave = async () => {
+    setIsSaving(true);
+    setError(null);
+    
+    const success = await saveAnnotation();
+    
+    setIsSaving(false);
+    
+    if (success) {
+      // Stay on current task
+      alert('保存成功!');
+    }
+  };
+
+  const handleSaveAndNext = async () => {
+    setIsSaving(true);
+    setError(null);
+    
+    const success = await saveAnnotation();
+    
+    setIsSaving(false);
+    
+    if (success) {
+      // Move to next task
+      if (currentTaskIndex < tasks.length - 1) {
+        setCurrentTaskIndex(currentTaskIndex + 1);
+      } else {
+        alert('已完成所有任务!');
+        navigate('/projects');
+      }
+    }
+  };
+
+  const handleTaskClick = (index: number) => {
+    setCurrentTaskIndex(index);
+  };
+
+  // 图片缩放控制函数
+  const applyImageScale = (scale: number) => {
+    const imageContainers = document.querySelectorAll('[class*="ImageView_container"]');
+    imageContainers.forEach((container: any) => {
+      container.style.transform = `scale(${scale / 100})`;
+      container.style.transformOrigin = 'center center';
+    });
+    
+    const konvaContents = document.querySelectorAll('.konvajs-content');
+    konvaContents.forEach((content: any) => {
+      content.style.transform = `scale(${scale / 100})`;
+      content.style.transformOrigin = 'center center';
+    });
+  };
+
+  const handleZoomIn = () => {
+    const newScale = Math.min(imageScale + 10, 200); // 最大 200%
+    setImageScale(newScale);
+    applyImageScale(newScale);
+  };
+
+  const handleZoomOut = () => {
+    const newScale = Math.max(imageScale - 10, 50); // 最小 50%
+    setImageScale(newScale);
+    applyImageScale(newScale);
+  };
+
+  const handleResetZoom = () => {
+    setImageScale(100);
+    applyImageScale(100);
+  };
+
+  // 键盘快捷键支持
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      // Ctrl/Cmd + Plus: 放大
+      if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) {
+        e.preventDefault();
+        handleZoomIn();
+      }
+      // Ctrl/Cmd + Minus: 缩小
+      else if ((e.ctrlKey || e.metaKey) && e.key === '-') {
+        e.preventDefault();
+        handleZoomOut();
+      }
+      // Ctrl/Cmd + 0: 重置
+      else if ((e.ctrlKey || e.metaKey) && e.key === '0') {
+        e.preventDefault();
+        handleResetZoom();
+      }
+    };
+
+    window.addEventListener('keydown', handleKeyDown);
+    return () => {
+      window.removeEventListener('keydown', handleKeyDown);
+    };
+  }, [imageScale]);
+
+  const getTaskIcon = (task: Task) => {
+    if (task.status === 'completed') {
+      return <CheckCircle size={16} className={styles.iconCompleted} />;
+    } else if (task.id === currentTask?.id) {
+      return <PlayCircle size={16} className={styles.iconCurrent} />;
+    } else {
+      return <Circle size={16} className={styles.iconPending} />;
+    }
+  };
+
+  if (loading) {
+    return <LoadingSpinner size="large" message="加载项目数据..." fullScreen />;
+  }
+
+  if (error && !currentProject) {
+    return (
+      <div className="flex flex-col gap-comfortable h-full">
+        <div className="flex items-center gap-comfortable pb-comfortable border-b border-neutral-border">
+          <Button
+            variant="neutral"
+            look="string"
+            size="small"
+            onClick={() => navigate('/projects')}
+            leading={<IconArrowLeft className="size-4" />}
+          >
+            返回
+          </Button>
+        </div>
+        <div className="flex-1 flex items-center justify-center">
+          <div className="bg-error-background text-error-foreground p-comfortable rounded-lg border border-error-border max-w-md">
+            <div className="flex flex-col gap-tight">
+              <span className="text-body-medium font-semibold">加载失败</span>
+              <span className="text-body-medium">{error}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  if (!currentProject || tasks.length === 0) {
+    return (
+      <div className="flex items-center justify-center h-full">
+        <div className="text-center">
+          <p className="text-body-medium text-secondary-foreground">
+            {!currentProject ? '项目不存在' : '该项目没有任务'}
+          </p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={styles.root}>
+      {/* Sidebar */}
+      <div className={styles.sidebar}>
+        <div className={styles.sidebarHeader}>
+          <h3 className={styles.sidebarTitle}>任务列表</h3>
+          <p className={styles.sidebarSubtitle}>
+            {tasks.filter(t => t.status === 'completed').length} / {tasks.length} 已完成
+          </p>
+        </div>
+        <div className={styles.taskList}>
+          {tasks.map((task, index) => (
+            <button
+              key={task.id}
+              className={`${styles.taskItem} ${
+                task.id === currentTask?.id ? styles.taskItemActive : ''
+              }`}
+              onClick={() => handleTaskClick(index)}
+            >
+              <div className={styles.taskIcon}>
+                {getTaskIcon(task)}
+              </div>
+              <div className={styles.taskInfo}>
+                <div className={styles.taskName}>{task.name}</div>
+                <div className={styles.taskStatus}>
+                  {task.status === 'completed' ? '已完成' : '待标注'}
+                </div>
+              </div>
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {/* Main Content */}
+      <div className={styles.main}>
+        {/* Header */}
+        <div className={styles.header}>
+          <div className={styles.headerLeft}>
+            <Button
+              variant="neutral"
+              look="string"
+              size="small"
+              onClick={() => navigate('/projects')}
+              leading={<IconArrowLeft className="size-4" />}
+            >
+              返回项目列表
+            </Button>
+            <div className={styles.headerDivider} />
+            <div className={styles.taskInfo}>
+              <h1 className={styles.taskName}>
+                {currentTask?.name}
+              </h1>
+              <p className={styles.taskMeta}>
+                {currentProject.name} · 任务 {currentTaskIndex + 1}/{tasks.length}
+              </p>
+            </div>
+          </div>
+          
+          <div className={styles.headerCenter}>
+            <div className={styles.zoomControls}>
+              <button
+                className={styles.zoomButton}
+                onClick={handleZoomOut}
+                disabled={imageScale <= 50}
+                title="缩小 (Ctrl + -)"
+              >
+                <ZoomOut size={16} />
+              </button>
+              <div className={styles.zoomDisplay}>
+                {imageScale}%
+              </div>
+              <button
+                className={styles.zoomButton}
+                onClick={handleZoomIn}
+                disabled={imageScale >= 200}
+                title="放大 (Ctrl + +)"
+              >
+                <ZoomIn size={16} />
+              </button>
+              <div className={styles.zoomDivider} />
+              <button
+                className={styles.zoomButton}
+                onClick={handleResetZoom}
+                title="重置缩放 (Ctrl + 0)"
+              >
+                <Maximize2 size={16} />
+              </button>
+            </div>
+          </div>
+          
+          <div className={styles.headerRight}>
+            <Button
+              variant="neutral"
+              size="medium"
+              onClick={handleSave}
+              disabled={isSaving}
+              leading={<IconCheck className="size-4" />}
+            >
+              {isSaving ? '保存中...' : '保存'}
+            </Button>
+            <Button
+              variant="primary"
+              size="medium"
+              onClick={handleSaveAndNext}
+              disabled={isSaving}
+              leading={<IconForward className="size-4" />}
+            >
+              {isSaving ? '保存中...' : '保存并下一个'}
+            </Button>
+          </div>
+        </div>
+
+        {/* Error Message */}
+        {error && (
+          <div className={styles.errorBanner}>
+            {error}
+          </div>
+        )}
+
+        {/* Editor Container */}
+        <div className={styles.editorContainer}>
+          {editorReady ? (
+            <div
+              ref={editorContainerRef}
+              id="label-studio-editor"
+              className="w-full h-full flex flex-col"
+            />
+          ) : (
+            <div className={styles.loadingContainer}>
+              <LoadingSpinner size="large" message="初始化编辑器..." />
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 21 - 0
web/apps/lq_label/src/views/project-list-view/project-list-view.module.scss

@@ -342,6 +342,27 @@
     border-color: var(--theme-button);
     color: var(--theme-button);
   }
+
+  &:disabled {
+    opacity: 0.4;
+    cursor: not-allowed;
+    
+    &:hover {
+      background: transparent;
+      border-color: var(--theme-border);
+      color: var(--theme-paragraph);
+    }
+  }
+}
+
+.actionButtonPrimary {
+  border-color: var(--theme-button);
+  color: var(--theme-button);
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button);
+    color: var(--theme-button-text);
+  }
 }
 
 .actionButtonDanger {

+ 13 - 0
web/apps/lq_label/src/views/project-list-view/project-list-view.tsx

@@ -16,6 +16,7 @@ import {
   Search,
   FolderOpen,
   AlertCircle,
+  Play,
 } from 'lucide-react';
 import {
   projectsAtom,
@@ -113,6 +114,10 @@ export const ProjectListView: React.FC = () => {
     setIsEditModalOpen(true);
   };
 
+  const handleStartAnnotation = (project: Project) => {
+    navigate(`/projects/${project.id}/annotate`);
+  };
+
   // Filter projects based on search query
   const filteredProjects = projects.filter((project) => {
     if (!searchQuery) return true;
@@ -249,6 +254,14 @@ export const ProjectListView: React.FC = () => {
                       </td>
                       <td>
                         <div className={styles.actions}>
+                          <button
+                            className={`${styles.actionButton} ${styles.actionButtonPrimary}`}
+                            onClick={() => handleStartAnnotation(project)}
+                            title="开始标注"
+                            disabled={project.task_count === 0}
+                          >
+                            <Play size={16} />
+                          </button>
                           <button
                             className={styles.actionButton}
                             onClick={() => handleViewProject(project)}