ソースを参照

-dev:联通标注流程,使结构更加稳定

LuoChinWen 3 週間 前
コミット
6aa1ea1074
36 ファイル変更4382 行追加378 行削除
  1. 3 0
      .gitignore
  2. 545 0
      .kiro/specs/role-based-menu-and-project-config/design.md
  3. 148 0
      .kiro/specs/role-based-menu-and-project-config/requirements.md
  4. 210 0
      .kiro/specs/role-based-menu-and-project-config/tasks.md
  5. 0 42
      backend/config.yaml
  6. 99 46
      backend/routers/project.py
  7. 48 13
      backend/routers/task.py
  8. BIN
      backend/schemas/__pycache__/project.cpython-311.pyc
  9. 6 2
      backend/schemas/project.py
  10. 40 12
      deploy.sh
  11. BIN
      lq_label_dist.tar.gz
  12. 29 3
      web/apps/lq_label/src/app/app.tsx
  13. 33 0
      web/apps/lq_label/src/components/admin-route/admin-route.tsx
  14. 1 0
      web/apps/lq_label/src/components/admin-route/index.ts
  15. 8 0
      web/apps/lq_label/src/components/index.ts
  16. 5 0
      web/apps/lq_label/src/components/label-editor/index.ts
  17. 321 0
      web/apps/lq_label/src/components/label-editor/label-editor.module.scss
  18. 262 0
      web/apps/lq_label/src/components/label-editor/label-editor.tsx
  19. 16 1
      web/apps/lq_label/src/components/layout/sidebar.tsx
  20. 9 0
      web/apps/lq_label/src/components/project-config-wizard/index.ts
  21. 326 0
      web/apps/lq_label/src/components/project-config-wizard/project-config-wizard.module.scss
  22. 287 0
      web/apps/lq_label/src/components/project-config-wizard/project-config-wizard.tsx
  23. 134 0
      web/apps/lq_label/src/components/project-form/project-form.module.scss
  24. 311 111
      web/apps/lq_label/src/components/project-form/project-form.tsx
  25. 5 0
      web/apps/lq_label/src/components/task-type-selector/index.ts
  26. 143 0
      web/apps/lq_label/src/components/task-type-selector/task-type-selector.module.scss
  27. 105 0
      web/apps/lq_label/src/components/task-type-selector/task-type-selector.tsx
  28. 4 2
      web/apps/lq_label/src/services/api.ts
  29. 192 0
      web/apps/lq_label/src/services/xml-generator.ts
  30. 430 0
      web/apps/lq_label/src/views/external-projects-view/external-projects-view.module.scss
  31. 355 0
      web/apps/lq_label/src/views/external-projects-view/external-projects-view.tsx
  32. 7 0
      web/apps/lq_label/src/views/external-projects-view/index.ts
  33. 1 0
      web/apps/lq_label/src/views/index.ts
  34. 42 0
      web/apps/lq_label/src/views/project-config-view/project-config-view.module.scss
  35. 209 45
      web/apps/lq_label/src/views/project-config-view/project-config-view.tsx
  36. 48 101
      web/apps/lq_label/src/views/project-list-view/project-list-view.tsx

+ 3 - 0
.gitignore

@@ -20,6 +20,9 @@ dist
 # Backend generated files
 backend/.hypothesis/
 backend/exports/
+backend/.last_deps_hash
+backend/.last_code_hash
+backend/.last_build_hash
 
 *.db
 

+ 545 - 0
.kiro/specs/role-based-menu-and-project-config/design.md

@@ -0,0 +1,545 @@
+# Design Document: Role-Based Menu and Project Config
+
+## Overview
+
+本设计文档描述了标注平台角色权限菜单优化和项目配置流程改进的技术实现方案。主要包括:
+
+1. **角色权限菜单**:根据用户角色(admin/annotator)显示不同的侧边栏菜单
+2. **外部项目管理页面**:新增管理员专属页面,管理外部API创建的项目
+3. **项目管理页面优化**:仅显示进行中和已完成的项目
+4. **选卡式项目配置**:提供可视化的标注类型选择和标签配置界面
+5. **XML配置生成**:根据配置自动生成 Label Studio XML
+
+## Architecture
+
+### 系统架构图
+
+```mermaid
+graph TB
+    subgraph "Frontend"
+        subgraph "Components"
+            SM[Sidebar Menu]
+            TC[Task Type Cards]
+            LE[Label Editor]
+            XP[XML Preview]
+            CW[Config Wizard]
+        end
+        
+        subgraph "Pages"
+            PL[Project List Page]
+            EP[External Projects Page]
+            PC[Project Config Page]
+            CP[Create Project Page]
+        end
+        
+        subgraph "State"
+            UA[User Auth Atoms]
+            PA[Project Atoms]
+        end
+    end
+    
+    subgraph "Services"
+        XG[XML Generator]
+        RF[Role Filter]
+    end
+    
+    SM --> RF
+    RF --> UA
+    TC --> XG
+    LE --> XG
+    XG --> XP
+    CW --> TC
+    CW --> LE
+    CW --> XP
+    PL --> PA
+    EP --> PA
+    PC --> PA
+```
+
+### 页面路由结构
+
+```
+/projects                    # 项目管理(仅显示进行中和已完成)
+/projects/:id/config         # 项目配置页面
+/projects/create             # 创建项目向导
+/external-projects           # 管理外部来源数据(管理员专属)
+/my-tasks                    # 我的任务
+/annotations                 # 我的标注
+/tasks                       # 任务管理(管理员专属)
+/users                       # 用户管理(管理员专属)
+```
+
+### 菜单权限配置
+
+```typescript
+// 菜单项配置
+const menuItems = [
+  { id: 'projects', label: '项目管理', path: '/projects', adminOnly: false },
+  { id: 'external-projects', label: '管理外部来源数据', path: '/external-projects', adminOnly: true },
+  { id: 'tasks', label: '任务管理', path: '/tasks', adminOnly: true },
+  { id: 'my-tasks', label: '我的任务', path: '/my-tasks', adminOnly: false },
+  { id: 'annotations', label: '我的标注', path: '/annotations', adminOnly: false },
+  { id: 'users', label: '用户管理', path: '/users', adminOnly: true },
+];
+
+// 角色过滤逻辑
+const visibleMenuItems = menuItems.filter(item => {
+  if (item.adminOnly && currentUser?.role !== 'admin') {
+    return false;
+  }
+  return true;
+});
+```
+
+## Components and Interfaces
+
+### 1. Task Type Selector Component
+
+选卡式标注类型选择组件。
+
+```typescript
+interface TaskTypeOption {
+  id: TaskType;
+  name: string;
+  description: string;
+  icon: React.ReactNode;
+  dataFormat: 'text' | 'image';
+}
+
+interface TaskTypeSelectorProps {
+  selectedType: TaskType | null;
+  onSelect: (type: TaskType) => void;
+}
+
+const TASK_TYPE_OPTIONS: TaskTypeOption[] = [
+  {
+    id: 'text_classification',
+    name: '文本分类',
+    description: '对文本内容进行分类标注',
+    icon: <FileText />,
+    dataFormat: 'text',
+  },
+  {
+    id: 'image_classification',
+    name: '图像分类',
+    description: '对图像进行分类标注',
+    icon: <Image />,
+    dataFormat: 'image',
+  },
+  {
+    id: 'object_detection',
+    name: '目标检测',
+    description: '在图像中标注目标边界框',
+    icon: <Square />,
+    dataFormat: 'image',
+  },
+  {
+    id: 'ner',
+    name: '命名实体识别',
+    description: '标注文本中的实体',
+    icon: <Tag />,
+    dataFormat: 'text',
+  },
+];
+```
+
+### 2. Label Editor Component
+
+可视化标签编辑组件。
+
+```typescript
+interface LabelConfig {
+  id: string;
+  name: string;
+  color: string;
+  hotkey?: string;
+}
+
+interface LabelEditorProps {
+  labels: LabelConfig[];
+  onChange: (labels: LabelConfig[]) => void;
+  taskType: TaskType;
+}
+
+// 预定义颜色列表
+const PRESET_COLORS = [
+  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
+  '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',
+  '#BB8FCE', '#85C1E9', '#F8B500', '#00CED1',
+];
+```
+
+### 3. XML Generator Service
+
+根据配置生成 Label Studio XML。
+
+```typescript
+interface XMLGeneratorConfig {
+  taskType: TaskType;
+  labels: LabelConfig[];
+  choiceType?: 'single' | 'multiple'; // 用于分类任务
+}
+
+class XMLGenerator {
+  static generate(config: XMLGeneratorConfig): string {
+    switch (config.taskType) {
+      case 'text_classification':
+        return this.generateTextClassification(config);
+      case 'image_classification':
+        return this.generateImageClassification(config);
+      case 'object_detection':
+        return this.generateObjectDetection(config);
+      case 'ner':
+        return this.generateNER(config);
+      default:
+        throw new Error(`Unsupported task type: ${config.taskType}`);
+    }
+  }
+
+  private static generateTextClassification(config: XMLGeneratorConfig): string {
+    const choiceAttr = config.choiceType === 'multiple' ? 'multiple' : 'single';
+    const labelsXML = config.labels
+      .map(l => `    <Choice value="${l.name}" ${l.hotkey ? `hotkey="${l.hotkey}"` : ''} />`)
+      .join('\n');
+    
+    return `<View>
+  <Text name="text" value="$text"/>
+  <Choices name="label" toName="text" choice="${choiceAttr}">
+${labelsXML}
+  </Choices>
+</View>`;
+  }
+
+  private static generateImageClassification(config: XMLGeneratorConfig): string {
+    const choiceAttr = config.choiceType === 'multiple' ? 'multiple' : 'single';
+    const labelsXML = config.labels
+      .map(l => `    <Choice value="${l.name}" ${l.hotkey ? `hotkey="${l.hotkey}"` : ''} />`)
+      .join('\n');
+    
+    return `<View>
+  <Image name="image" value="$image"/>
+  <Choices name="label" toName="image" choice="${choiceAttr}">
+${labelsXML}
+  </Choices>
+</View>`;
+  }
+
+  private static generateObjectDetection(config: XMLGeneratorConfig): string {
+    const labelsXML = config.labels
+      .map(l => `    <Label value="${l.name}" background="${l.color}" ${l.hotkey ? `hotkey="${l.hotkey}"` : ''} />`)
+      .join('\n');
+    
+    return `<View>
+  <Image name="image" value="$image"/>
+  <RectangleLabels name="label" toName="image">
+${labelsXML}
+  </RectangleLabels>
+</View>`;
+  }
+
+  private static generateNER(config: XMLGeneratorConfig): string {
+    const labelsXML = config.labels
+      .map(l => `    <Label value="${l.name}" background="${l.color}" ${l.hotkey ? `hotkey="${l.hotkey}"` : ''} />`)
+      .join('\n');
+    
+    return `<View>
+  <Text name="text" value="$text"/>
+  <Labels name="label" toName="text">
+${labelsXML}
+  </Labels>
+</View>`;
+  }
+}
+```
+
+### 4. Project Config Wizard Component
+
+项目配置向导组件。
+
+```typescript
+type WizardStep = 'task-type' | 'labels' | 'details' | 'preview';
+
+interface ProjectConfigWizardProps {
+  initialTaskType?: TaskType;
+  initialLabels?: LabelConfig[];
+  onComplete: (config: ProjectConfigResult) => void;
+  onCancel: () => void;
+}
+
+interface ProjectConfigResult {
+  taskType: TaskType;
+  labels: LabelConfig[];
+  xmlConfig: string;
+  name?: string;
+  description?: string;
+}
+```
+
+### 5. External Projects Page Component
+
+外部项目管理页面。
+
+```typescript
+interface ExternalProjectsPageProps {}
+
+// 页面状态
+interface ExternalProjectsState {
+  projects: Project[];
+  loading: boolean;
+  error: string | null;
+  statusFilter: ProjectStatus | 'all';
+}
+
+// 过滤逻辑:仅显示外部项目
+const loadExternalProjects = async (statusFilter?: ProjectStatus) => {
+  return await listProjects({
+    source: 'external',
+    status: statusFilter,
+  });
+};
+```
+
+### 6. Updated Project List Page
+
+优化后的项目管理页面。
+
+```typescript
+// 默认过滤条件:仅显示进行中和已完成的项目
+const DEFAULT_STATUS_FILTER = ['in_progress', 'completed'];
+
+// 加载项目时应用过滤
+const loadProjects = async (sourceFilter?: ProjectSource) => {
+  const allProjects = await listProjects({
+    source: sourceFilter,
+  });
+  
+  // 过滤只显示进行中和已完成的项目
+  return allProjects.filter(p => 
+    p.status === 'in_progress' || p.status === 'completed'
+  );
+};
+```
+
+## Data Models
+
+### 标签配置模型
+
+```typescript
+interface LabelConfig {
+  id: string;           // 唯一标识符
+  name: string;         // 标签名称
+  color: string;        // 标签颜色(十六进制)
+  hotkey?: string;      // 快捷键(可选)
+}
+```
+
+### 任务类型枚举
+
+```typescript
+type TaskType = 
+  | 'text_classification'
+  | 'image_classification'
+  | 'object_detection'
+  | 'ner';
+```
+
+### 项目配置扩展
+
+```typescript
+interface ProjectConfig {
+  taskType: TaskType;
+  labels: LabelConfig[];
+  choiceType?: 'single' | 'multiple';
+  xmlConfig: string;
+}
+```
+
+## 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* 用户角色,如果角色为 'admin',则可见菜单项数量应等于所有菜单项数量;如果角色为 'annotator',则可见菜单项数量应等于非管理员专属菜单项数量。
+
+**Validates: Requirements 1.1, 1.2**
+
+### Property 2: 角色识别正确性
+
+*For any* 用户对象,如果 role 字段为 'admin' 则 isAdmin 应为 true,如果 role 字段为 'annotator' 则 isAdmin 应为 false。
+
+**Validates: Requirements 1.5, 1.6**
+
+### Property 3: 项目来源过滤正确性
+
+*For any* 项目列表,外部项目管理页面应仅包含 source='external' 的项目,项目管理页面应仅包含 status='in_progress' 或 status='completed' 的项目。
+
+**Validates: Requirements 2.3, 3.1**
+
+### Property 4: 状态按钮显示正确性
+
+*For any* 项目,如果状态为 'draft' 或 'configuring' 则应显示配置按钮,如果状态为 'ready' 则应显示分发按钮。
+
+**Validates: Requirements 2.6, 2.7**
+
+### Property 5: 角色按钮可见性正确性
+
+*For any* 用户访问项目管理页面,如果用户角色为 'annotator' 则创建项目按钮应隐藏,如果用户角色为 'admin' 则创建项目按钮应显示。
+
+**Validates: Requirements 3.7, 3.8**
+
+### Property 6: XML生成正确性
+
+*For any* 有效的标注类型和标签配置,生成的 XML 应包含所有配置的标签,且 XML 格式应符合 Label Studio 规范(以 `<View>` 开头和结尾)。
+
+**Validates: Requirements 4.6, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7**
+
+### Property 7: 标签编辑器功能正确性
+
+*For any* 标签列表操作(添加、删除、编辑),操作后的标签列表应正确反映变更,且不应存在重复的标签名称。
+
+**Validates: Requirements 7.1, 7.2, 7.3, 7.8, 7.9, 7.10, 7.11**
+
+## Error Handling
+
+### 错误类型
+
+```typescript
+// 配置验证错误
+interface ConfigValidationError {
+  type: 'validation';
+  field: string;
+  message: string;
+}
+
+// 标签验证错误
+interface LabelValidationError {
+  type: 'label';
+  labelId: string;
+  message: string;
+}
+
+// 错误消息
+const ERROR_MESSAGES = {
+  EMPTY_LABEL_NAME: '标签名称不能为空',
+  DUPLICATE_LABEL_NAME: '标签名称不能重复',
+  INVALID_HOTKEY: '快捷键格式无效',
+  NO_LABELS: '至少需要添加一个标签',
+  INVALID_XML: 'XML 配置格式无效',
+};
+```
+
+### 验证逻辑
+
+```typescript
+function validateLabels(labels: LabelConfig[]): LabelValidationError[] {
+  const errors: LabelValidationError[] = [];
+  const names = new Set<string>();
+  
+  labels.forEach(label => {
+    // 检查空名称
+    if (!label.name.trim()) {
+      errors.push({
+        type: 'label',
+        labelId: label.id,
+        message: ERROR_MESSAGES.EMPTY_LABEL_NAME,
+      });
+    }
+    
+    // 检查重复名称
+    if (names.has(label.name)) {
+      errors.push({
+        type: 'label',
+        labelId: label.id,
+        message: ERROR_MESSAGES.DUPLICATE_LABEL_NAME,
+      });
+    }
+    names.add(label.name);
+  });
+  
+  return errors;
+}
+```
+
+## Testing Strategy
+
+### 单元测试
+
+1. **XML生成器测试**:验证不同任务类型生成正确的 XML 结构
+2. **标签验证测试**:验证标签名称、颜色、快捷键的验证逻辑
+3. **角色过滤测试**:验证菜单项根据角色正确过滤
+
+### 属性测试(Property-Based Testing)
+
+使用 `fast-check` 库进行属性测试:
+
+1. **Property 1 测试**:生成随机角色,验证菜单过滤逻辑
+2. **Property 3 测试**:生成随机项目列表,验证过滤结果
+3. **Property 6 测试**:生成随机标签配置,验证 XML 生成
+
+### 集成测试
+
+1. **配置向导流程测试**:完整的配置向导流程
+2. **权限控制测试**:验证不同角色的页面访问权限
+3. **项目创建流程测试**:从选择类型到创建项目的完整流程
+
+### 测试配置
+
+```typescript
+// jest.config.ts
+export default {
+  testEnvironment: 'jsdom',
+  setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
+  moduleNameMapper: {
+    '^@/(.*)$': '<rootDir>/src/$1',
+  },
+};
+
+// fast-check 配置
+const fcConfig = {
+  numRuns: 100,
+  verbose: true,
+};
+```
+
+## UI Components Design
+
+### Task Type Card
+
+```
+┌─────────────────────────────────┐
+│  [Icon]                         │
+│                                 │
+│  文本分类                        │
+│  对文本内容进行分类标注            │
+│                                 │
+│  ○ 选择                         │
+└─────────────────────────────────┘
+```
+
+### Label Editor
+
+```
+┌─────────────────────────────────────────────┐
+│ 标签配置                          [+ 添加]   │
+├─────────────────────────────────────────────┤
+│ ┌─────────────────────────────────────────┐ │
+│ │ [■] 正面评价        快捷键: 1    [编辑] [删除] │
+│ └─────────────────────────────────────────┘ │
+│ ┌─────────────────────────────────────────┐ │
+│ │ [■] 负面评价        快捷键: 2    [编辑] [删除] │
+│ └─────────────────────────────────────────┘ │
+│ ┌─────────────────────────────────────────┐ │
+│ │ [■] 中性评价        快捷键: 3    [编辑] [删除] │
+│ └─────────────────────────────────────────┘ │
+└─────────────────────────────────────────────┘
+```
+
+### Config Wizard Steps
+
+```
+步骤指示器:
+[1. 选择类型] ─── [2. 配置标签] ─── [3. 项目信息] ─── [4. 预览确认]
+     ●                ○                 ○                 ○
+```
+

+ 148 - 0
.kiro/specs/role-based-menu-and-project-config/requirements.md

@@ -0,0 +1,148 @@
+# Requirements Document
+
+## Introduction
+
+本文档定义了标注平台角色权限菜单优化和项目配置流程改进的需求规范。主要包括:
+
+1. 区分管理员和标注员的菜单权限,标注员只能看到有限的菜单
+2. 新增"管理外部来源数据"菜单,专门管理外部API创建的项目
+3. 优化项目管理页面,仅展示已完成和进行中的项目,同时显示项目来源
+4. 改进项目配置流程,支持选卡式的标注类型选择和标签配置(类似 Label Studio)
+5. 内部项目创建时也使用相同的配置流程
+
+## Glossary
+
+- **Admin**: 管理员用户,拥有完整的系统访问权限
+- **Annotator**: 标注员用户,只能访问与标注工作相关的功能
+- **External_Project**: 外部项目,通过外部API(如样本中心)创建的项目
+- **Internal_Project**: 内部项目,通过平台内部创建的项目
+- **Task_Type**: 任务类型,包括文本分类、图像分类、目标检测、命名实体识别
+- **Label_Config**: 标签配置,定义标注时可选的标签及其属性
+- **XML_Config**: Label Studio 的 XML 配置,定义标注界面的结构
+- **Project_Status**: 项目状态(draft/configuring/ready/in_progress/completed)
+- **Sidebar_Menu**: 侧边栏菜单,应用的主导航菜单
+
+## Requirements
+
+### Requirement 1: 角色权限菜单区分
+
+**User Story:** As a 系统管理员, I want to 根据用户角色显示不同的菜单, so that 标注员只能访问与其工作相关的功能,保持界面简洁。
+
+#### Acceptance Criteria
+
+1. WHEN 管理员登录系统 THEN THE Sidebar_Menu SHALL 显示所有菜单项:项目管理、任务管理、我的任务、我的标注、用户管理、管理外部来源数据
+2. WHEN 标注员登录系统 THEN THE Sidebar_Menu SHALL 仅显示:项目管理、我的任务、我的标注
+3. THE System SHALL 根据 currentUser.role 字段判断用户角色
+4. IF 标注员尝试访问管理员专属页面 THEN THE System SHALL 重定向到首页或显示无权限提示
+5. WHEN 用户角色为 'admin' THEN THE System SHALL 将其识别为管理员
+6. WHEN 用户角色为 'annotator' THEN THE System SHALL 将其识别为标注员
+
+### Requirement 2: 管理外部来源数据菜单
+
+**User Story:** As a 管理员, I want to 有一个专门的菜单来管理外部API创建的项目, so that 我可以集中处理外部项目的配置和任务分发工作。
+
+#### Acceptance Criteria
+
+1. THE Sidebar_Menu SHALL 包含"管理外部来源数据"菜单项,仅对管理员可见
+2. WHEN 管理员点击"管理外部来源数据"菜单 THEN THE System SHALL 导航到外部项目管理页面
+3. THE External_Project_Page SHALL 仅显示 source='external' 的项目
+4. THE External_Project_Page SHALL 显示项目名称、任务类型、任务数量、创建时间和当前状态
+5. THE External_Project_Page SHALL 支持按项目状态筛选(draft/configuring/ready/in_progress/completed)
+6. WHEN 项目状态为 draft 或 configuring THEN THE System SHALL 显示"配置项目"按钮
+7. WHEN 项目状态为 ready THEN THE System SHALL 显示"一键分发"按钮
+8. THE External_Project_Page SHALL 显示项目的标注进度(已完成/总数)
+
+### Requirement 3: 项目管理页面优化
+
+**User Story:** As a 用户, I want to 在项目管理页面只看到已完成和进行中的项目, so that 我可以专注于正在进行的标注工作。
+
+#### Acceptance Criteria
+
+1. THE Project_List_Page SHALL 默认仅显示状态为 'in_progress' 或 'completed' 的项目
+2. THE Project_List_Page SHALL 显示项目来源标签(内部/外部)
+3. THE Project_List_Page SHALL 保留搜索功能,支持按项目名称和描述搜索
+4. THE Project_List_Page SHALL 显示项目的标注进度
+5. THE Project_List_Page SHALL 移除状态筛选器(因为只显示进行中和已完成)
+6. THE Project_List_Page SHALL 保留来源筛选器(内部/外部)
+7. WHEN 标注员访问项目管理页面 THEN THE System SHALL 隐藏创建项目按钮
+8. WHEN 管理员访问项目管理页面 THEN THE System SHALL 显示创建项目按钮
+
+### Requirement 4: 选卡式标注类型配置
+
+**User Story:** As a 管理员, I want to 通过选卡式界面选择标注类型并配置标签, so that 我可以更直观地配置项目,无需手动编写 XML。
+
+#### Acceptance Criteria
+
+1. THE Project_Config_Page SHALL 提供选卡式的标注类型选择界面
+2. THE System SHALL 支持四种标注类型:文本分类(text_classification)、图像分类(image_classification)、目标检测(object_detection)、命名实体识别(ner)
+3. WHEN 用户选择标注类型 THEN THE System SHALL 显示该类型对应的配置选项
+4. THE System SHALL 提供可视化的标签编辑界面,支持添加、删除、编辑标签
+5. WHEN 用户添加标签 THEN THE System SHALL 允许设置标签名称、颜色和快捷键
+6. THE System SHALL 根据标注类型和标签配置自动生成 XML 配置
+7. THE System SHALL 提供 XML 配置预览功能
+8. THE System SHALL 验证配置的有效性
+9. IF 配置无效 THEN THE System SHALL 显示错误提示并阻止保存
+
+### Requirement 5: 标注类型配置详情
+
+**User Story:** As a 管理员, I want to 为不同标注类型配置特定的选项, so that 标注界面能够满足不同任务的需求。
+
+#### Acceptance Criteria
+
+1. WHEN 标注类型为 text_classification THEN THE System SHALL 显示文本分类配置选项
+2. WHEN 标注类型为 text_classification THEN THE System SHALL 支持配置分类标签列表
+3. WHEN 标注类型为 text_classification THEN THE System SHALL 支持设置单选或多选模式
+4. WHEN 标注类型为 image_classification THEN THE System SHALL 显示图像分类配置选项
+5. WHEN 标注类型为 image_classification THEN THE System SHALL 支持配置分类标签列表
+6. WHEN 标注类型为 object_detection THEN THE System SHALL 显示目标检测配置选项
+7. WHEN 标注类型为 object_detection THEN THE System SHALL 支持配置边界框标签列表
+8. WHEN 标注类型为 ner THEN THE System SHALL 显示命名实体识别配置选项
+9. WHEN 标注类型为 ner THEN THE System SHALL 支持配置实体标签列表
+
+### Requirement 6: 内部项目创建流程改进
+
+**User Story:** As a 管理员, I want to 在创建内部项目时使用相同的选卡式配置流程, so that 内部和外部项目的配置体验一致。
+
+#### Acceptance Criteria
+
+1. WHEN 管理员点击创建项目 THEN THE System SHALL 显示选卡式配置向导
+2. THE Create_Project_Wizard SHALL 包含以下步骤:选择标注类型 → 配置标签 → 填写项目信息
+3. WHEN 用户选择标注类型 THEN THE System SHALL 显示对应的标签配置界面
+4. WHEN 用户完成标签配置 THEN THE System SHALL 自动生成 XML 配置
+5. THE Create_Project_Wizard SHALL 显示配置预览
+6. WHEN 用户确认创建 THEN THE System SHALL 创建项目并设置状态为 'draft'
+7. THE System SHALL 支持在向导中返回上一步修改配置
+8. THE System SHALL 保留高级用户直接编辑 XML 的选项
+
+### Requirement 7: 标签配置组件
+
+**User Story:** As a 管理员, I want to 使用可视化组件配置标签, so that 我可以快速添加和管理标注标签。
+
+#### Acceptance Criteria
+
+1. THE Label_Editor SHALL 显示当前已配置的标签列表
+2. THE Label_Editor SHALL 提供添加新标签的按钮
+3. WHEN 用户点击添加标签 THEN THE System SHALL 显示标签编辑表单
+4. THE Label_Edit_Form SHALL 包含标签名称输入框
+5. THE Label_Edit_Form SHALL 包含颜色选择器
+6. THE Label_Edit_Form SHALL 包含快捷键输入框(可选)
+7. THE Label_Editor SHALL 支持拖拽排序标签
+8. THE Label_Editor SHALL 支持删除标签
+9. THE Label_Editor SHALL 支持编辑已有标签
+10. IF 标签名称为空 THEN THE System SHALL 显示验证错误
+11. IF 标签名称重复 THEN THE System SHALL 显示验证错误
+
+### Requirement 8: XML 配置生成
+
+**User Story:** As a 系统, I want to 根据标注类型和标签配置自动生成 XML, so that 用户无需手动编写配置。
+
+#### Acceptance Criteria
+
+1. WHEN 标注类型为 text_classification THEN THE System SHALL 生成包含 Text 和 Choices 元素的 XML
+2. WHEN 标注类型为 image_classification THEN THE System SHALL 生成包含 Image 和 Choices 元素的 XML
+3. WHEN 标注类型为 object_detection THEN THE System SHALL 生成包含 Image 和 RectangleLabels 元素的 XML
+4. WHEN 标注类型为 ner THEN THE System SHALL 生成包含 Text 和 Labels 元素的 XML
+5. THE Generated_XML SHALL 包含所有配置的标签及其属性(名称、颜色、快捷键)
+6. THE Generated_XML SHALL 符合 Label Studio 的 XML 规范
+7. THE System SHALL 在标签配置变化时实时更新 XML 预览
+

+ 210 - 0
.kiro/specs/role-based-menu-and-project-config/tasks.md

@@ -0,0 +1,210 @@
+# Implementation Plan: Role-Based Menu and Project Config
+
+## Overview
+
+本实现计划将角色权限菜单优化和项目配置流程改进分为多个阶段实现:
+1. 侧边栏菜单权限优化
+2. 外部项目管理页面
+3. 项目管理页面优化
+4. 选卡式项目配置组件
+5. 项目创建向导改进
+
+## Tasks
+
+- [x] 1. 侧边栏菜单权限优化
+  - [x] 1.1 更新侧边栏菜单配置
+    - 添加"管理外部来源数据"菜单项
+    - 设置 adminOnly 属性
+    - 调整菜单顺序
+    - _Requirements: 1.1, 1.2, 2.1_
+
+  - [x] 1.2 添加路由保护
+    - 创建 AdminRoute 组件
+    - 对管理员专属页面添加权限检查
+    - 未授权时重定向到首页
+    - _Requirements: 1.4_
+
+  - [ ]* 1.3 编写角色菜单过滤属性测试
+    - **Property 1: 角色菜单过滤正确性**
+    - 验证管理员和标注员看到的菜单项数量
+    - **Validates: Requirements 1.1, 1.2**
+
+- [x] 2. Checkpoint - 确保菜单权限功能完成
+  - 测试管理员登录后的菜单显示
+  - 测试标注员登录后的菜单显示
+  - 验证路由保护正常工作
+
+- [x] 3. 外部项目管理页面
+  - [x] 3.1 创建外部项目管理页面组件
+    - 创建 `external-projects-view` 目录和组件
+    - 实现项目列表展示(仅外部项目)
+    - 显示项目名称、任务类型、任务数量、创建时间、状态
+    - _Requirements: 2.2, 2.3, 2.4_
+
+  - [x] 3.2 实现状态筛选功能
+    - 添加状态筛选下拉框
+    - 实现筛选逻辑
+    - _Requirements: 2.5_
+
+  - [x] 3.3 实现操作按钮逻辑
+    - draft/configuring 状态显示"配置项目"按钮
+    - ready 状态显示"一键分发"按钮
+    - 显示标注进度
+    - _Requirements: 2.6, 2.7, 2.8_
+
+  - [x] 3.4 添加路由配置
+    - 在 App.tsx 中添加 /external-projects 路由
+    - _Requirements: 2.2_
+
+  - [ ]* 3.5 编写项目来源过滤属性测试
+    - **Property 3: 项目来源过滤正确性**
+    - 验证外部项目页面仅显示外部项目
+    - **Validates: Requirements 2.3, 3.1**
+
+- [x] 4. Checkpoint - 确保外部项目管理页面完成
+  - 测试外部项目列表显示
+  - 测试状态筛选功能
+  - 测试操作按钮显示逻辑
+
+- [x] 5. 项目管理页面优化
+  - [x] 5.1 修改项目列表过滤逻辑
+    - 默认仅显示 in_progress 和 completed 状态的项目
+    - 移除状态筛选器
+    - 保留来源筛选器
+    - _Requirements: 3.1, 3.5, 3.6_
+
+  - [x] 5.2 添加角色权限控制
+    - 标注员隐藏创建项目按钮
+    - 管理员显示创建项目按钮
+    - _Requirements: 3.7, 3.8_
+
+  - [x] 5.3 保留项目来源标签显示
+    - 确保项目来源标签正常显示
+    - _Requirements: 3.2_
+
+  - [ ]* 5.4 编写角色按钮可见性属性测试
+    - **Property 5: 角色按钮可见性正确性**
+    - 验证不同角色的按钮显示
+    - **Validates: Requirements 3.7, 3.8**
+
+- [x] 6. Checkpoint - 确保项目管理页面优化完成
+  - 测试项目列表仅显示进行中和已完成项目
+  - 测试标注员无法看到创建按钮
+  - 测试来源筛选功能
+
+- [x] 7. 选卡式项目配置组件
+  - [x] 7.1 创建任务类型选择器组件
+    - 创建 `task-type-selector` 组件
+    - 实现四种任务类型的卡片展示
+    - 支持选中状态切换
+    - _Requirements: 4.1, 4.2, 4.3_
+
+  - [x] 7.2 创建标签编辑器组件
+    - 创建 `label-editor` 组件
+    - 支持添加、删除、编辑标签
+    - 支持设置标签名称、颜色、快捷键
+    - 实现标签验证逻辑
+    - _Requirements: 4.4, 4.5, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.8, 7.9_
+
+  - [x] 7.3 创建颜色选择器组件
+    - 颜色选择器已集成在 label-editor 组件中
+    - 提供预设颜色列表
+    - 支持自定义颜色输入
+    - _Requirements: 7.5_
+
+  - [ ]* 7.4 编写标签编辑器功能属性测试
+    - **Property 7: 标签编辑器功能正确性**
+    - 验证标签的添加、删除、编辑操作
+    - **Validates: Requirements 7.1, 7.2, 7.3, 7.8, 7.9, 7.10, 7.11**
+
+- [x] 8. XML 配置生成服务
+  - [x] 8.1 创建 XML 生成器服务
+    - 创建 `xml-generator.ts` 服务文件
+    - 实现文本分类 XML 生成
+    - 实现图像分类 XML 生成
+    - 实现目标检测 XML 生成
+    - 实现命名实体识别 XML 生成
+    - _Requirements: 8.1, 8.2, 8.3, 8.4_
+
+  - [x] 8.2 实现标签属性映射
+    - 将标签配置映射到 XML 属性
+    - 支持颜色、快捷键属性
+    - _Requirements: 8.5_
+
+  - [ ]* 8.3 编写 XML 生成正确性属性测试
+    - **Property 6: XML生成正确性**
+    - 验证生成的 XML 包含所有标签
+    - 验证 XML 格式符合规范
+    - **Validates: Requirements 4.6, 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7**
+
+- [x] 9. Checkpoint - 确保配置组件完成
+  - 测试任务类型选择器
+  - 测试标签编辑器
+  - 测试 XML 生成器
+
+- [x] 10. 项目配置向导
+  - [x] 10.1 创建配置向导组件
+    - 创建 `project-config-wizard` 组件
+    - 实现步骤指示器
+    - 实现步骤切换逻辑
+    - _Requirements: 6.1, 6.2, 6.7_
+
+  - [x] 10.2 集成任务类型选择步骤
+    - 集成 TaskTypeSelector 组件
+    - 保存选中的任务类型
+    - _Requirements: 6.3_
+
+  - [x] 10.3 集成标签配置步骤
+    - 集成 LabelEditor 组件
+    - 实时生成 XML 预览
+    - _Requirements: 6.4, 6.5_
+
+  - [x] 10.4 实现 XML 预览步骤
+    - 显示生成的 XML 配置
+    - 提供高级编辑选项
+    - _Requirements: 4.7, 6.8_
+
+- [x] 11. 更新项目配置页面
+  - [x] 11.1 重构项目配置页面
+    - 使用新的配置向导组件
+    - 支持从现有配置初始化
+    - 支持向导模式和高级模式切换
+    - _Requirements: 4.1, 4.8, 4.9_
+
+  - [x] 11.2 更新项目创建流程
+    - 修改 ProjectForm 使用配置向导
+    - 集成 XML 生成逻辑
+    - 添加三步骤流程:模板选择 → 项目详情 → 配置标注
+    - 支持向导模式和高级模式切换
+    - _Requirements: 6.1, 6.6_
+
+- [x] 12. Checkpoint - 确保配置向导完成
+  - 测试完整的配置向导流程
+  - 测试项目创建流程
+  - 测试项目配置更新流程
+
+- [x] 13. 集成测试和样式优化
+  - [ ]* 13.1 编写集成测试
+    - 测试完整的角色权限流程
+    - 测试外部项目管理流程
+    - 测试项目配置流程
+    - _Requirements: 全部_
+
+  - [x] 13.2 样式优化
+    - 确保组件样式一致(统一使用 --theme-* CSS 变量)
+    - 适配深色模式
+    - 响应式布局优化
+    - _Requirements: 全部_
+
+- [x] 14. Final Checkpoint - 确保所有功能完成
+  - 运行所有测试
+  - 验证角色权限正确
+  - 验证项目配置流程完整
+
+## Notes
+
+- Tasks marked with `*` are optional and can be skipped for faster MVP
+- Each task references specific requirements for traceability
+- Checkpoints ensure incremental validation
+- Property tests validate universal correctness properties
+- Unit tests validate specific examples and edge cases

+ 0 - 42
backend/config.yaml

@@ -1,42 +0,0 @@
-# 标注平台配置文件
-
-# JWT 配置
-jwt:
-  secret_key: "your-secret-key-here"  # 生产环境请修改
-  algorithm: "HS256"
-  access_token_expire_minutes: 15
-  refresh_token_expire_days: 7
-
-# OAuth 2.0 单点登录配置
-oauth:
-  enabled: true
-  base_url: "http://192.168.92.61:8000"
-  client_id: "sRyfcQwNVoFimigzuuZxhqd36fPkVN5G"
-  client_secret: "96RuKb4obAn9bQ9i5NtINiKBMvF_9uuCR7eNzD9dWQMbOWZaV3P593-8yLOqzWRd"
-  redirect_uri: "http://192.168.92.61:9003/auth/callback"
-  scope: "profile email"
-  
-  # OAuth 端点
-  authorize_endpoint: "/oauth/login"
-  token_endpoint: "/oauth/token"
-  userinfo_endpoint: "/oauth/userinfo"
-  revoke_endpoint: "/oauth/revoke"
-
-# 数据库配置
-database:
-  type: "mysql"  # sqlite 或 mysql
-  path: "annotation_platform.db"  # SQLite 文件路径(仅 type=sqlite 时使用)
-  
-  # MySQL 配置(仅 type=mysql 时使用)
-  mysql:
-    host: "192.168.92.61"
-    port: 13306
-    user: "root"
-    password: "Lq123456!"
-    database: "annotation_platform"
-
-# 服务器配置
-server:
-  host: "0.0.0.0"
-  port: 8003
-  reload: true

+ 99 - 46
backend/routers/project.py

@@ -41,8 +41,11 @@ async def list_projects(
     source_filter: Optional[ProjectSource] = Query(None, alias="source", description="按来源筛选"),
 ):
     """
-    List all projects with extended information.
-    Returns a list of all projects with their task counts, status, and source.
+    List projects with extended information.
+    
+    For admin users: Returns all projects with their total task counts.
+    For annotator users: Returns only projects that have tasks assigned to them,
+                         with task counts reflecting only their assigned tasks.
     
     Query Parameters:
         status: Filter by project status (draft, configuring, ready, in_progress, completed)
@@ -50,44 +53,85 @@ async def list_projects(
     
     Requires authentication.
     """
+    user = request.state.user
+    user_id = user["id"]
+    user_role = user["role"]
+    
     with get_db_connection() as conn:
         cursor = conn.cursor()
         
-        # Build query with optional filters
-        query = """
-            SELECT 
-                p.id,
-                p.name,
-                p.description,
-                p.config,
-                p.task_type,
-                p.status,
-                p.source,
-                p.external_id,
-                p.created_at,
-                p.updated_at,
-                COUNT(t.id) as task_count,
-                SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
-                SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
-            FROM projects p
-            LEFT JOIN tasks t ON p.id = t.project_id
-        """
-        
-        conditions = []
-        params = []
-        
-        if status_filter:
-            conditions.append("p.status = ?")
-            params.append(status_filter.value)
-        
-        if source_filter:
-            conditions.append("p.source = ?")
-            params.append(source_filter.value)
-        
-        if conditions:
-            query += " WHERE " + " AND ".join(conditions)
-        
-        query += " GROUP BY p.id ORDER BY p.created_at DESC"
+        if user_role == "admin":
+            # 管理员:返回所有项目及其全部任务统计
+            query = """
+                SELECT 
+                    p.id,
+                    p.name,
+                    p.description,
+                    p.config,
+                    p.task_type,
+                    p.status,
+                    p.source,
+                    p.external_id,
+                    p.created_at,
+                    p.updated_at,
+                    COUNT(t.id) as task_count,
+                    SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
+                    SUM(CASE WHEN t.assigned_to IS NOT NULL THEN 1 ELSE 0 END) as assigned_task_count
+                FROM projects p
+                LEFT JOIN tasks t ON p.id = t.project_id
+            """
+            
+            conditions = []
+            params = []
+            
+            if status_filter:
+                conditions.append("p.status = ?")
+                params.append(status_filter.value)
+            
+            if source_filter:
+                conditions.append("p.source = ?")
+                params.append(source_filter.value)
+            
+            if conditions:
+                query += " WHERE " + " AND ".join(conditions)
+            
+            query += " GROUP BY p.id ORDER BY p.created_at DESC"
+        else:
+            # 标注员:只返回有分配给他们任务的项目,任务数量只统计分配给他们的任务
+            query = """
+                SELECT 
+                    p.id,
+                    p.name,
+                    p.description,
+                    p.config,
+                    p.task_type,
+                    p.status,
+                    p.source,
+                    p.external_id,
+                    p.created_at,
+                    p.updated_at,
+                    COUNT(t.id) as task_count,
+                    SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed_task_count,
+                    COUNT(t.id) as assigned_task_count
+                FROM projects p
+                INNER JOIN tasks t ON p.id = t.project_id AND t.assigned_to = ?
+            """
+            
+            params = [user_id]
+            conditions = []
+            
+            if status_filter:
+                conditions.append("p.status = ?")
+                params.append(status_filter.value)
+            
+            if source_filter:
+                conditions.append("p.source = ?")
+                params.append(source_filter.value)
+            
+            if conditions:
+                query += " WHERE " + " AND ".join(conditions)
+            
+            query += " GROUP BY p.id HAVING COUNT(t.id) > 0 ORDER BY p.created_at DESC"
         
         cursor.execute(query, params)
         rows = cursor.fetchall()
@@ -133,15 +177,16 @@ async def create_project(request: Request, project: ProjectCreate):
     with get_db_connection() as conn:
         cursor = conn.cursor()
         
-        # Insert new project
+        # Insert new project with task_type
         cursor.execute("""
-            INSERT INTO projects (id, name, description, config)
-            VALUES (?, ?, ?, ?)
+            INSERT INTO projects (id, name, description, config, task_type)
+            VALUES (?, ?, ?, ?, ?)
         """, (
             project_id,
             project.name,
             project.description,
-            project.config
+            project.config,
+            project.task_type
         ))
         
         # Fetch the created project
@@ -526,11 +571,19 @@ async def update_project_config(request: Request, project_id: str, config_update
         # Update config and set status to configuring if it was draft
         new_status = ProjectStatus.CONFIGURING if current_status == ProjectStatus.DRAFT else current_status
         
-        cursor.execute("""
-            UPDATE projects 
-            SET config = ?, status = ?, updated_at = ?
-            WHERE id = ?
-        """, (config, new_status.value, datetime.now(), project_id))
+        # Build update query based on provided fields
+        if config_update.task_type:
+            cursor.execute("""
+                UPDATE projects 
+                SET config = ?, task_type = ?, status = ?, updated_at = ?
+                WHERE id = ?
+            """, (config, config_update.task_type, new_status.value, datetime.now(), project_id))
+        else:
+            cursor.execute("""
+                UPDATE projects 
+                SET config = ?, status = ?, updated_at = ?
+                WHERE id = ?
+            """, (config, new_status.value, datetime.now(), project_id))
         
         # Fetch and return updated project
         cursor.execute("""

+ 48 - 13
backend/routers/task.py

@@ -44,9 +44,17 @@ async def list_tasks(
     assigned_to: Optional[str] = Query(None, description="Filter by assigned user")
 ):
     """
-    List all tasks with optional filters.
+    List tasks with optional filters.
+    
+    For admin users: Returns all tasks matching the filters.
+    For annotator users: Returns only tasks assigned to them (ignores assigned_to filter).
+    
     Requires authentication.
     """
+    user = request.state.user
+    user_id = user["id"]
+    user_role = user["role"]
+    
     with get_db_connection() as conn:
         cursor = conn.cursor()
         
@@ -75,7 +83,12 @@ async def list_tasks(
             query += " AND t.status = ?"
             params.append(status_filter)
         
-        if assigned_to:
+        # 标注员只能看到分配给自己的任务
+        if user_role != "admin":
+            query += " AND t.assigned_to = ?"
+            params.append(user_id)
+        elif assigned_to:
+            # 管理员可以按 assigned_to 过滤
             query += " AND t.assigned_to = ?"
             params.append(assigned_to)
         
@@ -511,9 +524,17 @@ async def delete_task(request: Request, task_id: str):
 @router.get("/projects/{project_id}/tasks", response_model=List[TaskResponse])
 async def get_project_tasks(request: Request, project_id: str):
     """
-    Get all tasks for a specific project.
+    Get tasks for a specific project.
+    
+    For admin users: Returns all tasks in the project.
+    For annotator users: Returns only tasks assigned to them.
+    
     Requires authentication.
     """
+    user = request.state.user
+    user_id = user["id"]
+    user_role = user["role"]
+    
     with get_db_connection() as conn:
         cursor = conn.cursor()
         
@@ -524,16 +545,30 @@ async def get_project_tasks(request: Request, project_id: str):
                 detail=f"Project with id '{project_id}' not found"
             )
         
-        cursor.execute("""
-            SELECT 
-                t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at,
-                COUNT(a.id) as annotation_count
-            FROM tasks t
-            LEFT JOIN annotations a ON t.id = a.task_id
-            WHERE t.project_id = ?
-            GROUP BY t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at
-            ORDER BY t.created_at DESC
-        """, (project_id,))
+        if user_role == "admin":
+            # 管理员:返回项目的所有任务
+            cursor.execute("""
+                SELECT 
+                    t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at,
+                    COUNT(a.id) as annotation_count
+                FROM tasks t
+                LEFT JOIN annotations a ON t.id = a.task_id
+                WHERE t.project_id = ?
+                GROUP BY t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at
+                ORDER BY t.created_at DESC
+            """, (project_id,))
+        else:
+            # 标注员:只返回分配给自己的任务
+            cursor.execute("""
+                SELECT 
+                    t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at,
+                    COUNT(a.id) as annotation_count
+                FROM tasks t
+                LEFT JOIN annotations a ON t.id = a.task_id
+                WHERE t.project_id = ? AND t.assigned_to = ?
+                GROUP BY t.id, t.project_id, t.name, t.data, t.status, t.assigned_to, t.created_at
+                ORDER BY t.created_at DESC
+            """, (project_id, user_id))
         
         rows = cursor.fetchall()
         

BIN
backend/schemas/__pycache__/project.cpython-311.pyc


+ 6 - 2
backend/schemas/project.py

@@ -33,13 +33,15 @@ class ProjectCreate(BaseModel):
     name: str = Field(..., min_length=1, description="Project name")
     description: str = Field(default="", description="Project description")
     config: str = Field(..., min_length=1, description="Label Studio configuration")
+    task_type: Optional[str] = Field(None, description="Task type (text_classification, image_classification, object_detection, ner)")
     
     class Config:
         json_schema_extra = {
             "example": {
                 "name": "Image Classification Project",
                 "description": "Classify images into categories",
-                "config": "<View><Image name='image' value='$image'/><Choices name='choice' toName='image'><Choice value='Cat'/><Choice value='Dog'/></Choices></View>"
+                "config": "<View><Image name='image' value='$image'/><Choices name='choice' toName='image'><Choice value='Cat'/><Choice value='Dog'/></Choices></View>",
+                "task_type": "image_classification"
             }
         }
 
@@ -117,6 +119,7 @@ class ProjectConfigUpdate(BaseModel):
     """项目配置更新请求"""
     config: str = Field(..., description="XML配置")
     labels: Optional[List[LabelConfig]] = Field(None, description="标签列表")
+    task_type: Optional[str] = Field(None, description="任务类型")
     
     class Config:
         json_schema_extra = {
@@ -125,7 +128,8 @@ class ProjectConfigUpdate(BaseModel):
                 "labels": [
                     {"name": "正面", "color": "#52c41a"},
                     {"name": "负面", "color": "#f5222d"}
-                ]
+                ],
+                "task_type": "text_classification"
             }
         }
 

+ 40 - 12
deploy.sh

@@ -61,32 +61,60 @@ mkdir -p data
 
 # 检查是否需要重新构建镜像
 NEED_BUILD=false
+NEED_FULL_BUILD=false
 
 # 检查镜像是否存在
 if ! docker image inspect lq-label-backend:latest > /dev/null 2>&1; then
-    echo "镜像不存在,需要构建..."
+    echo "镜像不存在,需要完整构建..."
     NEED_BUILD=true
+    NEED_FULL_BUILD=true
 fi
 
-# 检查 requirements.txt 或 Dockerfile 是否有变更
-if [ -f ".last_build_hash" ]; then
-    CURRENT_HASH=$(md5sum requirements.txt Dockerfile 2>/dev/null | md5sum | cut -d' ' -f1)
-    LAST_HASH=$(cat .last_build_hash)
-    if [ "$CURRENT_HASH" != "$LAST_HASH" ]; then
-        echo "检测到依赖或 Dockerfile 变更,需要重新构建..."
+# 计算当前代码哈希(包含所有 Python 文件)
+# 依赖文件哈希(变更需要完整重建)
+DEPS_HASH=$(md5sum requirements.txt Dockerfile 2>/dev/null | md5sum | cut -d' ' -f1)
+# 代码文件哈希(变更只需增量重建)
+CODE_HASH=$(find . -name "*.py" -type f ! -path "./.pytest_cache/*" ! -path "./__pycache__/*" -exec md5sum {} \; 2>/dev/null | sort | md5sum | cut -d' ' -f1)
+
+# 检查依赖是否变更(需要完整重建,不使用缓存)
+if [ -f ".last_deps_hash" ]; then
+    LAST_DEPS_HASH=$(cat .last_deps_hash)
+    if [ "$DEPS_HASH" != "$LAST_DEPS_HASH" ]; then
+        echo "检测到依赖或 Dockerfile 变更,需要完整重建..."
         NEED_BUILD=true
+        NEED_FULL_BUILD=true
     fi
 else
     NEED_BUILD=true
+    NEED_FULL_BUILD=true
 fi
 
+# 检查代码是否变更(增量重建,使用缓存)
+if [ -f ".last_code_hash" ]; then
+    LAST_CODE_HASH=$(cat .last_code_hash)
+    if [ "$CODE_HASH" != "$LAST_CODE_HASH" ]; then
+        echo "检测到 Python 代码变更,需要增量重建..."
+        NEED_BUILD=true
+    fi
+else
+    NEED_BUILD=true
+fi
+
+# 执行构建
 if [ "$NEED_BUILD" = true ]; then
-    echo "构建 Docker 镜像..."
-    docker build -t lq-label-backend:latest .
-    # 保存构建哈希
-    md5sum requirements.txt Dockerfile 2>/dev/null | md5sum | cut -d' ' -f1 > .last_build_hash
+    if [ "$NEED_FULL_BUILD" = true ]; then
+        echo "执行完整构建(不使用缓存)..."
+        docker build --no-cache -t lq-label-backend:latest .
+    else
+        echo "执行增量构建(使用缓存加速)..."
+        docker build -t lq-label-backend:latest .
+    fi
+    # 保存哈希
+    echo "$DEPS_HASH" > .last_deps_hash
+    echo "$CODE_HASH" > .last_code_hash
+    echo -e "${GREEN}镜像构建完成${NC}"
 else
-    echo "镜像无变更,跳过构建"
+    echo "代码无变更,跳过构建"
 fi
 
 echo "启动后端服务..."

BIN
lq_label_dist.tar.gz


+ 29 - 3
web/apps/lq_label/src/app/app.tsx

@@ -3,6 +3,7 @@ import { useAtomValue } from 'jotai';
 import { Layout } from '../components/layout';
 import { ThemeProvider } from '../components/theme-provider';
 import { ProtectedRoute } from '../components/protected-route';
+import { AdminRoute } from '../components/admin-route';
 import { LoginForm } from '../components/login-form';
 import { RegisterForm } from '../components/register-form';
 import { OAuthCallback } from '../components/oauth-callback';
@@ -18,6 +19,7 @@ import {
   AnnotationView,
   UserManagementView,
   MyTasksView,
+  ExternalProjectsView,
 } from '../views';
 import { ProjectAnnotationView } from '../views/project-annotation-view';
 import { EditorTest } from '../views/editor-test';
@@ -85,8 +87,25 @@ export function App() {
                       element={<ProjectAnnotationView />}
                     />
 
-                    {/* Tasks Routes */}
-                    <Route path="/tasks" element={<TasksView />} />
+                    {/* External Projects Routes (Admin Only) */}
+                    <Route
+                      path="/external-projects"
+                      element={
+                        <AdminRoute>
+                          <ExternalProjectsView />
+                        </AdminRoute>
+                      }
+                    />
+
+                    {/* Tasks Routes (Admin Only for /tasks) */}
+                    <Route
+                      path="/tasks"
+                      element={
+                        <AdminRoute>
+                          <TasksView />
+                        </AdminRoute>
+                      }
+                    />
                     <Route path="/my-tasks" element={<MyTasksView />} />
                     <Route
                       path="/tasks/:id/annotate"
@@ -97,7 +116,14 @@ export function App() {
                     <Route path="/annotations" element={<AnnotationsView />} />
 
                     {/* User Management Routes (Admin Only) */}
-                    <Route path="/users" element={<UserManagementView />} />
+                    <Route
+                      path="/users"
+                      element={
+                        <AdminRoute>
+                          <UserManagementView />
+                        </AdminRoute>
+                      }
+                    />
 
                     {/* 404 Not Found */}
                     <Route path="*" element={<NotFoundView />} />

+ 33 - 0
web/apps/lq_label/src/components/admin-route/admin-route.tsx

@@ -0,0 +1,33 @@
+/**
+ * Admin route component
+ * Redirects to home if user is not an admin
+ * 
+ * Requirements: 1.4 - Route protection for admin-only pages
+ */
+import React from 'react';
+import { Navigate, useLocation } from 'react-router-dom';
+import { useAtomValue } from 'jotai';
+import { isAuthenticatedAtom, isAdminAtom } from '../../atoms/auth-atoms';
+
+interface AdminRouteProps {
+  children: React.ReactNode;
+}
+
+export const AdminRoute: React.FC<AdminRouteProps> = ({ children }) => {
+  const isAuthenticated = useAtomValue(isAuthenticatedAtom);
+  const isAdmin = useAtomValue(isAdminAtom);
+  const location = useLocation();
+
+  // First check if user is authenticated
+  if (!isAuthenticated) {
+    return <Navigate to="/login" state={{ from: location }} replace />;
+  }
+
+  // Then check if user is admin
+  if (!isAdmin) {
+    // Redirect to home page for non-admin users
+    return <Navigate to="/" replace />;
+  }
+
+  return <>{children}</>;
+};

+ 1 - 0
web/apps/lq_label/src/components/admin-route/index.ts

@@ -0,0 +1 @@
+export { AdminRoute } from './admin-route';

+ 8 - 0
web/apps/lq_label/src/components/index.ts

@@ -8,6 +8,10 @@
 // Layout components
 export * from './layout';
 
+// Route protection components
+export * from './protected-route';
+export * from './admin-route';
+
 // Form components
 export * from './project-form';
 export * from './task-form';
@@ -29,3 +33,7 @@ export * from './config-editor';
 // Data export components
 export * from './data-export-dialog';
 
+// Project config wizard components
+export * from './task-type-selector';
+export * from './label-editor';
+export * from './project-config-wizard';

+ 5 - 0
web/apps/lq_label/src/components/label-editor/index.ts

@@ -0,0 +1,5 @@
+/**
+ * LabelEditor Index
+ */
+export { LabelEditor } from './label-editor';
+export type { LabelConfig, LabelEditorProps } from './label-editor';

+ 321 - 0
web/apps/lq_label/src/components/label-editor/label-editor.module.scss

@@ -0,0 +1,321 @@
+/**
+ * LabelEditor Styles
+ * Uses --theme-* variables for consistency with the rest of the app.
+ */
+.root {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-400);
+}
+
+.header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: var(--spacing-300);
+}
+
+.headerText {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-100);
+}
+
+.title {
+  font-size: var(--font-size-heading-small);
+  font-weight: var(--font-weight-semibold);
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.subtitle {
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-paragraph);
+  margin: 0;
+}
+
+.addButton {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  padding: var(--spacing-150) var(--spacing-300);
+  background: var(--theme-button);
+  color: var(--theme-button-text);
+  border: none;
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  cursor: pointer;
+  transition: all 0.2s ease;
+  white-space: nowrap;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button-hover);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.labelList {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-200);
+  min-height: 100px;
+}
+
+.emptyState {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: var(--spacing-600);
+  background: var(--theme-background-secondary);
+  border: 2px dashed var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  color: var(--theme-paragraph-subtle);
+  font-size: var(--font-size-body-medium);
+}
+
+.labelItem {
+  background: var(--theme-card-background);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  overflow: hidden;
+}
+
+.labelContent {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: var(--spacing-250) var(--spacing-300);
+}
+
+.labelInfo {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-200);
+}
+
+.colorIndicator {
+  width: 16px;
+  height: 16px;
+  border-radius: var(--corner-radius-small);
+  flex-shrink: 0;
+}
+
+.labelName {
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  color: var(--theme-headline);
+}
+
+.hotkeyBadge {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-50);
+  padding: var(--spacing-50) var(--spacing-150);
+  background: var(--theme-background-tertiary);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+}
+
+.labelActions {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-100);
+}
+
+.editButton,
+.deleteButton {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  background: transparent;
+  border: none;
+  border-radius: var(--corner-radius-small);
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-background-secondary);
+    color: var(--theme-headline);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.deleteButton:hover:not(:disabled) {
+  background: rgba(239, 68, 68, 0.1);
+  color: var(--theme-error);
+}
+
+.editForm {
+  padding: var(--spacing-250) var(--spacing-300);
+  background: var(--theme-background-secondary);
+}
+
+.editRow {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-200);
+}
+
+.colorPickerWrapper {
+  position: relative;
+}
+
+.colorButton {
+  width: 32px;
+  height: 32px;
+  border: 2px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    border-color: var(--theme-button);
+  }
+}
+
+.colorPicker {
+  position: absolute;
+  top: 100%;
+  left: 0;
+  z-index: 10;
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: var(--spacing-100);
+  padding: var(--spacing-200);
+  background: var(--theme-card-background);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  box-shadow: var(--theme-shadow);
+  margin-top: var(--spacing-100);
+}
+
+.colorOption {
+  width: 24px;
+  height: 24px;
+  border: 2px solid transparent;
+  border-radius: var(--corner-radius-small);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    transform: scale(1.1);
+  }
+
+  &.colorSelected {
+    border-color: var(--theme-headline);
+  }
+}
+
+.nameInput {
+  flex: 1;
+  min-width: 120px;
+  padding: var(--spacing-150) var(--spacing-200);
+  background: var(--theme-background);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-headline);
+
+  &:focus {
+    outline: none;
+    border-color: var(--theme-button);
+  }
+
+  &::placeholder {
+    color: var(--theme-paragraph-subtle);
+  }
+}
+
+.hotkeyInput {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  padding: var(--spacing-150) var(--spacing-200);
+  background: var(--theme-background);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  color: var(--theme-paragraph);
+
+  input {
+    width: 24px;
+    padding: 0;
+    background: transparent;
+    border: none;
+    font-size: var(--font-size-body-medium);
+    color: var(--theme-headline);
+    text-align: center;
+
+    &:focus {
+      outline: none;
+    }
+
+    &::placeholder {
+      color: var(--theme-paragraph-subtle);
+    }
+  }
+}
+
+.editActions {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-100);
+}
+
+.saveButton,
+.cancelButton {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border: none;
+  border-radius: var(--corner-radius-small);
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+
+.saveButton {
+  background: var(--theme-success);
+  color: white;
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.cancelButton {
+  background: var(--theme-background-tertiary);
+  color: var(--theme-paragraph);
+
+  &:hover {
+    background: var(--theme-border);
+    color: var(--theme-headline);
+  }
+}
+
+.summary {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: var(--spacing-200);
+  border-top: 1px solid var(--theme-border);
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+}

+ 262 - 0
web/apps/lq_label/src/components/label-editor/label-editor.tsx

@@ -0,0 +1,262 @@
+/**
+ * LabelEditor Component
+ * 
+ * Visual label configuration editor for annotation projects.
+ * Supports adding, editing, and deleting labels with color and hotkey settings.
+ * 
+ * Requirements: 4.4, 4.5, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.8, 7.9
+ */
+import React, { useState } from 'react';
+import { Plus, Trash2, Edit2, Check, X, Keyboard } from 'lucide-react';
+import type { TaskType } from '../task-type-selector';
+import styles from './label-editor.module.scss';
+
+export interface LabelConfig {
+  id: string;
+  name: string;
+  color: string;
+  hotkey?: string;
+}
+
+export interface LabelEditorProps {
+  labels: LabelConfig[];
+  onChange: (labels: LabelConfig[]) => void;
+  taskType: TaskType | null;
+  disabled?: boolean;
+}
+
+// 预定义颜色列表
+const PRESET_COLORS = [
+  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4',
+  '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F',
+  '#BB8FCE', '#85C1E9', '#F8B500', '#00CED1',
+];
+
+// 生成唯一ID
+const generateId = () => `label_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+
+// 获取下一个可用颜色
+const getNextColor = (usedColors: string[]): string => {
+  const availableColor = PRESET_COLORS.find(c => !usedColors.includes(c));
+  return availableColor || PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)];
+};
+
+export const LabelEditor: React.FC<LabelEditorProps> = ({
+  labels,
+  onChange,
+  taskType,
+  disabled = false,
+}) => {
+  const [editingId, setEditingId] = useState<string | null>(null);
+  const [editingName, setEditingName] = useState('');
+  const [editingColor, setEditingColor] = useState('');
+  const [editingHotkey, setEditingHotkey] = useState('');
+  const [showColorPicker, setShowColorPicker] = useState<string | null>(null);
+
+  const handleAddLabel = () => {
+    const usedColors = labels.map(l => l.color);
+    const newLabel: LabelConfig = {
+      id: generateId(),
+      name: `标签${labels.length + 1}`,
+      color: getNextColor(usedColors),
+      hotkey: labels.length < 9 ? String(labels.length + 1) : undefined,
+    };
+    onChange([...labels, newLabel]);
+    // 自动进入编辑模式
+    setEditingId(newLabel.id);
+    setEditingName(newLabel.name);
+    setEditingColor(newLabel.color);
+    setEditingHotkey(newLabel.hotkey || '');
+  };
+
+  const handleDeleteLabel = (id: string) => {
+    onChange(labels.filter(l => l.id !== id));
+    if (editingId === id) {
+      setEditingId(null);
+    }
+  };
+
+  const handleStartEdit = (label: LabelConfig) => {
+    setEditingId(label.id);
+    setEditingName(label.name);
+    setEditingColor(label.color);
+    setEditingHotkey(label.hotkey || '');
+  };
+
+  const handleSaveEdit = () => {
+    if (!editingId || !editingName.trim()) return;
+    
+    onChange(labels.map(l => 
+      l.id === editingId 
+        ? { ...l, name: editingName.trim(), color: editingColor, hotkey: editingHotkey || undefined }
+        : l
+    ));
+    setEditingId(null);
+    setShowColorPicker(null);
+  };
+
+  const handleCancelEdit = () => {
+    setEditingId(null);
+    setShowColorPicker(null);
+  };
+
+  const handleColorSelect = (color: string) => {
+    setEditingColor(color);
+    setShowColorPicker(null);
+  };
+
+  const getTaskTypeLabel = () => {
+    switch (taskType) {
+      case 'text_classification':
+      case 'image_classification':
+        return '分类选项';
+      case 'object_detection':
+        return '检测标签';
+      case 'ner':
+        return '实体标签';
+      default:
+        return '标签';
+    }
+  };
+
+  return (
+    <div className={styles.root}>
+      <div className={styles.header}>
+        <div className={styles.headerText}>
+          <h3 className={styles.title}>配置{getTaskTypeLabel()}</h3>
+          <p className={styles.subtitle}>
+            添加和编辑标注{getTaskTypeLabel()},设置颜色和快捷键
+          </p>
+        </div>
+        <button
+          type="button"
+          className={styles.addButton}
+          onClick={handleAddLabel}
+          disabled={disabled}
+        >
+          <Plus size={16} />
+          添加{getTaskTypeLabel()}
+        </button>
+      </div>
+
+      <div className={styles.labelList}>
+        {labels.length === 0 ? (
+          <div className={styles.emptyState}>
+            <p>暂无{getTaskTypeLabel()},点击上方按钮添加</p>
+          </div>
+        ) : (
+          labels.map((label, index) => (
+            <div key={label.id} className={styles.labelItem}>
+              {editingId === label.id ? (
+                // 编辑模式
+                <div className={styles.editForm}>
+                  <div className={styles.editRow}>
+                    <div className={styles.colorPickerWrapper}>
+                      <button
+                        type="button"
+                        className={styles.colorButton}
+                        style={{ backgroundColor: editingColor }}
+                        onClick={() => setShowColorPicker(showColorPicker === label.id ? null : label.id)}
+                      />
+                      {showColorPicker === label.id && (
+                        <div className={styles.colorPicker}>
+                          {PRESET_COLORS.map(color => (
+                            <button
+                              key={color}
+                              type="button"
+                              className={`${styles.colorOption} ${editingColor === color ? styles.colorSelected : ''}`}
+                              style={{ backgroundColor: color }}
+                              onClick={() => handleColorSelect(color)}
+                            />
+                          ))}
+                        </div>
+                      )}
+                    </div>
+                    <input
+                      type="text"
+                      className={styles.nameInput}
+                      value={editingName}
+                      onChange={(e) => setEditingName(e.target.value)}
+                      placeholder="标签名称"
+                      autoFocus
+                    />
+                    <div className={styles.hotkeyInput}>
+                      <Keyboard size={14} />
+                      <input
+                        type="text"
+                        value={editingHotkey}
+                        onChange={(e) => setEditingHotkey(e.target.value.slice(0, 1))}
+                        placeholder="快捷键"
+                        maxLength={1}
+                      />
+                    </div>
+                    <div className={styles.editActions}>
+                      <button
+                        type="button"
+                        className={styles.saveButton}
+                        onClick={handleSaveEdit}
+                        disabled={!editingName.trim()}
+                      >
+                        <Check size={16} />
+                      </button>
+                      <button
+                        type="button"
+                        className={styles.cancelButton}
+                        onClick={handleCancelEdit}
+                      >
+                        <X size={16} />
+                      </button>
+                    </div>
+                  </div>
+                </div>
+              ) : (
+                // 显示模式
+                <div className={styles.labelContent}>
+                  <div className={styles.labelInfo}>
+                    <span
+                      className={styles.colorIndicator}
+                      style={{ backgroundColor: label.color }}
+                    />
+                    <span className={styles.labelName}>{label.name}</span>
+                    {label.hotkey && (
+                      <span className={styles.hotkeyBadge}>
+                        <Keyboard size={12} />
+                        {label.hotkey}
+                      </span>
+                    )}
+                  </div>
+                  <div className={styles.labelActions}>
+                    <button
+                      type="button"
+                      className={styles.editButton}
+                      onClick={() => handleStartEdit(label)}
+                      disabled={disabled}
+                      title="编辑"
+                    >
+                      <Edit2 size={14} />
+                    </button>
+                    <button
+                      type="button"
+                      className={styles.deleteButton}
+                      onClick={() => handleDeleteLabel(label.id)}
+                      disabled={disabled}
+                      title="删除"
+                    >
+                      <Trash2 size={14} />
+                    </button>
+                  </div>
+                </div>
+              )}
+            </div>
+          ))
+        )}
+      </div>
+
+      {labels.length > 0 && (
+        <div className={styles.summary}>
+          <span>共 {labels.length} 个{getTaskTypeLabel()}</span>
+        </div>
+      )}
+    </div>
+  );
+};

+ 16 - 1
web/apps/lq_label/src/components/layout/sidebar.tsx

@@ -9,7 +9,7 @@
 import React, { useState } from 'react';
 import { useLocation, Link } from 'react-router-dom';
 import { useAtomValue } from 'jotai';
-import { FolderKanban, ClipboardList, FileCheck, Menu, X, Users, ListChecks } from 'lucide-react';
+import { FolderKanban, ClipboardList, FileCheck, Menu, X, Users, ListChecks, ExternalLink } from 'lucide-react';
 import { currentUserAtom } from '../../atoms/auth-atoms';
 import styles from './sidebar.module.scss';
 
@@ -26,6 +26,14 @@ interface MenuItem {
 
 /**
  * Menu items configuration
+ * 
+ * Menu order:
+ * - 项目管理 (all users)
+ * - 管理外部来源数据 (admin only)
+ * - 任务管理 (admin only)
+ * - 我的任务 (all users)
+ * - 我的标注 (all users)
+ * - 用户管理 (admin only)
  */
 const menuItems: MenuItem[] = [
   {
@@ -34,6 +42,13 @@ const menuItems: MenuItem[] = [
     path: '/projects',
     icon: <FolderKanban size={20} />,
   },
+  {
+    id: 'external-projects',
+    label: '管理外部来源数据',
+    path: '/external-projects',
+    icon: <ExternalLink size={20} />,
+    adminOnly: true,
+  },
   {
     id: 'tasks',
     label: '任务管理',

+ 9 - 0
web/apps/lq_label/src/components/project-config-wizard/index.ts

@@ -0,0 +1,9 @@
+/**
+ * ProjectConfigWizard Index
+ */
+export { ProjectConfigWizard } from './project-config-wizard';
+export type { 
+  WizardStep, 
+  ProjectConfigResult, 
+  ProjectConfigWizardProps 
+} from './project-config-wizard';

+ 326 - 0
web/apps/lq_label/src/components/project-config-wizard/project-config-wizard.module.scss

@@ -0,0 +1,326 @@
+/**
+ * ProjectConfigWizard Styles
+ * Uses --theme-* variables for consistency with the rest of the app.
+ */
+.root {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 500px;
+}
+
+.stepIndicator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: var(--spacing-400) var(--spacing-600);
+  background: var(--theme-background-secondary);
+  border-bottom: 1px solid var(--theme-border);
+}
+
+.step {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-150);
+}
+
+.stepNumber {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  background: var(--theme-background-tertiary);
+  border: 2px solid var(--theme-border);
+  border-radius: 50%;
+  font-size: var(--font-size-body-small);
+  font-weight: var(--font-weight-semibold);
+  color: var(--theme-paragraph-subtle);
+  transition: all 0.2s ease;
+
+  .active & {
+    background: var(--theme-button);
+    border-color: var(--theme-button);
+    color: var(--theme-button-text);
+  }
+
+  .completed & {
+    background: var(--theme-success);
+    border-color: var(--theme-success);
+    color: white;
+  }
+}
+
+.stepLabel {
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  color: var(--theme-paragraph-subtle);
+  transition: color 0.2s ease;
+
+  .active &,
+  .completed & {
+    color: var(--theme-headline);
+  }
+}
+
+.stepConnector {
+  width: 60px;
+  height: 2px;
+  background: var(--theme-border);
+  margin: 0 var(--spacing-200);
+  transition: background 0.2s ease;
+
+  &.completed {
+    background: var(--theme-success);
+  }
+}
+
+.content {
+  flex: 1;
+  padding: var(--spacing-500);
+  overflow-y: auto;
+}
+
+.labelsStep {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-400);
+}
+
+.choiceTypeSelector {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-300);
+  padding: var(--spacing-300);
+  background: var(--theme-background-secondary);
+  border-radius: var(--corner-radius-small);
+}
+
+.choiceTypeLabel {
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  color: var(--theme-headline);
+}
+
+.choiceTypeOptions {
+  display: flex;
+  gap: var(--spacing-300);
+}
+
+.choiceTypeOption {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  cursor: pointer;
+
+  input {
+    accent-color: var(--theme-button);
+  }
+
+  span {
+    font-size: var(--font-size-body-medium);
+    color: var(--theme-headline);
+  }
+}
+
+.previewStep {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-400);
+}
+
+.previewHeader {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.previewTitle {
+  font-size: var(--font-size-heading-small);
+  font-weight: var(--font-weight-semibold);
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.toggleButton {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  padding: var(--spacing-150) var(--spacing-250);
+  background: var(--theme-background-secondary);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover {
+    background: var(--theme-background-tertiary);
+    color: var(--theme-headline);
+  }
+}
+
+.xmlPreview {
+  background: var(--theme-background-secondary);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  padding: var(--spacing-400);
+  overflow-x: auto;
+
+  pre {
+    margin: 0;
+    font-family: var(--font-family-monospace);
+    font-size: var(--font-size-body-small);
+    color: var(--theme-headline);
+    white-space: pre-wrap;
+    word-break: break-word;
+  }
+}
+
+.visualPreview {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-300);
+}
+
+.previewSection {
+  padding: var(--spacing-300);
+  background: var(--theme-background-secondary);
+  border-radius: var(--corner-radius-small);
+
+  h4 {
+    font-size: var(--font-size-body-small);
+    font-weight: var(--font-weight-medium);
+    color: var(--theme-paragraph);
+    margin: 0 0 var(--spacing-150) 0;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+  }
+
+  p {
+    font-size: var(--font-size-body-medium);
+    font-weight: var(--font-weight-medium);
+    color: var(--theme-headline);
+    margin: 0;
+  }
+}
+
+.labelPreviewList {
+  display: flex;
+  flex-wrap: wrap;
+  gap: var(--spacing-150);
+}
+
+.labelPreviewItem {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  padding: var(--spacing-100) var(--spacing-200);
+  background: var(--theme-card-background);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+}
+
+.labelColor {
+  width: 12px;
+  height: 12px;
+  border-radius: var(--corner-radius-small);
+}
+
+.labelName {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-headline);
+}
+
+.labelHotkey {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+  padding: 0 var(--spacing-100);
+  background: var(--theme-background-tertiary);
+  border-radius: var(--corner-radius-small);
+}
+
+.footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: var(--spacing-400) var(--spacing-500);
+  background: var(--theme-background-secondary);
+  border-top: 1px solid var(--theme-border);
+}
+
+.cancelButton {
+  padding: var(--spacing-200) var(--spacing-400);
+  background: transparent;
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-background-tertiary);
+    color: var(--theme-headline);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.navButtons {
+  display: flex;
+  gap: var(--spacing-200);
+}
+
+.prevButton,
+.nextButton,
+.completeButton {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--spacing-100);
+  padding: var(--spacing-200) var(--spacing-400);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-medium);
+  font-weight: var(--font-weight-medium);
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+}
+
+.prevButton {
+  background: var(--theme-background-tertiary);
+  border: 1px solid var(--theme-border);
+  color: var(--theme-headline);
+
+  &:hover:not(:disabled) {
+    background: var(--theme-border);
+  }
+}
+
+.nextButton {
+  background: var(--theme-button);
+  border: none;
+  color: var(--theme-button-text);
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button-hover);
+  }
+}
+
+.completeButton {
+  background: var(--theme-success);
+  border: none;
+  color: white;
+
+  &:hover:not(:disabled) {
+    opacity: 0.9;
+  }
+}

+ 287 - 0
web/apps/lq_label/src/components/project-config-wizard/project-config-wizard.tsx

@@ -0,0 +1,287 @@
+/**
+ * ProjectConfigWizard Component
+ * 
+ * Step-by-step wizard for configuring annotation projects.
+ * Includes task type selection, label configuration, and XML preview.
+ * 
+ * Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.7, 6.8
+ */
+import React, { useState, useEffect } from 'react';
+import { ChevronLeft, ChevronRight, Check, Eye, Code } from 'lucide-react';
+import { TaskTypeSelector, type TaskType } from '../task-type-selector';
+import { LabelEditor, type LabelConfig } from '../label-editor';
+import { XMLGenerator } from '../../services/xml-generator';
+import styles from './project-config-wizard.module.scss';
+
+export type WizardStep = 'task-type' | 'labels' | 'preview';
+
+export interface ProjectConfigResult {
+  taskType: TaskType;
+  labels: LabelConfig[];
+  xmlConfig: string;
+  choiceType?: 'single' | 'multiple';
+}
+
+export interface ProjectConfigWizardProps {
+  initialTaskType?: TaskType;
+  initialLabels?: LabelConfig[];
+  initialChoiceType?: 'single' | 'multiple';
+  onComplete: (config: ProjectConfigResult) => void;
+  onCancel: () => void;
+  isSubmitting?: boolean;
+}
+
+const STEPS: { id: WizardStep; label: string }[] = [
+  { id: 'task-type', label: '选择类型' },
+  { id: 'labels', label: '配置标签' },
+  { id: 'preview', label: '预览确认' },
+];
+
+export const ProjectConfigWizard: React.FC<ProjectConfigWizardProps> = ({
+  initialTaskType,
+  initialLabels = [],
+  initialChoiceType = 'single',
+  onComplete,
+  onCancel,
+  isSubmitting = false,
+}) => {
+  const [currentStep, setCurrentStep] = useState<WizardStep>('task-type');
+  const [taskType, setTaskType] = useState<TaskType | null>(initialTaskType || null);
+  const [labels, setLabels] = useState<LabelConfig[]>(initialLabels);
+  const [choiceType, setChoiceType] = useState<'single' | 'multiple'>(initialChoiceType);
+  const [xmlConfig, setXmlConfig] = useState<string>('');
+  const [showRawXml, setShowRawXml] = useState(false);
+
+  // 当配置变化时更新 XML
+  useEffect(() => {
+    if (taskType && labels.length > 0) {
+      try {
+        const xml = XMLGenerator.generate({ taskType, labels, choiceType });
+        setXmlConfig(xml);
+      } catch {
+        setXmlConfig('');
+      }
+    } else {
+      setXmlConfig('');
+    }
+  }, [taskType, labels, choiceType]);
+
+  const currentStepIndex = STEPS.findIndex(s => s.id === currentStep);
+
+  const canGoNext = () => {
+    switch (currentStep) {
+      case 'task-type':
+        return taskType !== null;
+      case 'labels':
+        return labels.length > 0;
+      case 'preview':
+        return xmlConfig !== '';
+      default:
+        return false;
+    }
+  };
+
+  const handleNext = () => {
+    if (currentStep === 'task-type' && taskType) {
+      setCurrentStep('labels');
+    } else if (currentStep === 'labels' && labels.length > 0) {
+      setCurrentStep('preview');
+    }
+  };
+
+  const handlePrev = () => {
+    if (currentStep === 'labels') {
+      setCurrentStep('task-type');
+    } else if (currentStep === 'preview') {
+      setCurrentStep('labels');
+    }
+  };
+
+  const handleComplete = () => {
+    if (taskType && labels.length > 0 && xmlConfig) {
+      onComplete({
+        taskType,
+        labels,
+        xmlConfig,
+        choiceType,
+      });
+    }
+  };
+
+  const isClassificationTask = taskType === 'text_classification' || taskType === 'image_classification';
+
+  return (
+    <div className={styles.root}>
+      {/* Step Indicator */}
+      <div className={styles.stepIndicator}>
+        {STEPS.map((step, index) => (
+          <React.Fragment key={step.id}>
+            <div
+              className={`${styles.step} ${
+                index < currentStepIndex ? styles.completed :
+                index === currentStepIndex ? styles.active : ''
+              }`}
+            >
+              <div className={styles.stepNumber}>
+                {index < currentStepIndex ? <Check size={14} /> : index + 1}
+              </div>
+              <span className={styles.stepLabel}>{step.label}</span>
+            </div>
+            {index < STEPS.length - 1 && (
+              <div className={`${styles.stepConnector} ${index < currentStepIndex ? styles.completed : ''}`} />
+            )}
+          </React.Fragment>
+        ))}
+      </div>
+
+      {/* Step Content */}
+      <div className={styles.content}>
+        {currentStep === 'task-type' && (
+          <TaskTypeSelector
+            selectedType={taskType}
+            onSelect={setTaskType}
+          />
+        )}
+
+        {currentStep === 'labels' && (
+          <div className={styles.labelsStep}>
+            {isClassificationTask && (
+              <div className={styles.choiceTypeSelector}>
+                <span className={styles.choiceTypeLabel}>选择模式:</span>
+                <div className={styles.choiceTypeOptions}>
+                  <label className={styles.choiceTypeOption}>
+                    <input
+                      type="radio"
+                      name="choiceType"
+                      value="single"
+                      checked={choiceType === 'single'}
+                      onChange={() => setChoiceType('single')}
+                    />
+                    <span>单选</span>
+                  </label>
+                  <label className={styles.choiceTypeOption}>
+                    <input
+                      type="radio"
+                      name="choiceType"
+                      value="multiple"
+                      checked={choiceType === 'multiple'}
+                      onChange={() => setChoiceType('multiple')}
+                    />
+                    <span>多选</span>
+                  </label>
+                </div>
+              </div>
+            )}
+            <LabelEditor
+              labels={labels}
+              onChange={setLabels}
+              taskType={taskType}
+            />
+          </div>
+        )}
+
+        {currentStep === 'preview' && (
+          <div className={styles.previewStep}>
+            <div className={styles.previewHeader}>
+              <h3 className={styles.previewTitle}>配置预览</h3>
+              <button
+                type="button"
+                className={styles.toggleButton}
+                onClick={() => setShowRawXml(!showRawXml)}
+              >
+                {showRawXml ? <Eye size={16} /> : <Code size={16} />}
+                {showRawXml ? '可视化预览' : '查看 XML'}
+              </button>
+            </div>
+
+            {showRawXml ? (
+              <div className={styles.xmlPreview}>
+                <pre>{xmlConfig}</pre>
+              </div>
+            ) : (
+              <div className={styles.visualPreview}>
+                <div className={styles.previewSection}>
+                  <h4>任务类型</h4>
+                  <p>{taskType ? XMLGenerator.getTaskTypeName(taskType) : '-'}</p>
+                </div>
+                <div className={styles.previewSection}>
+                  <h4>数据格式</h4>
+                  <p>{taskType ? (XMLGenerator.getDataFormat(taskType) === 'text' ? '文本' : '图像') : '-'}</p>
+                </div>
+                {isClassificationTask && (
+                  <div className={styles.previewSection}>
+                    <h4>选择模式</h4>
+                    <p>{choiceType === 'single' ? '单选' : '多选'}</p>
+                  </div>
+                )}
+                <div className={styles.previewSection}>
+                  <h4>标签列表 ({labels.length})</h4>
+                  <div className={styles.labelPreviewList}>
+                    {labels.map(label => (
+                      <div key={label.id} className={styles.labelPreviewItem}>
+                        <span
+                          className={styles.labelColor}
+                          style={{ backgroundColor: label.color }}
+                        />
+                        <span className={styles.labelName}>{label.name}</span>
+                        {label.hotkey && (
+                          <span className={styles.labelHotkey}>{label.hotkey}</span>
+                        )}
+                      </div>
+                    ))}
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+
+      {/* Footer Actions */}
+      <div className={styles.footer}>
+        <button
+          type="button"
+          className={styles.cancelButton}
+          onClick={onCancel}
+          disabled={isSubmitting}
+        >
+          取消
+        </button>
+        <div className={styles.navButtons}>
+          {currentStepIndex > 0 && (
+            <button
+              type="button"
+              className={styles.prevButton}
+              onClick={handlePrev}
+              disabled={isSubmitting}
+            >
+              <ChevronLeft size={16} />
+              上一步
+            </button>
+          )}
+          {currentStepIndex < STEPS.length - 1 ? (
+            <button
+              type="button"
+              className={styles.nextButton}
+              onClick={handleNext}
+              disabled={!canGoNext() || isSubmitting}
+            >
+              下一步
+              <ChevronRight size={16} />
+            </button>
+          ) : (
+            <button
+              type="button"
+              className={styles.completeButton}
+              onClick={handleComplete}
+              disabled={!canGoNext() || isSubmitting}
+            >
+              {isSubmitting ? '保存中...' : '完成配置'}
+              {!isSubmitting && <Check size={16} />}
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};

+ 134 - 0
web/apps/lq_label/src/components/project-form/project-form.module.scss

@@ -208,3 +208,137 @@
   align-items: center;
   gap: var(--spacing-tight);
 }
+
+// Details step
+.detailsStep {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+// Config step styles
+.configStep {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-base);
+  height: 100%;
+  padding: var(--spacing-base);
+}
+
+.modeToggle {
+  display: flex;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-tight);
+  background: var(--theme-background-secondary);
+  border-radius: var(--corner-radius-small);
+  width: fit-content;
+}
+
+.modeButton {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tight);
+  padding: var(--spacing-tight) var(--spacing-base);
+  border: none;
+  border-radius: var(--corner-radius-small);
+  background: transparent;
+  color: var(--theme-paragraph-subtle);
+  font-size: var(--font-size-body-small);
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover:not(:disabled) {
+    color: var(--theme-headline);
+    background: var(--theme-background);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.modeButtonActive {
+  background: var(--theme-background);
+  color: var(--theme-button);
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.wizardContent {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-base);
+  overflow: auto;
+}
+
+.wizardSection {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-tight);
+}
+
+.wizardSectionTitle {
+  font-size: var(--font-size-body-medium);
+  font-weight: 600;
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.choiceTypeSelector {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-base);
+  padding: var(--spacing-tight) 0;
+}
+
+.choiceTypeLabel {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+}
+
+.choiceTypeOptions {
+  display: flex;
+  gap: var(--spacing-base);
+}
+
+.choiceTypeOption {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-tight);
+  cursor: pointer;
+  font-size: var(--font-size-body-small);
+  color: var(--theme-headline);
+
+  input[type="radio"] {
+    accent-color: var(--theme-button);
+  }
+}
+
+.xmlPreview {
+  background: var(--theme-background-secondary);
+  border: 1px solid var(--theme-border);
+  border-radius: var(--corner-radius-small);
+  padding: var(--spacing-base);
+  overflow: auto;
+  max-height: 200px;
+
+  pre {
+    margin: 0;
+    font-family: var(--font-family-monospace);
+    font-size: var(--font-size-body-small);
+    color: var(--theme-headline);
+    white-space: pre-wrap;
+    word-break: break-all;
+  }
+}
+
+.advancedContent {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.stepConnectorCompleted {
+  background: var(--theme-success);
+}

+ 311 - 111
web/apps/lq_label/src/components/project-form/project-form.tsx

@@ -2,8 +2,8 @@
  * ProjectForm Component
  *
  * Reusable form component for creating and editing projects.
- * Supports template selection for quick project setup.
- * Requirements: 1.2, 1.4, 4.4, 4.6
+ * Supports template selection and config wizard for quick project setup.
+ * Requirements: 1.2, 1.4, 4.4, 4.6, 6.1, 6.6
  */
 import React, { useState, useEffect, useCallback } from 'react';
 import { Button } from '@humansignal/ui';
@@ -14,8 +14,13 @@ import {
   Settings,
   Check,
   Grid3X3,
+  Wand2,
+  Code,
 } from 'lucide-react';
 import { TemplateGallery } from '../template-gallery';
+import { TaskTypeSelector, type TaskType } from '../task-type-selector';
+import { LabelEditor, type LabelConfig } from '../label-editor';
+import { XMLGenerator } from '../../services/xml-generator';
 import type { Template } from '../../services/api';
 import styles from './project-form.module.scss';
 
@@ -23,6 +28,7 @@ export interface ProjectFormData {
   name: string;
   description: string;
   config: string;
+  taskType?: TaskType;
 }
 
 export interface ProjectFormProps {
@@ -35,7 +41,8 @@ export interface ProjectFormProps {
   showTemplateStep?: boolean;
 }
 
-type FormStep = 'template' | 'details';
+type FormStep = 'template' | 'details' | 'config';
+type ConfigMode = 'wizard' | 'advanced';
 
 export const ProjectForm: React.FC<ProjectFormProps> = ({
   initialData,
@@ -50,11 +57,18 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
     showTemplateStep ? 'template' : 'details'
   );
   const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
+  
+  // Config wizard state
+  const [configMode, setConfigMode] = useState<ConfigMode>('wizard');
+  const [taskType, setTaskType] = useState<TaskType | null>(null);
+  const [labels, setLabels] = useState<LabelConfig[]>([]);
+  const [choiceType, setChoiceType] = useState<'single' | 'multiple'>('single');
 
   const [formData, setFormData] = useState<ProjectFormData>({
     name: initialData?.name || '',
     description: initialData?.description || '',
     config: initialData?.config || '',
+    taskType: initialData?.taskType,
   });
 
   const [formErrors, setFormErrors] = useState<Record<string, string>>({});
@@ -66,10 +80,23 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
         name: initialData.name || '',
         description: initialData.description || '',
         config: initialData.config || '',
+        taskType: initialData.taskType,
       });
     }
   }, [initialData]);
 
+  // 当向导配置变化时更新 XML
+  useEffect(() => {
+    if (configMode === 'wizard' && taskType && labels.length > 0) {
+      try {
+        const xml = XMLGenerator.generate({ taskType, labels, choiceType });
+        setFormData(prev => ({ ...prev, config: xml, taskType }));
+      } catch {
+        // 忽略生成错误
+      }
+    }
+  }, [configMode, taskType, labels, choiceType]);
+
   // Handle template selection
   const handleTemplateSelect = useCallback((template: Template) => {
     setSelectedTemplate(template);
@@ -78,11 +105,14 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
       ...prev,
       config: template.config,
     }));
+    // 使用模板时切换到高级模式
+    setConfigMode('advanced');
   }, []);
 
-  // Skip template selection
+  // Skip template selection - use wizard mode
   const handleSkipTemplate = useCallback(() => {
     setSelectedTemplate(null);
+    setConfigMode('wizard');
     setCurrentStep('details');
   }, []);
 
@@ -90,6 +120,8 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
   const handleNextStep = useCallback(() => {
     if (currentStep === 'template') {
       setCurrentStep('details');
+    } else if (currentStep === 'details') {
+      setCurrentStep('config');
     }
   }, [currentStep]);
 
@@ -97,9 +129,30 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
   const handlePrevStep = useCallback(() => {
     if (currentStep === 'details' && showTemplateStep) {
       setCurrentStep('template');
+    } else if (currentStep === 'config') {
+      setCurrentStep('details');
     }
   }, [currentStep, showTemplateStep]);
 
+  // 检查详情步骤是否可以继续
+  const canProceedFromDetails = (): boolean => {
+    const nameValid = formData.name && formData.name.trim().length >= 2;
+    const descValid = formData.description && formData.description.trim().length >= 5;
+    return !!(nameValid && descValid);
+  };
+
+  // 检查配置步骤是否完成
+  const isConfigComplete = (): boolean => {
+    if (configMode === 'wizard') {
+      return taskType !== null && labels.length > 0;
+    }
+    // 高级模式:检查 XML 是否有效
+    return formData.config.trim().length > 0;
+  };
+
+  // 判断是否为分类任务
+  const isClassificationTask = taskType === 'text_classification' || taskType === 'image_classification';
+
   const validateForm = (): boolean => {
     const errors: Record<string, string> = {};
 
@@ -149,6 +202,10 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
       setFormData({ name: '', description: '', config: '' });
       setFormErrors({});
       setSelectedTemplate(null);
+      setTaskType(null);
+      setLabels([]);
+      setChoiceType('single');
+      setConfigMode('wizard');
       if (showTemplateStep) {
         setCurrentStep('template');
       }
@@ -209,25 +266,36 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
   const renderStepIndicator = () => {
     if (!showTemplateStep) return null;
 
+    const steps = [
+      { id: 'template', label: '选择模板', icon: Grid3X3 },
+      { id: 'details', label: '项目详情', icon: Settings },
+      { id: 'config', label: '配置标注', icon: Wand2 },
+    ];
+
+    const currentIndex = steps.findIndex(s => s.id === currentStep);
+
     return (
       <div className={styles.stepIndicator}>
-        <div
-          className={`${styles.step} ${currentStep === 'template' ? styles.stepActive : styles.stepCompleted}`}
-        >
-          <div className={styles.stepIcon}>
-            {currentStep === 'details' ? <Check size={16} /> : <Grid3X3 size={16} />}
-          </div>
-          <span className={styles.stepLabel}>选择模板</span>
-        </div>
-        <div className={styles.stepConnector} />
-        <div
-          className={`${styles.step} ${currentStep === 'details' ? styles.stepActive : ''}`}
-        >
-          <div className={styles.stepIcon}>
-            <Settings size={16} />
-          </div>
-          <span className={styles.stepLabel}>项目详情</span>
-        </div>
+        {steps.map((step, index) => {
+          const Icon = step.icon;
+          const isCompleted = index < currentIndex;
+          const isActive = index === currentIndex;
+          return (
+            <React.Fragment key={step.id}>
+              <div
+                className={`${styles.step} ${isActive ? styles.stepActive : ''} ${isCompleted ? styles.stepCompleted : ''}`}
+              >
+                <div className={styles.stepIcon}>
+                  {isCompleted ? <Check size={16} /> : <Icon size={16} />}
+                </div>
+                <span className={styles.stepLabel}>{step.label}</span>
+              </div>
+              {index < steps.length - 1 && (
+                <div className={`${styles.stepConnector} ${isCompleted ? styles.stepConnectorCompleted : ''}`} />
+              )}
+            </React.Fragment>
+          );
+        })}
       </div>
     );
   };
@@ -268,96 +336,61 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
 
   // Render details step
   const renderDetailsStep = () => (
-    <form onSubmit={handleSubmit} className={styles.form}>
-      {/* Name field */}
-      <div className={styles.field}>
-        <label htmlFor="project-name" className={styles.label}>
-          项目名称 <span className={styles.required}>*</span>
-        </label>
-        <input
-          id="project-name"
-          type="text"
-          value={formData.name}
-          onChange={(e) => handleFieldChange('name', e.target.value)}
-          onBlur={() => handleFieldBlur('name')}
-          className={`${styles.input} ${formErrors.name ? styles.error : ''}`}
-          placeholder="输入项目名称"
-          disabled={isSubmitting}
-          aria-invalid={!!formErrors.name}
-          aria-describedby={formErrors.name ? 'name-error' : undefined}
-          maxLength={100}
-        />
-        {formErrors.name && (
-          <span id="name-error" className={styles.errorMessage}>
-            {formErrors.name}
-          </span>
-        )}
-      </div>
-
-      {/* Description field */}
-      <div className={styles.field}>
-        <label htmlFor="project-description" className={styles.label}>
-          项目描述 <span className={styles.required}>*</span>
-        </label>
-        <textarea
-          id="project-description"
-          value={formData.description}
-          onChange={(e) => handleFieldChange('description', e.target.value)}
-          onBlur={() => handleFieldBlur('description')}
-          className={`${styles.textarea} ${formErrors.description ? styles.error : ''}`}
-          placeholder="输入项目描述"
-          rows={3}
-          disabled={isSubmitting}
-          aria-invalid={!!formErrors.description}
-          aria-describedby={
-            formErrors.description ? 'description-error' : undefined
-          }
-          maxLength={500}
-        />
-        {formErrors.description && (
-          <span id="description-error" className={styles.errorMessage}>
-            {formErrors.description}
-          </span>
-        )}
-      </div>
+    <div className={styles.detailsStep}>
+      <div className={styles.form}>
+        {/* Name field */}
+        <div className={styles.field}>
+          <label htmlFor="project-name" className={styles.label}>
+            项目名称 <span className={styles.required}>*</span>
+          </label>
+          <input
+            id="project-name"
+            type="text"
+            value={formData.name}
+            onChange={(e) => handleFieldChange('name', e.target.value)}
+            onBlur={() => handleFieldBlur('name')}
+            className={`${styles.input} ${formErrors.name ? styles.error : ''}`}
+            placeholder="输入项目名称"
+            disabled={isSubmitting}
+            aria-invalid={!!formErrors.name}
+            aria-describedby={formErrors.name ? 'name-error' : undefined}
+            maxLength={100}
+          />
+          {formErrors.name && (
+            <span id="name-error" className={styles.errorMessage}>
+              {formErrors.name}
+            </span>
+          )}
+        </div>
 
-      {/* Config field */}
-      <div className={styles.field}>
-        <label htmlFor="project-config" className={styles.label}>
-          标注配置 <span className={styles.required}>*</span>
-          {selectedTemplate && (
-            <span className={styles.templateBadge}>
-              来自模板: {selectedTemplate.name}
+        {/* Description field */}
+        <div className={styles.field}>
+          <label htmlFor="project-description" className={styles.label}>
+            项目描述 <span className={styles.required}>*</span>
+          </label>
+          <textarea
+            id="project-description"
+            value={formData.description}
+            onChange={(e) => handleFieldChange('description', e.target.value)}
+            onBlur={() => handleFieldBlur('description')}
+            className={`${styles.textarea} ${formErrors.description ? styles.error : ''}`}
+            placeholder="输入项目描述"
+            rows={3}
+            disabled={isSubmitting}
+            aria-invalid={!!formErrors.description}
+            aria-describedby={
+              formErrors.description ? 'description-error' : undefined
+            }
+            maxLength={500}
+          />
+          {formErrors.description && (
+            <span id="description-error" className={styles.errorMessage}>
+              {formErrors.description}
             </span>
           )}
-        </label>
-        <textarea
-          id="project-config"
-          value={formData.config}
-          onChange={(e) => handleFieldChange('config', e.target.value)}
-          onBlur={() => handleFieldBlur('config')}
-          className={`${styles.textarea} ${styles.textareaCode} ${formErrors.config ? styles.error : ''}`}
-          placeholder='输入 Label Studio 配置 XML,例如:<View><Text name="text" value="$text"/></View>'
-          rows={10}
-          disabled={isSubmitting}
-          aria-invalid={!!formErrors.config}
-          aria-describedby={formErrors.config ? 'config-error' : undefined}
-        />
-        {formErrors.config && (
-          <span id="config-error" className={styles.errorMessage}>
-            {formErrors.config}
-          </span>
-        )}
-        <span className={styles.hint}>
-          请输入有效的 Label Studio XML 配置,可根据需要修改模板配置
-        </span>
+        </div>
       </div>
 
-      {/* Submit error */}
-      {formErrors.submit && (
-        <div className={styles.submitError}>{formErrors.submit}</div>
-      )}
-
       {/* Form actions */}
       <div className={styles.actions}>
         {showTemplateStep && (
@@ -382,21 +415,188 @@ export const ProjectForm: React.FC<ProjectFormProps> = ({
           >
             取消
           </Button>
-          <Button type="submit" variant="primary" disabled={isSubmitting}>
+          <Button
+            type="button"
+            variant="primary"
+            onClick={handleNextStep}
+            disabled={!canProceedFromDetails() || isSubmitting}
+          >
+            下一步
+            <ChevronRight size={16} />
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+
+  // Render config step (wizard or advanced mode)
+  const renderConfigStep = () => (
+    <div className={styles.configStep}>
+      {/* Mode toggle */}
+      <div className={styles.modeToggle}>
+        <button
+          type="button"
+          className={`${styles.modeButton} ${configMode === 'wizard' ? styles.modeButtonActive : ''}`}
+          onClick={() => setConfigMode('wizard')}
+          disabled={isSubmitting}
+        >
+          <Wand2 size={16} />
+          向导模式
+        </button>
+        <button
+          type="button"
+          className={`${styles.modeButton} ${configMode === 'advanced' ? styles.modeButtonActive : ''}`}
+          onClick={() => setConfigMode('advanced')}
+          disabled={isSubmitting}
+        >
+          <Code size={16} />
+          高级模式
+        </button>
+      </div>
+
+      {configMode === 'wizard' ? (
+        <div className={styles.wizardContent}>
+          {/* Task type selection */}
+          <div className={styles.wizardSection}>
+            <h3 className={styles.wizardSectionTitle}>选择任务类型</h3>
+            <TaskTypeSelector
+              selectedType={taskType}
+              onSelect={setTaskType}
+            />
+          </div>
+
+          {/* Labels configuration */}
+          {taskType && (
+            <div className={styles.wizardSection}>
+              <h3 className={styles.wizardSectionTitle}>配置标签</h3>
+              {isClassificationTask && (
+                <div className={styles.choiceTypeSelector}>
+                  <span className={styles.choiceTypeLabel}>选择模式:</span>
+                  <div className={styles.choiceTypeOptions}>
+                    <label className={styles.choiceTypeOption}>
+                      <input
+                        type="radio"
+                        name="choiceType"
+                        value="single"
+                        checked={choiceType === 'single'}
+                        onChange={() => setChoiceType('single')}
+                      />
+                      <span>单选</span>
+                    </label>
+                    <label className={styles.choiceTypeOption}>
+                      <input
+                        type="radio"
+                        name="choiceType"
+                        value="multiple"
+                        checked={choiceType === 'multiple'}
+                        onChange={() => setChoiceType('multiple')}
+                      />
+                      <span>多选</span>
+                    </label>
+                  </div>
+                </div>
+              )}
+              <LabelEditor
+                labels={labels}
+                onChange={setLabels}
+                taskType={taskType}
+              />
+            </div>
+          )}
+
+          {/* XML Preview */}
+          {taskType && labels.length > 0 && (
+            <div className={styles.wizardSection}>
+              <h3 className={styles.wizardSectionTitle}>生成的配置预览</h3>
+              <div className={styles.xmlPreview}>
+                <pre>{formData.config}</pre>
+              </div>
+            </div>
+          )}
+        </div>
+      ) : (
+        <div className={styles.advancedContent}>
+          {/* Config field */}
+          <div className={styles.field}>
+            <label htmlFor="project-config" className={styles.label}>
+              标注配置 <span className={styles.required}>*</span>
+              {selectedTemplate && (
+                <span className={styles.templateBadge}>
+                  来自模板: {selectedTemplate.name}
+                </span>
+              )}
+            </label>
+            <textarea
+              id="project-config"
+              value={formData.config}
+              onChange={(e) => handleFieldChange('config', e.target.value)}
+              onBlur={() => handleFieldBlur('config')}
+              className={`${styles.textarea} ${styles.textareaCode} ${formErrors.config ? styles.error : ''}`}
+              placeholder='输入 Label Studio 配置 XML,例如:<View><Text name="text" value="$text"/></View>'
+              rows={15}
+              disabled={isSubmitting}
+              aria-invalid={!!formErrors.config}
+              aria-describedby={formErrors.config ? 'config-error' : undefined}
+            />
+            {formErrors.config && (
+              <span id="config-error" className={styles.errorMessage}>
+                {formErrors.config}
+              </span>
+            )}
+            <span className={styles.hint}>
+              请输入有效的 Label Studio XML 配置
+            </span>
+          </div>
+        </div>
+      )}
+
+      {/* Submit error */}
+      {formErrors.submit && (
+        <div className={styles.submitError}>{formErrors.submit}</div>
+      )}
+
+      {/* Form actions */}
+      <div className={styles.actions}>
+        <Button
+          type="button"
+          variant="neutral"
+          look="outlined"
+          onClick={handlePrevStep}
+          disabled={isSubmitting}
+        >
+          <ChevronLeft size={16} />
+          上一步
+        </Button>
+        <div className={styles.actionsRight}>
+          <Button
+            type="button"
+            variant="neutral"
+            look="outlined"
+            onClick={onCancel}
+            disabled={isSubmitting}
+          >
+            取消
+          </Button>
+          <Button
+            type="button"
+            variant="primary"
+            onClick={handleSubmit}
+            disabled={!isConfigComplete() || isSubmitting}
+          >
             {isSubmitting ? '提交中...' : submitLabel}
           </Button>
         </div>
       </div>
-    </form>
+    </div>
   );
 
   return (
     <div className={styles.root}>
       {renderStepIndicator()}
       <div className={styles.content}>
-        {currentStep === 'template' && showTemplateStep
-          ? renderTemplateStep()
-          : renderDetailsStep()}
+        {currentStep === 'template' && showTemplateStep && renderTemplateStep()}
+        {currentStep === 'details' && renderDetailsStep()}
+        {currentStep === 'config' && renderConfigStep()}
       </div>
     </div>
   );

+ 5 - 0
web/apps/lq_label/src/components/task-type-selector/index.ts

@@ -0,0 +1,5 @@
+/**
+ * TaskTypeSelector Index
+ */
+export { TaskTypeSelector } from './task-type-selector';
+export type { TaskType, TaskTypeOption, TaskTypeSelectorProps } from './task-type-selector';

+ 143 - 0
web/apps/lq_label/src/components/task-type-selector/task-type-selector.module.scss

@@ -0,0 +1,143 @@
+/**
+ * TaskTypeSelector Styles
+ * 
+ * Card-based layout for task type selection.
+ * Uses --theme-* variables for consistency with the rest of the app.
+ */
+.root {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-400);
+}
+
+.header {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-100);
+}
+
+.title {
+  font-size: var(--font-size-heading-small);
+  font-weight: var(--font-weight-semibold);
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.subtitle {
+  font-size: var(--font-size-body-medium);
+  color: var(--theme-paragraph);
+  margin: 0;
+}
+
+.grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: var(--spacing-300);
+
+  @media (max-width: 768px) {
+    grid-template-columns: 1fr;
+  }
+}
+
+.card {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: var(--spacing-200);
+  padding: var(--spacing-400);
+  background: var(--theme-card-background);
+  border: 2px solid var(--theme-border);
+  border-radius: var(--corner-radius-medium);
+  cursor: pointer;
+  transition: all 0.2s ease;
+  text-align: left;
+  position: relative;
+
+  &:hover:not(:disabled) {
+    border-color: var(--theme-button);
+    background: var(--theme-background-secondary);
+  }
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+
+  &.selected {
+    border-color: var(--theme-button);
+    background: rgba(37, 99, 235, 0.1);
+  }
+}
+
+.iconWrapper {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 56px;
+  height: 56px;
+  background: var(--theme-background-secondary);
+  border-radius: var(--corner-radius-small);
+  color: var(--theme-button);
+
+  .selected & {
+    background: var(--theme-button);
+    color: var(--theme-button-text);
+  }
+}
+
+.content {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacing-100);
+  flex: 1;
+}
+
+.name {
+  font-size: var(--font-size-body-large);
+  font-weight: var(--font-weight-semibold);
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.description {
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph);
+  margin: 0;
+  line-height: 1.5;
+}
+
+.dataFormat {
+  display: flex;
+  align-items: center;
+}
+
+.formatBadge {
+  display: inline-flex;
+  align-items: center;
+  padding: var(--spacing-50) var(--spacing-150);
+  background: var(--theme-background-tertiary);
+  border-radius: var(--corner-radius-small);
+  font-size: var(--font-size-body-small);
+  color: var(--theme-paragraph-subtle);
+}
+
+.radioIndicator {
+  position: absolute;
+  top: var(--spacing-300);
+  right: var(--spacing-300);
+}
+
+.radio {
+  width: 20px;
+  height: 20px;
+  border: 2px solid var(--theme-border);
+  border-radius: 50%;
+  background: var(--theme-background);
+  transition: all 0.2s ease;
+
+  &.radioSelected {
+    border-color: var(--theme-button);
+    background: var(--theme-button);
+    box-shadow: inset 0 0 0 4px var(--theme-background);
+  }
+}

+ 105 - 0
web/apps/lq_label/src/components/task-type-selector/task-type-selector.tsx

@@ -0,0 +1,105 @@
+/**
+ * TaskTypeSelector Component
+ * 
+ * Card-based task type selection component for project configuration.
+ * Supports four task types: text_classification, image_classification,
+ * object_detection, and ner.
+ * 
+ * Requirements: 4.1, 4.2, 4.3
+ */
+import React from 'react';
+import { FileText, Image, Square, Tag } from 'lucide-react';
+import styles from './task-type-selector.module.scss';
+
+export type TaskType = 
+  | 'text_classification'
+  | 'image_classification'
+  | 'object_detection'
+  | 'ner';
+
+export interface TaskTypeOption {
+  id: TaskType;
+  name: string;
+  description: string;
+  icon: React.ReactNode;
+  dataFormat: 'text' | 'image';
+}
+
+export interface TaskTypeSelectorProps {
+  selectedType: TaskType | null;
+  onSelect: (type: TaskType) => void;
+  disabled?: boolean;
+}
+
+const TASK_TYPE_OPTIONS: TaskTypeOption[] = [
+  {
+    id: 'text_classification',
+    name: '文本分类',
+    description: '对文本内容进行分类标注',
+    icon: <FileText size={32} />,
+    dataFormat: 'text',
+  },
+  {
+    id: 'image_classification',
+    name: '图像分类',
+    description: '对图像进行分类标注',
+    icon: <Image size={32} />,
+    dataFormat: 'image',
+  },
+  {
+    id: 'object_detection',
+    name: '目标检测',
+    description: '在图像中标注目标边界框',
+    icon: <Square size={32} />,
+    dataFormat: 'image',
+  },
+  {
+    id: 'ner',
+    name: '命名实体识别',
+    description: '标注文本中的实体',
+    icon: <Tag size={32} />,
+    dataFormat: 'text',
+  },
+];
+
+export const TaskTypeSelector: React.FC<TaskTypeSelectorProps> = ({
+  selectedType,
+  onSelect,
+  disabled = false,
+}) => {
+  return (
+    <div className={styles.root}>
+      <div className={styles.header}>
+        <h3 className={styles.title}>选择标注类型</h3>
+        <p className={styles.subtitle}>选择适合您数据的标注任务类型</p>
+      </div>
+      <div className={styles.grid}>
+        {TASK_TYPE_OPTIONS.map((option) => (
+          <button
+            key={option.id}
+            type="button"
+            className={`${styles.card} ${selectedType === option.id ? styles.selected : ''}`}
+            onClick={() => onSelect(option.id)}
+            disabled={disabled}
+          >
+            <div className={styles.iconWrapper}>
+              {option.icon}
+            </div>
+            <div className={styles.content}>
+              <h4 className={styles.name}>{option.name}</h4>
+              <p className={styles.description}>{option.description}</p>
+            </div>
+            <div className={styles.dataFormat}>
+              <span className={styles.formatBadge}>
+                {option.dataFormat === 'text' ? '文本数据' : '图像数据'}
+              </span>
+            </div>
+            <div className={styles.radioIndicator}>
+              <div className={`${styles.radio} ${selectedType === option.id ? styles.radioSelected : ''}`} />
+            </div>
+          </button>
+        ))}
+      </div>
+    </div>
+  );
+};

+ 4 - 2
web/apps/lq_label/src/services/api.ts

@@ -322,6 +322,7 @@ export interface ProjectCreateData {
   name: string;
   description: string;
   config: string;
+  task_type?: string;
 }
 
 /**
@@ -406,11 +407,12 @@ export async function updateProjectStatus(
  */
 export async function updateProjectConfig(
   projectId: string,
-  config: string
+  config: string,
+  taskType?: string
 ): Promise<Project> {
   const response = await apiClient.put<Project>(
     `/api/projects/${projectId}/config`,
-    { config }
+    { config, task_type: taskType }
   );
   return response.data;
 }

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

@@ -0,0 +1,192 @@
+/**
+ * XML Generator Service
+ * 
+ * Generates Label Studio XML configuration based on task type and labels.
+ * Supports text_classification, image_classification, object_detection, and ner.
+ * 
+ * Requirements: 8.1, 8.2, 8.3, 8.4, 8.5
+ */
+import type { TaskType } from '../components/task-type-selector';
+import type { LabelConfig } from '../components/label-editor';
+
+export interface XMLGeneratorConfig {
+  taskType: TaskType;
+  labels: LabelConfig[];
+  choiceType?: 'single' | 'multiple'; // 用于分类任务
+}
+
+/**
+ * 转义 XML 特殊字符
+ */
+const escapeXml = (str: string): string => {
+  return str
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&apos;');
+};
+
+/**
+ * 生成文本分类 XML
+ */
+const generateTextClassification = (config: XMLGeneratorConfig): string => {
+  const choiceAttr = config.choiceType === 'multiple' ? 'multiple' : 'single';
+  const labelsXML = config.labels
+    .map(l => {
+      const hotkeyAttr = l.hotkey ? ` hotkey="${escapeXml(l.hotkey)}"` : '';
+      return `    <Choice value="${escapeXml(l.name)}"${hotkeyAttr} />`;
+    })
+    .join('\n');
+  
+  return `<View>
+  <Text name="text" value="$text"/>
+  <Choices name="label" toName="text" choice="${choiceAttr}">
+${labelsXML}
+  </Choices>
+</View>`;
+};
+
+/**
+ * 生成图像分类 XML
+ */
+const generateImageClassification = (config: XMLGeneratorConfig): string => {
+  const choiceAttr = config.choiceType === 'multiple' ? 'multiple' : 'single';
+  const labelsXML = config.labels
+    .map(l => {
+      const hotkeyAttr = l.hotkey ? ` hotkey="${escapeXml(l.hotkey)}"` : '';
+      return `    <Choice value="${escapeXml(l.name)}"${hotkeyAttr} />`;
+    })
+    .join('\n');
+  
+  return `<View>
+  <Image name="image" value="$image"/>
+  <Choices name="label" toName="image" choice="${choiceAttr}">
+${labelsXML}
+  </Choices>
+</View>`;
+};
+
+/**
+ * 生成目标检测 XML
+ */
+const generateObjectDetection = (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"/>
+  <RectangleLabels name="label" toName="image">
+${labelsXML}
+  </RectangleLabels>
+</View>`;
+};
+
+/**
+ * 生成命名实体识别 XML
+ */
+const generateNER = (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>
+  <Text name="text" value="$text"/>
+  <Labels name="label" toName="text">
+${labelsXML}
+  </Labels>
+</View>`;
+};
+
+/**
+ * XML 生成器类
+ */
+export class XMLGenerator {
+  /**
+   * 根据配置生成 Label Studio XML
+   */
+  static generate(config: XMLGeneratorConfig): string {
+    if (!config.taskType) {
+      throw new Error('任务类型不能为空');
+    }
+    
+    if (!config.labels || config.labels.length === 0) {
+      throw new Error('至少需要一个标签');
+    }
+
+    switch (config.taskType) {
+      case 'text_classification':
+        return generateTextClassification(config);
+      case 'image_classification':
+        return generateImageClassification(config);
+      case 'object_detection':
+        return generateObjectDetection(config);
+      case 'ner':
+        return generateNER(config);
+      default:
+        throw new Error(`不支持的任务类型: ${config.taskType}`);
+    }
+  }
+
+  /**
+   * 验证 XML 配置是否有效
+   */
+  static validate(xml: string): { valid: boolean; error?: string } {
+    if (!xml || !xml.trim()) {
+      return { valid: false, error: 'XML 配置不能为空' };
+    }
+
+    if (!xml.includes('<View>') || !xml.includes('</View>')) {
+      return { valid: false, error: 'XML 必须包含 <View> 标签' };
+    }
+
+    // 简单的 XML 格式检查
+    try {
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(xml, 'text/xml');
+      const parseError = doc.querySelector('parsererror');
+      if (parseError) {
+        return { valid: false, error: 'XML 格式无效' };
+      }
+      return { valid: true };
+    } catch {
+      return { valid: false, error: 'XML 解析失败' };
+    }
+  }
+
+  /**
+   * 获取任务类型对应的数据格式
+   */
+  static getDataFormat(taskType: TaskType): 'text' | 'image' {
+    switch (taskType) {
+      case 'text_classification':
+      case 'ner':
+        return 'text';
+      case 'image_classification':
+      case 'object_detection':
+        return 'image';
+      default:
+        return 'text';
+    }
+  }
+
+  /**
+   * 获取任务类型的中文名称
+   */
+  static getTaskTypeName(taskType: TaskType): string {
+    const names: Record<TaskType, string> = {
+      text_classification: '文本分类',
+      image_classification: '图像分类',
+      object_detection: '目标检测',
+      ner: '命名实体识别',
+    };
+    return names[taskType] || taskType;
+  }
+}

+ 430 - 0
web/apps/lq_label/src/views/external-projects-view/external-projects-view.module.scss

@@ -0,0 +1,430 @@
+/**
+ * ExternalProjectsView styles
+ * Modern table design with theme support for external projects management
+ */
+
+.root {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  gap: 24px;
+}
+
+.header {
+  border-bottom: 1px solid var(--theme-border);
+  padding-bottom: 24px;
+}
+
+.headerContent {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.headerText {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.titleRow {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.titleIcon {
+  color: var(--theme-button);
+}
+
+.title {
+  font-size: 28px;
+  font-weight: 700;
+  color: var(--theme-headline);
+  margin: 0;
+}
+
+.subtitle {
+  font-size: 14px;
+  color: var(--theme-paragraph);
+  margin: 0;
+}
+
+.searchBar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.searchInput {
+  position: relative;
+  flex: 1;
+  max-width: 400px;
+
+  input {
+    width: 100%;
+    padding: 10px 12px 10px 40px;
+    background: var(--theme-background-secondary);
+    border: 1px solid var(--theme-border);
+    border-radius: 8px;
+    font-size: 14px;
+    color: var(--theme-headline);
+    transition: all 0.2s ease;
+
+    &::placeholder {
+      color: var(--theme-paragraph-subtle);
+    }
+
+    &:focus {
+      outline: none;
+      border-color: var(--theme-button);
+      background: var(--theme-background);
+    }
+  }
+}
+
+.searchIcon {
+  position: absolute;
+  left: 12px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--theme-paragraph-subtle);
+  pointer-events: none;
+}
+
+.filterBar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+
+.filterSelect {
+  padding: 8px 12px;
+  background: var(--theme-background-secondary);
+  border: 1px solid var(--theme-border);
+  border-radius: 6px;
+  font-size: 13px;
+  color: var(--theme-headline);
+  cursor: pointer;
+  min-width: 120px;
+  transition: all 0.2s ease;
+
+  &:focus {
+    outline: none;
+    border-color: var(--theme-button);
+  }
+
+  &:hover {
+    border-color: var(--theme-button);
+  }
+}
+
+.filterLabel {
+  font-size: 13px;
+  color: var(--theme-paragraph);
+  font-weight: 500;
+}
+
+.errorMessage {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px 16px;
+  background: var(--color-neutral-background);
+  color: var(--color-negative-content);
+  border: 1px solid var(--color-negative-border);
+  border-left: 4px solid var(--color-negative-border-bold);
+  border-radius: 8px;
+  font-size: 14px;
+
+  svg {
+    flex-shrink: 0;
+    color: var(--color-negative-content);
+  }
+
+  span {
+    color: var(--color-neutral-content);
+  }
+}
+
+.content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+}
+
+.loadingState {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  padding: 60px 20px;
+  color: var(--theme-paragraph);
+
+  p {
+    font-size: 14px;
+    margin: 0;
+  }
+}
+
+.spinner {
+  width: 40px;
+  height: 40px;
+  border: 3px solid var(--theme-border);
+  border-top-color: var(--theme-button);
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.emptyState {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 16px;
+  padding: 60px 20px;
+  text-align: center;
+
+  h3 {
+    font-size: 18px;
+    font-weight: 600;
+    color: var(--theme-headline);
+    margin: 0;
+  }
+
+  p {
+    font-size: 14px;
+    color: var(--theme-paragraph);
+    margin: 0;
+    max-width: 400px;
+  }
+}
+
+.emptyIcon {
+  color: var(--theme-paragraph-subtle);
+  opacity: 0.5;
+}
+
+.tableWrapper {
+  flex: 1;
+  overflow: auto;
+  border: 1px solid var(--theme-border);
+  border-radius: 12px;
+  background: var(--theme-card-background);
+}
+
+.table {
+  width: 100%;
+  border-collapse: collapse;
+
+  thead {
+    position: sticky;
+    top: 0;
+    background: var(--theme-background-secondary);
+    z-index: 1;
+
+    tr {
+      border-bottom: 1px solid var(--theme-border);
+    }
+
+    th {
+      padding: 16px;
+      text-align: left;
+      font-size: 13px;
+      font-weight: 600;
+      color: var(--theme-paragraph);
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+    }
+  }
+
+  tbody {
+    tr {
+      border-bottom: 1px solid var(--theme-border-subtle);
+      transition: background 0.15s ease;
+
+      &:hover {
+        background: var(--theme-card-hover);
+      }
+
+      &:last-child {
+        border-bottom: none;
+      }
+    }
+
+    td {
+      padding: 16px;
+      font-size: 14px;
+      color: var(--theme-paragraph);
+    }
+  }
+}
+
+.projectInfo {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.projectName {
+  font-weight: 600;
+  color: var(--theme-headline);
+}
+
+.projectDescription {
+  font-size: 13px;
+  color: var(--theme-paragraph-subtle);
+  display: -webkit-box;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  line-clamp: 1;
+  overflow: hidden;
+}
+
+.taskTypeBadge {
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  background: var(--theme-background-tertiary);
+  border: 1px solid var(--theme-border);
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--theme-paragraph);
+}
+
+.statusBadge {
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 4px;
+  font-size: 12px;
+  font-weight: 500;
+  white-space: nowrap;
+}
+
+.progressInfo {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.progressBar {
+  width: 100px;
+  height: 6px;
+  background: var(--theme-background-tertiary);
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.progressFill {
+  height: 100%;
+  background: var(--theme-button);
+  border-radius: 3px;
+  transition: width 0.3s ease;
+}
+
+.progressText {
+  font-size: 12px;
+  color: var(--theme-paragraph-subtle);
+}
+
+.dateInfo {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.date {
+  font-weight: 500;
+  color: var(--theme-headline);
+}
+
+.time {
+  font-size: 12px;
+  color: var(--theme-paragraph-subtle);
+}
+
+.actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.actionButton {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  background: transparent;
+  border: 1px solid var(--theme-border);
+  border-radius: 6px;
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.15s ease;
+
+  &:hover {
+    background: var(--theme-background-secondary);
+    border-color: var(--theme-button);
+    color: var(--theme-button);
+  }
+}
+
+.configButton {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 12px;
+  background: transparent;
+  color: var(--theme-button);
+  border: 1px solid var(--theme-button);
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button);
+    color: var(--theme-button-text);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}
+
+.dispatchButton {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 12px;
+  background: var(--theme-button);
+  color: var(--theme-button-text);
+  border: none;
+  border-radius: 6px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  transition: all 0.2s ease;
+
+  &:hover:not(:disabled) {
+    background: var(--theme-button-hover);
+  }
+
+  &:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+  }
+}

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

@@ -0,0 +1,355 @@
+/**
+ * ExternalProjectsView Component
+ * 
+ * Admin-only page for managing external projects created via API.
+ * Displays projects with source='external' and provides configuration
+ * and dispatch functionality.
+ * 
+ * Requirements: 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8
+ */
+import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+  Search,
+  FolderOpen,
+  AlertCircle,
+  Settings,
+  Send,
+  Eye,
+  ExternalLink,
+} from 'lucide-react';
+import {
+  PROJECT_STATUS_CONFIG,
+  type Project,
+  type ProjectStatus,
+} from '../../atoms/project-atoms';
+import { listProjects } from '../../services/api';
+import { ProjectDetailModal } from '../../components/project-detail-modal';
+import { TaskDispatchDialog } from '../../components/task-dispatch-dialog';
+import styles from './external-projects-view.module.scss';
+
+export const ExternalProjectsView: React.FC = () => {
+  const navigate = useNavigate();
+  
+  // State
+  const [projects, setProjects] = useState<Project[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+  const [statusFilter, setStatusFilter] = useState<ProjectStatus | ''>('');
+  const [searchQuery, setSearchQuery] = useState('');
+  
+  // Dialog state
+  const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
+  const [isDispatchDialogOpen, setIsDispatchDialogOpen] = useState(false);
+  const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
+  const [projectToDispatch, setProjectToDispatch] = useState<Project | null>(null);
+
+  // Load external projects on mount and when filter changes
+  useEffect(() => {
+    loadProjects();
+  }, [statusFilter]);
+
+  const loadProjects = async () => {
+    try {
+      setLoading(true);
+      setError(null);
+      const data = await listProjects({
+        source: 'external',
+        status: statusFilter || undefined,
+      });
+      setProjects(data);
+    } catch (err: any) {
+      setError(err.message || '加载外部项目列表失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleConfigProject = (project: Project) => {
+    navigate(`/projects/${project.id}/config`);
+  };
+
+  const handleDispatchProject = (project: Project) => {
+    setProjectToDispatch(project);
+    setIsDispatchDialogOpen(true);
+  };
+
+  const handleViewProject = (project: Project) => {
+    setSelectedProjectId(project.id);
+    setIsDetailModalOpen(true);
+  };
+
+  const handleStatusFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
+    setStatusFilter(e.target.value as ProjectStatus | '');
+  };
+
+  // Filter projects based on search query
+  const filteredProjects = projects.filter((project) => {
+    if (!searchQuery) return true;
+    const query = searchQuery.toLowerCase();
+    return (
+      project.name.toLowerCase().includes(query) ||
+      project.description?.toLowerCase().includes(query)
+    );
+  });
+
+  // Render status badge
+  const renderStatusBadge = (status: ProjectStatus) => {
+    const config = PROJECT_STATUS_CONFIG[status] || PROJECT_STATUS_CONFIG.draft;
+    return (
+      <span
+        className={styles.statusBadge}
+        style={{ backgroundColor: config.bgColor, color: config.color }}
+      >
+        {config.label}
+      </span>
+    );
+  };
+
+  // Render task type label
+  const renderTaskTypeLabel = (taskType: string | undefined) => {
+    const typeLabels: Record<string, string> = {
+      text_classification: '文本分类',
+      image_classification: '图像分类',
+      object_detection: '目标检测',
+      ner: '命名实体识别',
+    };
+    return typeLabels[taskType || ''] || taskType || '-';
+  };
+
+  // Render action buttons based on project status
+  const renderActionButtons = (project: Project) => {
+    const buttons = [];
+
+    // Config button for draft/configuring status
+    if (project.status === 'draft' || project.status === 'configuring') {
+      buttons.push(
+        <button
+          key="config"
+          className={styles.configButton}
+          onClick={() => handleConfigProject(project)}
+          title="配置项目"
+        >
+          <Settings size={14} />
+          配置
+        </button>
+      );
+    }
+
+    // Dispatch button for ready status
+    if (project.status === 'ready') {
+      buttons.push(
+        <button
+          key="dispatch"
+          className={styles.dispatchButton}
+          onClick={() => handleDispatchProject(project)}
+          title="一键分发"
+        >
+          <Send size={14} />
+          分发
+        </button>
+      );
+    }
+
+    // View button for all statuses
+    buttons.push(
+      <button
+        key="view"
+        className={styles.actionButton}
+        onClick={() => handleViewProject(project)}
+        title="查看详情"
+      >
+        <Eye size={16} />
+      </button>
+    );
+
+    return buttons;
+  };
+
+  // Calculate progress percentage
+  const getProgressPercentage = (project: Project) => {
+    if (project.task_count === 0) return 0;
+    return Math.round((project.completed_task_count / project.task_count) * 100);
+  };
+
+  return (
+    <div className={styles.root}>
+      {/* Header */}
+      <div className={styles.header}>
+        <div className={styles.headerContent}>
+          <div className={styles.headerText}>
+            <div className={styles.titleRow}>
+              <ExternalLink size={28} className={styles.titleIcon} />
+              <h1 className={styles.title}>管理外部来源数据</h1>
+            </div>
+            <p className={styles.subtitle}>
+              管理通过外部API创建的标注项目,进行配置和任务分发
+            </p>
+          </div>
+        </div>
+      </div>
+
+      {/* Search and Filter Bar */}
+      <div className={styles.searchBar}>
+        <div className={styles.searchInput}>
+          <Search size={18} className={styles.searchIcon} />
+          <input
+            type="text"
+            placeholder="搜索项目名称或描述..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+          />
+        </div>
+        <div className={styles.filterBar}>
+          <span className={styles.filterLabel}>状态:</span>
+          <select
+            className={styles.filterSelect}
+            value={statusFilter}
+            onChange={handleStatusFilterChange}
+          >
+            <option value="">全部</option>
+            <option value="draft">草稿</option>
+            <option value="configuring">配置中</option>
+            <option value="ready">待分发</option>
+            <option value="in_progress">进行中</option>
+            <option value="completed">已完成</option>
+          </select>
+        </div>
+      </div>
+
+      {/* Error message */}
+      {error && (
+        <div className={styles.errorMessage}>
+          <AlertCircle size={18} />
+          <span>{error}</span>
+        </div>
+      )}
+
+      {/* Projects Content */}
+      <div className={styles.content}>
+        {loading ? (
+          <div className={styles.loadingState}>
+            <div className={styles.spinner} />
+            <p>加载中...</p>
+          </div>
+        ) : filteredProjects.length === 0 ? (
+          <div className={styles.emptyState}>
+            <FolderOpen size={48} className={styles.emptyIcon} />
+            <h3>{searchQuery ? '未找到匹配的项目' : '暂无外部项目'}</h3>
+            <p>
+              {searchQuery
+                ? '尝试使用不同的搜索关键词'
+                : '外部系统尚未创建任何项目,请通过外部API创建项目'}
+            </p>
+          </div>
+        ) : (
+          <div className={styles.tableWrapper}>
+            <table className={styles.table}>
+              <thead>
+                <tr>
+                  <th>项目名称</th>
+                  <th>任务类型</th>
+                  <th>状态</th>
+                  <th>进度</th>
+                  <th>创建时间</th>
+                  <th>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                {filteredProjects.map((project) => {
+                  const date = new Date(project.created_at);
+                  const progress = getProgressPercentage(project);
+                  return (
+                    <tr key={project.id}>
+                      <td>
+                        <div className={styles.projectInfo}>
+                          <span className={styles.projectName}>
+                            {project.name}
+                          </span>
+                          {project.description && (
+                            <span className={styles.projectDescription}>
+                              {project.description}
+                            </span>
+                          )}
+                        </div>
+                      </td>
+                      <td>
+                        <span className={styles.taskTypeBadge}>
+                          {renderTaskTypeLabel(project.task_type)}
+                        </span>
+                      </td>
+                      <td>
+                        {renderStatusBadge(project.status)}
+                      </td>
+                      <td>
+                        <div className={styles.progressInfo}>
+                          <div className={styles.progressBar}>
+                            <div
+                              className={styles.progressFill}
+                              style={{ width: `${progress}%` }}
+                            />
+                          </div>
+                          <span className={styles.progressText}>
+                            {project.completed_task_count}/{project.task_count} ({progress}%)
+                          </span>
+                        </div>
+                      </td>
+                      <td>
+                        <div className={styles.dateInfo}>
+                          <span className={styles.date}>
+                            {date.toLocaleDateString('zh-CN', {
+                              year: 'numeric',
+                              month: '2-digit',
+                              day: '2-digit',
+                            })}
+                          </span>
+                          <span className={styles.time}>
+                            {date.toLocaleTimeString('zh-CN', {
+                              hour: '2-digit',
+                              minute: '2-digit',
+                            })}
+                          </span>
+                        </div>
+                      </td>
+                      <td>
+                        <div className={styles.actions}>
+                          {renderActionButtons(project)}
+                        </div>
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          </div>
+        )}
+      </div>
+
+      {/* Project Detail Modal */}
+      {selectedProjectId && (
+        <ProjectDetailModal
+          projectId={selectedProjectId}
+          isOpen={isDetailModalOpen}
+          onClose={() => {
+            setIsDetailModalOpen(false);
+            setSelectedProjectId(null);
+          }}
+          onProjectUpdated={loadProjects}
+        />
+      )}
+
+      {/* Task Dispatch Dialog */}
+      {projectToDispatch && (
+        <TaskDispatchDialog
+          project={projectToDispatch}
+          isOpen={isDispatchDialogOpen}
+          onClose={() => {
+            setIsDispatchDialogOpen(false);
+            setProjectToDispatch(null);
+          }}
+          onDispatchComplete={loadProjects}
+        />
+      )}
+    </div>
+  );
+};

+ 7 - 0
web/apps/lq_label/src/views/external-projects-view/index.ts

@@ -0,0 +1,7 @@
+/**
+ * External Projects View Index
+ * 
+ * Exports the ExternalProjectsView component for managing
+ * external projects created via API.
+ */
+export { ExternalProjectsView } from './external-projects-view';

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

@@ -18,4 +18,5 @@ export { AnnotationsView } from './annotations-view';
 export { AnnotationView } from './annotation-view';
 export { UserManagementView } from './user-management-view';
 export { MyTasksView } from './my-tasks-view';
+export { ExternalProjectsView } from './external-projects-view';
 

+ 42 - 0
web/apps/lq_label/src/views/project-config-view/project-config-view.module.scss

@@ -122,6 +122,38 @@
   gap: 12px;
 }
 
+.modeToggle {
+  display: flex;
+  background: var(--theme-background-secondary);
+  border-radius: 8px;
+  padding: 4px;
+}
+
+.modeButton {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 8px 16px;
+  background: transparent;
+  border: none;
+  border-radius: 6px;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--theme-paragraph);
+  cursor: pointer;
+  transition: all 0.15s ease;
+
+  &:hover:not(.active) {
+    color: var(--theme-headline);
+  }
+
+  &.active {
+    background: var(--theme-background);
+    color: var(--theme-button);
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+  }
+}
+
 .saveButton {
   display: flex;
   align-items: center;
@@ -201,6 +233,16 @@
   overflow-y: auto;
 }
 
+.wizardContainer {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: var(--theme-background);
+  border: 1px solid var(--theme-border);
+  border-radius: 12px;
+  overflow: hidden;
+}
+
 .editorSection {
   display: flex;
   flex-direction: column;

+ 209 - 45
web/apps/lq_label/src/views/project-config-view/project-config-view.tsx

@@ -1,9 +1,10 @@
 /**
  * ProjectConfigView Component
  * 
- * Page for configuring project XML and labels.
+ * Page for configuring project XML and labels using a wizard interface.
  * Used by admins to configure external projects before dispatch.
- * Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10
+ * Supports both wizard mode and advanced XML editing mode.
+ * Requirements: 4.1, 4.8, 4.9, 6.1, 6.6, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7, 7.8, 7.9, 7.10
  */
 import React, { useEffect, useState } from 'react';
 import { useParams, useNavigate } from 'react-router-dom';
@@ -13,8 +14,16 @@ import {
   AlertCircle,
   CheckCircle,
   Settings,
+  Wand2,
+  Code,
 } from 'lucide-react';
 import { ConfigEditor } from '../../components/config-editor';
+import { 
+  ProjectConfigWizard, 
+  type ProjectConfigResult 
+} from '../../components/project-config-wizard';
+import type { TaskType } from '../../components/task-type-selector';
+import type { LabelConfig } from '../../components/label-editor';
 import {
   getProject,
   updateProjectConfig,
@@ -24,6 +33,8 @@ import type { Project, ProjectStatus } from '../../atoms/project-atoms';
 import { PROJECT_STATUS_CONFIG } from '../../atoms/project-atoms';
 import styles from './project-config-view.module.scss';
 
+type ConfigMode = 'wizard' | 'advanced';
+
 export const ProjectConfigView: React.FC = () => {
   const { projectId } = useParams<{ projectId: string }>();
   const navigate = useNavigate();
@@ -36,6 +47,13 @@ export const ProjectConfigView: React.FC = () => {
   const [success, setSuccess] = useState<string | null>(null);
   const [isConfigValid, setIsConfigValid] = useState(false);
   const [hasChanges, setHasChanges] = useState(false);
+  
+  // Config mode state
+  const [configMode, setConfigMode] = useState<ConfigMode>('wizard');
+  
+  // Wizard state (parsed from existing config if available)
+  const [initialTaskType, setInitialTaskType] = useState<TaskType | undefined>();
+  const [initialLabels, setInitialLabels] = useState<LabelConfig[]>([]);
 
   // Load project on mount
   useEffect(() => {
@@ -51,6 +69,16 @@ export const ProjectConfigView: React.FC = () => {
       const data = await getProject(projectId!);
       setProject(data as Project);
       setConfig(data.config || '');
+      
+      // Try to parse existing config to initialize wizard
+      if (data.config) {
+        parseExistingConfig(data.config);
+      }
+      
+      // If project has task_type, use it
+      if (data.task_type) {
+        setInitialTaskType(data.task_type as TaskType);
+      }
     } catch (err: any) {
       setError(err.message || '加载项目失败');
     } finally {
@@ -58,6 +86,72 @@ export const ProjectConfigView: React.FC = () => {
     }
   };
 
+  // Parse existing XML config to extract task type and labels
+  const parseExistingConfig = (xmlConfig: string) => {
+    try {
+      const parser = new DOMParser();
+      const doc = parser.parseFromString(xmlConfig, 'text/xml');
+      
+      // Try to detect task type and labels from XML
+      const choices = doc.querySelector('Choices');
+      const labels = doc.querySelector('Labels');
+      const rectangleLabels = doc.querySelector('RectangleLabels');
+      const text = doc.querySelector('Text');
+      const image = doc.querySelector('Image');
+      
+      const parsedLabels: LabelConfig[] = [];
+      
+      if (choices) {
+        // Classification task
+        const choiceElements = choices.querySelectorAll('Choice');
+        choiceElements.forEach((choice, index) => {
+          parsedLabels.push({
+            id: `label_${index}`,
+            name: choice.getAttribute('value') || '',
+            color: '#4ECDC4',
+            hotkey: choice.getAttribute('hotkey') || undefined,
+          });
+        });
+        
+        if (text) {
+          setInitialTaskType('text_classification');
+        } else if (image) {
+          setInitialTaskType('image_classification');
+        }
+      } else if (rectangleLabels) {
+        // Object detection
+        setInitialTaskType('object_detection');
+        const labelElements = rectangleLabels.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 (labels) {
+        // NER
+        setInitialTaskType('ner');
+        const labelElements = labels.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,
+          });
+        });
+      }
+      
+      if (parsedLabels.length > 0) {
+        setInitialLabels(parsedLabels);
+      }
+    } catch {
+      // If parsing fails, just use empty initial values
+    }
+  };
+
   const handleConfigChange = (newConfig: string) => {
     setConfig(newConfig);
     setHasChanges(newConfig !== project?.config);
@@ -68,6 +162,31 @@ export const ProjectConfigView: React.FC = () => {
     setIsConfigValid(valid);
   };
 
+  const handleWizardComplete = async (result: ProjectConfigResult) => {
+    if (!projectId) return;
+
+    try {
+      setSaving(true);
+      setError(null);
+      setSuccess(null);
+      
+      // Save the generated XML config
+      const updated = await updateProjectConfig(projectId, result.xmlConfig, result.taskType);
+      setProject(updated as Project);
+      setConfig(result.xmlConfig);
+      setHasChanges(false);
+      setSuccess('配置已保存');
+      
+      // Update initial values for wizard
+      setInitialTaskType(result.taskType);
+      setInitialLabels(result.labels);
+    } catch (err: any) {
+      setError(err.message || '保存配置失败');
+    } finally {
+      setSaving(false);
+    }
+  };
+
   const handleSave = async () => {
     if (!projectId || !config.trim()) return;
 
@@ -95,7 +214,7 @@ export const ProjectConfigView: React.FC = () => {
       setError(null);
       
       // First save config if there are changes
-      if (hasChanges) {
+      if (hasChanges && configMode === 'advanced') {
         await updateProjectConfig(projectId, config);
       }
       
@@ -112,7 +231,16 @@ export const ProjectConfigView: React.FC = () => {
   };
 
   const handleBack = () => {
-    navigate('/projects');
+    // Navigate back based on project source
+    if (project?.source === 'external') {
+      navigate('/external-projects');
+    } else {
+      navigate('/projects');
+    }
+  };
+
+  const handleCancel = () => {
+    handleBack();
   };
 
   // Render status badge
@@ -177,23 +305,45 @@ export const ProjectConfigView: React.FC = () => {
           </div>
         </div>
         <div className={styles.headerActions}>
-          <button
-            className={styles.saveButton}
-            onClick={handleSave}
-            disabled={saving || !hasChanges || !isConfigValid}
-          >
-            <Save size={18} />
-            {saving ? '保存中...' : '保存配置'}
-          </button>
-          {canMarkReady && (
+          {/* Mode toggle */}
+          <div className={styles.modeToggle}>
+            <button
+              className={`${styles.modeButton} ${configMode === 'wizard' ? styles.active : ''}`}
+              onClick={() => setConfigMode('wizard')}
+            >
+              <Wand2 size={16} />
+              向导模式
+            </button>
             <button
-              className={styles.readyButton}
-              onClick={handleMarkReady}
-              disabled={saving}
+              className={`${styles.modeButton} ${configMode === 'advanced' ? styles.active : ''}`}
+              onClick={() => setConfigMode('advanced')}
             >
-              <CheckCircle size={18} />
-              标记为待分发
+              <Code size={16} />
+              高级模式
             </button>
+          </div>
+          
+          {configMode === 'advanced' && (
+            <>
+              <button
+                className={styles.saveButton}
+                onClick={handleSave}
+                disabled={saving || !hasChanges || !isConfigValid}
+              >
+                <Save size={18} />
+                {saving ? '保存中...' : '保存配置'}
+              </button>
+              {canMarkReady && (
+                <button
+                  className={styles.readyButton}
+                  onClick={handleMarkReady}
+                  disabled={saving}
+                >
+                  <CheckCircle size={18} />
+                  标记为待分发
+                </button>
+              )}
+            </>
           )}
         </div>
       </div>
@@ -214,35 +364,49 @@ export const ProjectConfigView: React.FC = () => {
 
       {/* Content */}
       <div className={styles.content}>
-        <div className={styles.editorSection}>
-          <div className={styles.sectionHeader}>
-            <h2>XML 配置</h2>
-            <p>编辑 Label Studio XML 配置来定义标注界面</p>
+        {configMode === 'wizard' ? (
+          <div className={styles.wizardContainer}>
+            <ProjectConfigWizard
+              initialTaskType={initialTaskType}
+              initialLabels={initialLabels}
+              onComplete={handleWizardComplete}
+              onCancel={handleCancel}
+              isSubmitting={saving}
+            />
           </div>
-          <ConfigEditor
-            initialValue={config}
-            onChange={handleConfigChange}
-            onValidate={handleValidate}
-            height={500}
-            showToolbar={true}
-            autoValidate={true}
-          />
-        </div>
+        ) : (
+          <>
+            <div className={styles.editorSection}>
+              <div className={styles.sectionHeader}>
+                <h2>XML 配置</h2>
+                <p>编辑 Label Studio XML 配置来定义标注界面</p>
+              </div>
+              <ConfigEditor
+                initialValue={config}
+                onChange={handleConfigChange}
+                onValidate={handleValidate}
+                height={500}
+                showToolbar={true}
+                autoValidate={true}
+              />
+            </div>
 
-        {/* Help section */}
-        <div className={styles.helpSection}>
-          <h3>配置说明</h3>
-          <ul>
-            <li>使用 <code>&lt;View&gt;</code> 作为根元素</li>
-            <li>使用 <code>&lt;Image&gt;</code> 或 <code>&lt;Text&gt;</code> 显示数据</li>
-            <li>使用 <code>&lt;Choices&gt;</code> 创建分类选项</li>
-            <li>使用 <code>&lt;Labels&gt;</code> 创建文本标签</li>
-            <li>使用 <code>&lt;RectangleLabels&gt;</code> 创建边界框标签</li>
-          </ul>
-          <p>
-            配置验证通过后,点击"标记为待分发"将项目状态更新为可分发状态。
-          </p>
-        </div>
+            {/* Help section */}
+            <div className={styles.helpSection}>
+              <h3>配置说明</h3>
+              <ul>
+                <li>使用 <code>&lt;View&gt;</code> 作为根元素</li>
+                <li>使用 <code>&lt;Image&gt;</code> 或 <code>&lt;Text&gt;</code> 显示数据</li>
+                <li>使用 <code>&lt;Choices&gt;</code> 创建分类选项</li>
+                <li>使用 <code>&lt;Labels&gt;</code> 创建文本标签</li>
+                <li>使用 <code>&lt;RectangleLabels&gt;</code> 创建边界框标签</li>
+              </ul>
+              <p>
+                配置验证通过后,点击"标记为待分发"将项目状态更新为可分发状态。
+              </p>
+            </div>
+          </>
+        )}
       </div>
     </div>
   );

+ 48 - 101
web/apps/lq_label/src/views/project-list-view/project-list-view.tsx

@@ -3,12 +3,14 @@
  * 
  * Displays a list of projects with CRUD operations.
  * Modern design with theme support and clean table layout.
- * Supports status/source filtering and dispatch functionality.
- * Requirements: 1.1, 1.2, 1.5, 6.2, 6.3, 6.4, 6.5, 8.1
+ * Only shows in_progress and completed projects.
+ * Admin can create projects, annotators can only view.
+ * Requirements: 1.1, 1.2, 1.5, 3.1, 3.5, 3.6, 3.7, 3.8, 6.2, 6.3, 6.4, 6.5, 8.1
  */
 import React, { useEffect, useState } from 'react';
-import { useAtom } from 'jotai';
+import { useAtom, useAtomValue } from 'jotai';
 import { useNavigate } from 'react-router-dom';
+import { isAdminAtom } from '../../atoms/auth-atoms';
 import {
   Plus,
   Trash2,
@@ -18,8 +20,6 @@ import {
   FolderOpen,
   AlertCircle,
   Play,
-  Settings,
-  Send,
 } from 'lucide-react';
 import {
   projectsAtom,
@@ -40,11 +40,11 @@ import {
 import { ProjectForm, type ProjectFormData } from '../../components/project-form';
 import { ProjectDetailModal } from '../../components/project-detail-modal';
 import { ProjectEditModal } from '../../components/project-edit-modal';
-import { TaskDispatchDialog } from '../../components/task-dispatch-dialog';
 import styles from './project-list-view.module.scss';
 
 export const ProjectListView: React.FC = () => {
   const navigate = useNavigate();
+  const isAdmin = useAtomValue(isAdminAtom);
   const [projects, setProjects] = useAtom(projectsAtom);
   const [loading, setLoading] = useAtom(projectLoadingAtom);
   const [error, setError] = useAtom(projectErrorAtom);
@@ -55,16 +55,15 @@ export const ProjectListView: React.FC = () => {
   const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
   const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
   const [isEditModalOpen, setIsEditModalOpen] = useState(false);
-  const [isDispatchDialogOpen, setIsDispatchDialogOpen] = useState(false);
   const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
   const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
-  const [projectToDispatch, setProjectToDispatch] = useState<Project | null>(null);
   const [isSubmitting, setIsSubmitting] = useState(false);
   
   // Search state
   const [searchQuery, setSearchQuery] = useState('');
 
   // Load projects on mount and when filters change
+  // Only load in_progress and completed projects (Requirement 3.1)
   useEffect(() => {
     loadProjects();
   }, [filters]);
@@ -73,11 +72,18 @@ export const ProjectListView: React.FC = () => {
     try {
       setLoading(true);
       setError(null);
-      const data = await listProjects({
-        status: filters.status,
+      // Fetch in_progress projects
+      const inProgressData = await listProjects({
+        status: 'in_progress',
         source: filters.source,
       });
-      setProjects(data);
+      // Fetch completed projects
+      const completedData = await listProjects({
+        status: 'completed',
+        source: filters.source,
+      });
+      // Combine both lists
+      setProjects([...inProgressData, ...completedData]);
     } catch (err: any) {
       setError(err.message || '加载项目列表失败');
     } finally {
@@ -88,7 +94,12 @@ export const ProjectListView: React.FC = () => {
   const handleCreateProject = async (formData: ProjectFormData) => {
     try {
       setIsSubmitting(true);
-      await createProject(formData);
+      await createProject({
+        name: formData.name,
+        description: formData.description,
+        config: formData.config,
+        task_type: formData.taskType,
+      });
       setIsCreateDialogOpen(false);
       await loadProjects();
     } catch (err: any) {
@@ -129,28 +140,11 @@ export const ProjectListView: React.FC = () => {
     setIsEditModalOpen(true);
   };
 
-  const handleConfigProject = (project: Project) => {
-    // Navigate to project config page
-    navigate(`/projects/${project.id}/config`);
-  };
-
-  const handleDispatchProject = (project: Project) => {
-    setProjectToDispatch(project);
-    setIsDispatchDialogOpen(true);
-  };
-
   const handleStartAnnotation = (project: Project) => {
     navigate(`/projects/${project.id}/annotate`);
   };
 
-  const handleStatusFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
-    const value = e.target.value;
-    setFilters(prev => ({
-      ...prev,
-      status: value ? value as ProjectStatus : undefined,
-    }));
-  };
-
+  // Source filter change handler (status filter removed per Requirement 3.5)
   const handleSourceFilterChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
     const value = e.target.value;
     setFilters(prev => ({
@@ -196,40 +190,10 @@ export const ProjectListView: React.FC = () => {
   };
 
   // Render action buttons based on project status
-  const renderActionButtons = (project: Project) => {
-    const buttons = [];
-
-    // Config button for draft/configuring status
-    if (project.status === 'draft' || project.status === 'configuring') {
-      buttons.push(
-        <button
-          key="config"
-          className={styles.configButton}
-          onClick={() => handleConfigProject(project)}
-          title="配置项目"
-        >
-          <Settings size={14} />
-          配置
-        </button>
-      );
-    }
-
-    // Dispatch button for ready status
-    if (project.status === 'ready') {
-      buttons.push(
-        <button
-          key="dispatch"
-          className={styles.dispatchButton}
-          onClick={() => handleDispatchProject(project)}
-          title="一键分发"
-        >
-          <Send size={14} />
-          分发
-        </button>
-      );
-    }
-
-    return buttons;
+  // Config and dispatch buttons removed - only available in external projects page
+  const renderActionButtons = (_project: Project) => {
+    // No additional action buttons for in_progress/completed projects
+    return [];
   };
 
   // Calculate progress percentage
@@ -246,16 +210,21 @@ export const ProjectListView: React.FC = () => {
           <div className={styles.headerText}>
             <h1 className={styles.title}>项目管理</h1>
             <p className={styles.subtitle}>
-              创建和管理标注项目,配置标注任务
+              {isAdmin 
+                ? '查看进行中和已完成的标注项目'
+                : '查看分配给您的标注任务'}
             </p>
           </div>
-          <button
-            className={styles.createButton}
-            onClick={() => setIsCreateDialogOpen(true)}
-          >
-            <Plus size={18} />
-            <span>创建项目</span>
-          </button>
+          {/* Only admin can create projects (Requirement 3.7, 3.8) */}
+          {isAdmin && (
+            <button
+              className={styles.createButton}
+              onClick={() => setIsCreateDialogOpen(true)}
+            >
+              <Plus size={18} />
+              <span>创建项目</span>
+            </button>
+          )}
         </div>
       </div>
 
@@ -271,19 +240,7 @@ export const ProjectListView: React.FC = () => {
           />
         </div>
         <div className={styles.filterBar}>
-          <span className={styles.filterLabel}>状态:</span>
-          <select
-            className={styles.filterSelect}
-            value={filters.status || ''}
-            onChange={handleStatusFilterChange}
-          >
-            <option value="">全部</option>
-            <option value="draft">草稿</option>
-            <option value="configuring">配置中</option>
-            <option value="ready">待分发</option>
-            <option value="in_progress">进行中</option>
-            <option value="completed">已完成</option>
-          </select>
+          {/* Only source filter, status filter removed (Requirement 3.5, 3.6) */}
           <span className={styles.filterLabel}>来源:</span>
           <select
             className={styles.filterSelect}
@@ -315,13 +272,16 @@ export const ProjectListView: React.FC = () => {
         ) : filteredProjects.length === 0 ? (
           <div className={styles.emptyState}>
             <FolderOpen size={48} className={styles.emptyIcon} />
-            <h3>{searchQuery ? '未找到匹配的项目' : '暂无项目'}</h3>
+            <h3>{searchQuery ? '未找到匹配的项目' : '暂无进行中或已完成的项目'}</h3>
             <p>
               {searchQuery
                 ? '尝试使用不同的搜索关键词'
-                : '点击"创建项目"按钮开始创建您的第一个标注项目'}
+                : isAdmin
+                  ? '点击"创建项目"按钮开始创建您的第一个标注项目'
+                  : '暂无分配给您的标注任务,请等待管理员分发任务'}
             </p>
-            {!searchQuery && (
+            {/* Only admin can create projects (Requirement 3.7, 3.8) */}
+            {!searchQuery && isAdmin && (
               <button
                 className={styles.emptyButton}
                 onClick={() => setIsCreateDialogOpen(true)}
@@ -520,19 +480,6 @@ export const ProjectListView: React.FC = () => {
           onProjectUpdated={loadProjects}
         />
       )}
-
-      {/* Task Dispatch Dialog */}
-      {projectToDispatch && (
-        <TaskDispatchDialog
-          project={projectToDispatch}
-          isOpen={isDispatchDialogOpen}
-          onClose={() => {
-            setIsDispatchDialogOpen(false);
-            setProjectToDispatch(null);
-          }}
-          onDispatchComplete={loadProjects}
-        />
-      )}
     </div>
   );
 };