|
@@ -1,7 +1,7 @@
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { Router, RouterModule } from '@angular/router';
|
|
|
-import { Component, OnInit } from '@angular/core';
|
|
|
+import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
|
|
|
import { ProjectService } from '../../../services/project.service';
|
|
|
|
|
|
// 项目阶段定义
|
|
@@ -26,7 +26,9 @@ interface Project {
|
|
|
memberType: 'vip' | 'normal';
|
|
|
designerName: string;
|
|
|
status: string;
|
|
|
- expectedEndDate: Date;
|
|
|
+ expectedEndDate: Date; // TODO: 兼容旧字段,后续可移除
|
|
|
+ deadline: Date; // 真实截止时间字段
|
|
|
+ createdAt?: Date; // 真实开始时间字段(可选)
|
|
|
isOverdue: boolean;
|
|
|
overdueDays: number;
|
|
|
dueSoon: boolean;
|
|
@@ -36,6 +38,8 @@ interface Project {
|
|
|
// 新增:质量评级
|
|
|
qualityRating?: 'excellent' | 'qualified' | 'unqualified' | 'pending';
|
|
|
lastCustomerFeedback?: string;
|
|
|
+// 预构建的搜索索引,减少重复 toLowerCase 与拼接
|
|
|
+ searchIndex?: string;
|
|
|
}
|
|
|
|
|
|
interface TodoTask {
|
|
@@ -48,6 +52,7 @@ interface TodoTask {
|
|
|
targetId: string;
|
|
|
}
|
|
|
|
|
|
+declare const echarts: any;
|
|
|
@Component({
|
|
|
selector: 'app-dashboard',
|
|
|
imports: [CommonModule, FormsModule, RouterModule],
|
|
@@ -55,14 +60,26 @@ interface TodoTask {
|
|
|
styleUrl: './dashboard.scss'
|
|
|
})
|
|
|
|
|
|
-export class Dashboard implements OnInit {
|
|
|
+export class Dashboard implements OnInit, OnDestroy {
|
|
|
projects: Project[] = [];
|
|
|
filteredProjects: Project[] = [];
|
|
|
todoTasks: TodoTask[] = [];
|
|
|
overdueProjects: Project[] = [];
|
|
|
+ urgentPinnedProjects: Project[] = [];
|
|
|
showAlert: boolean = false;
|
|
|
selectedProjectId: string = '';
|
|
|
+ // 新增:关键词搜索
|
|
|
+ searchTerm: string = '';
|
|
|
+ searchSuggestions: Project[] = [];
|
|
|
+ showSuggestions: boolean = false;
|
|
|
+ private hideSuggestionsTimer: any;
|
|
|
|
|
|
+ // 搜索性能与交互控制
|
|
|
+ private searchDebounceTimer: any;
|
|
|
+ private readonly SEARCH_DEBOUNCE_MS = 200; // 防抖时长(毫秒)
|
|
|
+ private readonly MIN_SEARCH_LEN = 2; // 最小触发建议长度
|
|
|
+ private readonly MAX_SUGGESTIONS = 8; // 建议最大条数
|
|
|
+ private isSearchFocused: boolean = false; // 是否处于输入聚焦态
|
|
|
// 新增:临期项目与筛选状态
|
|
|
dueSoonProjects: Project[] = [];
|
|
|
selectedType: 'all' | 'soft' | 'hard' = 'all';
|
|
@@ -74,15 +91,15 @@ export class Dashboard implements OnInit {
|
|
|
selectedTimeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays' = 'all';
|
|
|
designers: string[] = [];
|
|
|
|
|
|
- // 新增:智能推荐设计师配置
|
|
|
+ // 设计师画像(用于智能推荐)
|
|
|
designerProfiles: any[] = [
|
|
|
{ id: 'zhang', name: '张三', skills: ['现代风格', '北欧风格'], workload: 70, avgRating: 4.5, experience: 3 },
|
|
|
{ id: 'li', name: '李四', skills: ['新中式', '宋式风格'], workload: 45, avgRating: 4.8, experience: 5 },
|
|
|
{ id: 'wang', name: '王五', skills: ['北欧风格', '日式风格'], workload: 85, avgRating: 4.2, experience: 2 },
|
|
|
{ id: 'zhao', name: '赵六', skills: ['现代风格', '轻奢风格'], workload: 30, avgRating: 4.6, experience: 4 }
|
|
|
];
|
|
|
-
|
|
|
- // 定义10个项目阶段
|
|
|
+
|
|
|
+ // 10个项目阶段
|
|
|
projectStages: ProjectStage[] = [
|
|
|
{ id: 'pendingApproval', name: '待确认', order: 1 },
|
|
|
{ id: 'pendingAssignment', name: '待分配', order: 2 },
|
|
@@ -96,7 +113,7 @@ export class Dashboard implements OnInit {
|
|
|
{ id: 'delivery', name: '交付完成', order: 10 }
|
|
|
];
|
|
|
|
|
|
- // 新增:5大核心阶段
|
|
|
+ // 5大核心阶段(聚合展示)
|
|
|
corePhases: ProjectStage[] = [
|
|
|
{ id: 'preparation', name: '前期准备', order: 1 }, // 待确认、待分配
|
|
|
{ id: 'design', name: '方案设计', order: 2 }, // 需求沟通、方案规划
|
|
@@ -104,11 +121,25 @@ export class Dashboard implements OnInit {
|
|
|
{ id: 'review', name: '评审修订', order: 4 }, // 评审、修改
|
|
|
{ id: 'delivery', name: '交付完成', order: 5 } // 交付
|
|
|
];
|
|
|
+ // 甘特视图开关与实例引用
|
|
|
+ showGanttView: boolean = false;
|
|
|
+ private ganttChart: any | null = null;
|
|
|
+ @ViewChild('ganttChartRef', { static: false }) ganttChartRef!: ElementRef<HTMLDivElement>;
|
|
|
+ // 新增:工作量概览图表引用与实例
|
|
|
+ @ViewChild('workloadChartRef', { static: false }) workloadChartRef!: ElementRef<HTMLDivElement>;
|
|
|
+ private workloadChart: any | null = null;
|
|
|
+ workloadDimension: 'designer' | 'member' = 'designer';
|
|
|
+ // 甘特时间尺度:仅周/月
|
|
|
+ ganttScale: 'week' | 'month' = 'week';
|
|
|
+ // 新增:甘特模式(项目 / 设计师排班)
|
|
|
+ ganttMode: 'project' | 'designer' = 'project';
|
|
|
constructor(private projectService: ProjectService, private router: Router) {}
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
this.loadProjects();
|
|
|
this.loadTodoTasks();
|
|
|
+ // 首次微任务后尝试初始化一次,确保容器已渲染
|
|
|
+ setTimeout(() => this.updateWorkloadChart(), 0);
|
|
|
}
|
|
|
|
|
|
loadProjects(): void {
|
|
@@ -122,6 +153,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '张三',
|
|
|
status: '进行中',
|
|
|
expectedEndDate: new Date(2023, 9, 15),
|
|
|
+ deadline: new Date(2023, 9, 15),
|
|
|
isOverdue: true,
|
|
|
overdueDays: 2,
|
|
|
dueSoon: false,
|
|
@@ -142,6 +174,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '李四',
|
|
|
status: '进行中',
|
|
|
expectedEndDate: new Date(2023, 9, 20),
|
|
|
+ deadline: new Date(2023, 9, 20),
|
|
|
isOverdue: false,
|
|
|
overdueDays: 0,
|
|
|
dueSoon: false,
|
|
@@ -162,6 +195,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '王五',
|
|
|
status: '进行中',
|
|
|
expectedEndDate: new Date(2023, 9, 25),
|
|
|
+ deadline: new Date(2023, 9, 25),
|
|
|
isOverdue: false,
|
|
|
overdueDays: 0,
|
|
|
dueSoon: false,
|
|
@@ -182,6 +216,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '赵六',
|
|
|
status: '进行中',
|
|
|
expectedEndDate: new Date(2023, 9, 10),
|
|
|
+ deadline: new Date(2023, 9, 10),
|
|
|
isOverdue: true,
|
|
|
overdueDays: 7,
|
|
|
dueSoon: false,
|
|
@@ -203,6 +238,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '',
|
|
|
status: '待分配',
|
|
|
expectedEndDate: new Date(2023, 10, 5),
|
|
|
+ deadline: new Date(2023, 10, 5),
|
|
|
isOverdue: false,
|
|
|
overdueDays: 0,
|
|
|
dueSoon: false,
|
|
@@ -218,6 +254,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '',
|
|
|
status: '待确认',
|
|
|
expectedEndDate: new Date(2023, 10, 10),
|
|
|
+ deadline: new Date(2023, 10, 10),
|
|
|
isOverdue: false,
|
|
|
overdueDays: 0,
|
|
|
dueSoon: false,
|
|
@@ -233,6 +270,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName: '钱七',
|
|
|
status: '已完成',
|
|
|
expectedEndDate: new Date(2023, 9, 5),
|
|
|
+ deadline: new Date(2023, 9, 5),
|
|
|
isOverdue: false,
|
|
|
overdueDays: 0,
|
|
|
dueSoon: false,
|
|
@@ -283,6 +321,7 @@ export class Dashboard implements OnInit {
|
|
|
designerName,
|
|
|
status,
|
|
|
expectedEndDate,
|
|
|
+ deadline: expectedEndDate,
|
|
|
isOverdue,
|
|
|
overdueDays,
|
|
|
dueSoon,
|
|
@@ -293,6 +332,15 @@ export class Dashboard implements OnInit {
|
|
|
}
|
|
|
// ===== 示例数据生成结束 =====
|
|
|
|
|
|
+ // 统一补齐真实时间字段(deadline/createdAt),以真实字段贯通筛选与甘特
|
|
|
+ const DAY = 24 * 60 * 60 * 1000;
|
|
|
+ this.projects = this.projects.map(p => {
|
|
|
+ const deadline = p.deadline || p.expectedEndDate;
|
|
|
+ const baseDays = p.type === 'hard' ? 30 : 14;
|
|
|
+ const createdAt = p.createdAt || new Date(new Date(deadline).getTime() - baseDays * DAY);
|
|
|
+ return { ...p, deadline, createdAt } as Project;
|
|
|
+ });
|
|
|
+
|
|
|
// 筛选超期与临期项目
|
|
|
this.overdueProjects = this.projects.filter(project => project.isOverdue);
|
|
|
this.dueSoonProjects = this.projects.filter(project => project.dueSoon && !project.isOverdue);
|
|
@@ -384,7 +432,9 @@ export class Dashboard implements OnInit {
|
|
|
|
|
|
// 筛选项目状态
|
|
|
filterByStatus(status: string): void {
|
|
|
- this.selectedStatus = (status && status.length ? status : 'all') as any;
|
|
|
+ // 点击同一状态时,切换回“全部”,以便恢复全量项目列表
|
|
|
+ const next = (this.selectedStatus === status) ? 'all' : (status && status.length ? status : 'all');
|
|
|
+ this.selectedStatus = next as any;
|
|
|
this.applyFilters();
|
|
|
}
|
|
|
|
|
@@ -395,16 +445,94 @@ export class Dashboard implements OnInit {
|
|
|
this.applyFilters();
|
|
|
}
|
|
|
|
|
|
+ // 新增:设计师筛选下拉事件处理
|
|
|
+ onDesignerChange(event: Event): void {
|
|
|
+ const target = event.target as HTMLSelectElement;
|
|
|
+ this.selectedDesigner = (target && target.value ? target.value : 'all');
|
|
|
+ this.applyFilters();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:会员类型筛选下拉事件处理
|
|
|
+ onMemberTypeChange(event: Event): void {
|
|
|
+ const target = event.target as HTMLSelectElement;
|
|
|
+ this.selectedMemberType = (target && target.value ? target.value : 'all') as any;
|
|
|
+ this.applyFilters();
|
|
|
+ }
|
|
|
+
|
|
|
// 时间窗快捷筛选(供UI按钮触发)
|
|
|
filterByTimeWindow(timeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays'): void {
|
|
|
this.selectedTimeWindow = timeWindow;
|
|
|
this.applyFilters();
|
|
|
}
|
|
|
|
|
|
+ // 新增:搜索输入变化
|
|
|
+ onSearchChange(): void {
|
|
|
+ if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
|
|
|
+ this.searchDebounceTimer = setTimeout(() => {
|
|
|
+ this.updateSearchSuggestions();
|
|
|
+ this.applyFilters();
|
|
|
+ }, this.SEARCH_DEBOUNCE_MS);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:搜索框聚焦/失焦控制建议显隐
|
|
|
+ onSearchFocus(): void {
|
|
|
+ if (this.hideSuggestionsTimer) clearTimeout(this.hideSuggestionsTimer);
|
|
|
+ this.isSearchFocused = true;
|
|
|
+ this.updateSearchSuggestions();
|
|
|
+ }
|
|
|
+ onSearchBlur(): void {
|
|
|
+ // 延迟隐藏以允许选择项的 mousedown 触发
|
|
|
+ this.isSearchFocused = false;
|
|
|
+ this.hideSuggestionsTimer = setTimeout(() => {
|
|
|
+ this.showSuggestions = false;
|
|
|
+ }, 150);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:更新搜索建议(不叠加其它筛选,仅基于关键字)
|
|
|
+ private updateSearchSuggestions(): void {
|
|
|
+ const q = (this.searchTerm || '').trim().toLowerCase();
|
|
|
+ if (q.length < this.MIN_SEARCH_LEN) {
|
|
|
+ this.searchSuggestions = [];
|
|
|
+ this.showSuggestions = false;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const scored = this.projects
|
|
|
+ .filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q))
|
|
|
+ .map(p => {
|
|
|
+ const dl = p.deadline || p.expectedEndDate;
|
|
|
+ const dlTime = dl ? new Date(dl).getTime() : NaN;
|
|
|
+ const daysToDl = Math.ceil(((isNaN(dlTime) ? 0 : dlTime) - Date.now()) / (1000 * 60 * 60 * 24));
|
|
|
+ const urgencyScore = p.urgency === 'high' ? 3 : p.urgency === 'medium' ? 2 : 1;
|
|
|
+ const overdueScore = p.isOverdue ? 10 : 0;
|
|
|
+ const score = overdueScore + (4 - urgencyScore) * 2 - (isNaN(daysToDl) ? 0 : daysToDl);
|
|
|
+ return { p, score };
|
|
|
+ })
|
|
|
+ .sort((a, b) => b.score - a.score)
|
|
|
+ .slice(0, this.MAX_SUGGESTIONS)
|
|
|
+ .map(x => x.p);
|
|
|
+
|
|
|
+ this.searchSuggestions = scored;
|
|
|
+ this.showSuggestions = this.isSearchFocused && this.searchSuggestions.length > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:选择建议项
|
|
|
+ selectSuggestion(project: Project): void {
|
|
|
+ this.searchTerm = project.name;
|
|
|
+ this.showSuggestions = false;
|
|
|
+ this.viewProjectDetails(project.id);
|
|
|
+ }
|
|
|
+
|
|
|
// 统一筛选
|
|
|
private applyFilters(): void {
|
|
|
let result = [...this.projects];
|
|
|
|
|
|
+ // 新增:关键词搜索(项目名 / 设计师名 / 含风格关键词的项目名)
|
|
|
+ const q = (this.searchTerm || '').trim().toLowerCase();
|
|
|
+ if (q) {
|
|
|
+ result = result.filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q));
|
|
|
+ }
|
|
|
+
|
|
|
// 类型筛选
|
|
|
if (this.selectedType !== 'all') {
|
|
|
result = result.filter(p => p.type === this.selectedType);
|
|
@@ -449,7 +577,7 @@ export class Dashboard implements OnInit {
|
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
|
|
result = result.filter(p => {
|
|
|
- const projectDeadline = new Date(p.expectedEndDate);
|
|
|
+ const projectDeadline = new Date(p.deadline);
|
|
|
const timeDiff = projectDeadline.getTime() - today.getTime();
|
|
|
const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
|
|
|
|
|
@@ -467,22 +595,426 @@ export class Dashboard implements OnInit {
|
|
|
}
|
|
|
|
|
|
this.filteredProjects = result;
|
|
|
+ // 新增:计算紧急任务固定区(超期 + 高紧急),与筛选联动
|
|
|
+ this.urgentPinnedProjects = this.filteredProjects
|
|
|
+ .filter(p => p.isOverdue && p.urgency === 'high')
|
|
|
+ .sort((a, b) => (b.overdueDays - a.overdueDays) || a.name.localeCompare(b.name, 'zh-Hans-CN'));
|
|
|
+ // 当显示甘特卡片时,同步刷新甘特图
|
|
|
+ if (this.showGanttView) {
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+ // 同步刷新工作量概览图
|
|
|
+ this.updateWorkloadChart();
|
|
|
}
|
|
|
|
|
|
- // 新增:设计师筛选
|
|
|
- onDesignerChange(event: Event): void {
|
|
|
- const target = event.target as HTMLSelectElement;
|
|
|
- this.selectedDesigner = target && target.value ? target.value : 'all';
|
|
|
- this.applyFilters();
|
|
|
+ // 切换项目看板/负载日历(甘特)视图
|
|
|
+ toggleView(): void {
|
|
|
+ this.showGanttView = !this.showGanttView;
|
|
|
+ if (this.showGanttView) {
|
|
|
+ setTimeout(() => this.initOrUpdateGantt(), 0);
|
|
|
+ } else {
|
|
|
+ if (this.ganttChart) {
|
|
|
+ this.ganttChart.dispose();
|
|
|
+ this.ganttChart = null;
|
|
|
+ }
|
|
|
+ if (this.workloadChart) {
|
|
|
+ this.workloadChart.dispose();
|
|
|
+ this.workloadChart = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // 新增:会员类型筛选
|
|
|
- onMemberTypeChange(event: Event): void {
|
|
|
- const target = event.target as HTMLSelectElement;
|
|
|
- this.selectedMemberType = (target && target.value ? target.value : 'all') as any;
|
|
|
- this.applyFilters();
|
|
|
+ // 设置甘特时间尺度
|
|
|
+ setGanttScale(scale: 'week' | 'month'): void {
|
|
|
+ if (this.ganttScale !== scale) {
|
|
|
+ this.ganttScale = scale;
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:切换甘特模式
|
|
|
+ setGanttMode(mode: 'project' | 'designer'): void {
|
|
|
+ if (this.ganttMode !== mode) {
|
|
|
+ this.ganttMode = mode;
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private initOrUpdateGantt(): void {
|
|
|
+ if (!this.ganttChartRef) return;
|
|
|
+ const el = this.ganttChartRef.nativeElement;
|
|
|
+ if (!this.ganttChart) {
|
|
|
+ this.ganttChart = echarts.init(el);
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
+ this.ganttChart && this.ganttChart.resize();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+
|
|
|
+ private updateGantt(): void {
|
|
|
+ if (!this.ganttChart) return;
|
|
|
+ if (this.ganttMode === 'designer') {
|
|
|
+ this.updateGanttDesigner();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按紧急程度从上到下排序(高->中->低),同级按到期时间升序
|
|
|
+ const urgencyRank: Record<'high'|'medium'|'low', number> = { high: 0, medium: 1, low: 2 };
|
|
|
+ const projects = [...this.filteredProjects]
|
|
|
+ .sort((a, b) => {
|
|
|
+ const u = urgencyRank[a.urgency] - urgencyRank[b.urgency];
|
|
|
+ if (u !== 0) return u;
|
|
|
+ // 二级排序:临期优先(到期更近)> 已分配人员 > VIP客户 > 其他
|
|
|
+ const endDiff = new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
|
|
|
+ if (endDiff !== 0) return endDiff;
|
|
|
+ const assignedA = !!a.designerName;
|
|
|
+ const assignedB = !!b.designerName;
|
|
|
+ if (assignedA !== assignedB) return assignedA ? -1 : 1; // 已分配在前
|
|
|
+ const vipA = a.memberType === 'vip';
|
|
|
+ const vipB = b.memberType === 'vip';
|
|
|
+ if (vipA !== vipB) return vipA ? -1 : 1; // VIP在前
|
|
|
+ return a.name.localeCompare(b.name, 'zh-CN');
|
|
|
+ });
|
|
|
+
|
|
|
+ const categories = projects.map(p => p.name);
|
|
|
+ const urgencyMap: Record<string, 'high'|'medium'|'low'> = Object.fromEntries(projects.map(p => [p.name, p.urgency])) as any;
|
|
|
+
|
|
|
+ const colorByUrgency: Record<'high'|'medium'|'low', string> = {
|
|
|
+ high: '#ef4444',
|
|
|
+ medium: '#f59e0b',
|
|
|
+ low: '#22c55e'
|
|
|
+ } as const;
|
|
|
+
|
|
|
+ const DAY = 24 * 60 * 60 * 1000;
|
|
|
+
|
|
|
+ const data = projects.map((p, idx) => {
|
|
|
+ const end = new Date(p.deadline).getTime();
|
|
|
+ const baseDays = p.type === 'hard' ? 30 : 14;
|
|
|
+ const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
|
|
|
+ const color = colorByUrgency[p.urgency] || '#60a5fa';
|
|
|
+ return {
|
|
|
+ name: p.name,
|
|
|
+ value: [idx, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
|
|
|
+ itemStyle: { color }
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ // 计算时间范围(仅周/月)
|
|
|
+ const now = new Date();
|
|
|
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
+ const todayTs = today.getTime();
|
|
|
+
|
|
|
+ let xMin: number;
|
|
|
+ let xMax: number;
|
|
|
+ let xSplitNumber: number;
|
|
|
+ let xLabelFormatter: (value: number) => string;
|
|
|
+
|
|
|
+ if (this.ganttScale === 'week') {
|
|
|
+ const day = today.getDay(); // 0=周日
|
|
|
+ const diffToMonday = (day === 0 ? 6 : day - 1);
|
|
|
+ const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
|
|
|
+ const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
|
|
|
+ xMin = startOfWeek.getTime();
|
|
|
+ xMax = endOfWeek.getTime();
|
|
|
+ xSplitNumber = 7;
|
|
|
+ const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
|
|
|
+ xLabelFormatter = (val) => {
|
|
|
+ const d = new Date(val);
|
|
|
+ return WEEK_LABELS[d.getDay()];
|
|
|
+ };
|
|
|
+ } else { // month
|
|
|
+ const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
|
+ const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
|
|
|
+ xMin = startOfMonth.getTime();
|
|
|
+ xMax = endOfMonth.getTime();
|
|
|
+ xSplitNumber = 4;
|
|
|
+ xLabelFormatter = (val) => {
|
|
|
+ const d = new Date(val);
|
|
|
+ const weekOfMonth = Math.ceil(d.getDate() / 7);
|
|
|
+ return `第${weekOfMonth}周`;
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 计算默认可视区,并尝试保留上一次的滚动/缩放位置
|
|
|
+ const total = categories.length;
|
|
|
+ const visible = Math.min(total, 15); // 默认首屏展开15条
|
|
|
+ const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
|
|
|
+ const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
|
|
|
+ const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
|
|
|
+ const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const v = params.value;
|
|
|
+ const start = new Date(v[1]);
|
|
|
+ const end = new Date(v[2]);
|
|
|
+ return `项目:${params.name}<br/>负责人:${v[3] || '未分配'}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: { left: 100, right: 64, top: 30, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'time',
|
|
|
+ min: xMin,
|
|
|
+ max: xMax,
|
|
|
+ splitNumber: xSplitNumber,
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } },
|
|
|
+ axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
|
|
|
+ splitLine: { lineStyle: { color: '#f1f5f9' } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: categories,
|
|
|
+ inverse: true,
|
|
|
+ axisLabel: {
|
|
|
+ color: '#374151',
|
|
|
+ margin: 8,
|
|
|
+ formatter: (val: string) => {
|
|
|
+ const u = urgencyMap[val] || 'low';
|
|
|
+ const text = val.length > 16 ? val.slice(0, 16) + '…' : val;
|
|
|
+ return `{${u}Dot|●} ${text}`;
|
|
|
+ },
|
|
|
+ rich: {
|
|
|
+ highDot: { color: '#ef4444' },
|
|
|
+ mediumDot: { color: '#f59e0b' },
|
|
|
+ lowDot: { color: '#22c55e' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } }
|
|
|
+ },
|
|
|
+ dataZoom: [
|
|
|
+ { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, width: 14, start: preservedStart, end: preservedEnd, zoomLock: false },
|
|
|
+ { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'custom',
|
|
|
+ renderItem: (params: any, api: any) => {
|
|
|
+ const categoryIndex = api.value(0);
|
|
|
+ const start = api.coord([api.value(1), categoryIndex]);
|
|
|
+ const end = api.coord([api.value(2), categoryIndex]);
|
|
|
+ const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
|
|
|
+ const rectShape = echarts.graphic.clipRectByRect({
|
|
|
+ x: start[0],
|
|
|
+ y: start[1] - height / 2,
|
|
|
+ width: Math.max(end[0] - start[0], 2),
|
|
|
+ height
|
|
|
+ }, {
|
|
|
+ x: params.coordSys.x,
|
|
|
+ y: params.coordSys.y,
|
|
|
+ width: params.coordSys.width,
|
|
|
+ height: params.coordSys.height
|
|
|
+ });
|
|
|
+ return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
|
|
|
+ },
|
|
|
+ encode: { x: [1, 2], y: 0 },
|
|
|
+ data,
|
|
|
+ itemStyle: { borderRadius: 4 },
|
|
|
+ emphasis: { focus: 'self' },
|
|
|
+ markLine: {
|
|
|
+ silent: true,
|
|
|
+ symbol: 'none',
|
|
|
+ lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
|
|
|
+ label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
|
|
|
+ data: [ { xAxis: todayTs } ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ // 强制刷新,避免缓存导致坐标轴不更新
|
|
|
+ this.ganttChart.clear();
|
|
|
+ this.ganttChart.setOption(option, true);
|
|
|
+ this.ganttChart.resize();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 新增:设计师排班甘特
|
|
|
+ private updateGanttDesigner(): void {
|
|
|
+ if (!this.ganttChart) return;
|
|
|
+
|
|
|
+ const DAY = 24 * 60 * 60 * 1000;
|
|
|
+ const now = new Date();
|
|
|
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
+ const todayTs = today.getTime();
|
|
|
+
|
|
|
+ // 时间轴按当前周/月
|
|
|
+ let xMin: number;
|
|
|
+ let xMax: number;
|
|
|
+ let xSplitNumber: number;
|
|
|
+ let xLabelFormatter: (value: number) => string;
|
|
|
+ if (this.ganttScale === 'week') {
|
|
|
+ const day = today.getDay();
|
|
|
+ const diffToMonday = (day === 0 ? 6 : day - 1);
|
|
|
+ const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
|
|
|
+ const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
|
|
|
+ xMin = startOfWeek.getTime();
|
|
|
+ xMax = endOfWeek.getTime();
|
|
|
+ xSplitNumber = 7;
|
|
|
+ const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
|
|
|
+ xLabelFormatter = (val) => WEEK_LABELS[new Date(val).getDay()];
|
|
|
+ } else {
|
|
|
+ const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
|
+ const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
|
|
|
+ xMin = startOfMonth.getTime();
|
|
|
+ xMax = endOfMonth.getTime();
|
|
|
+ xSplitNumber = 4;
|
|
|
+ xLabelFormatter = (val) => `第${Math.ceil(new Date(val).getDate() / 7)}周`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 仅统计已分配项目
|
|
|
+ const assigned = this.filteredProjects.filter(p => !!p.designerName);
|
|
|
+ const designers = Array.from(new Set(assigned.map(p => p.designerName)));
|
|
|
+
|
|
|
+ const byDesigner: Record<string, typeof assigned> = {} as any;
|
|
|
+ designers.forEach(n => byDesigner[n] = [] as any);
|
|
|
+ assigned.forEach(p => byDesigner[p.designerName].push(p));
|
|
|
+
|
|
|
+ const busyCountMap: Record<string, number> = Object.fromEntries(designers.map(n => [n, byDesigner[n].length]));
|
|
|
+
|
|
|
+ const sortedDesigners = designers.sort((a, b) => {
|
|
|
+ const diff = (busyCountMap[b] || 0) - (busyCountMap[a] || 0);
|
|
|
+ return diff !== 0 ? diff : a.localeCompare(b, 'zh-CN');
|
|
|
+ });
|
|
|
+
|
|
|
+ const categories = sortedDesigners;
|
|
|
+
|
|
|
+ // 工作量等级(用于左侧小圆点颜色)
|
|
|
+ const workloadLevelMap: Record<string, 'high'|'medium'|'low'> = {} as any;
|
|
|
+ categories.forEach(name => {
|
|
|
+ const cnt = busyCountMap[name] || 0;
|
|
|
+ workloadLevelMap[name] = cnt >= 5 ? 'high' : (cnt >= 3 ? 'medium' : 'low');
|
|
|
+ });
|
|
|
+
|
|
|
+ // 条形颜色仍按项目紧急度
|
|
|
+ const colorByUrgency: Record<'high'|'medium'|'low', string> = {
|
|
|
+ high: '#ef4444',
|
|
|
+ medium: '#f59e0b',
|
|
|
+ low: '#22c55e'
|
|
|
+ } as const;
|
|
|
+
|
|
|
+ const data = assigned.flatMap(p => {
|
|
|
+ const end = new Date(p.deadline).getTime();
|
|
|
+ const baseDays = p.type === 'hard' ? 30 : 14;
|
|
|
+ const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
|
|
|
+ const yIndex = categories.indexOf(p.designerName);
|
|
|
+ if (yIndex === -1) return [] as any[];
|
|
|
+ const color = colorByUrgency[p.urgency] || '#60a5fa';
|
|
|
+ return [{
|
|
|
+ name: p.name,
|
|
|
+ value: [yIndex, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
|
|
|
+ itemStyle: { color }
|
|
|
+ }];
|
|
|
+ });
|
|
|
+
|
|
|
+ const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
|
|
|
+ const total = categories.length || 1;
|
|
|
+ const visible = Math.min(total, 30);
|
|
|
+ const defaultEndPercent = Math.min(100, (visible / total) * 100);
|
|
|
+ const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
|
|
|
+ const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'item',
|
|
|
+ formatter: (params: any) => {
|
|
|
+ const v = params.value;
|
|
|
+ const start = new Date(v[1]);
|
|
|
+ const end = new Date(v[2]);
|
|
|
+ return `项目:${params.name}<br/>设计师:${v[3]}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ grid: { left: 110, right: 64, top: 30, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'time',
|
|
|
+ min: xMin,
|
|
|
+ max: xMax,
|
|
|
+ splitNumber: xSplitNumber,
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } },
|
|
|
+ axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
|
|
|
+ splitLine: { lineStyle: { color: '#f1f5f9' } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: categories,
|
|
|
+ inverse: true,
|
|
|
+ axisLabel: {
|
|
|
+ color: '#374151',
|
|
|
+ margin: 8,
|
|
|
+ formatter: (val: string) => {
|
|
|
+ const lvl = workloadLevelMap[val] || 'low';
|
|
|
+ const count = busyCountMap[val] || 0;
|
|
|
+ const text = val.length > 8 ? val.slice(0, 8) + '…' : val;
|
|
|
+ return `{${lvl}Dot|●} ${text}(${count}项)`;
|
|
|
+ },
|
|
|
+ rich: {
|
|
|
+ highDot: { color: '#ef4444' },
|
|
|
+ mediumDot: { color: '#f59e0b' },
|
|
|
+ lowDot: { color: '#22c55e' }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } }
|
|
|
+ },
|
|
|
+ dataZoom: [
|
|
|
+ { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, start: preservedStart, end: preservedEnd, zoomLock: false },
|
|
|
+ { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
|
|
|
+ ],
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ type: 'custom',
|
|
|
+ renderItem: (params: any, api: any) => {
|
|
|
+ const categoryIndex = api.value(0);
|
|
|
+ const start = api.coord([api.value(1), categoryIndex]);
|
|
|
+ const end = api.coord([api.value(2), categoryIndex]);
|
|
|
+ const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
|
|
|
+ const rectShape = echarts.graphic.clipRectByRect({
|
|
|
+ x: start[0],
|
|
|
+ y: start[1] - height / 2,
|
|
|
+ width: Math.max(end[0] - start[0], 2),
|
|
|
+ height
|
|
|
+ }, {
|
|
|
+ x: params.coordSys.x,
|
|
|
+ y: params.coordSys.y,
|
|
|
+ width: params.coordSys.width,
|
|
|
+ height: params.coordSys.height
|
|
|
+ });
|
|
|
+ return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
|
|
|
+ },
|
|
|
+ encode: { x: [1, 2], y: 0 },
|
|
|
+ data,
|
|
|
+ itemStyle: { borderRadius: 4 },
|
|
|
+ emphasis: { focus: 'self' },
|
|
|
+ markLine: {
|
|
|
+ silent: true,
|
|
|
+ symbol: 'none',
|
|
|
+ lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
|
|
|
+ label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
|
|
|
+ data: [ { xAxis: todayTs } ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ } as any;
|
|
|
+
|
|
|
+ this.ganttChart.clear();
|
|
|
+ this.ganttChart.setOption(option, true);
|
|
|
+ this.ganttChart.resize();
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy(): void {
|
|
|
+ if (this.ganttChart) {
|
|
|
+ this.ganttChart.dispose();
|
|
|
+ this.ganttChart = null;
|
|
|
+ }
|
|
|
+ if (this.workloadChart) {
|
|
|
+ this.workloadChart.dispose();
|
|
|
+ this.workloadChart = null;
|
|
|
+ }
|
|
|
}
|
|
|
-
|
|
|
// 选择单个项目
|
|
|
selectProject(): void {
|
|
|
if (this.selectedProjectId) {
|
|
@@ -519,6 +1051,18 @@ export class Dashboard implements OnInit {
|
|
|
return this.getProjectsByStage(stageId).length;
|
|
|
}
|
|
|
|
|
|
+ // 待审批项目:currentStage === 'pendingApproval'
|
|
|
+ get pendingApprovalProjects(): Project[] {
|
|
|
+ const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
|
|
|
+ return src.filter(p => p.currentStage === 'pendingApproval');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 待指派项目:currentStage === 'pendingAssignment'
|
|
|
+ get pendingAssignmentProjects(): Project[] {
|
|
|
+ const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
|
|
|
+ return src.filter(p => p.currentStage === 'pendingAssignment');
|
|
|
+ }
|
|
|
+
|
|
|
// 获取紧急程度标签
|
|
|
getUrgencyLabel(urgency: string): string {
|
|
|
const labels = {
|
|
@@ -666,13 +1210,62 @@ export class Dashboard implements OnInit {
|
|
|
this.showAlert = false;
|
|
|
}
|
|
|
|
|
|
- // 获取待确认项目数量
|
|
|
- get pendingApprovalProjects() {
|
|
|
- return this.projects.filter(project => project.currentStage === 'pendingApproval');
|
|
|
+ // 维度切换(设计师/会员类型)
|
|
|
+ setWorkloadDimension(dim: 'designer' | 'member'): void {
|
|
|
+ if (this.workloadDimension !== dim) {
|
|
|
+ this.workloadDimension = dim;
|
|
|
+ this.updateWorkloadChart();
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // 获取待分配项目数量
|
|
|
- get pendingAssignmentProjects() {
|
|
|
- return this.projects.filter(project => project.currentStage === 'pendingAssignment');
|
|
|
+ // 刷新“工作量概览”图表
|
|
|
+ private updateWorkloadChart(): void {
|
|
|
+ if (!this.workloadChartRef) { return; }
|
|
|
+ const el = this.workloadChartRef.nativeElement;
|
|
|
+ if (!el) { return; }
|
|
|
+
|
|
|
+ // 初始化实例(使用 SVG 渲染以获得更佳文本清晰度)
|
|
|
+ if (!this.workloadChart) {
|
|
|
+ this.workloadChart = echarts.init(el, null, { renderer: 'svg' });
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
|
|
|
+ const byDesigner = this.workloadDimension === 'designer';
|
|
|
+ const groupKey = byDesigner ? 'designerName' : 'memberType';
|
|
|
+
|
|
|
+ const labelMap: Record<string, string> = { vip: 'VIP', normal: '普通' };
|
|
|
+ const groupSet = new Set<string>();
|
|
|
+ data.forEach(p => {
|
|
|
+ const val = (p as any)[groupKey] || (byDesigner ? '未分配' : '未知');
|
|
|
+ groupSet.add(val);
|
|
|
+ });
|
|
|
+ const categories = Array.from(groupSet).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
|
|
+
|
|
|
+ const count = (urg: 'high'|'medium'|'low', group: string) =>
|
|
|
+ data.filter(p => (((p as any)[groupKey] || (byDesigner ? '未分配' : '未知')) === group) && p.urgency === urg).length;
|
|
|
+
|
|
|
+ const high = categories.map(c => count('high', c));
|
|
|
+ const medium = categories.map(c => count('medium', c));
|
|
|
+ const low = categories.map(c => count('low', c));
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
|
+ legend: { data: ['高', '中', '低'] },
|
|
|
+ grid: { left: 12, right: 16, top: 28, bottom: 8, containLabel: true },
|
|
|
+ xAxis: { type: 'value', boundaryGap: [0, 0.01] },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: categories.map(c => byDesigner ? c : (labelMap[c] || c))
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ { name: '高', type: 'bar', stack: 'workload', data: high, itemStyle: { color: '#ef4444' } },
|
|
|
+ { name: '中', type: 'bar', stack: 'workload', data: medium, itemStyle: { color: '#f59e0b' } },
|
|
|
+ { name: '低', type: 'bar', stack: 'workload', data: low, itemStyle: { color: '#10b981' } }
|
|
|
+ ]
|
|
|
+ } as any;
|
|
|
+
|
|
|
+ this.workloadChart.clear();
|
|
|
+ this.workloadChart.setOption(option, true);
|
|
|
+ this.workloadChart.resize();
|
|
|
}
|
|
|
}
|