组长端待办任务区域重构方案.md 34 KB

组长端待办任务区域重构方案

📋 需求概述

目标:将组长端待办任务区域从现有的模拟数据样式,重构为基于真实项目问题板块的紧凑列表式布局。

核心逻辑

  • 当任何项目的问题板块(ProjectIssue)中出现新问题或未处理的问题时,自动在组长端待办任务区域显示
  • 使用紧凑的、按时间排序的列表式布局
  • 支持快速查看、跳转和处理问题

🎯 功能设计

1. 数据来源

数据表ProjectIssue(问题表)

筛选条件

  • status = '待处理'(open)或 '处理中'(in_progress)
  • isDeletedtrue
  • createdAtupdatedAt 倒序排列

关联数据

  • project:关联的项目信息(项目名称、ID)
  • creator:问题创建人
  • assignee:负责人(默认为项目负责人或组长)
  • priority:优先级(low/medium/high/critical/urgent)
  • type:问题类型(bug/task/feedback/risk/feature)

2. UI/UX 设计

2.1 布局样式

列表式紧凑布局

┌─────────────────────────────────────────────────┐
│ 待办任务 (12)                    [刷新] [全部查看] │
├─────────────────────────────────────────────────┤
│ 🔴 [紧急] 厨房柜体尺寸与现场不符                    │
│    项目: 金地格林小镇 | 主卧 | 建模阶段              │
│    创建于 2小时前 · 指派给: 王刚                    │
│    [查看详情] [标记已读]                           │
├─────────────────────────────────────────────────┤
│ 🟠 [高] 主卧效果图灯光偏暗                          │
│    项目: 碧桂园天玺 | 主卧 | 渲染阶段                │
│    创建于 5小时前 · 指派给: 李娜                    │
│    [查看详情] [标记已读]                           │
├─────────────────────────────────────────────────┤
│ 🟡 [中] 确认客厅配色与材质样板                      │
│    项目: 万科城市之光 | 客厅 | 方案阶段              │
│    创建于 1天前 · 指派给: 张三                      │
│    [查看详情] [标记已读]                           │
└─────────────────────────────────────────────────┘

2.2 优先级视觉标识

  • 🔴 紧急(critical/urgent):红色圆点 + 红色文字
  • 🟠 高(high):橙色圆点 + 橙色文字
  • 🟡 中(medium):黄色圆点 + 灰色文字
  • ⚪ 低(low):灰色圆点 + 浅灰色文字

2.3 时间显示逻辑

  • 1小时内:显示"X分钟前"
  • 1-24小时:显示"X小时前"
  • 1-7天:显示"X天前"
  • 7天以上:显示完整日期"MM-dd"

3. 交互设计

3.1 点击行为

  1. [查看详情]:跳转到项目详情页的问题板块,高亮显示对应问题
  2. [标记已读]:(可选)标记问题为已读状态,从待办列表中移除(但不改变问题状态)
  3. 点击整行:同"查看详情"

3.2 刷新逻辑

  • 自动刷新:每隔 5 分钟自动从后端拉取最新问题列表
  • 手动刷新:点击刷新按钮立即更新
  • 实时推送(可选):使用 WebSocket 或轮询实现实时更新

3.3 分页/加载更多

  • 默认显示:最多显示 10 条待办任务
  • [全部查看]:跳转到专用的待办任务管理页面(可选)
  • 无限滚动:滚动到底部自动加载更多(可选)

🔧 技术实现

1. 数据模型

1.1 待办任务数据结构

interface TodoTaskFromIssue {
  id: string;                    // 问题ID
  title: string;                 // 问题标题
  description?: string;          // 问题描述
  priority: IssuePriority;       // 优先级
  type: IssueType;               // 问题类型
  status: IssueStatus;           // 问题状态
  projectId: string;             // 项目ID
  projectName: string;           // 项目名称
  relatedSpace?: string;         // 关联空间(如:主卧、客厅)
  relatedStage?: string;         // 关联阶段(如:建模、渲染)
  assigneeName?: string;         // 负责人姓名
  creatorName?: string;          // 创建人姓名
  createdAt: Date;               // 创建时间
  updatedAt: Date;               // 更新时间
  dueDate?: Date;                // 截止时间
  tags?: string[];               // 标签
}

1.2 优先级映射

const PRIORITY_CONFIG = {
  urgent: { 
    label: '紧急', 
    icon: '🔴', 
    color: '#dc2626', 
    order: 0 
  },
  critical: { 
    label: '紧急', 
    icon: '🔴', 
    color: '#dc2626', 
    order: 0 
  },
  high: { 
    label: '高', 
    icon: '🟠', 
    color: '#ea580c', 
    order: 1 
  },
  medium: { 
    label: '中', 
    icon: '🟡', 
    color: '#ca8a04', 
    order: 2 
  },
  low: { 
    label: '低', 
    icon: '⚪', 
    color: '#9ca3af', 
    order: 3 
  }
};

2. 后端数据查询

2.1 Parse Server 查询

async loadTodoTasksFromIssues(): Promise<TodoTaskFromIssue[]> {
  try {
    const query = new Parse.Query('ProjectIssue');
    
    // 筛选条件:待处理 + 处理中
    query.containedIn('status', ['待处理', '处理中']);
    query.notEqualTo('isDeleted', true);
    
    // 关联数据
    query.include(['project', 'creator', 'assignee']);
    
    // 排序:优先级 -> 更新时间
    query.descending('updatedAt');
    
    // 限制数量(首屏加载)
    query.limit(20);
    
    const results = await query.find();
    
    // 数据转换
    const tasks: TodoTaskFromIssue[] = results.map(obj => {
      const project = obj.get('project');
      const assignee = obj.get('assignee');
      const creator = obj.get('creator');
      
      return {
        id: obj.id,
        title: obj.get('title') || '未命名问题',
        description: obj.get('description'),
        priority: obj.get('priority') as IssuePriority,
        type: obj.get('issueType') as IssueType,
        status: this.zh2en(obj.get('status')) as IssueStatus,
        projectId: project?.id || '',
        projectName: project?.get('name') || '未知项目',
        relatedSpace: obj.get('relatedSpace'),
        relatedStage: obj.get('relatedStage'),
        assigneeName: assignee?.get('name') || assignee?.get('realname') || '未指派',
        creatorName: creator?.get('name') || creator?.get('realname') || '未知',
        createdAt: obj.createdAt,
        updatedAt: obj.updatedAt,
        dueDate: obj.get('dueDate'),
        tags: (obj.get('data')?.tags || []) as string[]
      };
    });
    
    // 二次排序:优先级 -> 时间
    return tasks.sort((a, b) => {
      const priorityA = PRIORITY_CONFIG[a.priority].order;
      const priorityB = PRIORITY_CONFIG[b.priority].order;
      
      if (priorityA !== priorityB) {
        return priorityA - priorityB;
      }
      
      return +new Date(b.updatedAt) - +new Date(a.updatedAt);
    });
    
  } catch (error) {
    console.error('❌ 加载待办任务失败:', error);
    return [];
  }
}

2.2 状态映射

private zh2en(status: string): IssueStatus {
  const map: Record<string, IssueStatus> = {
    '待处理': 'open',
    '处理中': 'in_progress',
    '已解决': 'resolved',
    '已关闭': 'closed'
  };
  return map[status] || 'open';
}

3. 前端组件实现

3.1 组件文件结构

src/app/pages/team-leader/dashboard/
├── dashboard.ts                 # 主组件逻辑
├── dashboard.html               # 模板文件
├── dashboard.scss               # 样式文件
└── components/
    └── todo-task-item/          # 待办任务列表项组件(可选)
        ├── todo-task-item.ts
        ├── todo-task-item.html
        └── todo-task-item.scss

3.2 dashboard.ts 核心代码

export class Dashboard implements OnInit {
  // 待办任务列表
  todoTasksFromIssues: TodoTaskFromIssue[] = [];
  loadingTodoTasks: boolean = false;
  todoTaskError: string = '';
  
  // 自动刷新定时器
  private todoTaskRefreshTimer: any;
  
  ngOnInit() {
    // 初始加载
    this.loadTodoTasksFromIssues();
    
    // 启动自动刷新(每5分钟)
    this.startAutoRefresh();
  }
  
  ngOnDestroy() {
    // 清理定时器
    if (this.todoTaskRefreshTimer) {
      clearInterval(this.todoTaskRefreshTimer);
    }
  }
  
  /**
   * 加载待办任务(从问题板块)
   */
  async loadTodoTasksFromIssues(): Promise<void> {
    this.loadingTodoTasks = true;
    this.todoTaskError = '';
    
    try {
      const query = new Parse.Query('ProjectIssue');
      query.containedIn('status', ['待处理', '处理中']);
      query.notEqualTo('isDeleted', true);
      query.include(['project', 'creator', 'assignee']);
      query.descending('updatedAt');
      query.limit(20);
      
      const results = await query.find();
      
      this.todoTasksFromIssues = results.map(obj => {
        const project = obj.get('project');
        const assignee = obj.get('assignee');
        const creator = obj.get('creator');
        
        return {
          id: obj.id,
          title: obj.get('title') || '未命名问题',
          description: obj.get('description'),
          priority: obj.get('priority') as IssuePriority,
          type: obj.get('issueType') as IssueType,
          status: this.zh2en(obj.get('status')) as IssueStatus,
          projectId: project?.id || '',
          projectName: project?.get('name') || '未知项目',
          relatedSpace: obj.get('relatedSpace'),
          relatedStage: obj.get('relatedStage'),
          assigneeName: assignee?.get('name') || assignee?.get('realname') || '未指派',
          creatorName: creator?.get('name') || creator?.get('realname') || '未知',
          createdAt: obj.createdAt,
          updatedAt: obj.updatedAt,
          dueDate: obj.get('dueDate'),
          tags: (obj.get('data')?.tags || []) as string[]
        };
      });
      
      // 排序:优先级 -> 时间
      this.todoTasksFromIssues.sort((a, b) => {
        const priorityA = this.getPriorityOrder(a.priority);
        const priorityB = this.getPriorityOrder(b.priority);
        
        if (priorityA !== priorityB) {
          return priorityA - priorityB;
        }
        
        return +new Date(b.updatedAt) - +new Date(a.updatedAt);
      });
      
      console.log(`✅ 加载待办任务成功,共 ${this.todoTasksFromIssues.length} 条`);
      
    } catch (error) {
      console.error('❌ 加载待办任务失败:', error);
      this.todoTaskError = '加载失败,请稍后重试';
    } finally {
      this.loadingTodoTasks = false;
    }
  }
  
  /**
   * 启动自动刷新
   */
  startAutoRefresh(): void {
    // 每5分钟刷新一次
    this.todoTaskRefreshTimer = setInterval(() => {
      this.loadTodoTasksFromIssues();
    }, 5 * 60 * 1000);
  }
  
  /**
   * 手动刷新
   */
  refreshTodoTasks(): void {
    this.loadTodoTasksFromIssues();
  }
  
  /**
   * 跳转到项目问题详情
   */
  navigateToIssue(task: TodoTaskFromIssue): void {
    const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
    // 跳转到项目详情页,并打开问题板块
    this.router.navigate(
      ['/wxwork', cid, 'project', task.projectId, 'order'],
      { 
        queryParams: { 
          openIssues: 'true',
          highlightIssue: task.id 
        } 
      }
    );
  }
  
  /**
   * 标记问题为已读(可选功能)
   */
  async markAsRead(task: TodoTaskFromIssue): Promise<void> {
    try {
      // 实现方式1: 本地隐藏(不修改数据库)
      this.todoTasksFromIssues = this.todoTasksFromIssues.filter(t => t.id !== task.id);
      
      // 实现方式2: 添加"已读"标记到数据库(需扩展数据模型)
      // const query = new Parse.Query('ProjectIssue');
      // const issue = await query.get(task.id);
      // const readBy = issue.get('readBy') || [];
      // if (!readBy.includes(currentUserId)) {
      //   readBy.push(currentUserId);
      //   issue.set('readBy', readBy);
      //   await issue.save();
      // }
      
      console.log(`✅ 标记问题为已读: ${task.title}`);
    } catch (error) {
      console.error('❌ 标记已读失败:', error);
    }
  }
  
  /**
   * 获取优先级配置
   */
  getPriorityConfig(priority: IssuePriority) {
    const config = {
      urgent: { label: '紧急', icon: '🔴', color: '#dc2626', order: 0 },
      critical: { label: '紧急', icon: '🔴', color: '#dc2626', order: 0 },
      high: { label: '高', icon: '🟠', color: '#ea580c', order: 1 },
      medium: { label: '中', icon: '🟡', color: '#ca8a04', order: 2 },
      low: { label: '低', icon: '⚪', color: '#9ca3af', order: 3 }
    };
    return config[priority] || config.medium;
  }
  
  getPriorityOrder(priority: IssuePriority): number {
    return this.getPriorityConfig(priority).order;
  }
  
  /**
   * 获取问题类型中文名
   */
  getIssueTypeLabel(type: IssueType): string {
    const map: Record<IssueType, string> = {
      bug: '问题',
      task: '任务',
      feedback: '反馈',
      risk: '风险',
      feature: '需求'
    };
    return map[type] || '任务';
  }
  
  /**
   * 格式化相对时间
   */
  formatRelativeTime(date: Date): string {
    const now = new Date();
    const diff = now.getTime() - new Date(date).getTime();
    const minutes = Math.floor(diff / 60000);
    const hours = Math.floor(minutes / 60);
    const days = Math.floor(hours / 24);
    
    if (minutes < 60) {
      return `${minutes}分钟前`;
    } else if (hours < 24) {
      return `${hours}小时前`;
    } else if (days < 7) {
      return `${days}天前`;
    } else {
      return new Date(date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
    }
  }
  
  /**
   * 状态映射
   */
  private zh2en(status: string): IssueStatus {
    const map: Record<string, IssueStatus> = {
      '待处理': 'open',
      '处理中': 'in_progress',
      '已解决': 'resolved',
      '已关闭': 'closed'
    };
    return map[status] || 'open';
  }
}

3.3 dashboard.html 模板代码

<!-- 待办任务优先级排序 -->
<section class="todo-section">
  <div class="section-header">
    <h2>
      待办任务
      @if (todoTasksFromIssues.length > 0) {
        <span class="task-count">({{ todoTasksFromIssues.length }})</span>
      }
    </h2>
    <div class="section-actions">
      <button 
        class="btn-refresh" 
        (click)="refreshTodoTasks()"
        [disabled]="loadingTodoTasks"
        title="刷新待办任务">
        <svg viewBox="0 0 24 24" width="16" height="16" [class.rotating]="loadingTodoTasks">
          <path fill="currentColor" d="M17.65 6.35A7.958 7.958 0 0 0 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
        </svg>
        刷新
      </button>
    </div>
  </div>
  
  <!-- 加载状态 -->
  @if (loadingTodoTasks) {
    <div class="loading-state">
      <div class="spinner"></div>
      <p>加载中...</p>
    </div>
  }
  
  <!-- 错误状态 -->
  @if (todoTaskError && !loadingTodoTasks) {
    <div class="error-state">
      <svg viewBox="0 0 24 24" width="48" height="48" fill="#dc2626">
        <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
      </svg>
      <p>{{ todoTaskError }}</p>
      <button class="btn-retry" (click)="refreshTodoTasks()">重试</button>
    </div>
  }
  
  <!-- 空状态 -->
  @if (!loadingTodoTasks && !todoTaskError && todoTasksFromIssues.length === 0) {
    <div class="empty-state">
      <svg viewBox="0 0 24 24" width="64" height="64" fill="#d1d5db">
        <path d="M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
      </svg>
      <p>暂无待办任务</p>
      <p class="hint">所有项目问题都已处理完毕 🎉</p>
    </div>
  }
  
  <!-- 待办任务列表 -->
  @if (!loadingTodoTasks && !todoTaskError && todoTasksFromIssues.length > 0) {
    <div class="todo-list-compact">
      @for (task of todoTasksFromIssues; track task.id) {
        <div class="todo-item-compact" 
             (click)="navigateToIssue(task)"
             [attr.data-priority]="task.priority">
          <!-- 优先级指示器 -->
          <div class="priority-indicator" 
               [style.background-color]="getPriorityConfig(task.priority).color">
          </div>
          
          <!-- 主要内容 -->
          <div class="task-content">
            <!-- 标题行 -->
            <div class="task-header">
              <span class="priority-icon">{{ getPriorityConfig(task.priority).icon }}</span>
              <span class="priority-label" 
                    [style.color]="getPriorityConfig(task.priority).color">
                [{{ getPriorityConfig(task.priority).label }}]
              </span>
              <h4 class="task-title">{{ task.title }}</h4>
              <span class="issue-type-badge">{{ getIssueTypeLabel(task.type) }}</span>
            </div>
            
            <!-- 元信息行 -->
            <div class="task-meta">
              <span class="meta-item">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
                  <path d="M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.54-2.5 1.35l-.5.67-.5-.68C10.96 2.54 10.05 2 9 2 7.34 2 6 3.34 6 5c0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zM9 4c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm11 15H4v-2h16v2zm0-5H4V8h5.08L7 10.83 8.62 12 11 8.76l1-1.36 1 1.36L15.38 12 17 10.83 14.92 8H20v6z"/>
                </svg>
                {{ task.projectName }}
              </span>
              
              @if (task.relatedSpace) {
                <span class="meta-item">
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
                    <path d="M12 2L2 7v10c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V7l-10-5zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V9.03l7-3.11v7.07z"/>
                  </svg>
                  {{ task.relatedSpace }}
                </span>
              }
              
              @if (task.relatedStage) {
                <span class="meta-item">
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
                    <path d="M9 11H7v2h2v-2zm4 0h-2v2h2v-2zm4 0h-2v2h2v-2zm2-7h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V9h14v11z"/>
                  </svg>
                  {{ task.relatedStage }}
                </span>
              }
            </div>
            
            <!-- 底部信息行 -->
            <div class="task-footer">
              <span class="time-info">
                <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
                  <path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/>
                </svg>
                创建于 {{ formatRelativeTime(task.createdAt) }}
              </span>
              
              <span class="assignee-info">
                <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">
                  <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
                </svg>
                指派给: {{ task.assigneeName }}
              </span>
              
              @if (task.tags && task.tags.length > 0) {
                <div class="tags">
                  @for (tag of task.tags.slice(0, 2); track tag) {
                    <span class="tag">{{ tag }}</span>
                  }
                  @if (task.tags.length > 2) {
                    <span class="tag-more">+{{ task.tags.length - 2 }}</span>
                  }
                </div>
              }
            </div>
          </div>
          
          <!-- 操作按钮 -->
          <div class="task-actions">
            <button 
              class="btn-view" 
              (click)="navigateToIssue(task); $event.stopPropagation()"
              title="查看详情">
              查看详情
            </button>
            <button 
              class="btn-mark-read" 
              (click)="markAsRead(task); $event.stopPropagation()"
              title="标记已读">
              标记已读
            </button>
          </div>
        </div>
      }
    </div>
  }
</section>

3.4 dashboard.scss 样式代码

.todo-section {
  background: white;
  border-radius: 12px;
  padding: 24px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  
  .section-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20px;
    
    h2 {
      font-size: 20px;
      font-weight: 600;
      color: #111827;
      display: flex;
      align-items: center;
      gap: 8px;
      
      .task-count {
        font-size: 16px;
        color: #6b7280;
        font-weight: 400;
      }
    }
    
    .section-actions {
      display: flex;
      gap: 12px;
      
      .btn-refresh {
        display: flex;
        align-items: center;
        gap: 6px;
        padding: 8px 16px;
        background: #f3f4f6;
        border: 1px solid #e5e7eb;
        border-radius: 6px;
        font-size: 14px;
        color: #374151;
        cursor: pointer;
        transition: all 0.2s;
        
        &:hover:not(:disabled) {
          background: #e5e7eb;
          border-color: #d1d5db;
        }
        
        &:disabled {
          opacity: 0.6;
          cursor: not-allowed;
        }
        
        svg.rotating {
          animation: rotate 1s linear infinite;
        }
      }
    }
  }
  
  // 加载/错误/空状态
  .loading-state,
  .error-state,
  .empty-state {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 48px 24px;
    text-align: center;
    
    .spinner {
      width: 40px;
      height: 40px;
      border: 4px solid #f3f4f6;
      border-top-color: #667eea;
      border-radius: 50%;
      animation: rotate 1s linear infinite;
    }
    
    p {
      margin-top: 16px;
      font-size: 14px;
      color: #6b7280;
      
      &.hint {
        font-size: 13px;
        color: #9ca3af;
        margin-top: 8px;
      }
    }
    
    .btn-retry {
      margin-top: 16px;
      padding: 8px 20px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 6px;
      font-size: 14px;
      cursor: pointer;
      transition: background 0.2s;
      
      &:hover {
        background: #5568d3;
      }
    }
  }
  
  // 紧凑列表
  .todo-list-compact {
    display: flex;
    flex-direction: column;
    gap: 12px;
    
    .todo-item-compact {
      position: relative;
      display: flex;
      align-items: stretch;
      background: #fafafa;
      border: 1px solid #e5e7eb;
      border-radius: 8px;
      overflow: hidden;
      transition: all 0.2s;
      cursor: pointer;
      
      &:hover {
        background: #f9fafb;
        border-color: #d1d5db;
        box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
        transform: translateY(-1px);
      }
      
      // 优先级指示条
      .priority-indicator {
        width: 4px;
        flex-shrink: 0;
      }
      
      // 主要内容区
      .task-content {
        flex: 1;
        padding: 16px;
        min-width: 0;
        
        .task-header {
          display: flex;
          align-items: center;
          gap: 8px;
          margin-bottom: 8px;
          
          .priority-icon {
            font-size: 16px;
            flex-shrink: 0;
          }
          
          .priority-label {
            font-size: 12px;
            font-weight: 600;
            flex-shrink: 0;
          }
          
          .task-title {
            font-size: 15px;
            font-weight: 500;
            color: #111827;
            flex: 1;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
          }
          
          .issue-type-badge {
            padding: 2px 8px;
            background: #e0e7ff;
            color: #4f46e5;
            border-radius: 4px;
            font-size: 11px;
            font-weight: 500;
            flex-shrink: 0;
          }
        }
        
        .task-meta {
          display: flex;
          align-items: center;
          gap: 16px;
          margin-bottom: 8px;
          flex-wrap: wrap;
          
          .meta-item {
            display: flex;
            align-items: center;
            gap: 4px;
            font-size: 13px;
            color: #6b7280;
            
            svg {
              opacity: 0.6;
            }
          }
        }
        
        .task-footer {
          display: flex;
          align-items: center;
          gap: 16px;
          flex-wrap: wrap;
          
          .time-info,
          .assignee-info {
            display: flex;
            align-items: center;
            gap: 4px;
            font-size: 12px;
            color: #9ca3af;
            
            svg {
              opacity: 0.7;
            }
          }
          
          .tags {
            display: flex;
            align-items: center;
            gap: 6px;
            
            .tag {
              padding: 2px 6px;
              background: #f3f4f6;
              color: #6b7280;
              border-radius: 3px;
              font-size: 11px;
            }
            
            .tag-more {
              font-size: 11px;
              color: #9ca3af;
            }
          }
        }
      }
      
      // 操作按钮区
      .task-actions {
        display: flex;
        flex-direction: column;
        gap: 8px;
        padding: 16px;
        border-left: 1px solid #e5e7eb;
        background: white;
        
        button {
          padding: 6px 12px;
          border: 1px solid #d1d5db;
          border-radius: 4px;
          font-size: 12px;
          cursor: pointer;
          transition: all 0.2s;
          white-space: nowrap;
          
          &.btn-view {
            background: #667eea;
            color: white;
            border-color: #667eea;
            
            &:hover {
              background: #5568d3;
              border-color: #5568d3;
            }
          }
          
          &.btn-mark-read {
            background: white;
            color: #6b7280;
            
            &:hover {
              background: #f9fafb;
              border-color: #9ca3af;
            }
          }
        }
      }
    }
  }
}

@keyframes rotate {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

// 响应式布局
@media (max-width: 768px) {
  .todo-section {
    padding: 16px;
    
    .section-header {
      flex-direction: column;
      align-items: flex-start;
      gap: 12px;
    }
    
    .todo-list-compact {
      .todo-item-compact {
        flex-direction: column;
        
        .task-actions {
          flex-direction: row;
          border-left: none;
          border-top: 1px solid #e5e7eb;
          padding: 12px;
          
          button {
            flex: 1;
          }
        }
      }
    }
  }
}

📊 数据流程图

┌─────────────────┐
│  ProjectIssue   │  Parse Server 数据表
│   (问题表)      │
└────────┬────────┘
         │
         │ Query: status in ['待处理','处理中']
         │        isDeleted != true
         │        include: ['project','creator','assignee']
         │        descending: 'updatedAt'
         │        limit: 20
         │
         ▼
┌─────────────────┐
│  Dashboard.ts   │  组件逻辑层
│                 │
│  - loadTodoTasks()     ◄── 初始加载 (ngOnInit)
│  - refreshTodoTasks()  ◄── 手动刷新
│  - startAutoRefresh()  ◄── 自动刷新 (5分钟)
│  - navigateToIssue()   ◄── 跳转详情
│  - markAsRead()        ◄── 标记已读
└────────┬────────┘
         │
         │ todoTasksFromIssues: TodoTaskFromIssue[]
         │
         ▼
┌─────────────────┐
│  Dashboard.html │  模板渲染层
│                 │
│  - 列表循环渲染
│  - 优先级样式
│  - 相对时间格式化
│  - 交互事件绑定
└─────────────────┘

🚀 实施步骤

阶段1: 数据模型与接口(第1天)

  1. ✅ 定义 TodoTaskFromIssue 接口
  2. ✅ 实现 loadTodoTasksFromIssues() 方法
  3. ✅ 实现优先级配置和排序逻辑
  4. ✅ 实现相对时间格式化工具函数
  5. ✅ 单元测试数据查询逻辑

阶段2: UI 组件开发(第2天)

  1. ✅ 清空现有待办任务区域的模拟数据
  2. ✅ 实现新的紧凑列表式 HTML 模板
  3. ✅ 实现 SCSS 样式(包括响应式)
  4. ✅ 实现加载/错误/空状态 UI
  5. ✅ 实现优先级颜色映射

阶段3: 交互功能(第3天)

  1. ✅ 实现点击跳转到项目详情+问题板块
  2. ✅ 实现手动刷新按钮
  3. ✅ 实现自动刷新定时器(5分钟)
  4. ✅ 实现"标记已读"功能(可选)
  5. ✅ 优化跳转时的 URL 参数传递

阶段4: 测试与优化(第4天)

  1. ✅ 真实环境数据测试
  2. ✅ 边界情况测试(无数据、大量数据)
  3. ✅ 性能优化(分页加载、虚拟滚动)
  4. ✅ 响应式布局测试(移动端)
  5. ✅ 代码审查与文档完善

🔍 边界情况处理

1. 无待办任务

场景:所有问题都已解决或关闭

处理

  • 显示空状态 UI
  • 提示文案:"暂无待办任务,所有项目问题都已处理完毕 🎉"

2. 大量待办任务(>100条)

场景:问题积压,待办任务过多

处理

  • 默认加载前 20 条
  • 提供"加载更多"按钮或无限滚动
  • 建议使用虚拟滚动优化性能

3. 网络请求失败

场景:网络异常导致数据加载失败

处理

  • 显示错误状态 UI
  • 提供"重试"按钮
  • 保留上一次成功加载的数据(可选)

4. 问题缺失关联数据

场景:问题的 projectassignee 等关联数据为空

处理

  • 使用默认值:'未知项目''未指派'
  • 不影响问题的正常显示

5. 并发刷新

场景:用户在自动刷新期间手动点击刷新

处理

  • 禁用刷新按钮(loadingTodoTasks = true
  • 忽略重复请求

📝 后续优化方向

1. 实时推送

技术方案

  • 使用 Parse Server 的 LiveQuery 实现实时数据更新
  • 或使用 WebSocket 推送新问题通知

2. 批量操作

功能

  • 批量标记已读
  • 批量指派负责人
  • 批量修改优先级

3. 筛选与搜索

功能

  • 按优先级筛选
  • 按项目筛选
  • 按负责人筛选
  • 关键词搜索

4. 通知提醒

功能

  • 新问题桌面通知
  • 紧急问题声音提醒
  • 问题催办提醒

5. 数据分析

功能

  • 问题趋势图表
  • 平均处理时长
  • 高频问题类型分析

✅ 验收标准

  1. ✅ 待办任务区域清空旧的模拟数据
  2. ✅ 新列表式布局紧凑美观,符合设计稿
  3. ✅ 数据来源于真实的 ProjectIssue
  4. ✅ 只显示状态为"待处理"或"处理中"的问题
  5. ✅ 按优先级(紧急→高→中→低)+ 时间排序
  6. ✅ 点击任务项能正确跳转到项目详情+问题板块
  7. ✅ 手动刷新功能正常工作
  8. ✅ 自动刷新(5分钟)正常工作
  9. ✅ 相对时间显示准确(X分钟前、X小时前、X天前)
  10. ✅ 空状态、加载状态、错误状态 UI 正常显示
  11. ✅ 移动端响应式布局正常
  12. ✅ 无 console 错误和警告

📌 注意事项

  1. 数据权限:确保组长有权限查询所有项目的问题数据
  2. 性能优化:大量数据时考虑分页或虚拟滚动
  3. 状态同步:标记已读后需要同步更新计数
  4. 路由参数:跳转时正确传递 openIssueshighlightIssue 参数
  5. 清理定时器:组件销毁时记得清理 setInterval

📚 相关文件清单

src/app/pages/team-leader/dashboard/
├── dashboard.ts                          # ✅ 修改
├── dashboard.html                        # ✅ 修改
├── dashboard.scss                        # ✅ 修改

src/modules/project/services/
├── project-issue.service.ts              # ✅ 已存在,无需修改

src/modules/project/components/
├── project-issues-modal/                 # ✅ 已存在,无需修改
    ├── project-issues-modal.component.ts
    ├── project-issues-modal.component.html
    └── project-issues-modal.component.scss

src/modules/project/pages/project-detail/
├── project-detail.component.ts           # ✅ 可能需要接收 queryParams
├── project-detail.component.html         # ✅ 可能需要支持高亮问题

🎉 总结

本方案将组长端待办任务区域从静态模拟数据升级为基于真实项目问题板块的动态列表,实现了:

  1. 数据真实性:直接从 ProjectIssue 表读取
  2. 布局优化:紧凑的列表式设计,信息密度高
  3. 交互流畅:一键跳转、快速刷新、标记已读
  4. 用户体验:优先级可视化、相对时间、状态提示
  5. 可扩展性:预留了实时推送、批量操作、筛选搜索等扩展接口

预计开发周期:3-4 天

技术风险:低

业务价值:高 ✅