team-leader-order-approval-implementation.md 41 KB

组长端订单审批功能实施方案

📅 创建日期

2025-10-28

🎯 功能目标

实现组长端对客服提交的订单分配进行审批的完整流程,包括审批入口、审批操作、状态流转和历史记录。


一、业务流程梳理

1.1 完整流程

客服端提交订单分配
    ↓
项目进入"待组长确认"状态 (currentStage: '订单分配', 附带 pendingApproval 标记)
    ↓
组长在工作台看到待审批项目
    ↓
组长点击进入项目详情页
    ↓
组长审核订单内容(报价、设计师分配、项目信息)
    ↓
组长做出决策:
    - 通过审批 → 项目进入"确认需求"阶段
    - 驳回审批 → 项目退回"订单分配"阶段,客服端显示驳回原因
    ↓
记录审批历史
    ↓
发送通知(可选)

1.2 关键状态定义

Project 数据结构扩展

Project {
  currentStage: string,  // '订单分配' | '确认需求' | '方案确认' | ...
  status: string,        // '待分配' | '进行中' | '已完成' | ...
  data: {
    // 新增审批相关字段
    approvalStatus?: 'pending' | 'approved' | 'rejected',  // 当前审批状态
    approvalHistory: ApprovalRecord[],  // 审批历史记录
    pendingApprovalBy?: string,  // 待审批人角色 'team-leader'
    lastRejectionReason?: string  // 最近一次驳回原因
  }
}

// 审批记录接口
interface ApprovalRecord {
  stage: string;              // 审批阶段:'订单分配'
  submitter: {                // 提交人信息
    id: string;
    name: string;
    role: string;
  };
  submitTime: Date;           // 提交时间
  status: 'pending' | 'approved' | 'rejected';  // 审批状态
  approver?: {                // 审批人信息(通过/驳回后填写)
    id: string;
    name: string;
    role: string;
  };
  approvalTime?: Date;        // 审批时间
  reason?: string;            // 驳回原因
  comment?: string;           // 审批备注
  quotationTotal: number;     // 报价总额快照
  teams: TeamSnapshot[];      // 团队分配快照
}

interface TeamSnapshot {
  id: string;
  name: string;
  spaces: string[];  // 分配的空间
}

二、前端实现方案

2.1 组长工作台增强(dashboard.ts)

2.1.1 待审批项目标识优化

当前已有 pendingApprovalProjects 计算属性,需要调整筛选逻辑:

// 位置:src/app/pages/team-leader/dashboard/dashboard.ts

// 修改现有的 getter
get pendingApprovalProjects(): Project[] {
  return this.projects.filter(p => {
    const stage = (p.currentStage || '').trim();
    const approvalStatus = p.data?.approvalStatus;
    
    // 1. 阶段为"订单分配"且审批状态为 pending
    // 2. 或者阶段为"待确认"/"待审批"
    return (stage === '订单分配' && approvalStatus === 'pending') ||
           stage === '待审批' || 
           stage === '待确认';
  });
}

2.1.2 待审批项目卡片视觉增强

在项目卡片上添加醒目的"待审批"标识:

<!-- 位置:src/app/pages/team-leader/dashboard/dashboard.html -->

<div class="project-card" 
     [class.pending-approval]="isPendingApproval(project)">
  
  <!-- 添加审批徽章 -->
  @if (isPendingApproval(project)) {
    <div class="approval-badge">
      <span class="badge-icon">📋</span>
      <span class="badge-text">待审批</span>
    </div>
  }
  
  <!-- 原有项目卡片内容 -->
  <div class="project-card-header">
    <h4>{{ project.name }}</h4>
    <!-- ... -->
  </div>
</div>
// dashboard.ts 中添加辅助方法
isPendingApproval(project: Project): boolean {
  return project.currentStage === '订单分配' && 
         project.data?.approvalStatus === 'pending';
}
// dashboard.scss 样式
.project-card {
  &.pending-approval {
    border: 2px solid #ff9800;
    box-shadow: 0 0 10px rgba(255, 152, 0, 0.3);
    position: relative;
    
    .approval-badge {
      position: absolute;
      top: 10px;
      right: 10px;
      background: linear-gradient(135deg, #ff9800, #ff6b00);
      color: white;
      padding: 4px 12px;
      border-radius: 20px;
      font-size: 12px;
      font-weight: bold;
      display: flex;
      align-items: center;
      gap: 4px;
      box-shadow: 0 2px 8px rgba(255, 152, 0, 0.4);
      animation: pulse 2s ease-in-out infinite;
      
      .badge-icon {
        font-size: 14px;
      }
    }
  }
}

@keyframes pulse {
  0%, 100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.05);
  }
}

2.2 项目详情页审批组件

2.2.1 创建订单审批组件

位置:src/app/shared/components/order-approval-panel/

// order-approval-panel.component.ts
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

interface ApprovalData {
  projectId: string;
  projectName: string;
  quotationTotal: number;
  assignedTeams: TeamInfo[];
  projectInfo: {
    title: string;
    projectType: string;
    demoday: Date;
    deadline?: Date;
  };
  submitter: {
    id: string;
    name: string;
    role: string;
  };
  submitTime: Date;
}

interface TeamInfo {
  id: string;
  name: string;
  spaces: string[];
}

@Component({
  selector: 'app-order-approval-panel',
  standalone: true,
  imports: [CommonModule, FormsModule],
  templateUrl: './order-approval-panel.component.html',
  styleUrls: ['./order-approval-panel.component.scss']
})
export class OrderApprovalPanelComponent implements OnInit {
  @Input() project: any;  // Parse Project 对象
  @Input() currentUser: any;  // 当前组长用户
  @Output() approvalCompleted = new EventEmitter<{
    action: 'approved' | 'rejected';
    reason?: string;
    comment?: string;
  }>();

  approvalData: ApprovalData | null = null;
  showRejectModal = false;
  rejectReason = '';
  approvalComment = '';
  isSubmitting = false;

  // 驳回原因快捷选项
  rejectReasons = [
    '报价不合理,需要调整',
    '设计师分配不当',
    '项目信息不完整',
    '需要补充项目资料',
    '其他原因(请在下方说明)'
  ];

  selectedRejectReason = '';

  ngOnInit() {
    this.loadApprovalData();
  }

  /**
   * 加载审批数据
   */
  private loadApprovalData() {
    if (!this.project) return;

    const data = this.project.get('data') || {};
    const approvalHistory = data.approvalHistory || [];
    const latestRecord = approvalHistory[approvalHistory.length - 1];

    this.approvalData = {
      projectId: this.project.id,
      projectName: this.project.get('title'),
      quotationTotal: latestRecord?.quotationTotal || 0,
      assignedTeams: latestRecord?.teams || [],
      projectInfo: {
        title: this.project.get('title'),
        projectType: this.project.get('projectType'),
        demoday: this.project.get('demoday'),
        deadline: this.project.get('deadline')
      },
      submitter: latestRecord?.submitter || {},
      submitTime: latestRecord?.submitTime || new Date()
    };
  }

  /**
   * 通过审批
   */
  async approveOrder() {
    if (this.isSubmitting) return;
    
    const confirmed = await window?.fmode?.confirm('确认通过此订单审批吗?');
    if (!confirmed) return;

    this.isSubmitting = true;

    try {
      this.approvalCompleted.emit({
        action: 'approved',
        comment: this.approvalComment || undefined
      });
    } finally {
      this.isSubmitting = false;
    }
  }

  /**
   * 打开驳回弹窗
   */
  openRejectModal() {
    this.showRejectModal = true;
    this.rejectReason = '';
    this.selectedRejectReason = '';
    this.approvalComment = '';
  }

  /**
   * 关闭驳回弹窗
   */
  closeRejectModal() {
    this.showRejectModal = false;
  }

  /**
   * 选择驳回原因
   */
  selectRejectReason(reason: string) {
    this.selectedRejectReason = reason;
    if (reason !== '其他原因(请在下方说明)') {
      this.rejectReason = reason;
    } else {
      this.rejectReason = '';
    }
  }

  /**
   * 提交驳回
   */
  async submitRejection() {
    const finalReason = this.selectedRejectReason === '其他原因(请在下方说明)' 
      ? this.rejectReason 
      : this.selectedRejectReason;

    if (!finalReason || !finalReason.trim()) {
      alert('请填写驳回原因');
      return;
    }

    if (this.isSubmitting) return;
    this.isSubmitting = true;

    try {
      this.approvalCompleted.emit({
        action: 'rejected',
        reason: finalReason,
        comment: this.approvalComment || undefined
      });
      this.closeRejectModal();
    } finally {
      this.isSubmitting = false;
    }
  }

  /**
   * 格式化金额
   */
  formatCurrency(amount: number): string {
    return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
  }
}
<!-- order-approval-panel.component.html -->
<div class="order-approval-panel" *ngIf="approvalData">
  <!-- 审批状态头部 -->
  <div class="approval-header">
    <div class="header-icon">📋</div>
    <div class="header-content">
      <h2>订单审批</h2>
      <p class="subtitle">请仔细审核以下订单信息</p>
    </div>
  </div>

  <!-- 审批信息卡片 -->
  <div class="approval-info-cards">
    <!-- 项目信息卡片 -->
    <div class="info-card">
      <div class="card-header">
        <span class="card-icon">📄</span>
        <h3>项目信息</h3>
      </div>
      <div class="card-body">
        <div class="info-row">
          <span class="label">项目名称:</span>
          <span class="value">{{ approvalData.projectInfo.title }}</span>
        </div>
        <div class="info-row">
          <span class="label">项目类型:</span>
          <span class="value">{{ approvalData.projectInfo.projectType }}</span>
        </div>
        <div class="info-row">
          <span class="label">小图日期:</span>
          <span class="value">{{ approvalData.projectInfo.demoday | date:'yyyy-MM-dd' }}</span>
        </div>
        <div class="info-row" *ngIf="approvalData.projectInfo.deadline">
          <span class="label">交付期限:</span>
          <span class="value">{{ approvalData.projectInfo.deadline | date:'yyyy-MM-dd' }}</span>
        </div>
      </div>
    </div>

    <!-- 报价信息卡片 -->
    <div class="info-card highlight">
      <div class="card-header">
        <span class="card-icon">💰</span>
        <h3>报价总额</h3>
      </div>
      <div class="card-body">
        <div class="quotation-amount">
          {{ formatCurrency(approvalData.quotationTotal) }}
        </div>
      </div>
    </div>

    <!-- 设计师分配卡片 -->
    <div class="info-card">
      <div class="card-header">
        <span class="card-icon">👥</span>
        <h3>设计师分配</h3>
      </div>
      <div class="card-body">
        <div class="team-list">
          <div class="team-item" *ngFor="let team of approvalData.assignedTeams">
            <div class="team-name">{{ team.name }}</div>
            <div class="team-spaces">
              <span class="space-tag" *ngFor="let space of team.spaces">{{ space }}</span>
            </div>
          </div>
          <div class="empty-state" *ngIf="approvalData.assignedTeams.length === 0">
            <span class="empty-icon">📦</span>
            <p>暂无分配设计师</p>
          </div>
        </div>
      </div>
    </div>

    <!-- 提交信息卡片 -->
    <div class="info-card">
      <div class="card-header">
        <span class="card-icon">👤</span>
        <h3>提交信息</h3>
      </div>
      <div class="card-body">
        <div class="info-row">
          <span class="label">提交人:</span>
          <span class="value">{{ approvalData.submitter.name }} ({{ approvalData.submitter.role }})</span>
        </div>
        <div class="info-row">
          <span class="label">提交时间:</span>
          <span class="value">{{ approvalData.submitTime | date:'yyyy-MM-dd HH:mm' }}</span>
        </div>
      </div>
    </div>
  </div>

  <!-- 审批备注 -->
  <div class="approval-comment-section">
    <label for="approvalComment">审批备注(可选)</label>
    <textarea 
      id="approvalComment"
      [(ngModel)]="approvalComment"
      placeholder="可以填写审批意见或建议..."
      rows="3"></textarea>
  </div>

  <!-- 审批操作按钮 -->
  <div class="approval-actions">
    <button 
      class="btn-reject" 
      (click)="openRejectModal()"
      [disabled]="isSubmitting">
      <span class="btn-icon">❌</span>
      驳回订单
    </button>
    <button 
      class="btn-approve" 
      (click)="approveOrder()"
      [disabled]="isSubmitting">
      <span class="btn-icon">✅</span>
      通过审批
    </button>
  </div>
</div>

<!-- 驳回弹窗 -->
<div class="reject-modal-overlay" *ngIf="showRejectModal" (click)="closeRejectModal()">
  <div class="reject-modal" (click)="$event.stopPropagation()">
    <div class="modal-header">
      <h3>驳回订单</h3>
      <button class="close-btn" (click)="closeRejectModal()">×</button>
    </div>
    
    <div class="modal-body">
      <div class="form-group">
        <label>驳回原因 <span class="required">*</span></label>
        <div class="reason-options">
          <label 
            class="reason-option" 
            *ngFor="let reason of rejectReasons"
            [class.selected]="selectedRejectReason === reason">
            <input 
              type="radio" 
              name="rejectReason" 
              [value]="reason"
              [(ngModel)]="selectedRejectReason"
              (change)="selectRejectReason(reason)">
            <span>{{ reason }}</span>
          </label>
        </div>
      </div>

      <div class="form-group" *ngIf="selectedRejectReason === '其他原因(请在下方说明)'">
        <label>详细说明 <span class="required">*</span></label>
        <textarea 
          [(ngModel)]="rejectReason"
          placeholder="请详细说明驳回原因..."
          rows="4"></textarea>
      </div>

      <div class="form-group">
        <label>补充说明(可选)</label>
        <textarea 
          [(ngModel)]="approvalComment"
          placeholder="可以补充其他建议或要求..."
          rows="3"></textarea>
      </div>
    </div>

    <div class="modal-footer">
      <button class="btn-cancel" (click)="closeRejectModal()">取消</button>
      <button 
        class="btn-submit" 
        (click)="submitRejection()"
        [disabled]="isSubmitting">
        确认驳回
      </button>
    </div>
  </div>
</div>
// order-approval-panel.component.scss
.order-approval-panel {
  background: #fff;
  border-radius: 12px;
  padding: 24px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  margin-bottom: 24px;

  .approval-header {
    display: flex;
    align-items: center;
    gap: 16px;
    padding-bottom: 20px;
    border-bottom: 2px solid #f0f0f0;
    margin-bottom: 24px;

    .header-icon {
      font-size: 48px;
      animation: float 3s ease-in-out infinite;
    }

    .header-content {
      h2 {
        margin: 0;
        font-size: 24px;
        color: #333;
      }

      .subtitle {
        margin: 4px 0 0;
        color: #666;
        font-size: 14px;
      }
    }
  }

  .approval-info-cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 16px;
    margin-bottom: 24px;

    .info-card {
      background: #f8f9fa;
      border-radius: 8px;
      padding: 16px;
      border: 1px solid #e9ecef;
      transition: all 0.3s;

      &:hover {
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
        transform: translateY(-2px);
      }

      &.highlight {
        background: linear-gradient(135deg, #fff3e0, #ffe0b2);
        border-color: #ff9800;

        .quotation-amount {
          font-size: 32px;
          font-weight: bold;
          color: #f57c00;
          text-align: center;
          padding: 16px 0;
        }
      }

      .card-header {
        display: flex;
        align-items: center;
        gap: 8px;
        margin-bottom: 12px;

        .card-icon {
          font-size: 24px;
        }

        h3 {
          margin: 0;
          font-size: 16px;
          color: #333;
        }
      }

      .card-body {
        .info-row {
          display: flex;
          justify-content: space-between;
          padding: 8px 0;
          border-bottom: 1px solid #e9ecef;

          &:last-child {
            border-bottom: none;
          }

          .label {
            color: #666;
            font-size: 14px;
          }

          .value {
            color: #333;
            font-weight: 500;
            font-size: 14px;
          }
        }

        .team-list {
          .team-item {
            padding: 12px;
            background: white;
            border-radius: 6px;
            margin-bottom: 8px;

            &:last-child {
              margin-bottom: 0;
            }

            .team-name {
              font-weight: 500;
              color: #333;
              margin-bottom: 8px;
            }

            .team-spaces {
              display: flex;
              flex-wrap: wrap;
              gap: 6px;

              .space-tag {
                background: #e3f2fd;
                color: #1976d2;
                padding: 4px 12px;
                border-radius: 12px;
                font-size: 12px;
              }
            }
          }

          .empty-state {
            text-align: center;
            padding: 24px;
            color: #999;

            .empty-icon {
              font-size: 48px;
              display: block;
              margin-bottom: 8px;
            }

            p {
              margin: 0;
              font-size: 14px;
            }
          }
        }
      }
    }
  }

  .approval-comment-section {
    margin-bottom: 24px;

    label {
      display: block;
      font-weight: 500;
      color: #333;
      margin-bottom: 8px;
    }

    textarea {
      width: 100%;
      padding: 12px;
      border: 1px solid #ddd;
      border-radius: 6px;
      font-size: 14px;
      resize: vertical;
      font-family: inherit;

      &:focus {
        outline: none;
        border-color: #4CAF50;
        box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
      }
    }
  }

  .approval-actions {
    display: flex;
    gap: 12px;
    justify-content: flex-end;

    button {
      padding: 12px 32px;
      border: none;
      border-radius: 6px;
      font-size: 16px;
      font-weight: 500;
      cursor: pointer;
      display: flex;
      align-items: center;
      gap: 8px;
      transition: all 0.3s;

      &:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }

      .btn-icon {
        font-size: 18px;
      }
    }

    .btn-reject {
      background: white;
      color: #f44336;
      border: 2px solid #f44336;

      &:hover:not(:disabled) {
        background: #f44336;
        color: white;
        transform: translateY(-2px);
        box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3);
      }
    }

    .btn-approve {
      background: linear-gradient(135deg, #4CAF50, #45a049);
      color: white;

      &:hover:not(:disabled) {
        background: linear-gradient(135deg, #45a049, #3d8b40);
        transform: translateY(-2px);
        box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
      }
    }
  }
}

// 驳回弹窗样式
.reject-modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10000;
  animation: fadeIn 0.3s;
}

.reject-modal {
  background: white;
  border-radius: 12px;
  width: 90%;
  max-width: 600px;
  max-height: 90vh;
  overflow-y: auto;
  animation: slideUp 0.3s;

  .modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 24px;
    border-bottom: 1px solid #e9ecef;

    h3 {
      margin: 0;
      font-size: 20px;
      color: #333;
    }

    .close-btn {
      background: none;
      border: none;
      font-size: 32px;
      color: #999;
      cursor: pointer;
      padding: 0;
      width: 32px;
      height: 32px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 50%;
      transition: all 0.3s;

      &:hover {
        background: #f5f5f5;
        color: #333;
      }
    }
  }

  .modal-body {
    padding: 24px;

    .form-group {
      margin-bottom: 20px;

      &:last-child {
        margin-bottom: 0;
      }

      label {
        display: block;
        font-weight: 500;
        color: #333;
        margin-bottom: 8px;

        .required {
          color: #f44336;
          margin-left: 4px;
        }
      }

      .reason-options {
        display: flex;
        flex-direction: column;
        gap: 8px;

        .reason-option {
          display: flex;
          align-items: center;
          gap: 12px;
          padding: 12px;
          border: 2px solid #e9ecef;
          border-radius: 6px;
          cursor: pointer;
          transition: all 0.3s;

          &:hover {
            border-color: #f44336;
            background: #fff5f5;
          }

          &.selected {
            border-color: #f44336;
            background: #ffebee;
          }

          input[type="radio"] {
            cursor: pointer;
          }

          span {
            flex: 1;
            font-size: 14px;
            color: #333;
          }
        }
      }

      textarea {
        width: 100%;
        padding: 12px;
        border: 1px solid #ddd;
        border-radius: 6px;
        font-size: 14px;
        resize: vertical;
        font-family: inherit;

        &:focus {
          outline: none;
          border-color: #f44336;
          box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.1);
        }
      }
    }
  }

  .modal-footer {
    padding: 16px 24px;
    border-top: 1px solid #e9ecef;
    display: flex;
    justify-content: flex-end;
    gap: 12px;

    button {
      padding: 10px 24px;
      border: none;
      border-radius: 6px;
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
      transition: all 0.3s;

      &:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
    }

    .btn-cancel {
      background: #f5f5f5;
      color: #666;

      &:hover:not(:disabled) {
        background: #e0e0e0;
      }
    }

    .btn-submit {
      background: #f44336;
      color: white;

      &:hover:not(:disabled) {
        background: #d32f2f;
        box-shadow: 0 2px 8px rgba(244, 67, 54, 0.3);
      }
    }
  }
}

@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-10px); }
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes slideUp {
  from { 
    opacity: 0;
    transform: translateY(30px);
  }
  to { 
    opacity: 1;
    transform: translateY(0);
  }
}

2.2.2 集成到项目详情页

修改组长查看的项目详情页(复用设计师详情页):

// src/app/pages/designer/project-detail/project-detail.ts

import { OrderApprovalPanelComponent } from '../../shared/components/order-approval-panel/order-approval-panel.component';

@Component({
  // ... 其他配置
  imports: [
    // ... 其他导入
    OrderApprovalPanelComponent
  ]
})
export class ProjectDetail implements OnInit, OnDestroy {
  // 添加审批相关属性
  showApprovalPanel = false;
  
  ngOnInit() {
    // ... 现有初始化代码
    
    // 检查是否需要显示审批面板
    this.checkApprovalStatus();
  }
  
  /**
   * 检查是否需要显示审批面板
   */
  private checkApprovalStatus() {
    if (!this.project) return;
    
    const isTeamLeader = this.roleContext === 'team-leader';
    const currentStage = this.project.get('currentStage');
    const approvalStatus = this.project.get('data')?.approvalStatus;
    
    // 组长视角 + 订单分配阶段 + 待审批状态
    this.showApprovalPanel = isTeamLeader && 
                             currentStage === '订单分配' && 
                             approvalStatus === 'pending';
  }
  
  /**
   * 处理审批完成事件
   */
  async onApprovalCompleted(event: { action: 'approved' | 'rejected'; reason?: string; comment?: string }) {
    if (!this.project || !this.currentUser) return;
    
    try {
      const data = this.project.get('data') || {};
      const approvalHistory = data.approvalHistory || [];
      const latestRecord = approvalHistory[approvalHistory.length - 1];
      
      if (!latestRecord) {
        alert('审批记录不存在');
        return;
      }
      
      // 更新最新的审批记录
      latestRecord.status = event.action === 'approved' ? 'approved' : 'rejected';
      latestRecord.approver = {
        id: this.currentUser.id,
        name: this.currentUser.get('name'),
        role: this.currentUser.get('roleName')
      };
      latestRecord.approvalTime = new Date();
      
      if (event.reason) {
        latestRecord.reason = event.reason;
      }
      if (event.comment) {
        latestRecord.comment = event.comment;
      }
      
      // 更新项目状态
      if (event.action === 'approved') {
        // 通过:推进到"确认需求"阶段
        this.project.set('currentStage', '确认需求');
        data.approvalStatus = 'approved';
        delete data.pendingApprovalBy;
      } else {
        // 驳回:保持在"订单分配"阶段,但标记为已驳回
        data.approvalStatus = 'rejected';
        data.lastRejectionReason = event.reason;
        delete data.pendingApprovalBy;
      }
      
      data.approvalHistory = approvalHistory;
      this.project.set('data', data);
      
      // 保存到数据库
      await this.project.save(null, { useMasterKey: true });
      
      // 提示用户
      if (event.action === 'approved') {
        alert('✅ 审批通过!项目已进入"确认需求"阶段');
      } else {
        alert('❌ 已驳回订单,客服将收到驳回通知');
      }
      
      // 刷新页面或返回列表
      this.showApprovalPanel = false;
      this.router.navigate(['/wxwork', this.companyId, 'team-leader', 'dashboard']);
      
    } catch (error) {
      console.error('审批操作失败:', error);
      alert('操作失败,请重试');
    }
  }
}
<!-- src/app/pages/designer/project-detail/project-detail.html -->

<!-- 在页面顶部添加审批面板 -->
@if (showApprovalPanel) {
  <app-order-approval-panel
    [project]="project"
    [currentUser]="currentUser"
    (approvalCompleted)="onApprovalCompleted($event)">
  </app-order-approval-panel>
}

<!-- 原有的项目详情内容 -->
<!-- ... -->

三、客服端适配

3.1 修改订单提交逻辑

// src/modules/project/pages/project-detail/stages/stage-order.component.ts

async submitForOrder() {
  // ... 现有验证逻辑 ...
  
  try {
    this.saving = true;
    
    // ... 现有保存逻辑 ...
    
    // ✨ 修改:不直接推进到"确认需求",而是标记为待审批
    // this.project.set('currentStage', '确认需求');  // 删除这行
    
    // 记录审批历史(包含团队快照)
    const data = this.project.get('data') || {};
    const approvalHistory = data.approvalHistory || [];
    
    const teamSnapshot = assignedTeams.map(team => {
      const profile = team.get('profile');
      const spaces = team.get('data')?.spaces || [];
      return {
        id: profile?.id,
        name: profile?.get('name'),
        spaces
      };
    });
    
    approvalHistory.push({
      stage: '订单分配',
      submitter: {
        id: this.currentUser?.id,
        name: this.currentUser?.get('name'),
        role: this.currentUser?.get('roleName')
      },
      submitTime: new Date(),
      status: 'pending',  // ✨ 标记为待审批
      quotationTotal: this.quotation.total,
      teams: teamSnapshot
    });
    
    // ✨ 新增:设置审批状态
    data.approvalHistory = approvalHistory;
    data.approvalStatus = 'pending';  // 待审批
    data.pendingApprovalBy = 'team-leader';  // 待组长审批
    this.project.set('data', data);
    
    // ✨ 保持在"订单分配"阶段
    // 但可以通过 approvalStatus 字段区分是否已提交
    
    await this.project.save();
    
    alert('✅ 提交成功!等待组长审批');
    
  } catch (err) {
    console.error('提交失败:', err);
    alert('提交失败');
  } finally {
    this.saving = false;
  }
}

3.2 显示审批状态

在客服端项目列表或详情页显示审批状态:

<!-- 审批状态徽章 -->
@if (project.data?.approvalStatus === 'pending') {
  <span class="status-badge pending">等待组长审批</span>
}
@else if (project.data?.approvalStatus === 'approved') {
  <span class="status-badge approved">已通过</span>
}
@else if (project.data?.approvalStatus === 'rejected') {
  <span class="status-badge rejected">已驳回</span>
}

3.3 显示驳回原因

<!-- 驳回提示 -->
@if (project.data?.approvalStatus === 'rejected') {
  <div class="rejection-notice">
    <div class="notice-icon">⚠️</div>
    <div class="notice-content">
      <h4>订单已被驳回</h4>
      <p><strong>驳回原因:</strong>{{ project.data?.lastRejectionReason }}</p>
      <button class="btn-primary" (click)="editAndResubmit()">修改并重新提交</button>
    </div>
  </div>
}
.rejection-notice {
  background: #fff3e0;
  border: 2px solid #ff9800;
  border-radius: 8px;
  padding: 16px;
  margin-bottom: 20px;
  display: flex;
  gap: 16px;
  align-items: flex-start;

  .notice-icon {
    font-size: 32px;
  }

  .notice-content {
    flex: 1;

    h4 {
      margin: 0 0 8px;
      color: #f57c00;
    }

    p {
      margin: 0 0 12px;
      color: #666;
    }

    .btn-primary {
      background: #ff9800;
      color: white;
      border: none;
      padding: 8px 20px;
      border-radius: 4px;
      cursor: pointer;

      &:hover {
        background: #f57c00;
      }
    }
  }
}

四、数据库查询优化

4.1 为组长工作台创建专用查询

// src/app/pages/team-leader/services/project-data.service.ts

/**
 * 获取待审批项目列表
 */
async getPendingApprovalProjects(companyId: string): Promise<any[]> {
  const Parse = await this.ensureParse();
  if (!Parse) return [];
  
  try {
    const query = new Parse.Query('Project');
    query.equalTo('company', companyId);
    query.equalTo('currentStage', '订单分配');
    query.notEqualTo('isDeleted', true);
    query.include('contact');
    query.descending('updatedAt');
    query.limit(100);
    
    const projects = await query.find({ useMasterKey: true });
    
    // 过滤出待审批的项目
    return projects.filter(p => {
      const data = p.get('data') || {};
      return data.approvalStatus === 'pending';
    });
    
  } catch (error) {
    console.error('获取待审批项目失败:', error);
    return [];
  }
}

五、通知机制(可选扩展)

5.1 企业微信消息通知

// src/app/services/notification.service.ts

@Injectable({ providedIn: 'root' })
export class NotificationService {
  
  /**
   * 发送审批通知给客服
   */
  async notifyCustomerServiceOfApproval(
    project: any, 
    approvalResult: 'approved' | 'rejected',
    reason?: string
  ) {
    // 获取项目的客服人员
    const customerServiceId = project.get('data')?.submitter?.id;
    if (!customerServiceId) return;
    
    // 构建消息内容
    const message = approvalResult === 'approved' 
      ? `✅ 您提交的订单"${project.get('title')}"已通过审批,可以进入下一阶段。`
      : `❌ 您提交的订单"${project.get('title')}"被驳回。\n驳回原因:${reason}`;
    
    // 调用企业微信API发送消息(需要后端支持)
    try {
      // await this.wxworkApi.sendMessage({
      //   toUser: customerServiceId,
      //   message: message,
      //   agentId: 'your-agent-id'
      // });
      
      console.log('📨 发送通知:', message);
    } catch (error) {
      console.error('发送通知失败:', error);
    }
  }
}

六、测试清单

6.1 功能测试

  • 客服提交订单后,项目标记为"待审批"
  • 组长工作台正确显示待审批项目数量
  • 组长可以筛选查看待审批项目
  • 点击待审批项目进入详情页,显示审批面板
  • 审批面板正确展示项目信息、报价、设计师分配
  • 通过审批后,项目进入"确认需求"阶段
  • 驳回审批后,项目保持在"订单分配"阶段,显示驳回状态
  • 客服端可以看到审批状态(待审批/已通过/已驳回)
  • 驳回后客服可以查看驳回原因
  • 驳回后客服可以修改并重新提交
  • 审批历史正确记录

6.2 权限测试

  • 只有组长角色可以看到审批按钮
  • 非组长角色无法进行审批操作
  • 审批记录中正确记录审批人信息

6.3 边界测试

  • 项目没有审批记录时的处理
  • 多次提交审批的历史记录
  • 审批过程中项目被删除的处理
  • 并发审批的处理

七、后续优化建议

7.1 短期优化(1-2周)

  1. 批量审批功能

    • 组长可以批量选择多个项目进行审批
    • 快速通过/驳回多个订单
  2. 审批提醒

    • 超过24小时未审批的项目高亮显示
    • 每日汇总待审批项目发送给组长
  3. 移动端适配

    • 审批面板响应式设计
    • 支持企业微信内审批

7.2 中期优化(1-2月)

  1. 审批流程可配置

    • 支持自定义审批规则
    • 根据项目金额自动路由审批人
  2. 审批数据分析

    • 统计审批通过率
    • 分析驳回原因分布
    • 监控审批时效
  3. 智能审批建议

    • 基于历史数据提供审批建议
    • 自动标记异常项目

八、实施步骤

Phase 1: 核心功能(2-3天)

  1. ✅ 创建 OrderApprovalPanelComponent
  2. ✅ 修改客服端提交逻辑
  3. ✅ 集成到项目详情页
  4. ✅ 实现审批操作(通过/驳回)
  5. ✅ 测试基本流程

Phase 2: 视觉优化(1天)

  1. ✅ 组长工作台待审批项目视觉标识
  2. ✅ 审批面板样式优化
  3. ✅ 驳回弹窗交互优化

Phase 3: 客服端适配(1天)

  1. ✅ 显示审批状态
  2. ✅ 显示驳回原因
  3. ✅ 重新提交功能

Phase 4: 测试与发布(1天)

  1. ✅ 功能测试
  2. ✅ 权限测试
  3. ✅ 用户验收测试
  4. ✅ 正式发布

预计总工时:5-6天


九、相关文档


文档创建: 2025-10-28
最后更新: 2025-10-28
版本: v1.1.0
状态: ✅ 已完成


十、问题与修复记录

问题1:路由跳转到错误的项目详情页

问题描述:
组长工作台点击待审批项目后,跳转到了开发版本的项目详情页(src/app/pages/designer/project-detail),而不是真实的项目详情页(src/modules/project/pages/project-detail),导致审批面板无法显示。

根本原因:
项目中存在两个项目详情组件:

  1. 开发版本src/app/pages/designer/project-detail/project-detail.ts - 用于开发测试
  2. 真实版本src/modules/project/pages/project-detail/project-detail.component.ts - 真实项目使用的组件

审批功能被误加到了开发版本,而路由配置也指向了开发版本。

解决方案:

  1. 修改路由配置src/app/app.routes.ts):

    // wxwork/:cid/team-leader 路由
    {
     path: 'team-leader',
     children: [
       {
         path: 'dashboard',
         loadComponent: () => import('./pages/team-leader/dashboard/dashboard').then(m => m.Dashboard),
         title: '组长工作台'
       },
       {
         path: 'project-detail/:projectId',
         // ✅ 修改为真实的项目详情组件
         loadComponent: () => import('../modules/project/pages/project-detail/project-detail.component').then(m => m.ProjectDetailComponent),
         title: '项目详情',
         children: [
           // ... 四阶段子路由
         ]
       }
     ]
    }
    
  2. 将审批功能集成到真实组件

    • 修改 src/modules/project/pages/project-detail/project-detail.component.ts
      • 导入 OrderApprovalPanelComponent
      • 添加 showApprovalPanelcompanyId 属性
      • 添加 checkApprovalStatus() 方法
      • 添加 onApprovalCompleted() 方法
    • 修改 src/modules/project/pages/project-detail/project-detail.component.html
      • <router-outlet> 之前添加审批面板

修复后效果:

  • ✅ 组长工作台点击待审批项目正确跳转到真实项目详情页
  • ✅ 审批面板正常显示
  • ✅ 四阶段导航正常工作
  • ✅ 审批通过/驳回功能正常

相关文件:

  • src/app/app.routes.ts - 路由配置
  • src/modules/project/pages/project-detail/project-detail.component.ts - 真实项目详情组件
  • src/modules/project/pages/project-detail/project-detail.component.html - 真实项目详情模板
  • src/app/pages/team-leader/dashboard/dashboard.ts - 组长工作台(跳转逻辑)

测试验证步骤:

  1. 访问组长工作台:http://localhost:4200/wxwork/cDL6R1hgSi/team-leader/dashboard
  2. 点击待审批项目(红桥新村)
  3. 验证URL:应为 http://localhost:4200/wxwork/cDL6R1hgSi/team-leader/project-detail/B2wtFHIF6k
  4. 验证页面显示:
    • ✅ 四阶段导航显示
    • ✅ 审批面板显示(橙色横幅)
    • ✅ "通过审批"和"驳回订单"按钮显示
    • ✅ 订单信息正确显示

功能增强2:审批时修改设计师分配

新增时间: 2025-10-28

功能描述:
组长在审批订单时,可以直接在审批面板中修改设计师分配,无需返回订单页面,提高审批效率。

实现内容:

  1. 编辑按钮

    • 在"设计师分配"卡片右上角添加"✏️ 编辑"按钮
    • 点击后进入编辑模式
  2. 编辑模式界面

    • 显示当前分配的设计师列表
    • 每个设计师显示:头像、姓名、负责空间
    • 提供操作按钮:
      • 🗑️ 移除设计师
      • 编辑空间 - 修改该设计师负责的空间
      • ➕ 添加设计师 - 添加新的设计师
  3. 编辑操作

    • 移除设计师:点击🗑️按钮移除该设计师
    • 编辑空间:弹出输入框,修改空间列表(逗号分隔)
    • 添加设计师:弹出输入框,输入设计师姓名
  4. 保存/取消

    • 保存修改:更新审批数据和项目数据
    • 取消:放弃修改,恢复原始数据

视觉效果:

  • 编辑模式卡片:蓝色边框 + 浅蓝背景
  • 设计师项:白色背景 + 悬停高亮
  • 按钮:彩色图标 + 悬停动画
  • 操作反馈:保存后显示"设计师分配已更新"提示

技术实现:

// TypeScript 关键方法
startEditTeams()      // 开启编辑模式
cancelEditTeams()     // 取消编辑
saveTeamsEdit()       // 保存修改
removeTeam(index)     // 移除设计师
addTeamMember()       // 添加设计师
editTeamSpaces(team)  // 编辑空间

涉及文件:

  • src/app/shared/components/order-approval-panel/order-approval-panel.component.ts
  • src/app/shared/components/order-approval-panel/order-approval-panel.component.html
  • src/app/shared/components/order-approval-panel/order-approval-panel.component.scss

使用流程:

  1. 组长查看待审批订单
  2. 在审批面板中点击"设计师分配"卡片右上角的"✏️ 编辑"按钮
  3. 进入编辑模式,修改设计师分配:
    • 移除不合适的设计师
    • 添加新的设计师(打开专业的设计师选择弹窗)
    • 调整各设计师负责的空间
  4. 点击"保存修改"确认,或点击"取消"放弃修改
  5. 继续进行审批操作(通过/驳回)

改进更新(2025-10-28):

集成了项目详情页的设计师分配弹窗组件(DesignerTeamAssignmentModalComponent),提供更专业的设计师选择体验:

弹窗功能特性:

  • 项目组视图:按项目组展示设计师列表
  • 设计师状态:显示设计师工作状态(空闲/忙碌/对图中)
  • 工作量显示:查看每个设计师当前工作量
  • 日历视图:查看设计师时间安排和可用性
  • 空间分配:选择设计师时同时分配负责空间
  • 跨组协作:支持选择多个项目组的设计师
  • 实时数据:从数据库加载真实的设计师和项目数据

交互流程:

  1. 点击 "➕ 添加设计师" 按钮
  2. 弹出专业的设计师选择界面
  3. 选择项目组,查看该组设计师
  4. 查看设计师状态、工作量、时间安排
  5. 选择合适的设计师
  6. 为设计师分配负责的空间
  7. 确认选择,设计师自动添加到审批面板
  8. 继续编辑或保存修改

技术实现:

// 关键方法更新
addTeamMember()              // 打开设计师选择弹窗
closeDesignerModal()         // 关闭弹窗
handleDesignerAssignment()   // 处理弹窗返回的选择结果

涉及组件:

  • DesignerTeamAssignmentModalComponent - 设计师分配弹窗(复用)
  • DesignerCalendarComponent - 设计师日历组件(弹窗内部使用)