LuoChinWen 3 недель назад
Родитель
Сommit
535ec53254

+ 255 - 0
.kiro/specs/external-api-enhancement/design.md

@@ -0,0 +1,255 @@
+# Design Document: External API Enhancement
+
+## Overview
+
+本设计文档描述了标注平台外部API增强功能的技术实现方案,包括:
+1. 项目初始化接口增加标签传入功能
+2. 增加多边形标注类型支持
+3. 创建长期有效的管理员Token脚本
+4. 编写API测试脚本
+
+## Architecture
+
+### 系统架构图
+
+```mermaid
+graph TB
+    subgraph External System
+        ES[样本中心]
+    end
+    
+    subgraph Backend API
+        ER[External Router]
+        ES_SVC[External Service]
+        JWT[JWT Service]
+    end
+    
+    subgraph Database
+        DB[(MySQL)]
+    end
+    
+    subgraph Scripts
+        TOKEN_SCRIPT[generate_admin_token.py]
+        TEST_SCRIPT[test_external_api.py]
+    end
+    
+    ES -->|POST /api/external/projects/init| ER
+    ER --> ES_SVC
+    ES_SVC --> DB
+    JWT --> ES_SVC
+    TOKEN_SCRIPT --> JWT
+    TEST_SCRIPT -->|HTTP Requests| ER
+```
+
+## Components and Interfaces
+
+### 1. 标签数据结构
+
+```python
+class TagItem(BaseModel):
+    """标签项"""
+    tag: str  # 标签名称
+    color: Optional[str] = None  # 颜色,可选,格式: #RRGGBB
+```
+
+### 2. 更新的项目初始化请求
+
+```python
+class ProjectInitRequest(BaseModel):
+    name: str
+    description: Optional[str] = ""
+    task_type: TaskType  # 新增 polygon 类型
+    data: List[TaskDataItem]
+    external_id: Optional[str] = None
+    tags: Optional[List[TagItem]] = None  # 新增标签列表
+```
+
+### 3. 任务类型枚举更新
+
+```python
+class TaskType(str, Enum):
+    TEXT_CLASSIFICATION = "text_classification"
+    IMAGE_CLASSIFICATION = "image_classification"
+    OBJECT_DETECTION = "object_detection"
+    NER = "ner"
+    POLYGON = "polygon"  # 新增多边形标注
+```
+
+### 4. XML配置生成逻辑
+
+根据任务类型和标签生成对应的XML配置:
+
+```python
+def generate_config_with_tags(task_type: TaskType, tags: List[TagItem]) -> str:
+    """根据任务类型和标签生成XML配置"""
+    # 为没有颜色的标签生成随机颜色
+    # 根据任务类型选择对应的标签组件
+    # 生成完整的XML配置
+```
+
+### 5. 颜色生成工具
+
+```python
+def generate_random_color() -> str:
+    """生成随机颜色,返回 #RRGGBB 格式"""
+    import random
+    return f"#{random.randint(0, 0xFFFFFF):06x}"
+```
+
+## Data Models
+
+### 标签存储
+
+标签信息直接嵌入到项目的XML配置中,不需要额外的数据库表。
+
+### XML配置示例
+
+**文本分类(带标签):**
+```xml
+<View>
+  <Text name="text" value="$text"/>
+  <Choices name="label" toName="text" choice="single">
+    <Choice value="猫猫" style="background-color: #FF5733"/>
+    <Choice value="狗狗" style="background-color: #33FF57"/>
+  </Choices>
+</View>
+```
+
+**多边形标注:**
+```xml
+<View>
+  <Image name="image" value="$image"/>
+  <PolygonLabels name="label" toName="image">
+    <Label value="区域A" background="#FF5733"/>
+    <Label value="区域B" background="#33FF57"/>
+  </PolygonLabels>
+</View>
+```
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: 标签处理完整性
+
+*For any* 项目初始化请求包含tags参数,创建的项目配置中应包含所有传入的标签,且:
+- 指定颜色的标签使用指定颜色
+- 未指定颜色的标签具有有效的颜色值(#RRGGBB格式)
+
+**Validates: Requirements 1.1, 1.2, 1.3, 1.4**
+
+### Property 2: Polygon配置生成正确性
+
+*For any* polygon类型的项目创建请求,生成的XML配置应包含PolygonLabels组件,且所有传入的标签都作为Label子元素存在。
+
+**Validates: Requirements 2.1, 2.2**
+
+## Error Handling
+
+### 错误场景
+
+| 场景 | 错误码 | HTTP状态码 | 处理方式 |
+|------|--------|-----------|---------|
+| 无效的颜色格式 | INVALID_COLOR_FORMAT | 400 | 返回错误信息,指出无效的颜色值 |
+| 标签名称为空 | INVALID_TAG_NAME | 400 | 返回错误信息,要求提供有效的标签名 |
+| 不支持的任务类型 | INVALID_TASK_TYPE | 400 | 返回错误信息,列出支持的任务类型 |
+
+### 颜色格式验证
+
+```python
+import re
+
+def validate_color(color: str) -> bool:
+    """验证颜色格式是否为有效的 #RRGGBB"""
+    return bool(re.match(r'^#[0-9A-Fa-f]{6}$', color))
+```
+
+## Testing Strategy
+
+### 单元测试
+
+1. **颜色生成测试**
+   - 验证生成的颜色格式正确
+   - 验证颜色值在有效范围内
+
+2. **XML配置生成测试**
+   - 验证各任务类型的配置生成
+   - 验证标签正确嵌入配置
+
+3. **标签验证测试**
+   - 验证颜色格式验证逻辑
+   - 验证标签名称验证逻辑
+
+### 属性测试
+
+使用 Hypothesis 库进行属性测试:
+
+1. **标签处理属性测试**
+   - 生成随机标签列表
+   - 验证所有标签都出现在配置中
+   - 验证颜色处理正确
+
+2. **Polygon配置属性测试**
+   - 生成随机polygon项目请求
+   - 验证配置包含PolygonLabels
+
+### 集成测试
+
+1. **API端到端测试**
+   - 测试5种任务类型的完整流程
+   - 验证标签传入功能
+   - 验证导出功能
+
+### 测试配置
+
+- 属性测试最少运行100次迭代
+- 使用 pytest + hypothesis 框架
+- 测试文件位于 `backend/test/` 目录
+
+## Implementation Notes
+
+### 脚本说明
+
+#### generate_admin_token.py
+
+```python
+"""
+生成长期有效的管理员Token脚本
+
+功能:
+1. 查找或创建管理员用户
+2. 生成99999天有效期的Token
+3. 输出Token并验证有效性
+
+使用方式:
+python scripts/generate_admin_token.py
+"""
+```
+
+#### test_external_api.py
+
+```python
+"""
+外部API测试脚本
+
+功能:
+1. 测试5种标注类型的项目创建
+2. 测试进度查询接口
+3. 测试数据导出接口
+4. 验证标签传入功能
+
+使用方式:
+python scripts/test_external_api.py --base-url http://localhost:8003 --token <admin_token>
+"""
+```
+
+### 文件变更清单
+
+| 文件 | 变更类型 | 说明 |
+|------|---------|------|
+| backend/schemas/external.py | 修改 | 添加TagItem、更新TaskType枚举、更新ProjectInitRequest |
+| backend/services/external_service.py | 修改 | 添加标签处理逻辑、添加polygon配置模板 |
+| backend/EXTERNAL_API_DOCUMENTATION.md | 修改 | 更新API文档,添加tags参数说明 |
+| backend/scripts/generate_admin_token.py | 新增 | 生成长期Token脚本 |
+| backend/scripts/test_external_api.py | 新增 | API测试脚本 |
+| backend/test/test_external_api_tags.py | 新增 | 标签功能单元测试 |

+ 69 - 0
.kiro/specs/external-api-enhancement/requirements.md

@@ -0,0 +1,69 @@
+# Requirements Document
+
+## Introduction
+
+本文档定义了标注平台外部API增强功能的需求,包括:
+1. 项目初始化接口增加标签传入功能
+2. 增加多边形标注类型支持
+3. 创建长期有效的管理员Token脚本
+4. 编写API测试脚本验证5种标注项目类型
+
+## Glossary
+
+- **External_API**: 标注平台提供给外部系统(如样本中心)调用的API接口
+- **Tag**: 标注标签,包含标签名称和可选的颜色属性
+- **Polygon_Annotation**: 多边形标注类型,用于标注不规则形状区域
+- **Admin_Token**: 管理员认证令牌,用于外部系统调用API
+- **Task_Type**: 任务类型,定义标注项目的类型(文本分类、图像分类、目标检测、NER、多边形标注)
+
+## Requirements
+
+### Requirement 1: 项目初始化接口增加标签传入
+
+**User Story:** As a 外部系统开发者, I want to 在创建项目时传入预定义的标签和颜色, so that 标注平台可以自动配置好标签选项,减少管理员手动配置工作。
+
+#### Acceptance Criteria
+
+1. WHEN 外部系统调用项目初始化接口时传入tags参数, THE External_API SHALL 接受并存储标签列表
+2. WHEN tags参数中的标签包含color字段, THE External_API SHALL 使用指定的颜色
+3. WHEN tags参数中的标签不包含color字段, THE External_API SHALL 自动生成随机颜色
+4. WHEN 项目创建成功后, THE External_API SHALL 根据传入的tags自动生成对应的XML配置
+5. WHEN 管理员配置项目时, THE System SHALL 允许修改已传入的标签名称和颜色
+6. IF tags参数为空或未传入, THEN THE External_API SHALL 使用默认的空标签配置(与现有行为一致)
+
+### Requirement 2: 增加多边形标注类型支持
+
+**User Story:** As a 标注人员, I want to 使用多边形工具标注不规则形状, so that 我可以精确标注非矩形的目标区域。
+
+#### Acceptance Criteria
+
+1. THE External_API SHALL 支持新的任务类型 "polygon"
+2. WHEN 创建polygon类型项目时, THE External_API SHALL 生成包含PolygonLabels的XML配置
+3. WHEN 导出polygon类型标注数据时, THE Export_Service SHALL 正确处理多边形坐标数据
+4. THE System SHALL 在COCO和YOLO导出格式中支持多边形标注数据
+
+### Requirement 3: 创建长期管理员Token脚本
+
+**User Story:** As a 系统管理员, I want to 生成一个长期有效的管理员Token, so that 外部系统可以使用该Token进行数据导入而无需频繁更新。
+
+#### Acceptance Criteria
+
+1. THE Script SHALL 创建一个有效期为99999天的管理员Token
+2. THE Script SHALL 输出生成的Token到控制台
+3. THE Script SHALL 验证Token的有效性
+4. IF 管理员用户不存在, THEN THE Script SHALL 提示创建管理员用户
+
+### Requirement 4: 编写API测试脚本
+
+**User Story:** As a 开发者, I want to 运行测试脚本验证所有标注类型的API功能, so that 我可以确保外部API接口正常工作。
+
+#### Acceptance Criteria
+
+1. THE Test_Script SHALL 测试text_classification类型项目的创建、进度查询和导出
+2. THE Test_Script SHALL 测试image_classification类型项目的创建、进度查询和导出
+3. THE Test_Script SHALL 测试object_detection类型项目的创建、进度查询和导出
+4. THE Test_Script SHALL 测试ner类型项目的创建、进度查询和导出
+5. THE Test_Script SHALL 测试polygon类型项目的创建、进度查询和导出
+6. WHEN 测试包含tags参数时, THE Test_Script SHALL 验证标签是否正确应用到项目配置中
+7. THE Test_Script SHALL 输出每个测试的结果(成功/失败)和详细信息
+8. IF 任何测试失败, THEN THE Test_Script SHALL 输出错误详情并继续执行其他测试

+ 89 - 0
.kiro/specs/external-api-enhancement/tasks.md

@@ -0,0 +1,89 @@
+# Implementation Plan: External API Enhancement
+
+## Overview
+
+本实现计划将外部API增强功能分解为可执行的编码任务,包括标签传入功能、多边形标注支持、Token生成脚本和API测试脚本。
+
+## Tasks
+
+- [x] 1. 更新Schema定义和枚举类型
+  - [x] 1.1 在 schemas/external.py 中添加 TagItem 模型
+    - 添加 tag 字段(必填)和 color 字段(可选)
+    - 添加颜色格式验证器
+    - _Requirements: 1.1, 1.2, 1.3_
+  - [x] 1.2 更新 TaskType 枚举添加 POLYGON 类型
+    - 在 TaskType 枚举中添加 POLYGON = "polygon"
+    - _Requirements: 2.1_
+  - [x] 1.3 更新 ProjectInitRequest 添加 tags 参数
+    - 添加 tags: Optional[List[TagItem]] = None
+    - _Requirements: 1.1_
+
+- [x] 2. 实现标签处理和配置生成逻辑
+  - [x] 2.1 在 services/external_service.py 中添加颜色生成函数
+    - 实现 generate_random_color() 返回 #RRGGBB 格式
+    - _Requirements: 1.3_
+  - [x] 2.2 添加 polygon 类型的默认配置模板
+    - 在 DEFAULT_CONFIGS 中添加 POLYGON 配置
+    - _Requirements: 2.2_
+  - [x] 2.3 实现 generate_config_with_tags 函数
+    - 根据任务类型和标签生成完整的XML配置
+    - 处理有颜色和无颜色的标签
+    - _Requirements: 1.2, 1.3, 1.4_
+  - [x] 2.4 更新 init_project 方法使用新的配置生成逻辑
+    - 调用 generate_config_with_tags 生成配置
+    - _Requirements: 1.1, 1.4_
+
+- [x] 3. Checkpoint - 验证标签功能
+  - 确保所有测试通过,如有问题请询问用户
+
+- [x] 4. 更新导出服务支持多边形数据
+  - [x] 4.1 更新 _export_yolo 方法支持多边形
+    - 处理 polygonlabels 类型的标注数据
+    - _Requirements: 2.3, 2.4_
+  - [x] 4.2 更新 _export_coco 方法支持多边形
+    - 添加 segmentation 字段支持
+    - _Requirements: 2.3, 2.4_
+
+- [x] 5. 创建管理员Token生成脚本
+  - [x] 5.1 创建 scripts/generate_admin_token.py
+    - 实现查找管理员用户逻辑
+    - 实现生成99999天有效期Token
+    - 输出Token并验证有效性
+    - _Requirements: 3.1, 3.2, 3.3, 3.4_
+
+- [x] 6. 创建API测试脚本
+  - [x] 6.1 创建 scripts/test_external_api.py
+    - 实现命令行参数解析(base-url, token)
+    - 实现5种任务类型的测试用例
+    - 实现标签传入功能测试
+    - 输出测试结果
+    - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8_
+
+- [x] 7. 更新API文档
+  - [x] 7.1 更新 EXTERNAL_API_DOCUMENTATION.md
+    - 添加 tags 参数说明
+    - 添加 polygon 任务类型说明
+    - 更新示例代码
+    - _Requirements: 1.1, 2.1_
+
+- [x] 8. 前端多边形标注类型支持
+  - [x] 8.1 更新 task-type-selector.tsx
+    - 添加 'polygon' 到 TaskType 类型联合
+    - 添加多边形选项到 TASK_TYPE_OPTIONS(使用 Hexagon 图标)
+  - [x] 8.2 更新 external-projects-view.tsx
+    - 添加 polygon: '多边形标注' 到 typeLabels
+  - [x] 8.3 更新 project-config-view.tsx
+    - 添加 PolygonLabels 配置解析逻辑
+  - [x] 8.4 更新 xml-generator.ts
+    - 添加 generatePolygon 函数生成多边形标注XML
+    - 更新 getDataFormat 和 getTaskTypeName 支持 polygon 类型
+
+- [x] 9. Final Checkpoint
+  - 确保所有测试通过,如有问题请询问用户
+
+## Notes
+
+- 任务按依赖顺序排列,先完成Schema更新,再实现服务逻辑
+- 脚本文件放在 backend/scripts/ 目录
+- 测试文件放在 backend/test/ 目录
+- 所有代码遵循项目现有的编码规范

+ 87 - 18
backend/EXTERNAL_API_DOCUMENTATION.md

@@ -69,7 +69,12 @@ POST /api/external/projects/init
       }
     }
   ],
-  "external_id": "sample_center_proj_001"
+  "external_id": "sample_center_proj_001",
+  "tags": [
+    {"tag": "猫猫", "color": "#FF5733"},
+    {"tag": "狗狗", "color": "#33FF57"},
+    {"tag": "其他"}
+  ]
 }
 ```
 
@@ -85,8 +90,13 @@ POST /api/external/projects/init
 | data[].content | string | 是 | 数据内容(文本或图像URL) |
 | data[].metadata | object | 否 | 额外元数据 |
 | external_id | string | 否 | 外部系统的项目ID,用于关联查询 |
+| tags | array | 否 | 标签列表,用于预定义标注选项 |
+| tags[].tag | string | 是 | 标签名称 |
+| tags[].color | string | 否 | 标签颜色,格式: #RRGGBB,不传则自动生成 |
 
-**注意**:标签(labels)由标注平台管理员在项目配置阶段设置,样本中心无需提供。
+**注意**:
+- 如果传入 `tags` 参数,系统会自动根据标签生成对应的XML配置
+- 如果不传 `tags` 参数,标签由标注平台管理员在项目配置阶段设置
 
 **支持的任务类型**
 
@@ -94,8 +104,9 @@ POST /api/external/projects/init
 |-----------|------|-------------------|
 | text_classification | 文本分类 | 文本内容 |
 | image_classification | 图像分类 | 图像URL |
-| object_detection | 目标检测 | 图像URL |
+| object_detection | 目标检测(矩形框) | 图像URL |
 | ner | 命名实体识别 | 文本内容 |
+| polygon | 多边形标注 | 图像URL |
 
 **响应**
 
@@ -385,7 +396,7 @@ headers = {
     "Content-Type": "application/json"
 }
 
-# 1. 创建项目
+# 1. 创建项目(带标签)
 init_data = {
     "name": "文本分类项目",
     "description": "对用户评论进行情感分类",
@@ -395,7 +406,12 @@ init_data = {
         {"id": "2", "content": "质量太差了,不推荐"},
         {"id": "3", "content": "一般般,没什么特别的"}
     ],
-    "external_id": "sample_center_001"
+    "external_id": "sample_center_001",
+    "tags": [
+        {"tag": "正面", "color": "#4CAF50"},
+        {"tag": "负面", "color": "#F44336"},
+        {"tag": "中性"}  # 不指定颜色,系统自动生成
+    ]
 }
 
 response = requests.post(
@@ -407,7 +423,30 @@ project = response.json()
 project_id = project["project_id"]
 print(f"项目创建成功: {project_id}")
 
-# 2. 查询进度
+# 2. 创建多边形标注项目
+polygon_data = {
+    "name": "多边形标注项目",
+    "description": "标注图像中的不规则区域",
+    "task_type": "polygon",
+    "data": [
+        {"id": "1", "content": "https://example.com/img1.jpg"},
+        {"id": "2", "content": "https://example.com/img2.jpg"}
+    ],
+    "tags": [
+        {"tag": "区域A", "color": "#FF5722"},
+        {"tag": "区域B", "color": "#607D8B"}
+    ]
+}
+
+response = requests.post(
+    f"{BASE_URL}/projects/init",
+    json=polygon_data,
+    headers=headers
+)
+polygon_project = response.json()
+print(f"多边形项目创建成功: {polygon_project['project_id']}")
+
+# 3. 查询进度
 response = requests.get(
     f"{BASE_URL}/projects/{project_id}/progress",
     headers=headers
@@ -415,7 +454,7 @@ response = requests.get(
 progress = response.json()
 print(f"完成进度: {progress['completion_percentage']}%")
 
-# 3. 导出数据(ShareGPT格式)
+# 4. 导出数据(ShareGPT格式)
 export_data = {
     "format": "sharegpt",
     "completed_only": True,
@@ -444,7 +483,7 @@ print(f"文件已保存: {export_result['file_name']}")
 ### cURL 示例
 
 ```bash
-# 1. 创建项目
+# 1. 创建项目(带标签)
 curl -X POST "http://localhost:8003/api/external/projects/init" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Content-Type: application/json" \
@@ -455,20 +494,40 @@ curl -X POST "http://localhost:8003/api/external/projects/init" \
       {"id": "1", "content": "https://example.com/img1.jpg"},
       {"id": "2", "content": "https://example.com/img2.jpg"}
     ],
-    "external_id": "sample_center_proj_001"
+    "external_id": "sample_center_proj_001",
+    "tags": [
+      {"tag": "猫猫", "color": "#FF9800"},
+      {"tag": "狗狗", "color": "#2196F3"}
+    ]
+  }'
+
+# 2. 创建多边形标注项目
+curl -X POST "http://localhost:8003/api/external/projects/init" \
+  -H "Authorization: Bearer your_admin_token" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "name": "多边形标注项目",
+    "task_type": "polygon",
+    "data": [
+      {"id": "1", "content": "https://example.com/img1.jpg"}
+    ],
+    "tags": [
+      {"tag": "区域A", "color": "#FF5722"},
+      {"tag": "区域B"}
+    ]
   }'
 
-# 2. 查询进度
+# 3. 查询进度
 curl -X GET "http://localhost:8003/api/external/projects/proj_xxx/progress" \
   -H "Authorization: Bearer your_admin_token"
 
-# 3. 导出数据(YOLO格式)
+# 4. 导出数据(YOLO格式)
 curl -X POST "http://localhost:8003/api/external/projects/proj_xxx/export" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Content-Type: application/json" \
   -d '{"format": "yolo", "completed_only": true}'
 
-# 4. 下载导出文件
+# 5. 下载导出文件
 curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
   -H "Authorization: Bearer your_admin_token" \
   -o exported_data.zip
@@ -512,17 +571,26 @@ curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
 
 1. **项目状态**:外部系统创建的项目初始状态为 `draft`,需要标注平台管理员完成配置(设置标签等)和任务分发后才会进入 `in_progress` 状态。
 
-2. **标签配置**:标签由标注平台管理员在项目配置阶段设置,样本中心只需提供任务数据,无需关心标签定义。
+2. **标签配置**:
+   - 如果在创建项目时传入 `tags` 参数,系统会自动生成包含这些标签的XML配置
+   - 如果不传 `tags` 参数,标签由标注平台管理员在项目配置阶段设置
+   - 管理员可以在配置阶段修改已传入的标签名称和颜色
+
+3. **颜色格式**:
+   - 颜色使用 `#RRGGBB` 格式(如 `#FF5733`)
+   - 如果不指定颜色,系统会自动生成随机颜色
 
-3. **数据格式**:
+4. **数据格式**:
    - 文本类任务(text_classification, ner):`content` 字段为文本内容
-   - 图像类任务(image_classification, object_detection):`content` 字段为图像URL
+   - 图像类任务(image_classification, object_detection, polygon):`content` 字段为图像URL
+
+5. **外部ID关联**:建议在创建项目时提供 `external_id`,方便后续在样本中心系统中关联查询。
 
-4. **外部ID关联**:建议在创建项目时提供 `external_id`,方便后续在样本中心系统中关联查询。
+6. **导出时机**:建议在项目状态为 `completed` 或 `completion_percentage` 达到预期值时再进行数据导出
 
-5. **导出时机**:建议在项目状态为 `completed` 或 `completion_percentage` 达到预期值时再进行数据导出
+7. **Token安全**:请妥善保管管理员Token,不要在客户端代码中暴露
 
-6. **Token安全**:请妥善保管管理员Token,不要在客户端代码中暴露
+8. **多边形标注**:polygon 类型支持标注不规则形状区域,导出时支持 COCO 和 YOLO 格式
 
 ---
 
@@ -530,4 +598,5 @@ curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
 
 | 版本 | 日期 | 说明 |
 |------|------|------|
+| 1.1.0 | 2026-02-05 | 添加tags参数支持、新增polygon任务类型 |
 | 1.0.0 | 2026-02-03 | 初始版本 |

+ 4 - 4
backend/config.prod.yaml

@@ -2,10 +2,10 @@
 
 # JWT 配置
 jwt:
-  # 生产环境请使用强随机密钥: python -c "import secrets; print(secrets.token_urlsafe(32))"
-  secret_key: "CHANGE_THIS_TO_A_SECURE_RANDOM_KEY"
+  # 与开发环境保持一致,确保 token 兼容
+  secret_key: "dev-secret-key-for-local-development"
   algorithm: "HS256"
-  access_token_expire_minutes: 15
+  access_token_expire_minutes: 60
   refresh_token_expire_days: 7
 
 # OAuth 2.0 单点登录配置
@@ -30,7 +30,7 @@ database:
     port: 13306
     user: "root"
     password: "Lq123456!"
-    database: "lq_lable_dev"
+    database: "lq_label_dev"
 
 # 服务器配置
 server:

+ 32 - 2
backend/schemas/external.py

@@ -5,7 +5,8 @@ Defines request and response models for external system integration.
 from datetime import datetime
 from typing import Optional, List, Any
 from enum import Enum
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
+import re
 
 
 # ============== 枚举定义 ==============
@@ -16,6 +17,7 @@ class TaskType(str, Enum):
     IMAGE_CLASSIFICATION = "image_classification"
     OBJECT_DETECTION = "object_detection"
     NER = "ner"
+    POLYGON = "polygon"  # 多边形标注
 
 
 class ExternalExportFormat(str, Enum):
@@ -30,6 +32,29 @@ class ExternalExportFormat(str, Enum):
 
 # ============== 项目初始化相关 ==============
 
+class TagItem(BaseModel):
+    """标签项,用于外部系统传入标注标签"""
+    tag: str = Field(..., min_length=1, description="标签名称")
+    color: Optional[str] = Field(None, description="标签颜色,格式: #RRGGBB,不传则自动生成")
+    
+    @field_validator('color')
+    @classmethod
+    def validate_color(cls, v):
+        """验证颜色格式"""
+        if v is not None:
+            if not re.match(r'^#[0-9A-Fa-f]{6}$', v):
+                raise ValueError('颜色格式无效,应为 #RRGGBB 格式')
+        return v
+    
+    class Config:
+        json_schema_extra = {
+            "example": {
+                "tag": "猫猫",
+                "color": "#FF5733"
+            }
+        }
+
+
 class TaskDataItem(BaseModel):
     """单个任务数据项"""
     id: Optional[str] = Field(None, description="外部系统的数据ID,用于关联")
@@ -53,6 +78,7 @@ class ProjectInitRequest(BaseModel):
     task_type: TaskType = Field(..., description="任务类型")
     data: List[TaskDataItem] = Field(..., min_length=1, description="任务数据列表")
     external_id: Optional[str] = Field(None, description="外部系统的项目ID,用于关联查询")
+    tags: Optional[List[TagItem]] = Field(None, description="标签列表,包含标签名称和可选颜色")
     
     class Config:
         json_schema_extra = {
@@ -67,7 +93,11 @@ class ProjectInitRequest(BaseModel):
                         "metadata": {"batch": "001"}
                     }
                 ],
-                "external_id": "sample_center_proj_001"
+                "external_id": "sample_center_proj_001",
+                "tags": [
+                    {"tag": "猫猫", "color": "#FF5733"},
+                    {"tag": "狗狗"}
+                ]
             }
         }
 

+ 156 - 0
backend/scripts/generate_admin_token.py

@@ -0,0 +1,156 @@
+#!/usr/bin/env python3
+"""
+生成长期有效的管理员Token脚本
+
+功能:
+1. 查找管理员用户
+2. 生成99999天有效期的Token
+3. 输出Token并验证有效性
+
+使用方式:
+    cd backend
+    python scripts/generate_admin_token.py
+
+注意:需要在backend目录下运行,以确保正确加载配置
+"""
+import sys
+import os
+
+# 添加backend目录到路径
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from datetime import datetime, timedelta
+import jwt
+from config import settings
+from database import get_db_connection
+
+
+def find_admin_user():
+    """查找管理员用户"""
+    with get_db_connection() as conn:
+        cursor = conn.cursor()
+        cursor.execute("""
+            SELECT id, username, email, role
+            FROM users
+            WHERE role = 'admin'
+            LIMIT 1
+        """)
+        row = cursor.fetchone()
+        if row:
+            return {
+                "id": row["id"],
+                "username": row["username"],
+                "email": row["email"],
+                "role": row["role"]
+            }
+        return None
+
+
+def create_long_term_token(user_data: dict, days: int = 99999) -> str:
+    """
+    创建长期有效的Token
+    
+    Args:
+        user_data: 用户信息字典
+        days: 有效天数,默认99999天
+        
+    Returns:
+        str: JWT Token
+    """
+    expire = datetime.utcnow() + timedelta(days=days)
+    payload = {
+        "sub": user_data["id"],
+        "username": user_data["username"],
+        "email": user_data["email"],
+        "role": user_data["role"],
+        "exp": expire,
+        "iat": datetime.utcnow(),
+        "type": "access"
+    }
+    return jwt.encode(
+        payload,
+        settings.JWT_SECRET_KEY,
+        algorithm=settings.JWT_ALGORITHM
+    )
+
+
+def verify_token(token: str) -> dict:
+    """
+    验证Token有效性
+    
+    Args:
+        token: JWT Token
+        
+    Returns:
+        dict: 解码后的payload
+    """
+    try:
+        payload = jwt.decode(
+            token,
+            settings.JWT_SECRET_KEY,
+            algorithms=[settings.JWT_ALGORITHM]
+        )
+        return payload
+    except jwt.ExpiredSignatureError:
+        raise Exception("Token已过期")
+    except jwt.InvalidTokenError as e:
+        raise Exception(f"Token无效: {str(e)}")
+
+
+def main():
+    print("=" * 60)
+    print("管理员长期Token生成工具")
+    print("=" * 60)
+    print()
+    
+    # 查找管理员用户
+    print("正在查找管理员用户...")
+    admin_user = find_admin_user()
+    
+    if not admin_user:
+        print("\n❌ 错误: 未找到管理员用户!")
+        print("\n请先创建管理员用户,可以使用以下方式:")
+        print("  1. 运行 python create_test_user.py 创建测试用户")
+        print("  2. 或通过API注册用户后在数据库中将role改为admin")
+        sys.exit(1)
+    
+    print(f"✓ 找到管理员用户: {admin_user['username']} ({admin_user['email']})")
+    print()
+    
+    # 生成Token
+    print("正在生成99999天有效期的Token...")
+    token = create_long_term_token(admin_user, days=99999)
+    print("✓ Token生成成功!")
+    print()
+    
+    # 验证Token
+    print("正在验证Token有效性...")
+    try:
+        payload = verify_token(token)
+        expire_time = datetime.fromtimestamp(payload["exp"])
+        print(f"✓ Token验证通过!")
+        print(f"  - 用户ID: {payload['sub']}")
+        print(f"  - 用户名: {payload['username']}")
+        print(f"  - 角色: {payload['role']}")
+        print(f"  - 过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')}")
+    except Exception as e:
+        print(f"❌ Token验证失败: {str(e)}")
+        sys.exit(1)
+    
+    print()
+    print("=" * 60)
+    print("生成的管理员Token (请妥善保管):")
+    print("=" * 60)
+    print()
+    print(token)
+    print()
+    print("=" * 60)
+    print()
+    print("使用方式:")
+    print("  在HTTP请求头中添加:")
+    print(f"  Authorization: Bearer {token[:50]}...")
+    print()
+
+
+if __name__ == "__main__":
+    main()

+ 134 - 0
backend/scripts/mock_external_projects.py

@@ -0,0 +1,134 @@
+"""
+Mock external projects script.
+Creates 4 external source projects with cat images for testing.
+"""
+import sys
+import os
+import uuid
+import json
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from database import get_db_connection
+
+# 猫猫图片 URL (使用支持跨域的图片)
+# 使用 picsum.photos 提供的随机图片服务,支持 CORS
+CAT_IMAGE_URL = "https://picsum.photos/id/40/800/600"  # 一张猫的图片
+
+# 4 个外部项目的配置
+MOCK_PROJECTS = [
+    {
+        "name": "猫咪品种分类项目",
+        "description": "对猫咪图片进行品种分类标注,包括英短、美短、布偶、橘猫等常见品种",
+        "external_id": "ext_cat_breed_001",
+        "task_type": "image_classification",
+        "task_count": 5,
+    },
+    {
+        "name": "猫咪目标检测项目",
+        "description": "检测图片中猫咪的位置,使用矩形框标注猫咪的头部、身体等部位",
+        "external_id": "ext_cat_detection_002",
+        "task_type": "object_detection",
+        "task_count": 8,
+    },
+    {
+        "name": "猫咪情绪识别项目",
+        "description": "识别猫咪的情绪状态,如开心、生气、困倦、好奇等",
+        "external_id": "ext_cat_emotion_003",
+        "task_type": "image_classification",
+        "task_count": 6,
+    },
+    {
+        "name": "猫咪姿态标注项目",
+        "description": "标注猫咪的姿态,包括站立、坐着、躺着、跳跃等动作",
+        "external_id": "ext_cat_pose_004",
+        "task_type": "image_classification",
+        "task_count": 4,
+    },
+]
+
+
+def create_mock_projects():
+    """Create mock external projects with tasks."""
+    
+    with get_db_connection() as conn:
+        cursor = conn.cursor()
+        
+        created_projects = []
+        
+        for project_info in MOCK_PROJECTS:
+            # Generate project ID
+            project_id = f"proj_{uuid.uuid4().hex[:12]}"
+            
+            # Insert project (draft status, external source, no config)
+            cursor.execute("""
+                INSERT INTO projects (id, name, description, config, status, source, task_type, external_id)
+                VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+            """, (
+                project_id,
+                project_info["name"],
+                project_info["description"],
+                "",  # 空配置,未配置状态
+                "draft",  # 草稿状态
+                "external",  # 外部来源
+                project_info["task_type"],
+                project_info["external_id"],
+            ))
+            
+            # Create tasks for this project
+            for i in range(project_info["task_count"]):
+                task_id = f"task_{uuid.uuid4().hex[:12]}"
+                task_name = f"{project_info['name']} - 任务 {i + 1}"
+                
+                # Task data with cat image
+                task_data = {
+                    "image": CAT_IMAGE_URL,
+                    "meta": {
+                        "source": "external_api",
+                        "index": i + 1,
+                        "total": project_info["task_count"],
+                    }
+                }
+                
+                cursor.execute("""
+                    INSERT INTO tasks (id, project_id, name, data, status, assigned_to)
+                    VALUES (%s, %s, %s, %s, %s, %s)
+                """, (
+                    task_id,
+                    project_id,
+                    task_name,
+                    json.dumps(task_data, ensure_ascii=False),
+                    "pending",  # 待处理状态
+                    None,  # 未分配
+                ))
+            
+            created_projects.append({
+                "id": project_id,
+                "name": project_info["name"],
+                "task_count": project_info["task_count"],
+            })
+            
+            print(f"✓ 创建项目: {project_info['name']} (ID: {project_id}, 任务数: {project_info['task_count']})")
+        
+        print(f"\n共创建 {len(created_projects)} 个外部项目")
+        return created_projects
+
+
+if __name__ == "__main__":
+    print("=" * 60)
+    print("Mock 外部项目创建脚本")
+    print("=" * 60)
+    print(f"图片 URL: {CAT_IMAGE_URL}")
+    print("-" * 60)
+    
+    try:
+        projects = create_mock_projects()
+        print("\n" + "=" * 60)
+        print("创建完成!")
+        print("=" * 60)
+    except Exception as e:
+        print(f"\n错误: {e}")
+        import traceback
+        traceback.print_exc()
+        sys.exit(1)

+ 415 - 0
backend/scripts/test_external_api.py

@@ -0,0 +1,415 @@
+#!/usr/bin/env python3
+"""
+外部API测试脚本
+
+功能:
+1. 测试5种标注类型的项目创建
+2. 测试进度查询接口
+3. 测试数据导出接口
+4. 验证标签传入功能
+
+使用方式:
+    python scripts/test_external_api.py --base-url http://localhost:8003 --token <admin_token>
+    
+    或使用环境变量:
+    export API_BASE_URL=http://localhost:8003
+    export ADMIN_TOKEN=your_token_here
+    python scripts/test_external_api.py
+"""
+import argparse
+import os
+import sys
+import json
+import requests
+from typing import Optional, Dict, Any, List
+from dataclasses import dataclass
+from datetime import datetime
+
+
+@dataclass
+class TestResult:
+    """测试结果"""
+    name: str
+    success: bool
+    message: str
+    details: Optional[Dict] = None
+
+
+class ExternalAPITester:
+    """外部API测试器"""
+    
+    def __init__(self, base_url: str, token: str):
+        self.base_url = base_url.rstrip('/')
+        self.token = token
+        self.headers = {
+            "Authorization": f"Bearer {token}",
+            "Content-Type": "application/json"
+        }
+        self.results: List[TestResult] = []
+        self.created_projects: List[str] = []
+    
+    def log(self, message: str):
+        """打印日志"""
+        print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
+    
+    def add_result(self, name: str, success: bool, message: str, details: Optional[Dict] = None):
+        """添加测试结果"""
+        result = TestResult(name, success, message, details)
+        self.results.append(result)
+        status = "✓" if success else "✗"
+        self.log(f"{status} {name}: {message}")
+        if details and not success:
+            self.log(f"  详情: {json.dumps(details, ensure_ascii=False, indent=2)}")
+    
+    def test_create_project(
+        self,
+        name: str,
+        task_type: str,
+        data: List[Dict],
+        tags: Optional[List[Dict]] = None,
+        description: str = ""
+    ) -> Optional[str]:
+        """
+        测试创建项目
+        
+        Returns:
+            str: 项目ID,失败返回None
+        """
+        test_name = f"创建{task_type}项目"
+        
+        payload = {
+            "name": name,
+            "description": description,
+            "task_type": task_type,
+            "data": data,
+            "external_id": f"test_{task_type}_{datetime.now().strftime('%Y%m%d%H%M%S')}"
+        }
+        
+        if tags:
+            payload["tags"] = tags
+        
+        try:
+            response = requests.post(
+                f"{self.base_url}/api/external/projects/init",
+                json=payload,
+                headers=self.headers,
+                timeout=30
+            )
+            
+            if response.status_code == 201:
+                result = response.json()
+                project_id = result.get("project_id")
+                self.created_projects.append(project_id)
+                
+                # 验证标签是否正确应用
+                if tags:
+                    config = result.get("config", "")
+                    tags_found = all(tag["tag"] in config for tag in tags)
+                    if tags_found:
+                        self.add_result(
+                            test_name,
+                            True,
+                            f"项目创建成功,ID: {project_id},标签已正确应用",
+                            {"project_id": project_id, "task_count": result.get("task_count")}
+                        )
+                    else:
+                        self.add_result(
+                            test_name,
+                            False,
+                            f"项目创建成功但标签未正确应用",
+                            {"project_id": project_id, "config": config[:200]}
+                        )
+                else:
+                    self.add_result(
+                        test_name,
+                        True,
+                        f"项目创建成功,ID: {project_id}",
+                        {"project_id": project_id, "task_count": result.get("task_count")}
+                    )
+                return project_id
+            else:
+                self.add_result(
+                    test_name,
+                    False,
+                    f"HTTP {response.status_code}",
+                    {"response": response.text[:500]}
+                )
+                return None
+                
+        except Exception as e:
+            self.add_result(test_name, False, f"请求异常: {str(e)}")
+            return None
+    
+    def test_get_progress(self, project_id: str) -> bool:
+        """测试获取项目进度"""
+        test_name = f"查询项目进度 ({project_id[:20]}...)"
+        
+        try:
+            response = requests.get(
+                f"{self.base_url}/api/external/projects/{project_id}/progress",
+                headers=self.headers,
+                timeout=30
+            )
+            
+            if response.status_code == 200:
+                result = response.json()
+                self.add_result(
+                    test_name,
+                    True,
+                    f"进度查询成功,完成率: {result.get('completion_percentage', 0)}%",
+                    {
+                        "total_tasks": result.get("total_tasks"),
+                        "completed_tasks": result.get("completed_tasks"),
+                        "status": result.get("status")
+                    }
+                )
+                return True
+            else:
+                self.add_result(
+                    test_name,
+                    False,
+                    f"HTTP {response.status_code}",
+                    {"response": response.text[:500]}
+                )
+                return False
+                
+        except Exception as e:
+            self.add_result(test_name, False, f"请求异常: {str(e)}")
+            return False
+    
+    def test_export_project(self, project_id: str, format: str = "json") -> bool:
+        """测试导出项目数据"""
+        test_name = f"导出项目数据 ({project_id[:20]}..., {format}格式)"
+        
+        try:
+            response = requests.post(
+                f"{self.base_url}/api/external/projects/{project_id}/export",
+                json={"format": format, "completed_only": False},
+                headers=self.headers,
+                timeout=60
+            )
+            
+            if response.status_code == 200:
+                result = response.json()
+                self.add_result(
+                    test_name,
+                    True,
+                    f"导出成功,共{result.get('total_exported', 0)}条数据",
+                    {
+                        "file_url": result.get("file_url"),
+                        "file_name": result.get("file_name"),
+                        "file_size": result.get("file_size")
+                    }
+                )
+                return True
+            else:
+                self.add_result(
+                    test_name,
+                    False,
+                    f"HTTP {response.status_code}",
+                    {"response": response.text[:500]}
+                )
+                return False
+                
+        except Exception as e:
+            self.add_result(test_name, False, f"请求异常: {str(e)}")
+            return False
+    
+    def run_all_tests(self):
+        """运行所有测试"""
+        self.log("=" * 60)
+        self.log("开始外部API测试")
+        self.log("=" * 60)
+        self.log(f"API地址: {self.base_url}")
+        self.log("")
+        
+        # 测试数据
+        text_data = [
+            {"id": "text_1", "content": "这个产品非常好用,推荐购买!"},
+            {"id": "text_2", "content": "质量太差了,不值这个价格"},
+            {"id": "text_3", "content": "一般般,没什么特别的"}
+        ]
+        
+        image_data = [
+            {"id": "img_1", "content": "https://picsum.photos/id/40/800/600"},
+            {"id": "img_2", "content": "https://picsum.photos/id/40/800/600"},
+            {"id": "img_3", "content": "https://picsum.photos/id/40/800/600"}
+        ]
+        
+        # 标签定义
+        sentiment_tags = [
+            {"tag": "正面", "color": "#4CAF50"},
+            {"tag": "负面", "color": "#F44336"},
+            {"tag": "中性", "color": "#9E9E9E"}
+        ]
+        
+        animal_tags = [
+            {"tag": "猫猫", "color": "#FF9800"},
+            {"tag": "狗狗", "color": "#2196F3"},
+            {"tag": "其他"}  # 不指定颜色,测试自动生成
+        ]
+        
+        object_tags = [
+            {"tag": "人物", "color": "#E91E63"},
+            {"tag": "车辆", "color": "#00BCD4"},
+            {"tag": "建筑", "color": "#795548"}
+        ]
+        
+        ner_tags = [
+            {"tag": "人名", "color": "#9C27B0"},
+            {"tag": "地名", "color": "#3F51B5"},
+            {"tag": "组织", "color": "#009688"}
+        ]
+        
+        polygon_tags = [
+            {"tag": "区域A", "color": "#FF5722"},
+            {"tag": "区域B", "color": "#607D8B"},
+            {"tag": "区域C"}  # 不指定颜色
+        ]
+        
+        # 1. 测试文本分类项目
+        self.log("\n--- 测试1: 文本分类项目 ---")
+        project_id = self.test_create_project(
+            name="测试-文本分类项目",
+            task_type="text_classification",
+            data=text_data,
+            tags=sentiment_tags,
+            description="情感分析测试项目"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+            self.test_export_project(project_id, "json")
+        
+        # 2. 测试图像分类项目
+        self.log("\n--- 测试2: 图像分类项目 ---")
+        project_id = self.test_create_project(
+            name="测试-图像分类项目",
+            task_type="image_classification",
+            data=image_data,
+            tags=animal_tags,
+            description="动物分类测试项目"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+            self.test_export_project(project_id, "csv")
+        
+        # 3. 测试目标检测项目
+        self.log("\n--- 测试3: 目标检测项目 ---")
+        project_id = self.test_create_project(
+            name="测试-目标检测项目",
+            task_type="object_detection",
+            data=image_data,
+            tags=object_tags,
+            description="目标检测测试项目"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+            self.test_export_project(project_id, "yolo")
+            self.test_export_project(project_id, "coco")
+        
+        # 4. 测试NER项目
+        self.log("\n--- 测试4: 命名实体识别项目 ---")
+        ner_data = [
+            {"id": "ner_1", "content": "张三在北京的阿里巴巴公司工作"},
+            {"id": "ner_2", "content": "李四去了上海参加腾讯的面试"},
+            {"id": "ner_3", "content": "王五是华为深圳总部的工程师"}
+        ]
+        project_id = self.test_create_project(
+            name="测试-NER项目",
+            task_type="ner",
+            data=ner_data,
+            tags=ner_tags,
+            description="命名实体识别测试项目"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+            self.test_export_project(project_id, "json")
+        
+        # 5. 测试多边形标注项目
+        self.log("\n--- 测试5: 多边形标注项目 ---")
+        project_id = self.test_create_project(
+            name="测试-多边形标注项目",
+            task_type="polygon",
+            data=image_data,
+            tags=polygon_tags,
+            description="多边形区域标注测试项目"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+            self.test_export_project(project_id, "coco")
+        
+        # 6. 测试不带标签的项目(验证默认行为)
+        self.log("\n--- 测试6: 不带标签的项目 ---")
+        project_id = self.test_create_project(
+            name="测试-无标签项目",
+            task_type="text_classification",
+            data=text_data,
+            tags=None,  # 不传标签
+            description="测试默认配置"
+        )
+        if project_id:
+            self.test_get_progress(project_id)
+        
+        # 输出测试结果汇总
+        self.print_summary()
+    
+    def print_summary(self):
+        """打印测试结果汇总"""
+        self.log("\n" + "=" * 60)
+        self.log("测试结果汇总")
+        self.log("=" * 60)
+        
+        total = len(self.results)
+        passed = sum(1 for r in self.results if r.success)
+        failed = total - passed
+        
+        self.log(f"总计: {total} 个测试")
+        self.log(f"通过: {passed} 个")
+        self.log(f"失败: {failed} 个")
+        self.log(f"通过率: {(passed/total*100):.1f}%" if total > 0 else "N/A")
+        
+        if failed > 0:
+            self.log("\n失败的测试:")
+            for r in self.results:
+                if not r.success:
+                    self.log(f"  ✗ {r.name}: {r.message}")
+        
+        self.log("\n创建的项目ID:")
+        for pid in self.created_projects:
+            self.log(f"  - {pid}")
+        
+        self.log("\n" + "=" * 60)
+        
+        # 返回退出码
+        return 0 if failed == 0 else 1
+
+
+def main():
+    parser = argparse.ArgumentParser(description="外部API测试脚本")
+    parser.add_argument(
+        "--base-url",
+        default=os.environ.get("API_BASE_URL", "http://localhost:8003"),
+        help="API基础URL (默认: http://localhost:8003)"
+    )
+    parser.add_argument(
+        "--token",
+        default=os.environ.get("ADMIN_TOKEN", ""),
+        help="管理员Token"
+    )
+    
+    args = parser.parse_args()
+    
+    if not args.token:
+        print("错误: 请提供管理员Token")
+        print("使用方式:")
+        print("  python scripts/test_external_api.py --token <your_token>")
+        print("  或设置环境变量 ADMIN_TOKEN")
+        sys.exit(1)
+    
+    tester = ExternalAPITester(args.base_url, args.token)
+    exit_code = tester.run_all_tests()
+    sys.exit(exit_code)
+
+
+if __name__ == "__main__":
+    main()

+ 74 - 0
backend/scripts/update_task_images.py

@@ -0,0 +1,74 @@
+"""
+Update task images script.
+Updates all tasks to use a CORS-friendly image URL.
+"""
+import sys
+import os
+import json
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from database import get_db_connection
+
+# 新的图片 URL (支持 CORS)
+NEW_IMAGE_URL = "https://picsum.photos/id/40/800/600"
+
+# 旧的图片 URL
+OLD_IMAGE_URL = "https://bkimg.cdn.bcebos.com/pic/8cb1cb1349540923dd54185ba804c609b3de9c82e3d8"
+
+
+def update_task_images():
+    """Update all tasks with the old image URL to use the new one."""
+    
+    with get_db_connection() as conn:
+        cursor = conn.cursor()
+        
+        # Get all tasks
+        cursor.execute("SELECT id, data FROM tasks")
+        rows = cursor.fetchall()
+        
+        updated_count = 0
+        
+        for row in rows:
+            task_id = row["id"]
+            data_str = row["data"]
+            
+            try:
+                data = json.loads(data_str) if isinstance(data_str, str) else data_str
+                
+                # Check if this task has the old image URL
+                if data.get("image") == OLD_IMAGE_URL:
+                    data["image"] = NEW_IMAGE_URL
+                    
+                    cursor.execute(
+                        "UPDATE tasks SET data = %s WHERE id = %s",
+                        (json.dumps(data, ensure_ascii=False), task_id)
+                    )
+                    updated_count += 1
+                    print(f"✓ 更新任务: {task_id}")
+            except Exception as e:
+                print(f"✗ 跳过任务 {task_id}: {e}")
+        
+        print(f"\n共更新 {updated_count} 个任务")
+        return updated_count
+
+
+if __name__ == "__main__":
+    print("=" * 60)
+    print("更新任务图片 URL 脚本")
+    print("=" * 60)
+    print(f"旧 URL: {OLD_IMAGE_URL}")
+    print(f"新 URL: {NEW_IMAGE_URL}")
+    print("-" * 60)
+    
+    try:
+        count = update_task_images()
+        print("\n" + "=" * 60)
+        print("更新完成!")
+        print("=" * 60)
+    except Exception as e:
+        print(f"\n错误: {e}")
+        import traceback
+        traceback.print_exc()
+        sys.exit(1)

+ 197 - 9
backend/services/external_service.py

@@ -5,6 +5,7 @@ Provides business logic for external system integration.
 import uuid
 import json
 import logging
+import random
 from datetime import datetime
 from typing import Optional, List, Dict, Any
 
@@ -13,12 +14,49 @@ from schemas.external import (
     TaskType, ProjectInitRequest, ProjectInitResponse,
     ProgressResponse, AnnotatorProgress,
     ExternalExportFormat, ExternalExportRequest, ExternalExportResponse,
-    TaskDataItem
+    TaskDataItem, TagItem
 )
 
 logger = logging.getLogger(__name__)
 
 
+def generate_random_color() -> str:
+    """
+    生成随机颜色
+    
+    Returns:
+        str: #RRGGBB 格式的颜色字符串
+    """
+    return f"#{random.randint(0, 0xFFFFFF):06x}"
+
+
+# 预定义的颜色列表,用于生成更美观的颜色
+PRESET_COLORS = [
+    "#FF5733", "#33FF57", "#3357FF", "#FF33F5", "#F5FF33",
+    "#33FFF5", "#FF8C33", "#8C33FF", "#33FF8C", "#FF338C",
+    "#5733FF", "#57FF33", "#FF3357", "#33F5FF", "#F533FF",
+    "#8CFF33", "#338CFF", "#FF338C", "#33FF57", "#5733FF"
+]
+
+
+def get_color_for_tag(index: int, specified_color: Optional[str] = None) -> str:
+    """
+    获取标签颜色
+    
+    Args:
+        index: 标签索引,用于从预设颜色中选择
+        specified_color: 指定的颜色,如果有则直接使用
+        
+    Returns:
+        str: #RRGGBB 格式的颜色字符串
+    """
+    if specified_color:
+        return specified_color
+    if index < len(PRESET_COLORS):
+        return PRESET_COLORS[index]
+    return generate_random_color()
+
+
 # 默认XML配置模板(不含标签,由管理员后续配置)
 DEFAULT_CONFIGS = {
     TaskType.TEXT_CLASSIFICATION: """<View>
@@ -44,10 +82,96 @@ DEFAULT_CONFIGS = {
   <Labels name="label" toName="text">
     <!-- 标签由管理员配置 -->
   </Labels>
+</View>""",
+    TaskType.POLYGON: """<View>
+  <Image name="image" value="$image"/>
+  <PolygonLabels name="label" toName="image">
+    <!-- 标签由管理员配置 -->
+  </PolygonLabels>
 </View>"""
 }
 
 
+def generate_config_with_tags(task_type: TaskType, tags: Optional[List[TagItem]] = None) -> str:
+    """
+    根据任务类型和标签生成XML配置
+    
+    Args:
+        task_type: 任务类型
+        tags: 标签列表,可选
+        
+    Returns:
+        str: 生成的XML配置字符串
+    """
+    if not tags or len(tags) == 0:
+        # 没有标签,返回默认配置
+        return DEFAULT_CONFIGS.get(task_type, DEFAULT_CONFIGS[TaskType.TEXT_CLASSIFICATION])
+    
+    # 根据任务类型生成带标签的配置
+    if task_type == TaskType.TEXT_CLASSIFICATION:
+        labels_xml = "\n".join([
+            f'    <Choice value="{tag.tag}" style="background-color: {get_color_for_tag(i, tag.color)}"/>'
+            for i, tag in enumerate(tags)
+        ])
+        return f"""<View>
+  <Text name="text" value="$text"/>
+  <Choices name="label" toName="text" choice="single">
+{labels_xml}
+  </Choices>
+</View>"""
+    
+    elif task_type == TaskType.IMAGE_CLASSIFICATION:
+        labels_xml = "\n".join([
+            f'    <Choice value="{tag.tag}" style="background-color: {get_color_for_tag(i, tag.color)}"/>'
+            for i, tag in enumerate(tags)
+        ])
+        return f"""<View>
+  <Image name="image" value="$image"/>
+  <Choices name="label" toName="image" choice="single">
+{labels_xml}
+  </Choices>
+</View>"""
+    
+    elif task_type == TaskType.OBJECT_DETECTION:
+        labels_xml = "\n".join([
+            f'    <Label value="{tag.tag}" background="{get_color_for_tag(i, tag.color)}"/>'
+            for i, tag in enumerate(tags)
+        ])
+        return f"""<View>
+  <Image name="image" value="$image"/>
+  <RectangleLabels name="label" toName="image">
+{labels_xml}
+  </RectangleLabels>
+</View>"""
+    
+    elif task_type == TaskType.NER:
+        labels_xml = "\n".join([
+            f'    <Label value="{tag.tag}" background="{get_color_for_tag(i, tag.color)}"/>'
+            for i, tag in enumerate(tags)
+        ])
+        return f"""<View>
+  <Text name="text" value="$text"/>
+  <Labels name="label" toName="text">
+{labels_xml}
+  </Labels>
+</View>"""
+    
+    elif task_type == TaskType.POLYGON:
+        labels_xml = "\n".join([
+            f'    <Label value="{tag.tag}" background="{get_color_for_tag(i, tag.color)}"/>'
+            for i, tag in enumerate(tags)
+        ])
+        return f"""<View>
+  <Image name="image" value="$image"/>
+  <PolygonLabels name="label" toName="image">
+{labels_xml}
+  </PolygonLabels>
+</View>"""
+    
+    else:
+        return DEFAULT_CONFIGS.get(task_type, DEFAULT_CONFIGS[TaskType.TEXT_CLASSIFICATION])
+
+
 class ExternalService:
     """对外API服务类"""
     
@@ -71,8 +195,11 @@ class ExternalService:
         # 生成项目ID
         project_id = f"proj_{uuid.uuid4().hex[:12]}"
         
-        # 获取默认配置
-        config = ExternalService.get_default_config(request.task_type)
+        # 根据是否有标签生成配置
+        if request.tags and len(request.tags) > 0:
+            config = generate_config_with_tags(request.task_type, request.tags)
+        else:
+            config = ExternalService.get_default_config(request.task_type)
         
         with get_db_connection() as conn:
             cursor = conn.cursor()
@@ -482,6 +609,7 @@ class ExternalService:
             
             image_url = original.get('image', '')
             boxes = []
+            polygons = []
             
             for ann in annotations:
                 if isinstance(ann, list):
@@ -495,12 +623,23 @@ class ExternalService:
                                 "width": value.get('width', 0) / 100,
                                 "height": value.get('height', 0) / 100
                             })
+                        elif a.get('type') == 'polygonlabels':
+                            value = a.get('value', {})
+                            points = value.get('points', [])
+                            # 将点坐标归一化到0-1范围
+                            normalized_points = [[p[0] / 100, p[1] / 100] for p in points]
+                            polygons.append({
+                                "label": value.get('polygonlabels', [''])[0],
+                                "points": normalized_points
+                            })
             
             if image_url:
-                yolo_data.append({
-                    "image": image_url,
-                    "boxes": boxes
-                })
+                entry = {"image": image_url}
+                if boxes:
+                    entry["boxes"] = boxes
+                if polygons:
+                    entry["polygons"] = polygons
+                yolo_data.append(entry)
         
         content = json.dumps(yolo_data, ensure_ascii=False, indent=2)
         return file_name, content
@@ -537,8 +676,10 @@ class ExternalService:
             for ann in annotations:
                 if isinstance(ann, list):
                     for a in ann:
-                        if a.get('type') == 'rectanglelabels':
-                            value = a.get('value', {})
+                        ann_type = a.get('type', '')
+                        value = a.get('value', {})
+                        
+                        if ann_type == 'rectanglelabels':
                             label = value.get('rectanglelabels', [''])[0]
                             
                             # 添加类别
@@ -565,6 +706,53 @@ class ExternalService:
                                     "iscrowd": 0
                                 })
                                 annotation_id += 1
+                        
+                        elif ann_type == 'polygonlabels':
+                            label = value.get('polygonlabels', [''])[0]
+                            points = value.get('points', [])
+                            
+                            # 添加类别
+                            if label and label not in category_map:
+                                cat_id = len(category_map) + 1
+                                category_map[label] = cat_id
+                                coco_data["categories"].append({
+                                    "id": cat_id,
+                                    "name": label
+                                })
+                            
+                            if label and points:
+                                # 将点列表转换为COCO segmentation格式 [x1, y1, x2, y2, ...]
+                                segmentation = []
+                                for p in points:
+                                    segmentation.extend([p[0], p[1]])
+                                
+                                # 计算边界框
+                                x_coords = [p[0] for p in points]
+                                y_coords = [p[1] for p in points]
+                                x_min, x_max = min(x_coords), max(x_coords)
+                                y_min, y_max = min(y_coords), max(y_coords)
+                                width = x_max - x_min
+                                height = y_max - y_min
+                                
+                                # 计算面积(使用鞋带公式)
+                                n = len(points)
+                                area = 0
+                                for i in range(n):
+                                    j = (i + 1) % n
+                                    area += points[i][0] * points[j][1]
+                                    area -= points[j][0] * points[i][1]
+                                area = abs(area) / 2
+                                
+                                coco_data["annotations"].append({
+                                    "id": annotation_id,
+                                    "image_id": idx + 1,
+                                    "category_id": category_map.get(label, 0),
+                                    "segmentation": [segmentation],
+                                    "bbox": [x_min, y_min, width, height],
+                                    "area": area,
+                                    "iscrowd": 0
+                                })
+                                annotation_id += 1
         
         content = json.dumps(coco_data, ensure_ascii=False, indent=2)
         return file_name, content

BIN
lq_label_dist.tar.gz


+ 10 - 2
web/apps/lq_label/src/components/task-type-selector/task-type-selector.tsx

@@ -8,14 +8,15 @@
  * Requirements: 4.1, 4.2, 4.3
  */
 import React from 'react';
-import { FileText, Image, Square, Tag } from 'lucide-react';
+import { FileText, Image, Square, Tag, Hexagon } from 'lucide-react';
 import styles from './task-type-selector.module.scss';
 
 export type TaskType = 
   | 'text_classification'
   | 'image_classification'
   | 'object_detection'
-  | 'ner';
+  | 'ner'
+  | 'polygon';
 
 export interface TaskTypeOption {
   id: TaskType;
@@ -60,6 +61,13 @@ const TASK_TYPE_OPTIONS: TaskTypeOption[] = [
     icon: <Tag size={32} />,
     dataFormat: 'text',
   },
+  {
+    id: 'polygon',
+    name: '多边形标注',
+    description: '在图像中标注多边形区域',
+    icon: <Hexagon size={32} />,
+    dataFormat: 'image',
+  },
 ];
 
 export const TaskTypeSelector: React.FC<TaskTypeSelectorProps> = ({

+ 23 - 0
web/apps/lq_label/src/services/xml-generator.ts

@@ -105,6 +105,25 @@ ${labelsXML}
 </View>`;
 };
 
+/**
+ * 生成多边形标注 XML
+ */
+const generatePolygon = (config: XMLGeneratorConfig): string => {
+  const labelsXML = config.labels
+    .map(l => {
+      const hotkeyAttr = l.hotkey ? ` hotkey="${escapeXml(l.hotkey)}"` : '';
+      return `    <Label value="${escapeXml(l.name)}" background="${escapeXml(l.color)}"${hotkeyAttr} />`;
+    })
+    .join('\n');
+  
+  return `<View>
+  <Image name="image" value="$image"/>
+  <PolygonLabels name="label" toName="image">
+${labelsXML}
+  </PolygonLabels>
+</View>`;
+};
+
 /**
  * XML 生成器类
  */
@@ -130,6 +149,8 @@ export class XMLGenerator {
         return generateObjectDetection(config);
       case 'ner':
         return generateNER(config);
+      case 'polygon':
+        return generatePolygon(config);
       default:
         throw new Error(`不支持的任务类型: ${config.taskType}`);
     }
@@ -171,6 +192,7 @@ export class XMLGenerator {
         return 'text';
       case 'image_classification':
       case 'object_detection':
+      case 'polygon':
         return 'image';
       default:
         return 'text';
@@ -186,6 +208,7 @@ export class XMLGenerator {
       image_classification: '图像分类',
       object_detection: '目标检测',
       ner: '命名实体识别',
+      polygon: '多边形标注',
     };
     return names[taskType] || taskType;
   }

+ 1 - 0
web/apps/lq_label/src/views/external-projects-view/external-projects-view.tsx

@@ -113,6 +113,7 @@ export const ExternalProjectsView: React.FC = () => {
       image_classification: '图像分类',
       object_detection: '目标检测',
       ner: '命名实体识别',
+      polygon: '多边形标注',
     };
     return typeLabels[taskType || ''] || taskType || '-';
   };

+ 28 - 2
web/apps/lq_label/src/views/project-config-view/project-config-view.tsx

@@ -96,6 +96,7 @@ export const ProjectConfigView: React.FC = () => {
       const choices = doc.querySelector('Choices');
       const labels = doc.querySelector('Labels');
       const rectangleLabels = doc.querySelector('RectangleLabels');
+      const polygonLabels = doc.querySelector('PolygonLabels');
       const text = doc.querySelector('Text');
       const image = doc.querySelector('Image');
       
@@ -118,6 +119,18 @@ export const ProjectConfigView: React.FC = () => {
         } else if (image) {
           setInitialTaskType('image_classification');
         }
+      } else if (polygonLabels) {
+        // Polygon annotation
+        setInitialTaskType('polygon');
+        const labelElements = polygonLabels.querySelectorAll('Label');
+        labelElements.forEach((label, index) => {
+          parsedLabels.push({
+            id: `label_${index}`,
+            name: label.getAttribute('value') || '',
+            color: label.getAttribute('background') || '#4ECDC4',
+            hotkey: label.getAttribute('hotkey') || undefined,
+          });
+        });
       } else if (rectangleLabels) {
         // Object detection
         setInitialTaskType('object_detection');
@@ -171,15 +184,28 @@ export const ProjectConfigView: React.FC = () => {
       setSuccess(null);
       
       // Save the generated XML config
-      const updated = await updateProjectConfig(projectId, result.xmlConfig, result.taskType);
+      await updateProjectConfig(projectId, result.xmlConfig, result.taskType);
+      
+      // Update status to ready
+      const updated = await updateProjectStatus(projectId, 'ready');
       setProject(updated as Project);
       setConfig(result.xmlConfig);
       setHasChanges(false);
-      setSuccess('配置已保存');
       
       // Update initial values for wizard
       setInitialTaskType(result.taskType);
       setInitialLabels(result.labels);
+      
+      setSuccess('配置已保存,项目已标记为待分发状态');
+      
+      // Navigate back to external projects page after a short delay
+      setTimeout(() => {
+        if (project?.source === 'external') {
+          navigate('/external-projects');
+        } else {
+          navigate('/projects');
+        }
+      }, 1000);
     } catch (err: any) {
       setError(err.message || '保存配置失败');
     } finally {