|
@@ -1,4 +1,4 @@
|
|
|
-import { Component, OnInit, signal, computed, Inject, ViewChild, ElementRef, OnDestroy } from '@angular/core';
|
|
|
+import { Component, OnInit, signal, computed, Inject, ViewChild, ElementRef, OnDestroy, NgZone, ChangeDetectorRef, AfterViewInit } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
@@ -252,19 +252,54 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
],
|
|
|
templateUrl: './attendance.html',
|
|
|
styleUrl: './attendance.scss'
|
|
|
-}) export class Attendance implements OnDestroy, OnInit {
|
|
|
+}) export class Attendance implements OnDestroy, OnInit, AfterViewInit {
|
|
|
@ViewChild('ganttChartRef') ganttChartRef!: ElementRef;
|
|
|
private ganttChart: any = null;
|
|
|
+ private onResize = () => {
|
|
|
+ if (this.ganttChart) {
|
|
|
+ this.ganttChart.resize();
|
|
|
+ }
|
|
|
+ };
|
|
|
+ private onChartClick = (params: any) => {
|
|
|
+ this.zone.run(() => {
|
|
|
+ const v = params?.value;
|
|
|
+ if (!v) return;
|
|
|
+ const start = new Date(v[1]);
|
|
|
+ const end = new Date(v[2]);
|
|
|
+ const employee = v[3];
|
|
|
+ const progress = v[4];
|
|
|
+ alert(`任务:${params.name}\n负责人:${employee}\n进度:${progress}%\n起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`);
|
|
|
+ });
|
|
|
+ };
|
|
|
showGanttView = signal(false);
|
|
|
- ganttScale = signal<'week' | 'month'>('week');
|
|
|
+ ganttScale = signal<'day' | 'week' | 'month'>('week');
|
|
|
+ ganttMode = signal<'attendance' | 'workload'>('attendance');
|
|
|
+ selectedDepartment = 'all';
|
|
|
+
|
|
|
// 数据
|
|
|
- attendanceData = signal<AttendanceModel[]>([]);
|
|
|
- employees = signal<Employee[]>([]);
|
|
|
+ attendanceData = signal<AttendanceModel[]>(generateMockAttendanceData());
|
|
|
+ employees = signal<Employee[]>(generateMockEmployees());
|
|
|
+
|
|
|
+ // 计算属性
|
|
|
+ departments = computed(() => {
|
|
|
+ const depts = new Set(this.employees().map(emp => emp.department));
|
|
|
+ return Array.from(depts).sort();
|
|
|
+ });
|
|
|
+
|
|
|
+ filteredEmployees = computed(() => {
|
|
|
+ if (this.selectedDepartment === 'all') {
|
|
|
+ return this.employees();
|
|
|
+ }
|
|
|
+ return this.employees().filter(emp => emp.department === this.selectedDepartment);
|
|
|
+ });
|
|
|
selectedView = signal<'day' | 'week' | 'month'>('month');
|
|
|
selectedDate = signal<Date>(new Date());
|
|
|
selectedEmployeeId = signal<string>('');
|
|
|
selectedProjectId = signal<string>('');
|
|
|
|
|
|
+ // 通过computed缓存当月日历,避免模板每次变更检测都创建新数组
|
|
|
+ calendarDays = computed(() => this.computeCalendarDays());
|
|
|
+
|
|
|
// 获取日期tooltip信息
|
|
|
getDayTooltip(day: any): string {
|
|
|
if (!day.attendance) {
|
|
@@ -414,22 +449,29 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
// 显示的表格列
|
|
|
displayedColumns = ['date', 'employeeName', 'status', 'workHours', 'projectName', 'actions'];
|
|
|
|
|
|
- constructor(private dialog: MatDialog) {}
|
|
|
+ constructor(private dialog: MatDialog, private zone: NgZone, private cdr: ChangeDetectorRef) {}
|
|
|
|
|
|
ngOnInit() {
|
|
|
- // 加载模拟数据
|
|
|
- this.attendanceData.set(generateMockAttendanceData());
|
|
|
- this.employees.set(generateMockEmployees());
|
|
|
+ // 已在字段声明处初始化模拟数据,避免首次变更检测期间的状态跃迁导致 ExpressionChanged
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
+ ngAfterViewInit() {
|
|
|
+ // 稳定首次变更检测,避免控制流指令在初始化过程中值变化触发 NG0100
|
|
|
+ this.cdr.detectChanges();
|
|
|
+ }
|
|
|
+
|
|
|
ngOnDestroy() {
|
|
|
if (this.ganttChart) {
|
|
|
+ this.ganttChart.off('click', this.onChartClick);
|
|
|
this.ganttChart.dispose();
|
|
|
this.ganttChart = null;
|
|
|
}
|
|
|
+ window.removeEventListener('resize', this.onResize);
|
|
|
}
|
|
|
|
|
|
- // 切换视图(日/周/月)
|
|
|
+ // 移除 ngAfterViewInit 钩子
|
|
|
+
|
|
|
+ // 切换视图(日/周/月)
|
|
|
switchView(view: 'day' | 'week' | 'month') {
|
|
|
this.selectedView.set(view);
|
|
|
// 重置到当前日期
|
|
@@ -437,25 +479,113 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
}
|
|
|
|
|
|
// 切换视图(考勤/任务)
|
|
|
- toggleView() {
|
|
|
- this.showGanttView.set(!this.showGanttView());
|
|
|
- if (this.showGanttView()) {
|
|
|
- setTimeout(() => this.initOrUpdateGantt(), 0);
|
|
|
- } else {
|
|
|
+ toggleView(target?: 'attendance' | 'task') {
|
|
|
+ try {
|
|
|
+ const next = target ? (target === 'task') : !this.showGanttView();
|
|
|
+ this.showGanttView.set(next);
|
|
|
+
|
|
|
+ if (next) {
|
|
|
+ // 等待DOM渲染后初始化图表
|
|
|
+ setTimeout(() => {
|
|
|
+ try {
|
|
|
+ this.initOrUpdateGantt();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('甘特图初始化失败:', error);
|
|
|
+ }
|
|
|
+ }, 100);
|
|
|
+ } else {
|
|
|
+ this.cleanupGanttChart();
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('视图切换失败:', error);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 清理甘特图资源
|
|
|
+ private cleanupGanttChart() {
|
|
|
+ try {
|
|
|
if (this.ganttChart) {
|
|
|
+ this.ganttChart.off('click', this.onChartClick);
|
|
|
this.ganttChart.dispose();
|
|
|
this.ganttChart = null;
|
|
|
+ window.removeEventListener('resize', this.onResize);
|
|
|
}
|
|
|
+ } catch (error) {
|
|
|
+ console.error('甘特图清理失败:', error);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// 设置甘特时间尺度
|
|
|
- setGanttScale(scale: 'week' | 'month') {
|
|
|
+ setGanttScale(scale: 'day' | 'week' | 'month') {
|
|
|
if (this.ganttScale() !== scale) {
|
|
|
this.ganttScale.set(scale);
|
|
|
this.updateGantt();
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ // 设置甘特图显示模式
|
|
|
+ setGanttMode(mode: 'attendance' | 'workload') {
|
|
|
+ if (this.ganttMode() !== mode) {
|
|
|
+ this.ganttMode.set(mode);
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 部门筛选变化
|
|
|
+ onDepartmentChange() {
|
|
|
+ this.updateGantt();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取时间范围文本
|
|
|
+ getTimeRangeText(): string {
|
|
|
+ const date = this.selectedDate();
|
|
|
+ const scale = this.ganttScale();
|
|
|
+
|
|
|
+ if (scale === 'day') {
|
|
|
+ return date.toLocaleDateString('zh-CN');
|
|
|
+ } else if (scale === 'week') {
|
|
|
+ const startOfWeek = new Date(date);
|
|
|
+ const day = startOfWeek.getDay();
|
|
|
+ const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
|
|
|
+ startOfWeek.setDate(diff);
|
|
|
+
|
|
|
+ const endOfWeek = new Date(startOfWeek);
|
|
|
+ endOfWeek.setDate(startOfWeek.getDate() + 6);
|
|
|
+
|
|
|
+ return `${startOfWeek.toLocaleDateString('zh-CN')} - ${endOfWeek.toLocaleDateString('zh-CN')}`;
|
|
|
+ } else {
|
|
|
+ return `${date.getFullYear()}年${date.getMonth() + 1}月`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取平均出勤率
|
|
|
+ getAverageAttendanceRate(): number {
|
|
|
+ const employees = this.filteredEmployees();
|
|
|
+ if (employees.length === 0) return 0;
|
|
|
+
|
|
|
+ const totalRate = employees.reduce((sum, emp) => {
|
|
|
+ const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
|
|
|
+ const normalDays = empAttendance.filter(att => att.status === '正常').length;
|
|
|
+ const rate = empAttendance.length > 0 ? (normalDays / empAttendance.length) * 100 : 0;
|
|
|
+ return sum + rate;
|
|
|
+ }, 0);
|
|
|
+
|
|
|
+ return Math.round(totalRate / employees.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取平均工时
|
|
|
+ getAverageWorkHours(): number {
|
|
|
+ const employees = this.filteredEmployees();
|
|
|
+ if (employees.length === 0) return 0;
|
|
|
+
|
|
|
+ const totalHours = employees.reduce((sum, emp) => {
|
|
|
+ const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
|
|
|
+ const avgHours = empAttendance.reduce((h, att) => h + (att.workHours || 0), 0) / empAttendance.length;
|
|
|
+ return sum + (avgHours || 0);
|
|
|
+ }, 0);
|
|
|
+
|
|
|
+ return Math.round((totalHours / employees.length) * 10) / 10;
|
|
|
+ }
|
|
|
|
|
|
// 获取员工姓名
|
|
|
getEmployeeName(employeeId: string): string {
|
|
@@ -510,32 +640,71 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
this.selectedDate.set(newDate);
|
|
|
}
|
|
|
|
|
|
+ private isDialogOpening = false;
|
|
|
+
|
|
|
// 打开补卡申请对话框
|
|
|
openAttendanceDialog(attendance: AttendanceModel) {
|
|
|
- const employee = this.employees().find(emp => emp.id === attendance.employeeId);
|
|
|
- const dialogRef = this.dialog.open(AttendanceDialog, {
|
|
|
- width: '500px',
|
|
|
- maxWidth: '90vw',
|
|
|
- disableClose: true,
|
|
|
- data: {
|
|
|
- ...attendance,
|
|
|
- employeeName: employee ? employee.name : '未知员工'
|
|
|
- }
|
|
|
- });
|
|
|
+ // 防止重复点击
|
|
|
+ if (this.isDialogOpening) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
|
|
|
- dialogRef.afterClosed().subscribe(result => {
|
|
|
- if (result) {
|
|
|
- // 在实际应用中,这里会提交补卡申请到服务器
|
|
|
- alert('补卡申请已提交,等待审核');
|
|
|
- }
|
|
|
- });
|
|
|
+ try {
|
|
|
+ this.isDialogOpening = true;
|
|
|
+ const employee = this.employees().find(emp => emp.id === attendance.employeeId);
|
|
|
+
|
|
|
+ const dialogRef = this.dialog.open(AttendanceDialog, {
|
|
|
+ width: '500px',
|
|
|
+ maxWidth: '90vw',
|
|
|
+ disableClose: true,
|
|
|
+ panelClass: 'hr-dialog',
|
|
|
+ backdropClass: 'hr-dialog-backdrop',
|
|
|
+ data: {
|
|
|
+ ...attendance,
|
|
|
+ employeeName: employee ? employee.name : '未知员工'
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ dialogRef.afterClosed().subscribe({
|
|
|
+ next: (result) => {
|
|
|
+ this.isDialogOpening = false;
|
|
|
+ if (result) {
|
|
|
+ // 在实际应用中,这里会提交补卡申请到服务器
|
|
|
+ alert('补卡申请已提交,等待审核');
|
|
|
+ }
|
|
|
+ },
|
|
|
+ error: (error) => {
|
|
|
+ this.isDialogOpening = false;
|
|
|
+ console.error('对话框关闭时出错:', error);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ this.isDialogOpening = false;
|
|
|
+ console.error('打开补卡对话框失败:', error);
|
|
|
+ alert('打开对话框失败,请重试');
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 导出考勤数据
|
|
|
exportAttendanceData(): void {
|
|
|
- const data = this.filteredAttendance();
|
|
|
- const csvContent = this.convertToCSV(data);
|
|
|
- this.downloadCSV(csvContent, '考勤数据.csv');
|
|
|
+ try {
|
|
|
+ const data = this.filteredAttendance();
|
|
|
+ if (data.length === 0) {
|
|
|
+ alert('暂无数据可导出');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const csvContent = this.convertToCSV(data);
|
|
|
+ this.downloadCSV(csvContent, '考勤数据.csv');
|
|
|
+
|
|
|
+ // 显示成功提示
|
|
|
+ setTimeout(() => {
|
|
|
+ alert(`成功导出 ${data.length} 条考勤记录`);
|
|
|
+ }, 100);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('导出数据失败:', error);
|
|
|
+ alert('导出失败,请重试');
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
// 将数据转换为CSV格式
|
|
@@ -577,6 +746,11 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
|
|
|
// 获取日历数据
|
|
|
getCalendarDays() {
|
|
|
+ return this.calendarDays();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 实际计算日历数据
|
|
|
+ private computeCalendarDays() {
|
|
|
const year = this.selectedDate().getFullYear();
|
|
|
const month = this.selectedDate().getMonth();
|
|
|
|
|
@@ -587,7 +761,7 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
// 获取当月第一天是星期几
|
|
|
const firstDayIndex = firstDay.getDay();
|
|
|
|
|
|
- const days = [];
|
|
|
+ const days: any[] = [];
|
|
|
|
|
|
// 添加上月的最后几天
|
|
|
for (let i = firstDayIndex; i > 0; i--) {
|
|
@@ -641,219 +815,271 @@ const generateMockEmployees = (): Employee[] => {
|
|
|
}
|
|
|
|
|
|
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();
|
|
|
- });
|
|
|
+ if (!this.ganttChartRef?.nativeElement) {
|
|
|
+ console.warn('甘特图容器未找到');
|
|
|
+ return;
|
|
|
}
|
|
|
- this.updateGantt();
|
|
|
+
|
|
|
+ const el = this.ganttChartRef.nativeElement;
|
|
|
+
|
|
|
+ this.zone.runOutsideAngular(() => {
|
|
|
+ try {
|
|
|
+ if (!this.ganttChart) {
|
|
|
+ // 检查容器尺寸
|
|
|
+ if (el.offsetWidth === 0 || el.offsetHeight === 0) {
|
|
|
+ console.warn('甘特图容器尺寸为0,延迟初始化');
|
|
|
+ setTimeout(() => this.initOrUpdateGantt(), 200);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.ganttChart = echarts.init(el);
|
|
|
+ window.addEventListener('resize', this.onResize);
|
|
|
+ }
|
|
|
+ this.updateGantt();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('甘特图初始化失败:', error);
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
private updateGantt(): void {
|
|
|
- if (!this.ganttChart) return;
|
|
|
-
|
|
|
- // 生成甘特图数据 - 模拟订单任务数据
|
|
|
- const tasks = [
|
|
|
- {
|
|
|
- name: '需求补充',
|
|
|
- startDate: new Date(2024, 4, 1), // 2024-05-01
|
|
|
- endDate: new Date(2024, 4, 5), // 2024-05-05
|
|
|
- progress: 33,
|
|
|
- employee: '张三',
|
|
|
- unfinishedItems: ['图纸上传(2/3)', '设计文档(0/1)', '客户确认(0/1)']
|
|
|
- },
|
|
|
- {
|
|
|
- name: '方案设计',
|
|
|
- startDate: new Date(2024, 4, 3),
|
|
|
- endDate: new Date(2024, 4, 8),
|
|
|
- progress: 75,
|
|
|
- employee: '李四',
|
|
|
- unfinishedItems: ['效果图(3/4)', '材料清单(1/1)']
|
|
|
- },
|
|
|
- {
|
|
|
- name: '施工图绘制',
|
|
|
- startDate: new Date(2024, 4, 6),
|
|
|
- endDate: new Date(2024, 4, 12),
|
|
|
- progress: 50,
|
|
|
- employee: '王五',
|
|
|
- unfinishedItems: ['平面图(1/2)', '立面图(2/4)', '节点图(0/3)']
|
|
|
- },
|
|
|
- {
|
|
|
- name: '预算编制',
|
|
|
- startDate: new Date(2024, 4, 10),
|
|
|
- endDate: new Date(2024, 4, 15),
|
|
|
- progress: 100,
|
|
|
- employee: '赵六',
|
|
|
- unfinishedItems: []
|
|
|
- }
|
|
|
- ];
|
|
|
-
|
|
|
- const categories = tasks.map(t => t.name);
|
|
|
- const DAY = 24 * 60 * 60 * 1000;
|
|
|
+ if (!this.ganttChart) {
|
|
|
+ console.warn('甘特图实例不存在');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 工作负荷模式和考勤状态模式使用相同的数据结构,只是颜色映射不同
|
|
|
|
|
|
- const data = tasks.map((task, idx) => {
|
|
|
- const start = task.startDate.getTime();
|
|
|
- const end = task.endDate.getTime();
|
|
|
- const progress = task.progress;
|
|
|
+ // 考勤状态甘特图
|
|
|
+ const employees = this.filteredEmployees();
|
|
|
+ const categories = employees.map(emp => emp.name);
|
|
|
|
|
|
- return {
|
|
|
- name: task.name,
|
|
|
- value: [idx, start, end, task.employee, progress, task.unfinishedItems],
|
|
|
- itemStyle: {
|
|
|
- color: task?.progress === 100 ? '#22c55e' : (task?.progress && task.progress >= 75) ? '#f59e0b' : '#ef4444'
|
|
|
- }
|
|
|
+ // 考勤状态颜色映射
|
|
|
+ const statusColorMap: Record<string, string> = {
|
|
|
+ '正常': '#22c55e',
|
|
|
+ '迟到': '#f59e0b',
|
|
|
+ '早退': '#f97316',
|
|
|
+ '旷工': '#ef4444',
|
|
|
+ '请假': '#8b5cf6',
|
|
|
+ '加班': '#06b6d4'
|
|
|
};
|
|
|
- });
|
|
|
|
|
|
- // 计算时间范围
|
|
|
- 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;
|
|
|
+ const DAY = 24 * 60 * 60 * 1000;
|
|
|
+ const now = new Date();
|
|
|
+ const selectedDate = this.selectedDate();
|
|
|
+
|
|
|
+ // 计算时间范围
|
|
|
+ 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)}周`;
|
|
|
- }
|
|
|
+ if (this.ganttScale() === 'day') {
|
|
|
+ // 日视图:显示当天的小时
|
|
|
+ const startOfDay = new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate());
|
|
|
+ const endOfDay = new Date(startOfDay.getTime() + DAY - 1);
|
|
|
+ xMin = startOfDay.getTime();
|
|
|
+ xMax = endOfDay.getTime();
|
|
|
+ xSplitNumber = 24;
|
|
|
+ xLabelFormatter = (val) => `${new Date(val).getHours()}:00`;
|
|
|
+ } else if (this.ganttScale() === 'week') {
|
|
|
+ // 周视图:显示一周的天数
|
|
|
+ const day = selectedDate.getDay();
|
|
|
+ const diffToMonday = (day === 0 ? 6 : day - 1);
|
|
|
+ const startOfWeek = new Date(selectedDate.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(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
|
|
|
+ const endOfMonth = new Date(selectedDate.getFullYear(), selectedDate.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 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]);
|
|
|
- const progress = v[4];
|
|
|
- const unfinished = v[5];
|
|
|
- let html = `任务:${params.name}<br/>负责人:${v[3]}<br/>进度:${progress}%<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
|
|
|
+ // 生成考勤数据
|
|
|
+ const data: any[] = [];
|
|
|
+ employees.forEach((emp, empIndex) => {
|
|
|
+ const empAttendance = this.attendanceData().filter(att => att.employeeId === emp.id);
|
|
|
+
|
|
|
+ if (this.ganttScale() === 'day') {
|
|
|
+ // 日视图:显示工作时间段
|
|
|
+ const dayAttendance = empAttendance.find(att => {
|
|
|
+ const attDate = new Date(att.date);
|
|
|
+ return attDate.toDateString() === selectedDate.toDateString();
|
|
|
+ });
|
|
|
|
|
|
- if (unfinished.length > 0) {
|
|
|
- html += '<br/><br/>未完成项:<br/>' + unfinished.join('<br/>');
|
|
|
+ if (dayAttendance && dayAttendance.checkInTime && dayAttendance.checkOutTime) {
|
|
|
+ const checkIn = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkInTime}`);
|
|
|
+ const checkOut = new Date(`${selectedDate.toDateString()} ${dayAttendance.checkOutTime}`);
|
|
|
+
|
|
|
+ data.push({
|
|
|
+ name: emp.name,
|
|
|
+ value: [empIndex, checkIn.getTime(), checkOut.getTime(), dayAttendance.status, dayAttendance.workHours],
|
|
|
+ itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 周视图和月视图:显示每天的考勤状态
|
|
|
+ const timeRange = this.ganttScale() === 'week' ? 7 : 30;
|
|
|
+ const startTime = this.ganttScale() === 'week' ?
|
|
|
+ new Date(selectedDate.getTime() - ((selectedDate.getDay() === 0 ? 6 : selectedDate.getDay() - 1) * DAY)) :
|
|
|
+ new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1);
|
|
|
+
|
|
|
+ for (let i = 0; i < timeRange; i++) {
|
|
|
+ const currentDay = new Date(startTime.getTime() + i * DAY);
|
|
|
+ const dayAttendance = empAttendance.find(att => {
|
|
|
+ const attDate = new Date(att.date);
|
|
|
+ return attDate.toDateString() === currentDay.toDateString();
|
|
|
+ });
|
|
|
+
|
|
|
+ if (dayAttendance) {
|
|
|
+ const dayStart = new Date(currentDay.getFullYear(), currentDay.getMonth(), currentDay.getDate());
|
|
|
+ const dayEnd = new Date(dayStart.getTime() + DAY - 1);
|
|
|
+
|
|
|
+ data.push({
|
|
|
+ name: emp.name,
|
|
|
+ value: [empIndex, dayStart.getTime(), dayEnd.getTime(), dayAttendance.status, dayAttendance.workHours],
|
|
|
+ itemStyle: { color: statusColorMap[dayAttendance.status] || '#94a3b8' }
|
|
|
+ });
|
|
|
+ }
|
|
|
}
|
|
|
- return html;
|
|
|
}
|
|
|
- },
|
|
|
- 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: xLabelFormatter },
|
|
|
- splitLine: { lineStyle: { color: '#f1f5f9' } }
|
|
|
- },
|
|
|
- yAxis: {
|
|
|
- type: 'category',
|
|
|
- data: categories,
|
|
|
- inverse: true,
|
|
|
- axisLabel: {
|
|
|
- color: '#374151',
|
|
|
- margin: 8,
|
|
|
- formatter: (val: string) => {
|
|
|
- const task = tasks.find(t => t.name === val);
|
|
|
- const color = task?.progress === 100 ? '#22c55e' : (task?.progress && task.progress >= 75) ? '#f59e0b' : '#ef4444';
|
|
|
- const text = val.length > 16 ? val.slice(0, 16) + '…' : val;
|
|
|
- return `{dot|●} ${text}`;
|
|
|
- },
|
|
|
- rich: {
|
|
|
- dot: { color: '#ef4444' }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 计算默认可视区域
|
|
|
+ const total = categories.length;
|
|
|
+ const visible = Math.min(total, 15);
|
|
|
+ const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
|
|
|
+ const todayTs = now.getTime();
|
|
|
+
|
|
|
+ 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]);
|
|
|
+ const status = v[3];
|
|
|
+ const workHours = v[4];
|
|
|
+
|
|
|
+ if (this.ganttScale() === 'day') {
|
|
|
+ return `员工:${params.name}<br/>状态:${status}<br/>工作时间:${start.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})} - ${end.toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'})}<br/>工时:${workHours || 0}小时`;
|
|
|
+ } else {
|
|
|
+ return `员工:${params.name}<br/>日期:${start.toLocaleDateString('zh-CN')}<br/>状态:${status}<br/>工时:${workHours || 0}小时`;
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
- axisTick: { show: false },
|
|
|
- axisLine: { lineStyle: { color: '#e5e7eb' } }
|
|
|
- },
|
|
|
+ grid: { left: 120, right: 64, top: 30, bottom: 30 },
|
|
|
+ xAxis: {
|
|
|
+ type: 'time',
|
|
|
+ min: xMin,
|
|
|
+ max: xMax,
|
|
|
+ splitNumber: xSplitNumber,
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } },
|
|
|
+ axisLabel: { color: '#6b7280', formatter: xLabelFormatter },
|
|
|
+ splitLine: { lineStyle: { color: '#f1f5f9' } }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: categories,
|
|
|
+ inverse: true,
|
|
|
+ axisLabel: {
|
|
|
+ color: '#374151',
|
|
|
+ margin: 8,
|
|
|
+ formatter: (val: string) => {
|
|
|
+ const text = val.length > 12 ? val.slice(0, 12) + '…' : val;
|
|
|
+ return text;
|
|
|
+ }
|
|
|
+ },
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } }
|
|
|
+ },
|
|
|
dataZoom: [
|
|
|
- { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, width: 14, start: 0, end: 100, 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
|
|
|
- });
|
|
|
-
|
|
|
- // 进度指示条
|
|
|
- const progress = api.value(4);
|
|
|
- const progressWidth = Math.max((end[0] - start[0]) * (progress / 100), 2);
|
|
|
- const progressShape = echarts.graphic.clipRectByRect({
|
|
|
- x: start[0],
|
|
|
- y: start[1] - height / 2,
|
|
|
- width: progressWidth,
|
|
|
- height
|
|
|
- }, {
|
|
|
- x: params.coordSys.x,
|
|
|
- y: params.coordSys.y,
|
|
|
- width: params.coordSys.width,
|
|
|
- height: params.coordSys.height
|
|
|
- });
|
|
|
+ {
|
|
|
+ type: 'slider',
|
|
|
+ yAxisIndex: 0,
|
|
|
+ orient: 'vertical',
|
|
|
+ right: 6,
|
|
|
+ width: 14,
|
|
|
+ start: 0,
|
|
|
+ end: defaultEndPercent,
|
|
|
+ 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.6, 12);
|
|
|
+
|
|
|
+ // 考勤状态条
|
|
|
+ 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 {
|
|
|
- type: 'group',
|
|
|
- children: [
|
|
|
+ // 今日标记线
|
|
|
+ const isToday = this.ganttScale() === 'day' ||
|
|
|
+ (api.value(1) <= todayTs && api.value(2) >= todayTs);
|
|
|
+
|
|
|
+ const elements = [
|
|
|
{
|
|
|
type: 'rect',
|
|
|
shape: rectShape,
|
|
|
style: {
|
|
|
- fill: 'rgba(0,0,0,0.1)',
|
|
|
- stroke: 'transparent'
|
|
|
+ ...api.style(),
|
|
|
+ stroke: isToday ? '#3b82f6' : 'transparent',
|
|
|
+ strokeWidth: isToday ? 2 : 0
|
|
|
}
|
|
|
- },
|
|
|
- {
|
|
|
- type: 'rect',
|
|
|
- shape: progressShape,
|
|
|
- style: api.style()
|
|
|
}
|
|
|
- ]
|
|
|
- };
|
|
|
- },
|
|
|
- encode: { x: [1, 2], y: 0 },
|
|
|
- data,
|
|
|
- itemStyle: { borderRadius: 4 },
|
|
|
- emphasis: { focus: 'self' }
|
|
|
- }
|
|
|
- ]
|
|
|
- };
|
|
|
+ ];
|
|
|
|
|
|
- this.ganttChart.setOption(option, true);
|
|
|
- this.ganttChart.resize();
|
|
|
+ return {
|
|
|
+ type: 'group',
|
|
|
+ children: elements
|
|
|
+ };
|
|
|
+ },
|
|
|
+ encode: { x: [1, 2], y: 0 },
|
|
|
+ data,
|
|
|
+ itemStyle: { borderRadius: 6 },
|
|
|
+ emphasis: { focus: 'self' }
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+
|
|
|
+ this.ganttChart.setOption(option, true);
|
|
|
+ // 绑定点击事件,避免重复绑定
|
|
|
+ this.ganttChart.off('click', this.onChartClick);
|
|
|
+ this.ganttChart.on('click', this.onChartClick);
|
|
|
+ this.ganttChart.resize();
|
|
|
+ } catch (error) {
|
|
|
+ console.error('甘特图更新失败:', error);
|
|
|
+ }
|
|
|
}
|
|
|
}
|