|
|
@@ -0,0 +1,677 @@
|
|
|
+# 组长端Dashboard综合优化方案
|
|
|
+
|
|
|
+**实施日期**: 2025-10-22
|
|
|
+**文档状态**: 最新版(基于原版dashboard重新规划)
|
|
|
+
|
|
|
+## 一、现状分析
|
|
|
+
|
|
|
+### 1.1 当前功能清单
|
|
|
+
|
|
|
+| 模块 | 功能 | 数据来源 | 问题 |
|
|
|
+|-----|------|---------|-----|
|
|
|
+| **KPI卡片** | 4个指标(延期、临期、待确认、待分配) | 模拟数据 | ❌ 未对接真实数据 |
|
|
|
+| **工作量概览** | ECharts横向堆叠柱状图 | 模拟数据 | ❌ 仅按项目数量统计,未考虑复杂度 |
|
|
|
+| **甘特图** | 两种模式(项目/设计师) | 模拟数据 | ❌ 切换过多,信息密度过高 |
|
|
|
+| **项目看板** | 4列看板(订单→需求→交付→售后) | 模拟数据 | ✅ 结构合理,需对接真实数据 |
|
|
|
+| **筛选器** | 7个维度筛选 | - | ⚠️ 功能完整但UI可简化 |
|
|
|
+| **搜索** | 关键词搜索+建议 | 模拟数据 | ✅ 交互良好,需对接真实数据 |
|
|
|
+| **设计师画像** | 硬编码8个设计师 | 模拟数据 | ❌ 需改为Profile.data.tags动态读取 |
|
|
|
+
|
|
|
+### 1.2 核心问题
|
|
|
+
|
|
|
+1. **数据层**:100%模拟数据,未使用fmode-ng查询Parse Server
|
|
|
+2. **算法层**:工作量计算过于简单(项目数量≠真实工作量)
|
|
|
+3. **UI层**:切换过多(工作量按设计师/会员切换,甘特按项目/设计师切换)
|
|
|
+4. **功能层**:缺少智能推荐、设计师标签体系
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 二、优化目标
|
|
|
+
|
|
|
+### 2.1 业务目标
|
|
|
+
|
|
|
+| 目标 | 当前耗时 | 优化后 | 提升 |
|
|
|
+|-----|---------|-------|-----|
|
|
|
+| **发现问题项目** | 5-10秒(需扫描多个视图) | 1-2秒(统一预警面板) | 80%↓ |
|
|
|
+| **分配项目** | 5-10分钟(手动查看负载+擅长) | 1-2分钟(智能推荐) | 70%↓ |
|
|
|
+| **负载评估** | 仅凭项目数量 | 加权工作量科学评估 | 准确度↑ |
|
|
|
+
|
|
|
+### 2.2 技术目标
|
|
|
+
|
|
|
+- ✅ 100% fmode-ng真实数据对接(移除所有模拟数据)
|
|
|
+- ✅ 实现加权工作量计算(项目类型×剩余工期×紧急度)
|
|
|
+- ✅ 建立设计师标签体系(Profile.data.tags)
|
|
|
+- ✅ 实现智能匹配算法
|
|
|
+- ✅ 简化UI切换(减少50%模式切换)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 三、详细优化方案
|
|
|
+
|
|
|
+### 3.1 数据层优化:fmode-ng真实对接
|
|
|
+
|
|
|
+#### 创建DesignerService
|
|
|
+
|
|
|
+```typescript
|
|
|
+// src/app/pages/team-leader/services/designer.service.ts
|
|
|
+import { Injectable } from '@angular/core';
|
|
|
+import { FmodeParse as Parse } from 'fmode-ng/parse';
|
|
|
+
|
|
|
+interface DesignerTags {
|
|
|
+ expertise: {
|
|
|
+ styles: string[]; // 擅长风格
|
|
|
+ skills: string[]; // 专业技能
|
|
|
+ spaceTypes: string[]; // 擅长空间
|
|
|
+ };
|
|
|
+ capacity: {
|
|
|
+ weeklyProjects: number; // 单周可处理项目量
|
|
|
+ maxConcurrent: number; // 最大并发数
|
|
|
+ avgDaysPerProject: number; // 平均完成天数
|
|
|
+ };
|
|
|
+ emergency: {
|
|
|
+ willing: boolean; // 是否接受紧急单
|
|
|
+ premium: number; // 加急溢价(%)
|
|
|
+ maxPerWeek: number; // 每周最多紧急单数
|
|
|
+ };
|
|
|
+ history: {
|
|
|
+ totalProjects: number; // 历史总项目数
|
|
|
+ completionRate: number; // 完成率(%)
|
|
|
+ avgRating: number; // 平均评分
|
|
|
+ onTimeRate: number; // 按时交付率(%)
|
|
|
+ excellentCount: number; // 优秀作品数
|
|
|
+ };
|
|
|
+ portfolio: string[]; // 代表作(ProjectFile objectId数组)
|
|
|
+}
|
|
|
+
|
|
|
+@Injectable({
|
|
|
+ providedIn: 'root'
|
|
|
+})
|
|
|
+export class DesignerService {
|
|
|
+ private cid: string = '';
|
|
|
+
|
|
|
+ constructor() {
|
|
|
+ this.cid = localStorage.getItem('company') || '';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有设计师(组员角色)
|
|
|
+ */
|
|
|
+ async getDesigners(): Promise<any[]> {
|
|
|
+ const query = new Parse.Query('Profile');
|
|
|
+ query.equalTo('company', this.cid);
|
|
|
+ query.equalTo('roleName', '组员'); // 设计师角色
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
+ query.select('name', 'department', 'data');
|
|
|
+ query.limit(1000);
|
|
|
+
|
|
|
+ const profiles = await query.find();
|
|
|
+
|
|
|
+ return profiles.map(p => {
|
|
|
+ const data = p.get('data') || {};
|
|
|
+ const tags = data.tags || this.getDefaultTags();
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: p.id,
|
|
|
+ name: p.get('name'),
|
|
|
+ department: p.get('department')?.get?.('name') || '未分组',
|
|
|
+ tags,
|
|
|
+ profile: p
|
|
|
+ };
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 查询设计师当前负载
|
|
|
+ */
|
|
|
+ async getDesignerWorkload(designerId: string): Promise<{
|
|
|
+ projects: any[];
|
|
|
+ weightedTotal: number;
|
|
|
+ overdueCount: number;
|
|
|
+ loadRate: number;
|
|
|
+ }> {
|
|
|
+ // 查询设计师负责的所有进行中项目
|
|
|
+ const query = new Parse.Query('Project');
|
|
|
+ query.equalTo('assignee', Parse.Object.extend('Profile').createWithoutData(designerId));
|
|
|
+ query.equalTo('company', this.cid);
|
|
|
+ query.containedIn('status', ['进行中', '待分配']);
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
+ query.select('title', 'deadline', 'status', 'data');
|
|
|
+ query.limit(1000);
|
|
|
+
|
|
|
+ const projects = await query.find();
|
|
|
+
|
|
|
+ // 计算加权总量
|
|
|
+ let weightedTotal = 0;
|
|
|
+ let overdueCount = 0;
|
|
|
+ const now = new Date();
|
|
|
+
|
|
|
+ projects.forEach(proj => {
|
|
|
+ const weight = this.calculateProjectWeight(proj);
|
|
|
+ weightedTotal += weight;
|
|
|
+
|
|
|
+ const deadline = proj.get('deadline');
|
|
|
+ if (deadline && deadline < now) {
|
|
|
+ overdueCount++;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 获取设计师单周处理量
|
|
|
+ const designerProfiles = await this.getDesigners();
|
|
|
+ const designer = designerProfiles.find(d => d.id === designerId);
|
|
|
+ const weeklyCapacity = designer?.tags?.capacity?.weeklyProjects || 3;
|
|
|
+
|
|
|
+ // 计算负载率
|
|
|
+ const loadRate = weeklyCapacity > 0 ? (weightedTotal / weeklyCapacity * 100) : 0;
|
|
|
+
|
|
|
+ return {
|
|
|
+ projects: projects.map(p => p.toJSON()),
|
|
|
+ weightedTotal,
|
|
|
+ overdueCount,
|
|
|
+ loadRate
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算项目加权值
|
|
|
+ */
|
|
|
+ private calculateProjectWeight(project: any): number {
|
|
|
+ const data = project.get('data') || {};
|
|
|
+
|
|
|
+ // 1. 项目类型系数
|
|
|
+ const projectType = data.projectType || 'soft';
|
|
|
+ const typeWeight = projectType === 'hard' ? 2.0 : 1.0;
|
|
|
+
|
|
|
+ // 2. 剩余工期系数
|
|
|
+ const deadline = project.get('deadline');
|
|
|
+ const now = new Date();
|
|
|
+ const daysLeft = deadline ? Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : 30;
|
|
|
+
|
|
|
+ let timeWeight = 0.8;
|
|
|
+ if (daysLeft < 0) timeWeight = 1.5; // 超期
|
|
|
+ else if (daysLeft <= 3) timeWeight = 1.3; // 临期
|
|
|
+ else if (daysLeft <= 7) timeWeight = 1.0;
|
|
|
+ else timeWeight = 0.8;
|
|
|
+
|
|
|
+ // 3. 紧急度系数
|
|
|
+ const urgency = data.urgency || 'low';
|
|
|
+ const urgencyWeight = urgency === 'high' ? 1.2 : urgency === 'medium' ? 1.0 : 0.8;
|
|
|
+
|
|
|
+ return typeWeight * timeWeight * urgencyWeight;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有项目
|
|
|
+ */
|
|
|
+ async getProjects(): Promise<any[]> {
|
|
|
+ const query = new Parse.Query('Project');
|
|
|
+ query.equalTo('company', this.cid);
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
+ query.include('assignee', 'contact');
|
|
|
+ query.descending('updatedAt');
|
|
|
+ query.limit(1000);
|
|
|
+
|
|
|
+ const projects = await query.find();
|
|
|
+ return projects.map(p => this.transformProject(p));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 转换Project为前端格式
|
|
|
+ */
|
|
|
+ private transformProject(project: any): any {
|
|
|
+ const data = project.get('data') || {};
|
|
|
+ const deadline = project.get('deadline') || new Date();
|
|
|
+ const now = new Date();
|
|
|
+ const daysLeft = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
+
|
|
|
+ const assignee = project.get('assignee');
|
|
|
+ const contact = project.get('contact');
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: project.id,
|
|
|
+ name: project.get('title') || '未命名项目',
|
|
|
+ type: data.projectType || 'soft',
|
|
|
+ memberType: data.memberType || 'normal',
|
|
|
+ designerName: assignee?.get('name') || '未分配',
|
|
|
+ designerId: assignee?.id || '',
|
|
|
+ customerName: contact?.get('name') || '未知客户',
|
|
|
+ status: project.get('status') || '待分配',
|
|
|
+ currentStage: project.get('currentStage') || 'pendingAssignment',
|
|
|
+ deadline,
|
|
|
+ createdAt: project.get('createdAt'),
|
|
|
+ isOverdue: daysLeft < 0,
|
|
|
+ overdueDays: daysLeft < 0 ? Math.abs(daysLeft) : 0,
|
|
|
+ dueSoon: daysLeft >= 0 && daysLeft <= 3,
|
|
|
+ urgency: data.urgency || 'low',
|
|
|
+ weight: this.calculateProjectWeight(project),
|
|
|
+ data,
|
|
|
+ project
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 默认标签(新员工)
|
|
|
+ */
|
|
|
+ private getDefaultTags(): DesignerTags {
|
|
|
+ return {
|
|
|
+ expertise: {
|
|
|
+ styles: [],
|
|
|
+ skills: [],
|
|
|
+ spaceTypes: []
|
|
|
+ },
|
|
|
+ capacity: {
|
|
|
+ weeklyProjects: 2,
|
|
|
+ maxConcurrent: 3,
|
|
|
+ avgDaysPerProject: 10
|
|
|
+ },
|
|
|
+ emergency: {
|
|
|
+ willing: false,
|
|
|
+ premium: 0,
|
|
|
+ maxPerWeek: 0
|
|
|
+ },
|
|
|
+ history: {
|
|
|
+ totalProjects: 0,
|
|
|
+ completionRate: 0,
|
|
|
+ avgRating: 0,
|
|
|
+ onTimeRate: 0,
|
|
|
+ excellentCount: 0
|
|
|
+ },
|
|
|
+ portfolio: []
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 智能匹配算法
|
|
|
+ */
|
|
|
+ async getRecommendedDesigners(
|
|
|
+ project: any,
|
|
|
+ allDesigners?: any[]
|
|
|
+ ): Promise<Array<{
|
|
|
+ designer: any;
|
|
|
+ matchScore: number;
|
|
|
+ reason: string;
|
|
|
+ }>> {
|
|
|
+ if (!allDesigners) {
|
|
|
+ allDesigners = await this.getDesigners();
|
|
|
+ }
|
|
|
+
|
|
|
+ const projectStyle = project.data?.style || project.data?.requirement?.style || '';
|
|
|
+ const isEmergency = project.urgency === 'high';
|
|
|
+
|
|
|
+ const recommendations = [];
|
|
|
+
|
|
|
+ for (const designer of allDesigners) {
|
|
|
+ // 1. 风格匹配分(30分)
|
|
|
+ let styleScore = 0;
|
|
|
+ const styles = designer.tags.expertise.styles || [];
|
|
|
+ if (projectStyle && styles.includes(projectStyle)) {
|
|
|
+ styleScore = 30;
|
|
|
+ } else if (styles.length > 0) {
|
|
|
+ styleScore = 10; // 有标签但不完全匹配
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 负载适配分(30分)
|
|
|
+ const workload = await this.getDesignerWorkload(designer.id);
|
|
|
+ let loadScore = 0;
|
|
|
+ if (workload.loadRate < 60) loadScore = 30;
|
|
|
+ else if (workload.loadRate < 80) loadScore = 20;
|
|
|
+ else if (workload.loadRate < 100) loadScore = 10;
|
|
|
+ else loadScore = 0;
|
|
|
+
|
|
|
+ // 3. 历史表现分(25分)
|
|
|
+ const history = designer.tags.history;
|
|
|
+ const performanceScore = (
|
|
|
+ (history.completionRate || 0) * 0.4 +
|
|
|
+ (history.onTimeRate || 0) * 0.3 +
|
|
|
+ ((history.avgRating || 0) / 5 * 100) * 0.3
|
|
|
+ ) * 0.25;
|
|
|
+
|
|
|
+ // 4. 紧急适配分(15分,仅紧急项目)
|
|
|
+ let emergencyScore = 0;
|
|
|
+ if (isEmergency) {
|
|
|
+ if (designer.tags.emergency.willing) {
|
|
|
+ emergencyScore = 15;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const matchScore = styleScore + loadScore + performanceScore + emergencyScore;
|
|
|
+
|
|
|
+ // 生成推荐理由
|
|
|
+ let reason = [];
|
|
|
+ if (styleScore >= 20) reason.push('风格匹配度高');
|
|
|
+ if (loadScore >= 25) reason.push('负载空闲');
|
|
|
+ else if (loadScore >= 15) reason.push('负载适中');
|
|
|
+ if (performanceScore >= 20) reason.push('历史表现优秀');
|
|
|
+ if (emergencyScore > 0) reason.push('愿意接急单');
|
|
|
+
|
|
|
+ if (matchScore >= 40) {
|
|
|
+ recommendations.push({
|
|
|
+ designer,
|
|
|
+ matchScore: Math.round(matchScore),
|
|
|
+ reason: reason.join('、') || '基础匹配',
|
|
|
+ loadRate: workload.loadRate,
|
|
|
+ currentProjects: workload.projects.length
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return recommendations
|
|
|
+ .sort((a, b) => b.matchScore - a.matchScore)
|
|
|
+ .slice(0, 5);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 3.2 UI层优化:简化视图
|
|
|
+
|
|
|
+#### 优化前 vs 优化后
|
|
|
+
|
|
|
+| 模块 | 优化前 | 优化后 | 改进 |
|
|
|
+|-----|-------|-------|-----|
|
|
|
+| **KPI卡片** | 4个 | 6个(新增超负荷设计师、平均负载率) | +2个关键指标 |
|
|
|
+| **工作量概览** | ECharts切换按设计师/会员 | 卡片式,仅按设计师,直接显示负载率 | 移除不必要切换 |
|
|
|
+| **甘特图** | 两种模式切换 | 仅保留设计师排班模式 | 移除按项目模式 |
|
|
|
+| **预警面板** | 无 | 新增(超期+超负荷+即将到期) | 集中展示异常 |
|
|
|
+
|
|
|
+#### 新增组件:智能推荐弹窗
|
|
|
+
|
|
|
+```html
|
|
|
+<!-- 智能推荐弹窗 -->
|
|
|
+<div class="smart-match-modal" *ngIf="showSmartMatch">
|
|
|
+ <div class="modal-backdrop" (click)="closeSmartMatch()"></div>
|
|
|
+ <div class="modal-content">
|
|
|
+ <div class="modal-header">
|
|
|
+ <h3>智能推荐设计师</h3>
|
|
|
+ <button class="btn-close" (click)="closeSmartMatch()">×</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="project-info">
|
|
|
+ <h4>{{ selectedProject?.name }}</h4>
|
|
|
+ <div class="tags">
|
|
|
+ <span class="tag">{{ selectedProject?.type === 'hard' ? '硬装' : '软装' }}</span>
|
|
|
+ <span class="tag">{{ selectedProject?.memberType === 'vip' ? 'VIP' : '普通' }}</span>
|
|
|
+ <span class="tag urgency" [class]="'u-' + selectedProject?.urgency">
|
|
|
+ {{ getUrgencyLabel(selectedProject?.urgency) }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="recommendations-list">
|
|
|
+ <div class="rec-card" *ngFor="let rec of recommendations; let i = index">
|
|
|
+ <div class="rank" [class.gold]="i===0" [class.silver]="i===1" [class.bronze]="i===2">
|
|
|
+ {{ i + 1 }}
|
|
|
+ </div>
|
|
|
+ <div class="designer-info">
|
|
|
+ <h4>{{ rec.designer.name }}</h4>
|
|
|
+ <div class="match-score" [style.width.%]="rec.matchScore">
|
|
|
+ <span>{{ rec.matchScore }}分</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="details">
|
|
|
+ <p><strong>擅长:</strong>{{ rec.designer.tags.expertise.styles.join('、') || '暂无标签' }}</p>
|
|
|
+ <p><strong>负载:</strong>{{ rec.loadRate.toFixed(0) }}% ({{ rec.currentProjects }}个项目)</p>
|
|
|
+ <p><strong>评分:</strong>⭐ {{ rec.designer.tags.history.avgRating || '暂无' }}</p>
|
|
|
+ <p class="reason"><strong>推荐理由:</strong>{{ rec.reason }}</p>
|
|
|
+ </div>
|
|
|
+ <button class="btn-assign" (click)="assignToDesigner(rec.designer.id)">
|
|
|
+ 分配给TA
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="empty" *ngIf="!recommendations.length">
|
|
|
+ <p>未找到合适的设计师</p>
|
|
|
+ <p>您可以手动分配或调整项目参数</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+```
|
|
|
+
|
|
|
+### 3.3 算法层:加权工作量计算
|
|
|
+
|
|
|
+```typescript
|
|
|
+// dashboard.ts 中添加
|
|
|
+
|
|
|
+/**
|
|
|
+ * 计算项目加权值(复用DesignerService逻辑)
|
|
|
+ */
|
|
|
+calculateWorkloadWeight(project: any): number {
|
|
|
+ // 1. 项目类型系数
|
|
|
+ const typeWeight = project.type === 'hard' ? 2.0 : 1.0;
|
|
|
+
|
|
|
+ // 2. 剩余工期系数
|
|
|
+ const now = new Date();
|
|
|
+ const daysLeft = Math.ceil((project.deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
|
|
+
|
|
|
+ let timeWeight = 0.8;
|
|
|
+ if (daysLeft < 0) timeWeight = 1.5; // 超期
|
|
|
+ else if (daysLeft <= 3) timeWeight = 1.3; // 临期
|
|
|
+ else if (daysLeft <= 7) timeWeight = 1.0;
|
|
|
+
|
|
|
+ // 3. 紧急度系数
|
|
|
+ const urgencyWeight = project.urgency === 'high' ? 1.2 : project.urgency === 'medium' ? 1.0 : 0.8;
|
|
|
+
|
|
|
+ return typeWeight * timeWeight * urgencyWeight;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取设计师加权工作量
|
|
|
+ */
|
|
|
+getDesignerWeightedWorkload(designerName: string): {
|
|
|
+ weightedTotal: number;
|
|
|
+ projectCount: number;
|
|
|
+ overdueCount: number;
|
|
|
+ loadRate: number;
|
|
|
+} {
|
|
|
+ const designerProjects = this.filteredProjects.filter(p => p.designerName === designerName);
|
|
|
+ const weightedTotal = designerProjects.reduce((sum, p) => sum + this.calculateWorkloadWeight(p), 0);
|
|
|
+ const overdueCount = designerProjects.filter(p => p.isOverdue).length;
|
|
|
+
|
|
|
+ // 假设单周处理量为3(后续从Profile.data.tags读取)
|
|
|
+ const weeklyCapacity = 3;
|
|
|
+ const loadRate = (weightedTotal / weeklyCapacity) * 100;
|
|
|
+
|
|
|
+ return {
|
|
|
+ weightedTotal,
|
|
|
+ projectCount: designerProjects.length,
|
|
|
+ overdueCount,
|
|
|
+ loadRate
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 工作量卡片数据(替代ECharts)
|
|
|
+ */
|
|
|
+get designerWorkloadCards(): Array<{
|
|
|
+ name: string;
|
|
|
+ loadRate: number;
|
|
|
+ weightedValue: number;
|
|
|
+ projectCount: number;
|
|
|
+ overdueCount: number;
|
|
|
+ status: 'overload' | 'busy' | 'idle';
|
|
|
+}> {
|
|
|
+ const designers = Array.from(new Set(this.filteredProjects.map(p => p.designerName).filter(n => n)));
|
|
|
+
|
|
|
+ return designers.map(name => {
|
|
|
+ const workload = this.getDesignerWeightedWorkload(name);
|
|
|
+ let status: 'overload' | 'busy' | 'idle' = 'idle';
|
|
|
+ if (workload.loadRate > 80) status = 'overload';
|
|
|
+ else if (workload.loadRate > 50) status = 'busy';
|
|
|
+
|
|
|
+ return {
|
|
|
+ name,
|
|
|
+ loadRate: workload.loadRate,
|
|
|
+ weightedValue: workload.weightedTotal,
|
|
|
+ projectCount: workload.projectCount,
|
|
|
+ overdueCount: workload.overdueCount,
|
|
|
+ status
|
|
|
+ };
|
|
|
+ }).sort((a, b) => b.loadRate - a.loadRate); // 按负载率降序
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、实施步骤
|
|
|
+
|
|
|
+### Phase 1: 创建DesignerService(2小时)
|
|
|
+
|
|
|
+1. ✅ 创建 `src/app/pages/team-leader/services/designer.service.ts`
|
|
|
+2. ✅ 实现Profile查询、Project查询、加权计算、智能匹配
|
|
|
+
|
|
|
+### Phase 2: 修改dashboard.ts数据源(3小时)
|
|
|
+
|
|
|
+1. 🔨 注入DesignerService
|
|
|
+2. 🔨 loadProjects() 改为调用 `designerService.getProjects()`
|
|
|
+3. 🔨 移除硬编码的 `designerProfiles`
|
|
|
+4. 🔨 实现 `calculateWorkloadWeight()`、`designerWorkloadCards`
|
|
|
+
|
|
|
+### Phase 3: 简化UI(2小时)
|
|
|
+
|
|
|
+1. 🔨 移除工作量概览的按会员类型切换
|
|
|
+2. 🔨 移除甘特图的按项目模式
|
|
|
+3. 🔨 新增预警面板HTML
|
|
|
+4. 🔨 新增智能推荐弹窗HTML
|
|
|
+
|
|
|
+### Phase 4: 集成智能推荐(2小时)
|
|
|
+
|
|
|
+1. 🔨 添加"智能推荐"按钮到待分配项目卡片
|
|
|
+2. 🔨 实现推荐弹窗逻辑
|
|
|
+3. 🔨 实现一键分配功能
|
|
|
+
|
|
|
+### Phase 5: 样式优化(1小时)
|
|
|
+
|
|
|
+1. 🔨 优化卡片式工作量展示
|
|
|
+2. 🔨 添加预警面板样式
|
|
|
+3. 🔨 添加智能推荐弹窗样式
|
|
|
+
|
|
|
+### Phase 6: 测试与修复(2小时)
|
|
|
+
|
|
|
+1. 🔨 测试fmode-ng数据查询
|
|
|
+2. 🔨 测试智能匹配准确性
|
|
|
+3. 🔨 测试页面滚动(确保之前的CSS修复生效)
|
|
|
+4. 🔨 测试所有筛选功能
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、数据库Schema要求
|
|
|
+
|
|
|
+### Profile.data.tags结构(与之前方案一致)
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface ProfileData {
|
|
|
+ tags: {
|
|
|
+ expertise: {
|
|
|
+ styles: string[]; // ['现代简约', '北欧', ...]
|
|
|
+ skills: string[]; // ['建模', '渲染', ...]
|
|
|
+ spaceTypes: string[]; // ['卧室', '客厅', ...]
|
|
|
+ };
|
|
|
+ capacity: {
|
|
|
+ weeklyProjects: number;
|
|
|
+ maxConcurrent: number;
|
|
|
+ avgDaysPerProject: number;
|
|
|
+ };
|
|
|
+ emergency: {
|
|
|
+ willing: boolean;
|
|
|
+ premium: number;
|
|
|
+ maxPerWeek: number;
|
|
|
+ };
|
|
|
+ history: {
|
|
|
+ totalProjects: number;
|
|
|
+ completionRate: number;
|
|
|
+ avgRating: number;
|
|
|
+ onTimeRate: number;
|
|
|
+ excellentCount: number;
|
|
|
+ };
|
|
|
+ portfolio: string[];
|
|
|
+ };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### Project.data扩展字段
|
|
|
+
|
|
|
+```typescript
|
|
|
+interface ProjectData {
|
|
|
+ projectType?: 'soft' | 'hard'; // 软装/硬装
|
|
|
+ memberType?: 'vip' | 'normal'; // 会员类型
|
|
|
+ urgency?: 'high' | 'medium' | 'low'; // 紧急度
|
|
|
+ style?: string; // 风格(用于智能匹配)
|
|
|
+ requirement?: {
|
|
|
+ style?: string;
|
|
|
+ spaces?: string[];
|
|
|
+ // ...
|
|
|
+ };
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、效率对比
|
|
|
+
|
|
|
+| 维度 | 优化前 | 优化后 | 提升 |
|
|
|
+|-----|-------|-------|-----|
|
|
|
+| **问题发现** | 5-10秒(扫描多视图) | 1-2秒(预警面板) | 80%↓ |
|
|
|
+| **项目分配** | 5-10分钟 | 1-2分钟(智能推荐) | 70%↓ |
|
|
|
+| **负载评估** | 不准确(仅项目数) | 准确(加权计算) | 准确度↑ |
|
|
|
+| **数据真实性** | 0%(模拟数据) | 100%(fmode-ng) | 100%↑ |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 七、兼容性保障
|
|
|
+
|
|
|
+- ✅ 保留所有现有筛选功能
|
|
|
+- ✅ 保留项目看板4列结构
|
|
|
+- ✅ 保留搜索建议功能
|
|
|
+- ✅ 仅移除不必要的切换,不破坏核心功能
|
|
|
+- ✅ 新增功能(智能推荐)为独立模块,不影响手动分配
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 八、待确认事项
|
|
|
+
|
|
|
+### 1. Profile.data.tags数据初始化
|
|
|
+
|
|
|
+对于现有员工,需要批量初始化tags字段:
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 管理员后台运行
|
|
|
+async initializeAllDesignerTags() {
|
|
|
+ const query = new Parse.Query('Profile');
|
|
|
+ query.equalTo('company', this.cid);
|
|
|
+ query.equalTo('roleName', '组员');
|
|
|
+ query.notEqualTo('isDeleted', true);
|
|
|
+
|
|
|
+ const profiles = await query.find();
|
|
|
+
|
|
|
+ for (const profile of profiles) {
|
|
|
+ const data = profile.get('data') || {};
|
|
|
+ if (!data.tags) {
|
|
|
+ data.tags = {
|
|
|
+ expertise: { styles: [], skills: [], spaceTypes: [] },
|
|
|
+ capacity: { weeklyProjects: 2, maxConcurrent: 3, avgDaysPerProject: 10 },
|
|
|
+ emergency: { willing: false, premium: 0, maxPerWeek: 0 },
|
|
|
+ history: { totalProjects: 0, completionRate: 0, avgRating: 0, onTimeRate: 0, excellentCount: 0 },
|
|
|
+ portfolio: []
|
|
|
+ };
|
|
|
+ profile.set('data', data);
|
|
|
+ await profile.save();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+### 2. Project.data字段补充
|
|
|
+
|
|
|
+确认现有Project记录的data字段是否包含:
|
|
|
+- `projectType`(软装/硬装)
|
|
|
+- `urgency`(紧急度)
|
|
|
+- `memberType`(VIP/普通)
|
|
|
+
|
|
|
+如缺失,需补充或使用默认值。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+**文档版本**: v1.0
|
|
|
+**最后更新**: 2025-10-22
|
|
|
+**维护者**: YSS Project Team
|
|
|
+
|
|
|
+
|