dashboard.ts 68 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793
  1. import { CommonModule } from '@angular/common';
  2. import { FormsModule } from '@angular/forms';
  3. import { Router, RouterModule } from '@angular/router';
  4. import { Component, OnInit, OnDestroy, ElementRef, ViewChild } from '@angular/core';
  5. import { ProjectService } from '../../../services/project.service';
  6. // 项目阶段定义
  7. interface ProjectStage {
  8. id: string;
  9. name: string;
  10. order: number;
  11. }
  12. interface ProjectPhase {
  13. name: string;
  14. percentage: number;
  15. startPercentage: number;
  16. isCompleted: boolean;
  17. isCurrent: boolean;
  18. }
  19. interface Project {
  20. id: string;
  21. name: string;
  22. type: 'soft' | 'hard';
  23. memberType: 'vip' | 'normal';
  24. designerName: string;
  25. status: string;
  26. expectedEndDate: Date; // TODO: 兼容旧字段,后续可移除
  27. deadline: Date; // 真实截止时间字段
  28. createdAt?: Date; // 真实开始时间字段(可选)
  29. isOverdue: boolean;
  30. overdueDays: number;
  31. dueSoon: boolean;
  32. urgency: 'high' | 'medium' | 'low';
  33. phases: ProjectPhase[];
  34. currentStage: string; // 新增:当前项目阶段
  35. // 新增:质量评级
  36. qualityRating?: 'excellent' | 'qualified' | 'unqualified' | 'pending';
  37. lastCustomerFeedback?: string;
  38. // 预构建的搜索索引,减少重复 toLowerCase 与拼接
  39. searchIndex?: string;
  40. }
  41. interface TodoTask {
  42. id: string;
  43. title: string;
  44. description: string;
  45. deadline: Date;
  46. priority: 'high' | 'medium' | 'low';
  47. type: 'review' | 'assign' | 'performance';
  48. targetId: string;
  49. }
  50. // 员工请假记录接口
  51. interface LeaveRecord {
  52. id: string;
  53. employeeName: string;
  54. date: string; // YYYY-MM-DD 格式
  55. isLeave: boolean;
  56. leaveType?: 'sick' | 'personal' | 'annual' | 'other'; // 请假类型
  57. reason?: string; // 请假原因
  58. }
  59. // 员工详情面板数据接口
  60. interface EmployeeDetail {
  61. name: string;
  62. currentProjects: number; // 当前负责项目数
  63. projectNames: string[]; // 项目名称列表(用于显示)
  64. leaveRecords: LeaveRecord[]; // 未来7天请假记录
  65. redMarkExplanation: string; // 红色标记说明
  66. }
  67. declare const echarts: any;
  68. @Component({
  69. selector: 'app-dashboard',
  70. imports: [CommonModule, FormsModule, RouterModule],
  71. templateUrl: './dashboard.html',
  72. styleUrl: './dashboard.scss'
  73. })
  74. export class Dashboard implements OnInit, OnDestroy {
  75. projects: Project[] = [];
  76. filteredProjects: Project[] = [];
  77. todoTasks: TodoTask[] = [];
  78. overdueProjects: Project[] = [];
  79. urgentPinnedProjects: Project[] = [];
  80. showAlert: boolean = false;
  81. selectedProjectId: string = '';
  82. // 新增:关键词搜索
  83. searchTerm: string = '';
  84. searchSuggestions: Project[] = [];
  85. showSuggestions: boolean = false;
  86. private hideSuggestionsTimer: any;
  87. // 搜索性能与交互控制
  88. private searchDebounceTimer: any;
  89. private readonly SEARCH_DEBOUNCE_MS = 200; // 防抖时长(毫秒)
  90. private readonly MIN_SEARCH_LEN = 2; // 最小触发建议长度
  91. private readonly MAX_SUGGESTIONS = 8; // 建议最大条数
  92. private isSearchFocused: boolean = false; // 是否处于输入聚焦态
  93. // 新增:临期项目与筛选状态
  94. dueSoonProjects: Project[] = [];
  95. selectedType: 'all' | 'soft' | 'hard' = 'all';
  96. selectedUrgency: 'all' | 'high' | 'medium' | 'low' = 'all';
  97. selectedStatus: 'all' | 'progress' | 'completed' | 'overdue' | 'pendingApproval' | 'pendingAssignment' | 'dueSoon' = 'all';
  98. selectedDesigner: string = 'all';
  99. selectedMemberType: 'all' | 'vip' | 'normal' = 'all';
  100. // 新增:时间窗筛选
  101. selectedTimeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays' = 'all';
  102. designers: string[] = [];
  103. // 新增:四大板块筛选
  104. selectedCorePhase: 'all' | 'order' | 'requirements' | 'delivery' | 'aftercare' = 'all';
  105. // 设计师画像(用于智能推荐)
  106. designerProfiles: any[] = [
  107. { id: 'zhang', name: '张三', skills: ['现代风格', '北欧风格'], workload: 95, avgRating: 4.5, experience: 3 },
  108. { id: 'li', name: '李四', skills: ['新中式', '宋式风格'], workload: 25, avgRating: 4.8, experience: 5 },
  109. { id: 'wang', name: '王五', skills: ['北欧风格', '日式风格'], workload: 75, avgRating: 4.2, experience: 2 },
  110. { id: 'zhao', name: '赵六', skills: ['现代风格', '轻奢风格'], workload: 15, avgRating: 4.6, experience: 4 },
  111. { id: 'sun', name: '孙七', skills: ['简约风格', '工业风格'], workload: 35, avgRating: 4.3, experience: 3 },
  112. { id: 'zhou', name: '周八', skills: ['欧式风格', '美式风格'], workload: 5, avgRating: 4.7, experience: 6 },
  113. { id: 'wu', name: '吴九', skills: ['地中海风格', '田园风格'], workload: 60, avgRating: 4.4, experience: 4 },
  114. { id: 'chen', name: '陈十', skills: ['现代简约', '新古典'], workload: 0, avgRating: 4.9, experience: 7 }
  115. ];
  116. // 10个项目阶段
  117. projectStages: ProjectStage[] = [
  118. { id: 'pendingApproval', name: '待确认', order: 1 },
  119. { id: 'pendingAssignment', name: '待分配', order: 2 },
  120. { id: 'requirement', name: '需求沟通', order: 3 },
  121. { id: 'planning', name: '方案规划', order: 4 },
  122. { id: 'modeling', name: '建模阶段', order: 5 },
  123. { id: 'rendering', name: '渲染阶段', order: 6 },
  124. { id: 'postProduction', name: '后期处理', order: 7 },
  125. { id: 'review', name: '方案评审', order: 8 },
  126. { id: 'revision', name: '方案修改', order: 9 },
  127. { id: 'delivery', name: '交付完成', order: 10 }
  128. ];
  129. // 5大核心阶段(聚合展示)
  130. corePhases: ProjectStage[] = [
  131. { id: 'order', name: '订单创建', order: 1 }, // 待确认、待分配
  132. { id: 'requirements', name: '确认需求', order: 2 }, // 需求沟通、方案规划
  133. { id: 'delivery', name: '交付执行', order: 3 }, // 建模、渲染、后期/评审/修改
  134. { id: 'aftercare', name: '售后', order: 4 } // 交付完成 → 售后
  135. ];
  136. // 甘特视图开关与实例引用
  137. showGanttView: boolean = false;
  138. private ganttChart: any | null = null;
  139. @ViewChild('ganttChartRef', { static: false }) ganttChartRef!: ElementRef<HTMLDivElement>;
  140. // 新增:工作量概览图表引用与实例
  141. @ViewChild('workloadChartRef', { static: false }) workloadChartRef!: ElementRef<HTMLDivElement>;
  142. private workloadChart: any | null = null;
  143. workloadDimension: 'designer' | 'member' = 'designer';
  144. // 甘特时间尺度:仅周/月
  145. ganttScale: 'day' | 'week' | 'month' = 'week';
  146. // 新增:甘特模式(项目 / 设计师排班)
  147. ganttMode: 'project' | 'designer' = 'project';
  148. // 个人详情面板相关属性
  149. showEmployeeDetailPanel: boolean = false;
  150. selectedEmployeeDetail: EmployeeDetail | null = null;
  151. // 员工请假数据(模拟数据)
  152. private leaveRecords: LeaveRecord[] = [
  153. { id: '1', employeeName: '张三', date: '2024-01-20', isLeave: true, leaveType: 'personal', reason: '事假' },
  154. { id: '2', employeeName: '张三', date: '2024-01-21', isLeave: false },
  155. { id: '3', employeeName: '张三', date: '2024-01-22', isLeave: false },
  156. { id: '4', employeeName: '张三', date: '2024-01-23', isLeave: false },
  157. { id: '5', employeeName: '张三', date: '2024-01-24', isLeave: false },
  158. { id: '6', employeeName: '张三', date: '2024-01-25', isLeave: false },
  159. { id: '7', employeeName: '张三', date: '2024-01-26', isLeave: false },
  160. { id: '8', employeeName: '李四', date: '2024-01-20', isLeave: false },
  161. { id: '9', employeeName: '李四', date: '2024-01-21', isLeave: true, leaveType: 'sick', reason: '病假' },
  162. { id: '10', employeeName: '李四', date: '2024-01-22', isLeave: true, leaveType: 'sick', reason: '病假' },
  163. { id: '11', employeeName: '李四', date: '2024-01-23', isLeave: false },
  164. { id: '12', employeeName: '李四', date: '2024-01-24', isLeave: false },
  165. { id: '13', employeeName: '李四', date: '2024-01-25', isLeave: false },
  166. { id: '14', employeeName: '李四', date: '2024-01-26', isLeave: false },
  167. { id: '15', employeeName: '王五', date: '2024-01-20', isLeave: false },
  168. { id: '16', employeeName: '王五', date: '2024-01-21', isLeave: false },
  169. { id: '17', employeeName: '王五', date: '2024-01-22', isLeave: false },
  170. { id: '18', employeeName: '王五', date: '2024-01-23', isLeave: true, leaveType: 'annual', reason: '年假' },
  171. { id: '19', employeeName: '王五', date: '2024-01-24', isLeave: false },
  172. { id: '20', employeeName: '王五', date: '2024-01-25', isLeave: false },
  173. { id: '21', employeeName: '王五', date: '2024-01-26', isLeave: false },
  174. { id: '22', employeeName: '赵六', date: '2024-01-20', isLeave: false },
  175. { id: '23', employeeName: '赵六', date: '2024-01-21', isLeave: false },
  176. { id: '24', employeeName: '赵六', date: '2024-01-22', isLeave: false },
  177. { id: '25', employeeName: '赵六', date: '2024-01-23', isLeave: false },
  178. { id: '26', employeeName: '赵六', date: '2024-01-24', isLeave: false },
  179. { id: '27', employeeName: '赵六', date: '2024-01-25', isLeave: false },
  180. { id: '28', employeeName: '赵六', date: '2024-01-26', isLeave: false }
  181. ];
  182. constructor(private projectService: ProjectService, private router: Router) {}
  183. ngOnInit(): void {
  184. this.loadProjects();
  185. this.loadTodoTasks();
  186. // 首次微任务后尝试初始化一次,确保容器已渲染
  187. setTimeout(() => this.updateWorkloadChart(), 0);
  188. }
  189. loadProjects(): void {
  190. // 模拟数据加载 - 增强数据结构,添加currentStage
  191. this.projects = [
  192. {
  193. id: 'proj-001',
  194. name: '现代风格客厅设计',
  195. type: 'soft',
  196. memberType: 'vip',
  197. designerName: '张三',
  198. status: '进行中',
  199. expectedEndDate: new Date(2023, 9, 15),
  200. deadline: new Date(2023, 9, 15),
  201. isOverdue: true,
  202. overdueDays: 2,
  203. dueSoon: false,
  204. urgency: 'high',
  205. currentStage: 'rendering',
  206. phases: [
  207. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  208. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: true },
  209. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  210. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  211. ]
  212. },
  213. {
  214. id: 'proj-002',
  215. name: '北欧风格卧室设计',
  216. type: 'soft',
  217. memberType: 'normal',
  218. designerName: '李四',
  219. status: '进行中',
  220. expectedEndDate: new Date(2023, 9, 20),
  221. deadline: new Date(2023, 9, 20),
  222. isOverdue: false,
  223. overdueDays: 0,
  224. dueSoon: false,
  225. urgency: 'medium',
  226. currentStage: 'postProduction',
  227. phases: [
  228. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  229. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  230. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: true },
  231. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  232. ]
  233. },
  234. {
  235. id: 'proj-003',
  236. name: '新中式餐厅设计',
  237. type: 'hard',
  238. memberType: 'normal',
  239. designerName: '王五',
  240. status: '进行中',
  241. expectedEndDate: new Date(2023, 9, 25),
  242. deadline: new Date(2023, 9, 25),
  243. isOverdue: false,
  244. overdueDays: 0,
  245. dueSoon: false,
  246. urgency: 'low',
  247. currentStage: 'modeling',
  248. phases: [
  249. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: false, isCurrent: true },
  250. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: false },
  251. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  252. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  253. ]
  254. },
  255. {
  256. id: 'proj-004',
  257. name: '工业风办公室设计',
  258. type: 'hard',
  259. memberType: 'normal',
  260. designerName: '赵六',
  261. status: '进行中',
  262. expectedEndDate: new Date(2023, 9, 10),
  263. deadline: new Date(2023, 9, 10),
  264. isOverdue: true,
  265. overdueDays: 7,
  266. dueSoon: false,
  267. urgency: 'high',
  268. currentStage: 'review',
  269. phases: [
  270. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  271. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  272. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  273. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  274. ]
  275. },
  276. // 添加更多不同阶段的项目
  277. {
  278. id: 'proj-005',
  279. name: '现代简约厨房设计',
  280. type: 'soft',
  281. memberType: 'normal',
  282. designerName: '',
  283. status: '待分配',
  284. expectedEndDate: new Date(2023, 10, 5),
  285. deadline: new Date(2023, 10, 5),
  286. isOverdue: false,
  287. overdueDays: 0,
  288. dueSoon: false,
  289. urgency: 'medium',
  290. currentStage: 'pendingAssignment',
  291. phases: []
  292. },
  293. {
  294. id: 'proj-006',
  295. name: '日式风格书房设计',
  296. type: 'hard',
  297. memberType: 'normal',
  298. designerName: '',
  299. status: '待确认',
  300. expectedEndDate: new Date(2023, 10, 10),
  301. deadline: new Date(2023, 10, 10),
  302. isOverdue: false,
  303. overdueDays: 0,
  304. dueSoon: false,
  305. urgency: 'low',
  306. currentStage: 'pendingApproval',
  307. phases: []
  308. },
  309. {
  310. id: 'proj-007',
  311. name: '轻奢风格浴室设计',
  312. type: 'soft',
  313. memberType: 'normal',
  314. designerName: '钱七',
  315. status: '已完成',
  316. expectedEndDate: new Date(2023, 9, 5),
  317. deadline: new Date(2023, 9, 5),
  318. isOverdue: false,
  319. overdueDays: 0,
  320. dueSoon: false,
  321. urgency: 'medium',
  322. currentStage: 'delivery',
  323. phases: []
  324. }
  325. ];
  326. // ===== 追加生成示例数据:保证总量达到100条 =====
  327. const stageIds = this.projectStages.map(s => s.id);
  328. const designers = ['张三','李四','王五','赵六','孙七','周八','吴九','陈十'];
  329. const statusMap: Record<string, string> = {
  330. pendingApproval: '待确认',
  331. pendingAssignment: '待分配',
  332. requirement: '进行中',
  333. planning: '进行中',
  334. modeling: '进行中',
  335. rendering: '进行中',
  336. postProduction: '进行中',
  337. review: '进行中',
  338. revision: '进行中',
  339. delivery: '已完成'
  340. };
  341. // 为有项目的设计师分配项目
  342. const busyDesigners = ['张三', '王五', '吴九']; // 高负荷设计师
  343. const moderateDesigners = ['孙七']; // 中等负荷设计师
  344. const idleDesigners = ['李四', '赵六', '周八', '陈十']; // 空闲设计师
  345. // 为忙碌的设计师分配更多项目
  346. for (let i = 8; i <= 30; i++) {
  347. const designerIndex = (i - 8) % busyDesigners.length;
  348. const designerName = busyDesigners[designerIndex];
  349. const stageIndex = (i - 1) % 7 + 3; // 主要在进行中的阶段
  350. const currentStage = stageIds[stageIndex];
  351. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  352. const urgency: 'high' | 'medium' | 'low' = i % 4 === 0 ? 'high' : (i % 3 === 0 ? 'medium' : 'low');
  353. const isOverdue = i % 8 === 0;
  354. const overdueDays = isOverdue ? (i % 5) + 1 : 0;
  355. const status = statusMap[currentStage] || '进行中';
  356. const expectedEndDate = new Date();
  357. const daysOffset = isOverdue ? -(overdueDays + (i % 3)) : ((i % 15) + 5);
  358. expectedEndDate.setDate(expectedEndDate.getDate() + daysOffset);
  359. const daysToDeadline = Math.ceil((expectedEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
  360. const dueSoon = !isOverdue && daysToDeadline >= 0 && daysToDeadline <= 3;
  361. const memberType: 'vip' | 'normal' = i % 5 === 0 ? 'vip' : 'normal';
  362. this.projects.push({
  363. id: `proj-${String(i).padStart(3, '0')}`,
  364. name: `${designerName}负责的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  365. type,
  366. memberType,
  367. designerName,
  368. status,
  369. expectedEndDate,
  370. deadline: expectedEndDate,
  371. isOverdue,
  372. overdueDays,
  373. dueSoon,
  374. urgency,
  375. currentStage,
  376. phases: []
  377. });
  378. }
  379. // 为中等负荷设计师分配少量项目
  380. for (let i = 31; i <= 35; i++) {
  381. const designerName = moderateDesigners[0];
  382. const stageIndex = (i - 1) % 5 + 4; // 中间阶段
  383. const currentStage = stageIds[stageIndex];
  384. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  385. const urgency: 'high' | 'medium' | 'low' = 'medium';
  386. const status = statusMap[currentStage] || '进行中';
  387. const expectedEndDate = new Date();
  388. expectedEndDate.setDate(expectedEndDate.getDate() + (i % 10) + 7);
  389. const memberType: 'vip' | 'normal' = 'normal';
  390. this.projects.push({
  391. id: `proj-${String(i).padStart(3, '0')}`,
  392. name: `${designerName}负责的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  393. type,
  394. memberType,
  395. designerName,
  396. status,
  397. expectedEndDate,
  398. deadline: expectedEndDate,
  399. isOverdue: false,
  400. overdueDays: 0,
  401. dueSoon: false,
  402. urgency,
  403. currentStage,
  404. phases: []
  405. });
  406. }
  407. // 空闲设计师不分配项目,或只分配很少的已完成项目
  408. for (let i = 36; i <= 40; i++) {
  409. const designerIndex = (i - 36) % idleDesigners.length;
  410. const designerName = idleDesigners[designerIndex];
  411. const currentStage = 'delivery'; // 已完成的项目
  412. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  413. const urgency: 'high' | 'medium' | 'low' = 'low';
  414. const status = '已完成';
  415. const expectedEndDate = new Date();
  416. expectedEndDate.setDate(expectedEndDate.getDate() - (i % 10) - 5); // 过去的日期
  417. const memberType: 'vip' | 'normal' = 'normal';
  418. this.projects.push({
  419. id: `proj-${String(i).padStart(3, '0')}`,
  420. name: `${designerName}已完成的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  421. type,
  422. memberType,
  423. designerName,
  424. status,
  425. expectedEndDate,
  426. deadline: expectedEndDate,
  427. isOverdue: false,
  428. overdueDays: 0,
  429. dueSoon: false,
  430. urgency,
  431. currentStage,
  432. phases: []
  433. });
  434. }
  435. // ===== 示例数据生成结束 =====
  436. // 统一补齐真实时间字段(deadline/createdAt),以真实字段贯通筛选与甘特
  437. const DAY = 24 * 60 * 60 * 1000;
  438. this.projects = this.projects.map(p => {
  439. const deadline = p.deadline || p.expectedEndDate;
  440. const baseDays = p.type === 'hard' ? 30 : 14;
  441. const createdAt = p.createdAt || new Date(new Date(deadline).getTime() - baseDays * DAY);
  442. return { ...p, deadline, createdAt } as Project;
  443. });
  444. // 筛选超期与临期项目
  445. this.overdueProjects = this.projects.filter(project => project.isOverdue);
  446. this.dueSoonProjects = this.projects.filter(project => project.dueSoon && !project.isOverdue);
  447. this.filteredProjects = [...this.projects];
  448. // 供筛选用的设计师列表
  449. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  450. // 显示超期提醒
  451. if (this.overdueProjects.length > 0) {
  452. this.showAlert = true;
  453. }
  454. }
  455. loadTodoTasks(): void {
  456. // 模拟待办任务数据
  457. this.todoTasks = [
  458. {
  459. id: 'todo-001',
  460. title: '待评审效果图',
  461. description: '现代风格客厅设计项目需要进行效果图评审',
  462. deadline: new Date(2023, 9, 18, 15, 0),
  463. priority: 'high',
  464. type: 'review',
  465. targetId: 'proj-001'
  466. },
  467. {
  468. id: 'todo-002',
  469. title: '待分配项目',
  470. description: '新中式厨房设计项目需要分配给合适的设计师',
  471. deadline: new Date(2023, 9, 19, 10, 0),
  472. priority: 'high',
  473. type: 'assign',
  474. targetId: 'proj-new'
  475. },
  476. {
  477. id: 'todo-003',
  478. title: '待确认绩效',
  479. description: '9月份团队绩效需要进行审核确认',
  480. deadline: new Date(2023, 9, 22, 18, 0),
  481. priority: 'medium',
  482. type: 'performance',
  483. targetId: 'sep-2023'
  484. },
  485. {
  486. id: 'todo-004',
  487. title: '待处理客户反馈',
  488. description: '北欧风格卧室设计项目有客户反馈需要处理',
  489. deadline: new Date(2023, 9, 20, 14, 0),
  490. priority: 'medium',
  491. type: 'review',
  492. targetId: 'proj-002'
  493. },
  494. {
  495. id: 'todo-005',
  496. title: '团队会议',
  497. description: '每周团队进度沟通会议',
  498. deadline: new Date(2023, 9, 18, 10, 0),
  499. priority: 'low',
  500. type: 'performance',
  501. targetId: 'weekly-meeting'
  502. }
  503. ];
  504. // 按优先级排序:紧急且重要 > 重要不紧急 > 紧急不重要
  505. this.todoTasks.sort((a, b) => {
  506. const priorityOrder = {
  507. 'high': 3,
  508. 'medium': 2,
  509. 'low': 1
  510. };
  511. return priorityOrder[b.priority] - priorityOrder[a.priority];
  512. });
  513. }
  514. // 筛选项目类型
  515. filterProjects(event: Event): void {
  516. const target = event.target as HTMLSelectElement;
  517. this.selectedType = (target && target.value ? target.value : 'all') as any;
  518. this.applyFilters();
  519. }
  520. // 筛选紧急程度
  521. filterByUrgency(event: Event): void {
  522. const target = event.target as HTMLSelectElement;
  523. this.selectedUrgency = (target && target.value ? target.value : 'all') as any;
  524. this.applyFilters();
  525. }
  526. // 筛选项目状态
  527. filterByStatus(status: string): void {
  528. // 点击同一状态时,切换回“全部”,以便恢复全量项目列表
  529. const next = (this.selectedStatus === status) ? 'all' : (status && status.length ? status : 'all');
  530. this.selectedStatus = next as any;
  531. this.applyFilters();
  532. }
  533. // 处理状态筛选下拉框变化
  534. onStatusChange(event: Event): void {
  535. const target = event.target as HTMLSelectElement;
  536. this.selectedStatus = (target && target.value ? target.value : 'all') as any;
  537. this.applyFilters();
  538. }
  539. // 新增:设计师筛选下拉事件处理
  540. onDesignerChange(event: Event): void {
  541. const target = event.target as HTMLSelectElement;
  542. this.selectedDesigner = (target && target.value ? target.value : 'all');
  543. this.applyFilters();
  544. }
  545. // 新增:会员类型筛选下拉事件处理
  546. onMemberTypeChange(event: Event): void {
  547. const select = event.target as HTMLSelectElement;
  548. this.selectedMemberType = select.value as any;
  549. this.applyFilters();
  550. }
  551. // 新增:四大板块改变
  552. onCorePhaseChange(event: Event): void {
  553. const select = event.target as HTMLSelectElement;
  554. this.selectedCorePhase = select.value as any;
  555. this.applyFilters();
  556. }
  557. // 时间窗快捷筛选(供UI按钮触发)
  558. filterByTimeWindow(timeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays'): void {
  559. this.selectedTimeWindow = timeWindow;
  560. this.applyFilters();
  561. }
  562. // 新增:搜索输入变化
  563. onSearchChange(): void {
  564. if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
  565. this.searchDebounceTimer = setTimeout(() => {
  566. this.updateSearchSuggestions();
  567. this.applyFilters();
  568. }, this.SEARCH_DEBOUNCE_MS);
  569. }
  570. // 新增:搜索框聚焦/失焦控制建议显隐
  571. onSearchFocus(): void {
  572. if (this.hideSuggestionsTimer) clearTimeout(this.hideSuggestionsTimer);
  573. this.isSearchFocused = true;
  574. this.updateSearchSuggestions();
  575. }
  576. onSearchBlur(): void {
  577. // 延迟隐藏以允许选择项的 mousedown 触发
  578. this.isSearchFocused = false;
  579. this.hideSuggestionsTimer = setTimeout(() => {
  580. this.showSuggestions = false;
  581. }, 150);
  582. }
  583. // 新增:更新搜索建议(不叠加其它筛选,仅基于关键字)
  584. private updateSearchSuggestions(): void {
  585. const q = (this.searchTerm || '').trim().toLowerCase();
  586. if (q.length < this.MIN_SEARCH_LEN) {
  587. this.searchSuggestions = [];
  588. this.showSuggestions = false;
  589. return;
  590. }
  591. const scored = this.projects
  592. .filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q))
  593. .map(p => {
  594. const dl = p.deadline || p.expectedEndDate;
  595. const dlTime = dl ? new Date(dl).getTime() : NaN;
  596. const daysToDl = Math.ceil(((isNaN(dlTime) ? 0 : dlTime) - Date.now()) / (1000 * 60 * 60 * 24));
  597. const urgencyScore = p.urgency === 'high' ? 3 : p.urgency === 'medium' ? 2 : 1;
  598. const overdueScore = p.isOverdue ? 10 : 0;
  599. const score = overdueScore + (4 - urgencyScore) * 2 - (isNaN(daysToDl) ? 0 : daysToDl);
  600. return { p, score };
  601. })
  602. .sort((a, b) => b.score - a.score)
  603. .slice(0, this.MAX_SUGGESTIONS)
  604. .map(x => x.p);
  605. this.searchSuggestions = scored;
  606. this.showSuggestions = this.isSearchFocused && this.searchSuggestions.length > 0;
  607. }
  608. // 新增:选择建议项
  609. selectSuggestion(project: Project): void {
  610. this.searchTerm = project.name;
  611. this.showSuggestions = false;
  612. this.viewProjectDetails(project.id);
  613. }
  614. // 统一筛选
  615. private applyFilters(): void {
  616. let result = [...this.projects];
  617. // 新增:关键词搜索(项目名 / 设计师名 / 含风格关键词的项目名)
  618. const q = (this.searchTerm || '').trim().toLowerCase();
  619. if (q) {
  620. result = result.filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q));
  621. }
  622. // 类型筛选
  623. if (this.selectedType !== 'all') {
  624. result = result.filter(p => p.type === this.selectedType);
  625. }
  626. // 紧急程度筛选
  627. if (this.selectedUrgency !== 'all') {
  628. result = result.filter(p => p.urgency === this.selectedUrgency);
  629. }
  630. // 项目状态筛选
  631. if (this.selectedStatus !== 'all') {
  632. if (this.selectedStatus === 'overdue') {
  633. result = result.filter(p => p.isOverdue);
  634. } else if (this.selectedStatus === 'dueSoon') {
  635. result = result.filter(p => p.dueSoon && !p.isOverdue);
  636. } else if (this.selectedStatus === 'pendingApproval') {
  637. result = result.filter(p => p.currentStage === 'pendingApproval');
  638. } else if (this.selectedStatus === 'pendingAssignment') {
  639. result = result.filter(p => p.currentStage === 'pendingAssignment');
  640. } else if (this.selectedStatus === 'progress') {
  641. const progressStages = ['requirement','planning','modeling','rendering','postProduction','review','revision'];
  642. result = result.filter(p => progressStages.includes(p.currentStage));
  643. } else if (this.selectedStatus === 'completed') {
  644. result = result.filter(p => p.currentStage === 'delivery');
  645. }
  646. }
  647. // 新增:四大板块筛选
  648. if (this.selectedCorePhase !== 'all') {
  649. result = result.filter(p => this.mapStageToCorePhase(p.currentStage) === this.selectedCorePhase);
  650. }
  651. // 设计师筛选
  652. if (this.selectedDesigner !== 'all') {
  653. result = result.filter(p => p.designerName === this.selectedDesigner);
  654. }
  655. // 会员类型筛选
  656. if (this.selectedMemberType !== 'all') {
  657. result = result.filter(p => p.memberType === this.selectedMemberType);
  658. }
  659. // 新增:时间窗筛选
  660. if (this.selectedTimeWindow !== 'all') {
  661. const now = new Date();
  662. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  663. result = result.filter(p => {
  664. const projectDeadline = new Date(p.deadline);
  665. const timeDiff = projectDeadline.getTime() - today.getTime();
  666. const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
  667. switch (this.selectedTimeWindow) {
  668. case 'today':
  669. return daysDiff <= 1 && daysDiff >= 0;
  670. case 'threeDays':
  671. return daysDiff <= 3 && daysDiff >= 0;
  672. case 'sevenDays':
  673. return daysDiff <= 7 && daysDiff >= 0;
  674. default:
  675. return true;
  676. }
  677. });
  678. }
  679. this.filteredProjects = result;
  680. // 新增:计算紧急任务固定区(超期 + 高紧急),与筛选联动
  681. this.urgentPinnedProjects = this.filteredProjects
  682. .filter(p => p.isOverdue && p.urgency === 'high')
  683. .sort((a, b) => (b.overdueDays - a.overdueDays) || a.name.localeCompare(b.name, 'zh-Hans-CN'));
  684. // 当显示甘特卡片时,同步刷新甘特图
  685. if (this.showGanttView) {
  686. this.updateGantt();
  687. }
  688. // 同步刷新工作量概览图
  689. this.updateWorkloadChart();
  690. }
  691. // 切换项目看板/负载日历(甘特)视图
  692. toggleView(): void {
  693. this.showGanttView = !this.showGanttView;
  694. if (this.showGanttView) {
  695. setTimeout(() => this.initOrUpdateGantt(), 0);
  696. } else {
  697. if (this.ganttChart) {
  698. this.ganttChart.dispose();
  699. this.ganttChart = null;
  700. }
  701. if (this.workloadChart) {
  702. this.workloadChart.dispose();
  703. this.workloadChart = null;
  704. }
  705. }
  706. }
  707. // 设置甘特时间尺度
  708. setGanttScale(scale: 'day' | 'week' | 'month'): void {
  709. if (this.ganttScale !== scale) {
  710. this.ganttScale = scale;
  711. this.updateGantt();
  712. }
  713. }
  714. // 新增:切换甘特模式
  715. setGanttMode(mode: 'project' | 'designer'): void {
  716. if (this.ganttMode !== mode) {
  717. this.ganttMode = mode;
  718. this.updateGantt();
  719. }
  720. }
  721. private initOrUpdateGantt(): void {
  722. if (!this.ganttChartRef) return;
  723. const el = this.ganttChartRef.nativeElement;
  724. if (!this.ganttChart) {
  725. this.ganttChart = echarts.init(el);
  726. // 添加点击事件监听器
  727. this.ganttChart.on('click', (params: any) => {
  728. if (params.componentType === 'series' && params.seriesType === 'custom') {
  729. // 获取点击的员工名称(从y轴类目数据中获取)
  730. const yAxisData = this.ganttChart.getOption().yAxis[0].data;
  731. if (yAxisData && params.dataIndex !== undefined) {
  732. const employeeName = yAxisData[params.value[0]];
  733. if (employeeName && employeeName !== '未分配') {
  734. this.onEmployeeClick(employeeName);
  735. }
  736. }
  737. }
  738. });
  739. window.addEventListener('resize', () => {
  740. this.ganttChart && this.ganttChart.resize();
  741. });
  742. }
  743. this.updateGantt();
  744. }
  745. private updateGantt(): void {
  746. if (!this.ganttChart) return;
  747. if (this.ganttMode === 'designer') {
  748. this.updateGanttDesigner();
  749. return;
  750. }
  751. // 按紧急程度从上到下排序(高->中->低),同级按到期时间升序
  752. const urgencyRank: Record<'high'|'medium'|'low', number> = { high: 0, medium: 1, low: 2 };
  753. const projects = [...this.filteredProjects]
  754. .sort((a, b) => {
  755. const u = urgencyRank[a.urgency] - urgencyRank[b.urgency];
  756. if (u !== 0) return u;
  757. // 二级排序:临期优先(到期更近)> 已分配人员 > VIP客户 > 其他
  758. const endDiff = new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
  759. if (endDiff !== 0) return endDiff;
  760. const assignedA = !!a.designerName;
  761. const assignedB = !!b.designerName;
  762. if (assignedA !== assignedB) return assignedA ? -1 : 1; // 已分配在前
  763. const vipA = a.memberType === 'vip';
  764. const vipB = b.memberType === 'vip';
  765. if (vipA !== vipB) return vipA ? -1 : 1; // VIP在前
  766. return a.name.localeCompare(b.name, 'zh-CN');
  767. });
  768. const categories = projects.map(p => p.name);
  769. const urgencyMap: Record<string, 'high'|'medium'|'low'> = Object.fromEntries(projects.map(p => [p.name, p.urgency])) as any;
  770. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  771. high: '#ef4444',
  772. medium: '#f59e0b',
  773. low: '#22c55e'
  774. } as const;
  775. const DAY = 24 * 60 * 60 * 1000;
  776. const data = projects.map((p, idx) => {
  777. const end = new Date(p.deadline).getTime();
  778. const baseDays = p.type === 'hard' ? 30 : 14;
  779. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  780. const color = colorByUrgency[p.urgency] || '#60a5fa';
  781. return {
  782. name: p.name,
  783. value: [idx, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
  784. itemStyle: { color }
  785. };
  786. });
  787. // 计算时间范围(仅周/月)
  788. const now = new Date();
  789. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  790. const todayTs = today.getTime();
  791. let xMin: number;
  792. let xMax: number;
  793. let xSplitNumber: number;
  794. let xLabelFormatter: (value: number) => string;
  795. if (this.ganttScale === 'week') {
  796. const day = today.getDay(); // 0=周日
  797. const diffToMonday = (day === 0 ? 6 : day - 1);
  798. const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
  799. const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
  800. xMin = startOfWeek.getTime();
  801. xMax = endOfWeek.getTime();
  802. xSplitNumber = 7;
  803. const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
  804. xLabelFormatter = (val) => {
  805. const d = new Date(val);
  806. return WEEK_LABELS[d.getDay()];
  807. };
  808. } else { // month
  809. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  810. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
  811. xMin = startOfMonth.getTime();
  812. xMax = endOfMonth.getTime();
  813. xSplitNumber = 4;
  814. xLabelFormatter = (val) => {
  815. const d = new Date(val);
  816. const weekOfMonth = Math.ceil(d.getDate() / 7);
  817. return `第${weekOfMonth}周`;
  818. };
  819. }
  820. // 计算默认可视区,并尝试保留上一次的滚动/缩放位置
  821. const total = categories.length;
  822. const visible = Math.min(total, 15); // 默认首屏展开15条
  823. const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
  824. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  825. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  826. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  827. // 生成请假覆盖层数据
  828. const leaveOverlayData = this.generateLeaveOverlayData(categories, xMin, xMax);
  829. const option = {
  830. backgroundColor: 'transparent',
  831. tooltip: {
  832. trigger: 'item',
  833. formatter: (params: any) => {
  834. const v = params.value;
  835. const start = new Date(v[1]);
  836. const end = new Date(v[2]);
  837. return `项目:${params.name}<br/>负责人:${v[3] || '未分配'}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
  838. }
  839. },
  840. grid: { left: 100, right: 64, top: 30, bottom: 30 },
  841. xAxis: {
  842. type: 'time',
  843. min: xMin,
  844. max: xMax,
  845. splitNumber: xSplitNumber,
  846. axisLine: { lineStyle: { color: '#e5e7eb' } },
  847. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  848. splitLine: { lineStyle: { color: '#f1f5f9' } }
  849. },
  850. yAxis: {
  851. type: 'category',
  852. data: categories,
  853. inverse: true,
  854. axisLabel: {
  855. color: '#374151',
  856. margin: 8,
  857. formatter: (val: string) => {
  858. const u = urgencyMap[val] || 'low';
  859. const text = val.length > 16 ? val.slice(0, 16) + '…' : val;
  860. return `{${u}Dot|●} ${text}`;
  861. },
  862. rich: {
  863. highDot: { color: '#ef4444' },
  864. mediumDot: { color: '#f59e0b' },
  865. lowDot: { color: '#22c55e' }
  866. }
  867. },
  868. axisTick: { show: false },
  869. axisLine: { lineStyle: { color: '#e5e7eb' } }
  870. },
  871. dataZoom: [
  872. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, width: 14, start: preservedStart, end: preservedEnd, zoomLock: false },
  873. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  874. ],
  875. series: [
  876. // 项目条形图系列
  877. {
  878. type: 'custom',
  879. name: '项目进度',
  880. renderItem: (params: any, api: any) => {
  881. const categoryIndex = api.value(0);
  882. const start = api.coord([api.value(1), categoryIndex]);
  883. const end = api.coord([api.value(2), categoryIndex]);
  884. const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
  885. const rectShape = echarts.graphic.clipRectByRect({
  886. x: start[0],
  887. y: start[1] - height / 2,
  888. width: Math.max(end[0] - start[0], 2),
  889. height
  890. }, {
  891. x: params.coordSys.x,
  892. y: params.coordSys.y,
  893. width: params.coordSys.width,
  894. height: params.coordSys.height
  895. });
  896. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  897. },
  898. encode: { x: [1, 2], y: 0 },
  899. data,
  900. itemStyle: { borderRadius: 4 },
  901. emphasis: { focus: 'self' },
  902. markLine: {
  903. silent: true,
  904. symbol: 'none',
  905. lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
  906. label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
  907. data: [ { xAxis: todayTs } ]
  908. }
  909. },
  910. // 请假覆盖层系列
  911. {
  912. type: 'custom',
  913. name: '请假/繁忙标记',
  914. renderItem: (params: any, api: any) => {
  915. const categoryIndex = api.value(0);
  916. const start = api.coord([api.value(1), categoryIndex]);
  917. const end = api.coord([api.value(2), categoryIndex]);
  918. const height = Math.max(api.size([0, 1])[1] * 0.8, 12); // 稍微高一点,覆盖项目条
  919. const rectShape = echarts.graphic.clipRectByRect({
  920. x: start[0],
  921. y: start[1] - height / 2,
  922. width: Math.max(end[0] - start[0], 2),
  923. height
  924. }, {
  925. x: params.coordSys.x,
  926. y: params.coordSys.y,
  927. width: params.coordSys.width,
  928. height: params.coordSys.height
  929. });
  930. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  931. },
  932. encode: { x: [1, 2], y: 0 },
  933. data: leaveOverlayData,
  934. itemStyle: { borderRadius: 4 },
  935. emphasis: { focus: 'self' },
  936. z: 10 // 确保覆盖层在项目条之上
  937. }
  938. ]
  939. };
  940. // 强制刷新,避免缓存导致坐标轴不更新
  941. this.ganttChart.clear();
  942. this.ganttChart.setOption(option, true);
  943. this.ganttChart.resize();
  944. }
  945. // 新增:设计师排班甘特
  946. private updateGanttDesigner(): void {
  947. if (!this.ganttChart) return;
  948. const DAY = 24 * 60 * 60 * 1000;
  949. const now = new Date();
  950. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  951. const todayTs = today.getTime();
  952. // 时间轴按当前周/月/日
  953. let xMin: number;
  954. let xMax: number;
  955. let xSplitNumber: number;
  956. let xLabelFormatter: (value: number) => string;
  957. if (this.ganttScale === 'day') {
  958. // 日视图:显示今日24小时
  959. const startOfDay = new Date(today.getTime());
  960. const endOfDay = new Date(today.getTime() + DAY - 1);
  961. xMin = startOfDay.getTime();
  962. xMax = endOfDay.getTime();
  963. xSplitNumber = 24;
  964. xLabelFormatter = (val) => `${new Date(val).getHours()}:00`;
  965. } else if (this.ganttScale === 'week') {
  966. // 周视图:从今天开始显示未来7天的具体日期
  967. const startOfWeek = new Date(today.getTime());
  968. const endOfWeek = new Date(today.getTime() + 7 * DAY - 1);
  969. xMin = startOfWeek.getTime();
  970. xMax = endOfWeek.getTime();
  971. xSplitNumber = 7;
  972. xLabelFormatter = (val) => {
  973. const date = new Date(val);
  974. const month = date.getMonth() + 1;
  975. const day = date.getDate();
  976. return `${month}月${day}日`;
  977. };
  978. } else {
  979. // 月视图:从当前月份开始显示未来几个月
  980. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  981. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 3, 0, 23, 59, 59, 999); // 显示未来3个月
  982. xMin = startOfMonth.getTime();
  983. xMax = endOfMonth.getTime();
  984. xSplitNumber = 3;
  985. xLabelFormatter = (val) => {
  986. const date = new Date(val);
  987. const year = date.getFullYear();
  988. const month = date.getMonth() + 1;
  989. return `${year}年${month}月`;
  990. };
  991. }
  992. // 仅统计已分配项目
  993. const assigned = this.filteredProjects.filter(p => !!p.designerName);
  994. const designers = Array.from(new Set(assigned.map(p => p.designerName)));
  995. const byDesigner: Record<string, typeof assigned> = {} as any;
  996. designers.forEach(n => byDesigner[n] = [] as any);
  997. assigned.forEach(p => byDesigner[p.designerName].push(p));
  998. const busyCountMap: Record<string, number> = Object.fromEntries(designers.map(n => [n, byDesigner[n].length]));
  999. const sortedDesigners = designers.sort((a, b) => {
  1000. const diff = (busyCountMap[b] || 0) - (busyCountMap[a] || 0);
  1001. return diff !== 0 ? diff : a.localeCompare(b, 'zh-CN');
  1002. });
  1003. const categories = sortedDesigners;
  1004. // 工作量等级(用于左侧小圆点颜色和负荷状态判断)
  1005. const workloadLevelMap: Record<string, 'high'|'medium'|'low'> = {} as any;
  1006. const workloadStatusMap: Record<string, 'overloaded'|'busy'|'available'> = {} as any;
  1007. categories.forEach(name => {
  1008. const cnt = busyCountMap[name] || 0;
  1009. if (cnt >= 5) {
  1010. workloadLevelMap[name] = 'high';
  1011. workloadStatusMap[name] = 'overloaded'; // 不宜派单
  1012. } else if (cnt >= 3) {
  1013. workloadLevelMap[name] = 'medium';
  1014. workloadStatusMap[name] = 'busy'; // 适度忙碌
  1015. } else {
  1016. workloadLevelMap[name] = 'low';
  1017. workloadStatusMap[name] = 'available'; // 可接单
  1018. }
  1019. });
  1020. // 条形颜色按项目紧急度,增强高负荷时段的视觉效果
  1021. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  1022. high: '#dc2626', // 更深的红色,突出高紧急度
  1023. medium: '#ea580c', // 更深的橙色
  1024. low: '#16a34a' // 更深的绿色
  1025. } as const;
  1026. const data = assigned.flatMap(p => {
  1027. const end = new Date(p.deadline).getTime();
  1028. const baseDays = p.type === 'hard' ? 30 : 14;
  1029. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  1030. const yIndex = categories.indexOf(p.designerName);
  1031. if (yIndex === -1) return [] as any[];
  1032. // 根据设计师工作负荷状态调整项目条的视觉效果
  1033. const workloadStatus = workloadStatusMap[p.designerName];
  1034. let color = colorByUrgency[p.urgency] || '#60a5fa';
  1035. let borderWidth = 1;
  1036. let borderColor = 'transparent';
  1037. // 高负荷时段增强视觉效果
  1038. if (workloadStatus === 'overloaded') {
  1039. borderWidth = 3;
  1040. borderColor = '#991b1b'; // 深红色边框
  1041. // 对于超负荷状态,使用更深的红色调
  1042. if (p.urgency === 'high') {
  1043. color = '#7f1d1d'; // 深红色
  1044. } else if (p.urgency === 'medium') {
  1045. color = '#c2410c'; // 深橙色
  1046. } else {
  1047. color = '#dc2626'; // 红色(即使是低紧急度也用红色表示超负荷)
  1048. }
  1049. }
  1050. return [{
  1051. name: p.name,
  1052. value: [yIndex, start, end, p.designerName, p.urgency, p.memberType, p.currentStage, workloadStatus],
  1053. itemStyle: {
  1054. color,
  1055. borderWidth,
  1056. borderColor,
  1057. opacity: workloadStatus === 'overloaded' ? 0.9 : 1.0
  1058. }
  1059. }];
  1060. });
  1061. // 生成空闲时段背景数据 - 只在真正空闲的时间段显示
  1062. const idleBackgroundData: any[] = [];
  1063. categories.forEach((designerName, yIndex) => {
  1064. const designerProjects = byDesigner[designerName] || [];
  1065. const workloadStatus = workloadStatusMap[designerName];
  1066. // 获取该设计师的所有项目时间段
  1067. const projectTimeRanges = designerProjects.map(p => {
  1068. const end = new Date(p.deadline).getTime();
  1069. const baseDays = p.type === 'hard' ? 30 : 14;
  1070. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  1071. return { start, end };
  1072. }).sort((a, b) => a.start - b.start);
  1073. // 找出空闲时间段
  1074. const idleTimeRanges: { start: number; end: number }[] = [];
  1075. if (projectTimeRanges.length === 0) {
  1076. // 完全没有项目,整个时间轴都是空闲
  1077. idleTimeRanges.push({ start: xMin, end: xMax });
  1078. } else {
  1079. // 检查项目之间的空隙
  1080. let currentTime = xMin;
  1081. for (const range of projectTimeRanges) {
  1082. if (currentTime < range.start) {
  1083. // 在项目开始前有空闲时间
  1084. idleTimeRanges.push({ start: currentTime, end: range.start });
  1085. }
  1086. currentTime = Math.max(currentTime, range.end);
  1087. }
  1088. // 检查最后一个项目后是否还有空闲时间
  1089. if (currentTime < xMax) {
  1090. idleTimeRanges.push({ start: currentTime, end: xMax });
  1091. }
  1092. }
  1093. // 为每个空闲时间段创建背景数据
  1094. idleTimeRanges.forEach((idleRange, index) => {
  1095. // 只有当空闲时间段足够长时才显示(至少1天)
  1096. if (idleRange.end - idleRange.start >= DAY) {
  1097. let backgroundColor = 'transparent';
  1098. if (workloadStatus === 'available') {
  1099. backgroundColor = 'rgba(34, 197, 94, 0.15)'; // 淡绿色背景表示空闲可接单
  1100. } else if (workloadStatus === 'overloaded') {
  1101. backgroundColor = 'rgba(239, 68, 68, 0.1)'; // 淡红色背景表示超负荷
  1102. }
  1103. if (backgroundColor !== 'transparent') {
  1104. idleBackgroundData.push({
  1105. name: `${designerName}-空闲${index + 1}`,
  1106. value: [yIndex, idleRange.start, idleRange.end, designerName, 'background', workloadStatus],
  1107. itemStyle: {
  1108. color: backgroundColor,
  1109. borderWidth: 0
  1110. }
  1111. });
  1112. }
  1113. }
  1114. });
  1115. });
  1116. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  1117. const total = categories.length || 1;
  1118. const visible = Math.min(total, 30);
  1119. const defaultEndPercent = Math.min(100, (visible / total) * 100);
  1120. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  1121. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  1122. const option = {
  1123. backgroundColor: 'transparent',
  1124. tooltip: {
  1125. trigger: 'item',
  1126. formatter: (params: any) => {
  1127. const v = params.value;
  1128. if (v[4] === 'background') {
  1129. const workloadStatus = v[5];
  1130. const statusText = workloadStatus === 'available' ? '空闲可接单' :
  1131. workloadStatus === 'overloaded' ? '超负荷不宜派单' : '适度忙碌';
  1132. return `设计师:${v[3]}<br/>状态:${statusText}`;
  1133. }
  1134. const start = new Date(v[1]);
  1135. const end = new Date(v[2]);
  1136. const workloadStatus = v[7];
  1137. const statusText = workloadStatus === 'available' ? '可接单' :
  1138. workloadStatus === 'overloaded' ? '超负荷' : '适度忙碌';
  1139. return `项目:${params.name}<br/>设计师:${v[3]}(${statusText})<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
  1140. }
  1141. },
  1142. legend: {
  1143. data: ['空闲可接单', '适度忙碌', '超负荷不宜派单', '高紧急', '中紧急', '低紧急'],
  1144. bottom: 0,
  1145. itemGap: 20,
  1146. textStyle: { fontSize: 12 }
  1147. },
  1148. grid: { left: 110, right: 64, top: 30, bottom: 60 },
  1149. xAxis: {
  1150. type: 'time',
  1151. min: xMin,
  1152. max: xMax,
  1153. splitNumber: xSplitNumber,
  1154. axisLine: { lineStyle: { color: '#e5e7eb' } },
  1155. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  1156. splitLine: { lineStyle: { color: '#f1f5f9' } }
  1157. },
  1158. yAxis: {
  1159. type: 'category',
  1160. data: categories,
  1161. inverse: true,
  1162. axisLabel: {
  1163. color: '#374151',
  1164. margin: 8,
  1165. formatter: (val: string) => {
  1166. const lvl = workloadLevelMap[val] || 'low';
  1167. const count = busyCountMap[val] || 0;
  1168. const status = workloadStatusMap[val] || 'available';
  1169. const text = val.length > 8 ? val.slice(0, 8) + '…' : val;
  1170. const statusIcon = status === 'available' ? '🟢' :
  1171. status === 'overloaded' ? '🔴' : '🟡';
  1172. return `{${lvl}Dot|●} ${statusIcon} ${text}(${count}项)`;
  1173. },
  1174. rich: {
  1175. highDot: { color: '#dc2626' },
  1176. mediumDot: { color: '#ea580c' },
  1177. lowDot: { color: '#16a34a' }
  1178. }
  1179. },
  1180. axisTick: { show: false },
  1181. axisLine: { lineStyle: { color: '#e5e7eb' } }
  1182. },
  1183. dataZoom: [
  1184. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, start: preservedStart, end: preservedEnd, zoomLock: false },
  1185. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  1186. ],
  1187. series: [
  1188. // 背景层 - 显示空闲时段
  1189. {
  1190. type: 'custom',
  1191. name: '工作负荷背景',
  1192. renderItem: (params: any, api: any) => {
  1193. const categoryIndex = api.value(0);
  1194. const start = api.coord([api.value(1), categoryIndex]);
  1195. const end = api.coord([api.value(2), categoryIndex]);
  1196. const height = api.size([0, 1])[1] * 0.8;
  1197. const rectShape = echarts.graphic.clipRectByRect({
  1198. x: start[0],
  1199. y: start[1] - height / 2,
  1200. width: Math.max(end[0] - start[0], 2),
  1201. height
  1202. }, {
  1203. x: params.coordSys.x,
  1204. y: params.coordSys.y,
  1205. width: params.coordSys.width,
  1206. height: params.coordSys.height
  1207. });
  1208. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  1209. },
  1210. encode: { x: [1, 2], y: 0 },
  1211. data: idleBackgroundData,
  1212. z: 1
  1213. },
  1214. // 项目条层
  1215. {
  1216. type: 'custom',
  1217. name: '项目进度',
  1218. renderItem: (params: any, api: any) => {
  1219. const categoryIndex = api.value(0);
  1220. const start = api.coord([api.value(1), categoryIndex]);
  1221. const end = api.coord([api.value(2), categoryIndex]);
  1222. const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
  1223. const rectShape = echarts.graphic.clipRectByRect({
  1224. x: start[0],
  1225. y: start[1] - height / 2,
  1226. width: Math.max(end[0] - start[0], 2),
  1227. height
  1228. }, {
  1229. x: params.coordSys.x,
  1230. y: params.coordSys.y,
  1231. width: params.coordSys.width,
  1232. height: params.coordSys.height
  1233. });
  1234. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  1235. },
  1236. encode: { x: [1, 2], y: 0 },
  1237. data,
  1238. itemStyle: { borderRadius: 4 },
  1239. emphasis: { focus: 'self' },
  1240. z: 2,
  1241. markLine: {
  1242. silent: true,
  1243. symbol: 'none',
  1244. lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
  1245. label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
  1246. data: [ { xAxis: todayTs } ]
  1247. }
  1248. }
  1249. ]
  1250. } as any;
  1251. this.ganttChart.clear();
  1252. this.ganttChart.setOption(option, true);
  1253. this.ganttChart.resize();
  1254. }
  1255. ngOnDestroy(): void {
  1256. if (this.ganttChart) {
  1257. this.ganttChart.dispose();
  1258. this.ganttChart = null;
  1259. }
  1260. if (this.workloadChart) {
  1261. this.workloadChart.dispose();
  1262. this.workloadChart = null;
  1263. }
  1264. }
  1265. // 选择单个项目
  1266. selectProject(): void {
  1267. if (this.selectedProjectId) {
  1268. this.router.navigate(['/team-leader/project-detail', this.selectedProjectId]);
  1269. }
  1270. }
  1271. // 获取特定阶段的项目
  1272. getProjectsByStage(stageId: string): Project[] {
  1273. return this.filteredProjects.filter(project => project.currentStage === stageId);
  1274. }
  1275. // 新增:阶段到核心阶段的映射
  1276. private mapStageToCorePhase(stageId: string): 'order' | 'requirements' | 'delivery' | 'aftercare' {
  1277. // 订单创建:立项初期
  1278. if (stageId === 'pendingApproval' || stageId === 'pendingAssignment') return 'order';
  1279. // 确认需求:需求沟通 + 方案规划
  1280. if (stageId === 'requirement' || stageId === 'planning') return 'requirements';
  1281. // 交付执行:制作与评审修订过程
  1282. if (stageId === 'modeling' || stageId === 'rendering' || stageId === 'postProduction' || stageId === 'review' || stageId === 'revision') return 'delivery';
  1283. // 售后:交付完成后的跟进(当前数据以交付完成代表进入售后)
  1284. return 'aftercare';
  1285. }
  1286. // 新增:获取核心阶段的项目
  1287. getProjectsByCorePhase(coreId: string): Project[] {
  1288. return this.filteredProjects.filter(p => this.mapStageToCorePhase(p.currentStage) === coreId);
  1289. }
  1290. // 新增:获取核心阶段的项目数量
  1291. getProjectCountByCorePhase(coreId: string): number {
  1292. return this.getProjectsByCorePhase(coreId).length;
  1293. }
  1294. // 获取特定阶段的项目数量
  1295. getProjectCountByStage(stageId: string): number {
  1296. return this.getProjectsByStage(stageId).length;
  1297. }
  1298. // 待审批项目:currentStage === 'pendingApproval'
  1299. get pendingApprovalProjects(): Project[] {
  1300. const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  1301. return src.filter(p => p.currentStage === 'pendingApproval');
  1302. }
  1303. // 待指派项目:currentStage === 'pendingAssignment'
  1304. get pendingAssignmentProjects(): Project[] {
  1305. const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  1306. return src.filter(p => p.currentStage === 'pendingAssignment');
  1307. }
  1308. // 获取紧急程度标签
  1309. getUrgencyLabel(urgency: string): string {
  1310. const labels = {
  1311. high: '紧急',
  1312. medium: '一般',
  1313. low: '普通'
  1314. };
  1315. return labels[urgency as keyof typeof labels] || urgency;
  1316. }
  1317. // 智能推荐设计师
  1318. private getRecommendedDesigner(projectType: 'soft' | 'hard') {
  1319. if (!this.designerProfiles || !this.designerProfiles.length) return null;
  1320. const scoreOf = (p: any) => {
  1321. const workloadScore = 100 - (p.workload ?? 0); // 负载越低越好
  1322. const ratingScore = (p.avgRating ?? 0) * 10; // 评分越高越好
  1323. const expScore = (p.experience ?? 0) * 5; // 经验越高越好
  1324. return workloadScore * 0.5 + ratingScore * 0.3 + expScore * 0.2;
  1325. };
  1326. const sorted = [...this.designerProfiles].sort((a, b) => scoreOf(b) - scoreOf(a));
  1327. return sorted[0] || null;
  1328. }
  1329. // 质量评审
  1330. reviewProjectQuality(projectId: string, rating: 'excellent' | 'qualified' | 'unqualified'): void {
  1331. const project = this.projects.find(p => p.id === projectId);
  1332. if (!project) return;
  1333. project.qualityRating = rating;
  1334. if (rating === 'unqualified') {
  1335. // 不合格:回退到修改阶段
  1336. project.currentStage = 'revision';
  1337. }
  1338. this.applyFilters();
  1339. alert('质量评审已提交');
  1340. }
  1341. // 查看绩效预警(占位:跳转到团队管理)
  1342. viewPerformanceDetails(): void {
  1343. this.router.navigate(['/team-leader/team-management']);
  1344. }
  1345. // 打开负载日历(占位:跳转到团队管理)
  1346. navigateToWorkloadCalendar(): void {
  1347. this.router.navigate(['/team-leader/workload-calendar']);
  1348. }
  1349. // 查看项目详情
  1350. viewProjectDetails(projectId: string): void {
  1351. // 检测是否在iframe中运行(即从客服端访问)
  1352. const isInIframe = window.self !== window.top;
  1353. if (isInIframe) {
  1354. // 如果在iframe中,跳转到设计师端项目详情页面,并传递客服角色标识
  1355. // 使用parent.window来在父窗口中进行导航
  1356. const targetUrl = `/designer/project-detail/${projectId}?role=customer-service`;
  1357. if (window.parent) {
  1358. window.parent.postMessage({
  1359. type: 'navigate',
  1360. url: targetUrl
  1361. }, '*');
  1362. }
  1363. } else {
  1364. // 正常情况下跳转到组长端项目详情页面
  1365. this.router.navigate(['/team-leader/project-detail', projectId]);
  1366. }
  1367. }
  1368. // 快速分配项目(增强:加入智能推荐)
  1369. quickAssignProject(projectId: string): void {
  1370. const project = this.projects.find(p => p.id === projectId);
  1371. if (!project) {
  1372. alert('未找到对应项目');
  1373. return;
  1374. }
  1375. const recommended = this.getRecommendedDesigner(project.type);
  1376. if (recommended) {
  1377. const reassigning = !!project.designerName;
  1378. const message = `推荐设计师:${recommended.name}(工作负载:${recommended.workload}%,评分:${recommended.avgRating}分)` +
  1379. (reassigning ? `\n\n该项目当前已由「${project.designerName}」负责,是否改为分配给「${recommended.name}」?` : '\n\n是否确认分配?');
  1380. const confirmAssign = confirm(message);
  1381. if (confirmAssign) {
  1382. project.designerName = recommended.name;
  1383. if (project.currentStage === 'pendingAssignment' || project.currentStage === 'pendingApproval') {
  1384. project.currentStage = 'requirement';
  1385. }
  1386. project.status = '进行中';
  1387. // 更新设计师筛选列表
  1388. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  1389. this.applyFilters();
  1390. alert(`项目已${reassigning ? '重新' : ''}分配给 ${recommended.name}`);
  1391. return;
  1392. }
  1393. }
  1394. // 无推荐或用户取消,跳转到详细分配页面
  1395. // 改为跳转到复用详情(组长视图),通过 query 参数标记 assign 模式
  1396. this.router.navigate(['/team-leader/project-detail', projectId], { queryParams: { mode: 'assign' } });
  1397. }
  1398. // 导航到待办任务
  1399. navigateToTask(task: TodoTask): void {
  1400. switch (task.type) {
  1401. case 'review':
  1402. this.router.navigate(['team-leader/quality-management', task.targetId]);
  1403. break;
  1404. case 'assign':
  1405. this.router.navigate(['/team-leader/dashboard']);
  1406. break;
  1407. case 'performance':
  1408. this.router.navigate(['team-leader/team-management']);
  1409. break;
  1410. }
  1411. }
  1412. // 获取优先级标签
  1413. getPriorityLabel(priority: 'high' | 'medium' | 'low'): string {
  1414. const labels: Record<'high' | 'medium' | 'low', string> = {
  1415. 'high': '紧急且重要',
  1416. 'medium': '重要不紧急',
  1417. 'low': '紧急不重要'
  1418. };
  1419. return labels[priority];
  1420. }
  1421. // 导航到团队管理
  1422. navigateToTeamManagement(): void {
  1423. this.router.navigate(['/team-leader/team-management']);
  1424. }
  1425. // 导航到项目评审
  1426. navigateToProjectReview(): void {
  1427. // 统一入口:跳转到项目列表/看板,而非旧评审页
  1428. this.router.navigate(['/team-leader/dashboard']);
  1429. }
  1430. // 导航到质量管理
  1431. navigateToQualityManagement(): void {
  1432. this.router.navigate(['/team-leader/quality-management']);
  1433. }
  1434. // 打开工作量预估工具(已迁移)
  1435. openWorkloadEstimator(): void {
  1436. // 工具迁移至详情页:引导前往当前选中项目详情
  1437. if (this.selectedProjectId) {
  1438. this.router.navigate(['/team-leader/project-detail', this.selectedProjectId], { queryParams: { tool: 'estimator' } });
  1439. } else {
  1440. this.router.navigate(['/team-leader/dashboard']);
  1441. }
  1442. alert('工作量预估工具已迁移至项目详情页,您可以在建模阶段之前使用该工具进行工作量计算。');
  1443. }
  1444. // 查看所有超期项目
  1445. viewAllOverdueProjects(): void {
  1446. this.filterByStatus('overdue');
  1447. this.closeAlert();
  1448. }
  1449. // 关闭提醒
  1450. closeAlert(): void {
  1451. this.showAlert = false;
  1452. }
  1453. // 维度切换(设计师/会员类型)
  1454. setWorkloadDimension(dim: 'designer' | 'member'): void {
  1455. if (this.workloadDimension !== dim) {
  1456. this.workloadDimension = dim;
  1457. this.updateWorkloadChart();
  1458. }
  1459. }
  1460. // 刷新“工作量概览”图表
  1461. private updateWorkloadChart(): void {
  1462. if (!this.workloadChartRef) { return; }
  1463. const el = this.workloadChartRef.nativeElement;
  1464. if (!el) { return; }
  1465. // 初始化实例(使用 SVG 渲染以获得更佳文本清晰度)
  1466. if (!this.workloadChart) {
  1467. this.workloadChart = echarts.init(el, null, { renderer: 'svg' });
  1468. }
  1469. const data = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  1470. const byDesigner = this.workloadDimension === 'designer';
  1471. const groupKey = byDesigner ? 'designerName' : 'memberType';
  1472. const labelMap: Record<string, string> = { vip: 'VIP', normal: '普通' };
  1473. const groupSet = new Set<string>();
  1474. data.forEach(p => {
  1475. const val = (p as any)[groupKey] || (byDesigner ? '未分配' : '未知');
  1476. groupSet.add(val);
  1477. });
  1478. const categories = Array.from(groupSet).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  1479. const count = (urg: 'high'|'medium'|'low', group: string) =>
  1480. data.filter(p => (((p as any)[groupKey] || (byDesigner ? '未分配' : '未知')) === group) && p.urgency === urg).length;
  1481. const high = categories.map(c => count('high', c));
  1482. const medium = categories.map(c => count('medium', c));
  1483. const low = categories.map(c => count('low', c));
  1484. const option = {
  1485. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  1486. legend: { data: ['高', '中', '低'] },
  1487. grid: { left: 12, right: 16, top: 28, bottom: 8, containLabel: true },
  1488. xAxis: { type: 'value', boundaryGap: [0, 0.01] },
  1489. yAxis: {
  1490. type: 'category',
  1491. data: categories.map(c => byDesigner ? c : (labelMap[c] || c))
  1492. },
  1493. series: [
  1494. { name: '高', type: 'bar', stack: 'workload', data: high, itemStyle: { color: '#ef4444' } },
  1495. { name: '中', type: 'bar', stack: 'workload', data: medium, itemStyle: { color: '#f59e0b' } },
  1496. { name: '低', type: 'bar', stack: 'workload', data: low, itemStyle: { color: '#10b981' } }
  1497. ]
  1498. } as any;
  1499. this.workloadChart.clear();
  1500. this.workloadChart.setOption(option, true);
  1501. this.workloadChart.resize();
  1502. }
  1503. resetStatusFilter(): void {
  1504. this.selectedStatus = 'all';
  1505. this.applyFilters();
  1506. }
  1507. // 处理甘特图员工点击事件
  1508. onEmployeeClick(employeeName: string): void {
  1509. if (!employeeName || employeeName === '未分配') {
  1510. return;
  1511. }
  1512. // 生成员工详情数据
  1513. this.selectedEmployeeDetail = this.generateEmployeeDetail(employeeName);
  1514. this.showEmployeeDetailPanel = true;
  1515. }
  1516. // 生成员工详情数据
  1517. private generateEmployeeDetail(employeeName: string): EmployeeDetail {
  1518. // 获取该员工负责的项目
  1519. const employeeProjects = this.filteredProjects.filter(p => p.designerName === employeeName);
  1520. const currentProjects = employeeProjects.length;
  1521. const projectNames = employeeProjects.slice(0, 3).map(p => p.name); // 最多显示3个项目名称
  1522. // 获取该员工的请假记录(未来7天)
  1523. const today = new Date();
  1524. const next7Days = Array.from({ length: 7 }, (_, i) => {
  1525. const date = new Date(today);
  1526. date.setDate(today.getDate() + i);
  1527. return date.toISOString().split('T')[0]; // YYYY-MM-DD 格式
  1528. });
  1529. const employeeLeaveRecords = this.leaveRecords.filter(record =>
  1530. record.employeeName === employeeName && next7Days.includes(record.date)
  1531. );
  1532. // 生成红色标记说明
  1533. const redMarkExplanation = this.generateRedMarkExplanation(employeeName, employeeLeaveRecords, currentProjects);
  1534. return {
  1535. name: employeeName,
  1536. currentProjects,
  1537. projectNames,
  1538. leaveRecords: employeeLeaveRecords,
  1539. redMarkExplanation
  1540. };
  1541. }
  1542. // 生成红色标记说明
  1543. private generateRedMarkExplanation(employeeName: string, leaveRecords: LeaveRecord[], projectCount: number): string {
  1544. const explanations: string[] = [];
  1545. // 检查请假情况
  1546. const leaveDays = leaveRecords.filter(record => record.isLeave);
  1547. if (leaveDays.length > 0) {
  1548. leaveDays.forEach(leave => {
  1549. const date = new Date(leave.date);
  1550. const dateStr = `${date.getMonth() + 1}月${date.getDate()}日`;
  1551. explanations.push(`${dateStr}(${leave.reason || '请假'})`);
  1552. });
  1553. }
  1554. // 检查项目繁忙情况
  1555. if (projectCount >= 3) {
  1556. const today = new Date();
  1557. const dateStr = `${today.getMonth() + 1}月${today.getDate()}日`;
  1558. explanations.push(`${dateStr}(${projectCount}个项目繁忙)`);
  1559. }
  1560. if (explanations.length === 0) {
  1561. return '当前无红色标记时段';
  1562. }
  1563. return `甘特图中红色时段说明:${explanations.map((exp, index) => `${index + 1}${exp}`).join(';')}`;
  1564. }
  1565. // 关闭员工详情面板
  1566. closeEmployeeDetailPanel(): void {
  1567. this.showEmployeeDetailPanel = false;
  1568. this.selectedEmployeeDetail = null;
  1569. }
  1570. // 获取请假类型显示文本
  1571. getLeaveTypeText(leaveType?: string): string {
  1572. const typeMap: Record<string, string> = {
  1573. 'sick': '病假',
  1574. 'personal': '事假',
  1575. 'annual': '年假',
  1576. 'other': '其他'
  1577. };
  1578. return typeMap[leaveType || ''] || '请假';
  1579. }
  1580. // 生成请假覆盖层数据
  1581. private generateLeaveOverlayData(categories: string[], xMin: number, xMax: number): any[] {
  1582. const DAY = 24 * 60 * 60 * 1000;
  1583. const overlayData: any[] = [];
  1584. categories.forEach((employeeName, yIndex) => {
  1585. // 获取该员工在时间范围内的请假记录
  1586. const employeeLeaves = this.leaveRecords.filter(record => {
  1587. if (record.employeeName !== employeeName || !record.isLeave) {
  1588. return false;
  1589. }
  1590. const recordDate = new Date(record.date).getTime();
  1591. return recordDate >= xMin && recordDate <= xMax;
  1592. });
  1593. // 为每个请假日期创建覆盖层
  1594. employeeLeaves.forEach(leave => {
  1595. const leaveDate = new Date(leave.date);
  1596. const startOfDay = new Date(leaveDate.getFullYear(), leaveDate.getMonth(), leaveDate.getDate()).getTime();
  1597. const endOfDay = startOfDay + DAY - 1;
  1598. overlayData.push({
  1599. name: `${employeeName} - ${this.getLeaveTypeText(leave.leaveType)}`,
  1600. value: [yIndex, startOfDay, endOfDay, employeeName, leave.leaveType, leave.reason],
  1601. itemStyle: {
  1602. color: 'rgba(239, 68, 68, 0.6)', // 半透明红色
  1603. borderColor: '#ef4444',
  1604. borderWidth: 1
  1605. }
  1606. });
  1607. });
  1608. // 检查项目繁忙情况,如果项目数>=3,也添加红色标记
  1609. const employeeProjects = this.filteredProjects.filter(p => p.designerName === employeeName);
  1610. if (employeeProjects.length >= 3) {
  1611. // 在当前日期添加繁忙标记
  1612. const today = new Date();
  1613. const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
  1614. const endOfToday = startOfToday + DAY - 1;
  1615. if (startOfToday >= xMin && startOfToday <= xMax) {
  1616. overlayData.push({
  1617. name: `${employeeName} - 项目繁忙(${employeeProjects.length}个项目)`,
  1618. value: [yIndex, startOfToday, endOfToday, employeeName, 'busy', `负责${employeeProjects.length}个项目`],
  1619. itemStyle: {
  1620. color: 'rgba(239, 68, 68, 0.4)', // 稍微透明的红色
  1621. borderColor: '#ef4444',
  1622. borderWidth: 1,
  1623. borderType: 'dashed' // 虚线边框区分请假和繁忙
  1624. }
  1625. });
  1626. }
  1627. }
  1628. });
  1629. return overlayData;
  1630. }
  1631. }