瀏覽代碼

-dev:新功能

LuoChinWen 3 周之前
父節點
當前提交
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[].content | string | 是 | 数据内容(文本或图像URL) |
 | data[].metadata | object | 否 | 额外元数据 |
 | data[].metadata | object | 否 | 额外元数据 |
 | external_id | string | 否 | 外部系统的项目ID,用于关联查询 |
 | 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 | 文本分类 | 文本内容 |
 | text_classification | 文本分类 | 文本内容 |
 | image_classification | 图像分类 | 图像URL |
 | image_classification | 图像分类 | 图像URL |
-| object_detection | 目标检测 | 图像URL |
+| object_detection | 目标检测(矩形框) | 图像URL |
 | ner | 命名实体识别 | 文本内容 |
 | ner | 命名实体识别 | 文本内容 |
+| polygon | 多边形标注 | 图像URL |
 
 
 **响应**
 **响应**
 
 
@@ -385,7 +396,7 @@ headers = {
     "Content-Type": "application/json"
     "Content-Type": "application/json"
 }
 }
 
 
-# 1. 创建项目
+# 1. 创建项目(带标签)
 init_data = {
 init_data = {
     "name": "文本分类项目",
     "name": "文本分类项目",
     "description": "对用户评论进行情感分类",
     "description": "对用户评论进行情感分类",
@@ -395,7 +406,12 @@ init_data = {
         {"id": "2", "content": "质量太差了,不推荐"},
         {"id": "2", "content": "质量太差了,不推荐"},
         {"id": "3", "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(
 response = requests.post(
@@ -407,7 +423,30 @@ project = response.json()
 project_id = project["project_id"]
 project_id = project["project_id"]
 print(f"项目创建成功: {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(
 response = requests.get(
     f"{BASE_URL}/projects/{project_id}/progress",
     f"{BASE_URL}/projects/{project_id}/progress",
     headers=headers
     headers=headers
@@ -415,7 +454,7 @@ response = requests.get(
 progress = response.json()
 progress = response.json()
 print(f"完成进度: {progress['completion_percentage']}%")
 print(f"完成进度: {progress['completion_percentage']}%")
 
 
-# 3. 导出数据(ShareGPT格式)
+# 4. 导出数据(ShareGPT格式)
 export_data = {
 export_data = {
     "format": "sharegpt",
     "format": "sharegpt",
     "completed_only": True,
     "completed_only": True,
@@ -444,7 +483,7 @@ print(f"文件已保存: {export_result['file_name']}")
 ### cURL 示例
 ### cURL 示例
 
 
 ```bash
 ```bash
-# 1. 创建项目
+# 1. 创建项目(带标签)
 curl -X POST "http://localhost:8003/api/external/projects/init" \
 curl -X POST "http://localhost:8003/api/external/projects/init" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Content-Type: application/json" \
   -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": "1", "content": "https://example.com/img1.jpg"},
       {"id": "2", "content": "https://example.com/img2.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" \
 curl -X GET "http://localhost:8003/api/external/projects/proj_xxx/progress" \
   -H "Authorization: Bearer your_admin_token"
   -H "Authorization: Bearer your_admin_token"
 
 
-# 3. 导出数据(YOLO格式)
+# 4. 导出数据(YOLO格式)
 curl -X POST "http://localhost:8003/api/external/projects/proj_xxx/export" \
 curl -X POST "http://localhost:8003/api/external/projects/proj_xxx/export" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Content-Type: application/json" \
   -H "Content-Type: application/json" \
   -d '{"format": "yolo", "completed_only": true}'
   -d '{"format": "yolo", "completed_only": true}'
 
 
-# 4. 下载导出文件
+# 5. 下载导出文件
 curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
 curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
   -H "Authorization: Bearer your_admin_token" \
   -H "Authorization: Bearer your_admin_token" \
   -o exported_data.zip
   -o exported_data.zip
@@ -512,17 +571,26 @@ curl -X GET "http://localhost:8003/api/exports/export_xxx/download" \
 
 
 1. **项目状态**:外部系统创建的项目初始状态为 `draft`,需要标注平台管理员完成配置(设置标签等)和任务分发后才会进入 `in_progress` 状态。
 1. **项目状态**:外部系统创建的项目初始状态为 `draft`,需要标注平台管理员完成配置(设置标签等)和任务分发后才会进入 `in_progress` 状态。
 
 
-2. **标签配置**:标签由标注平台管理员在项目配置阶段设置,样本中心只需提供任务数据,无需关心标签定义。
+2. **标签配置**:
+   - 如果在创建项目时传入 `tags` 参数,系统会自动生成包含这些标签的XML配置
+   - 如果不传 `tags` 参数,标签由标注平台管理员在项目配置阶段设置
+   - 管理员可以在配置阶段修改已传入的标签名称和颜色
+
+3. **颜色格式**:
+   - 颜色使用 `#RRGGBB` 格式(如 `#FF5733`)
+   - 如果不指定颜色,系统会自动生成随机颜色
 
 
-3. **数据格式**:
+4. **数据格式**:
    - 文本类任务(text_classification, ner):`content` 字段为文本内容
    - 文本类任务(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 | 初始版本 |
 | 1.0.0 | 2026-02-03 | 初始版本 |

+ 4 - 4
backend/config.prod.yaml

@@ -2,10 +2,10 @@
 
 
 # JWT 配置
 # JWT 配置
 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"
   algorithm: "HS256"
-  access_token_expire_minutes: 15
+  access_token_expire_minutes: 60
   refresh_token_expire_days: 7
   refresh_token_expire_days: 7
 
 
 # OAuth 2.0 单点登录配置
 # OAuth 2.0 单点登录配置
@@ -30,7 +30,7 @@ database:
     port: 13306
     port: 13306
     user: "root"
     user: "root"
     password: "Lq123456!"
     password: "Lq123456!"
-    database: "lq_lable_dev"
+    database: "lq_label_dev"
 
 
 # 服务器配置
 # 服务器配置
 server:
 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 datetime import datetime
 from typing import Optional, List, Any
 from typing import Optional, List, Any
 from enum import Enum
 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"
     IMAGE_CLASSIFICATION = "image_classification"
     OBJECT_DETECTION = "object_detection"
     OBJECT_DETECTION = "object_detection"
     NER = "ner"
     NER = "ner"
+    POLYGON = "polygon"  # 多边形标注
 
 
 
 
 class ExternalExportFormat(str, Enum):
 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):
 class TaskDataItem(BaseModel):
     """单个任务数据项"""
     """单个任务数据项"""
     id: Optional[str] = Field(None, description="外部系统的数据ID,用于关联")
     id: Optional[str] = Field(None, description="外部系统的数据ID,用于关联")
@@ -53,6 +78,7 @@ class ProjectInitRequest(BaseModel):
     task_type: TaskType = Field(..., description="任务类型")
     task_type: TaskType = Field(..., description="任务类型")
     data: List[TaskDataItem] = Field(..., min_length=1, description="任务数据列表")
     data: List[TaskDataItem] = Field(..., min_length=1, description="任务数据列表")
     external_id: Optional[str] = Field(None, description="外部系统的项目ID,用于关联查询")
     external_id: Optional[str] = Field(None, description="外部系统的项目ID,用于关联查询")
+    tags: Optional[List[TagItem]] = Field(None, description="标签列表,包含标签名称和可选颜色")
     
     
     class Config:
     class Config:
         json_schema_extra = {
         json_schema_extra = {
@@ -67,7 +93,11 @@ class ProjectInitRequest(BaseModel):
                         "metadata": {"batch": "001"}
                         "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 uuid
 import json
 import json
 import logging
 import logging
+import random
 from datetime import datetime
 from datetime import datetime
 from typing import Optional, List, Dict, Any
 from typing import Optional, List, Dict, Any
 
 
@@ -13,12 +14,49 @@ from schemas.external import (
     TaskType, ProjectInitRequest, ProjectInitResponse,
     TaskType, ProjectInitRequest, ProjectInitResponse,
     ProgressResponse, AnnotatorProgress,
     ProgressResponse, AnnotatorProgress,
     ExternalExportFormat, ExternalExportRequest, ExternalExportResponse,
     ExternalExportFormat, ExternalExportRequest, ExternalExportResponse,
-    TaskDataItem
+    TaskDataItem, TagItem
 )
 )
 
 
 logger = logging.getLogger(__name__)
 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配置模板(不含标签,由管理员后续配置)
 # 默认XML配置模板(不含标签,由管理员后续配置)
 DEFAULT_CONFIGS = {
 DEFAULT_CONFIGS = {
     TaskType.TEXT_CLASSIFICATION: """<View>
     TaskType.TEXT_CLASSIFICATION: """<View>
@@ -44,10 +82,96 @@ DEFAULT_CONFIGS = {
   <Labels name="label" toName="text">
   <Labels name="label" toName="text">
     <!-- 标签由管理员配置 -->
     <!-- 标签由管理员配置 -->
   </Labels>
   </Labels>
+</View>""",
+    TaskType.POLYGON: """<View>
+  <Image name="image" value="$image"/>
+  <PolygonLabels name="label" toName="image">
+    <!-- 标签由管理员配置 -->
+  </PolygonLabels>
 </View>"""
 </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:
 class ExternalService:
     """对外API服务类"""
     """对外API服务类"""
     
     
@@ -71,8 +195,11 @@ class ExternalService:
         # 生成项目ID
         # 生成项目ID
         project_id = f"proj_{uuid.uuid4().hex[:12]}"
         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:
         with get_db_connection() as conn:
             cursor = conn.cursor()
             cursor = conn.cursor()
@@ -482,6 +609,7 @@ class ExternalService:
             
             
             image_url = original.get('image', '')
             image_url = original.get('image', '')
             boxes = []
             boxes = []
+            polygons = []
             
             
             for ann in annotations:
             for ann in annotations:
                 if isinstance(ann, list):
                 if isinstance(ann, list):
@@ -495,12 +623,23 @@ class ExternalService:
                                 "width": value.get('width', 0) / 100,
                                 "width": value.get('width', 0) / 100,
                                 "height": value.get('height', 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:
             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)
         content = json.dumps(yolo_data, ensure_ascii=False, indent=2)
         return file_name, content
         return file_name, content
@@ -537,8 +676,10 @@ class ExternalService:
             for ann in annotations:
             for ann in annotations:
                 if isinstance(ann, list):
                 if isinstance(ann, list):
                     for a in ann:
                     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]
                             label = value.get('rectanglelabels', [''])[0]
                             
                             
                             # 添加类别
                             # 添加类别
@@ -565,6 +706,53 @@ class ExternalService:
                                     "iscrowd": 0
                                     "iscrowd": 0
                                 })
                                 })
                                 annotation_id += 1
                                 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)
         content = json.dumps(coco_data, ensure_ascii=False, indent=2)
         return file_name, content
         return file_name, content

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

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

@@ -105,6 +105,25 @@ ${labelsXML}
 </View>`;
 </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 生成器类
  * XML 生成器类
  */
  */
@@ -130,6 +149,8 @@ export class XMLGenerator {
         return generateObjectDetection(config);
         return generateObjectDetection(config);
       case 'ner':
       case 'ner':
         return generateNER(config);
         return generateNER(config);
+      case 'polygon':
+        return generatePolygon(config);
       default:
       default:
         throw new Error(`不支持的任务类型: ${config.taskType}`);
         throw new Error(`不支持的任务类型: ${config.taskType}`);
     }
     }
@@ -171,6 +192,7 @@ export class XMLGenerator {
         return 'text';
         return 'text';
       case 'image_classification':
       case 'image_classification':
       case 'object_detection':
       case 'object_detection':
+      case 'polygon':
         return 'image';
         return 'image';
       default:
       default:
         return 'text';
         return 'text';
@@ -186,6 +208,7 @@ export class XMLGenerator {
       image_classification: '图像分类',
       image_classification: '图像分类',
       object_detection: '目标检测',
       object_detection: '目标检测',
       ner: '命名实体识别',
       ner: '命名实体识别',
+      polygon: '多边形标注',
     };
     };
     return names[taskType] || taskType;
     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: '图像分类',
       image_classification: '图像分类',
       object_detection: '目标检测',
       object_detection: '目标检测',
       ner: '命名实体识别',
       ner: '命名实体识别',
+      polygon: '多边形标注',
     };
     };
     return typeLabels[taskType || ''] || taskType || '-';
     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 choices = doc.querySelector('Choices');
       const labels = doc.querySelector('Labels');
       const labels = doc.querySelector('Labels');
       const rectangleLabels = doc.querySelector('RectangleLabels');
       const rectangleLabels = doc.querySelector('RectangleLabels');
+      const polygonLabels = doc.querySelector('PolygonLabels');
       const text = doc.querySelector('Text');
       const text = doc.querySelector('Text');
       const image = doc.querySelector('Image');
       const image = doc.querySelector('Image');
       
       
@@ -118,6 +119,18 @@ export const ProjectConfigView: React.FC = () => {
         } else if (image) {
         } else if (image) {
           setInitialTaskType('image_classification');
           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) {
       } else if (rectangleLabels) {
         // Object detection
         // Object detection
         setInitialTaskType('object_detection');
         setInitialTaskType('object_detection');
@@ -171,15 +184,28 @@ export const ProjectConfigView: React.FC = () => {
       setSuccess(null);
       setSuccess(null);
       
       
       // Save the generated XML config
       // 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);
       setProject(updated as Project);
       setConfig(result.xmlConfig);
       setConfig(result.xmlConfig);
       setHasChanges(false);
       setHasChanges(false);
-      setSuccess('配置已保存');
       
       
       // Update initial values for wizard
       // Update initial values for wizard
       setInitialTaskType(result.taskType);
       setInitialTaskType(result.taskType);
       setInitialLabels(result.labels);
       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) {
     } catch (err: any) {
       setError(err.message || '保存配置失败');
       setError(err.message || '保存配置失败');
     } finally {
     } finally {