dashboard.ts 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292
  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. declare const echarts: any;
  51. @Component({
  52. selector: 'app-dashboard',
  53. imports: [CommonModule, FormsModule, RouterModule],
  54. templateUrl: './dashboard.html',
  55. styleUrl: './dashboard.scss'
  56. })
  57. export class Dashboard implements OnInit, OnDestroy {
  58. projects: Project[] = [];
  59. filteredProjects: Project[] = [];
  60. todoTasks: TodoTask[] = [];
  61. overdueProjects: Project[] = [];
  62. urgentPinnedProjects: Project[] = [];
  63. showAlert: boolean = false;
  64. selectedProjectId: string = '';
  65. // 新增:关键词搜索
  66. searchTerm: string = '';
  67. searchSuggestions: Project[] = [];
  68. showSuggestions: boolean = false;
  69. private hideSuggestionsTimer: any;
  70. // 搜索性能与交互控制
  71. private searchDebounceTimer: any;
  72. private readonly SEARCH_DEBOUNCE_MS = 200; // 防抖时长(毫秒)
  73. private readonly MIN_SEARCH_LEN = 2; // 最小触发建议长度
  74. private readonly MAX_SUGGESTIONS = 8; // 建议最大条数
  75. private isSearchFocused: boolean = false; // 是否处于输入聚焦态
  76. // 新增:临期项目与筛选状态
  77. dueSoonProjects: Project[] = [];
  78. selectedType: 'all' | 'soft' | 'hard' = 'all';
  79. selectedUrgency: 'all' | 'high' | 'medium' | 'low' = 'all';
  80. selectedStatus: 'all' | 'progress' | 'completed' | 'overdue' | 'pendingApproval' | 'pendingAssignment' | 'dueSoon' = 'all';
  81. selectedDesigner: string = 'all';
  82. selectedMemberType: 'all' | 'vip' | 'normal' = 'all';
  83. // 新增:时间窗筛选
  84. selectedTimeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays' = 'all';
  85. designers: string[] = [];
  86. // 新增:四大板块筛选
  87. selectedCorePhase: 'all' | 'order' | 'requirements' | 'delivery' | 'aftercare' = 'all';
  88. // 设计师画像(用于智能推荐)
  89. designerProfiles: any[] = [
  90. { id: 'zhang', name: '张三', skills: ['现代风格', '北欧风格'], workload: 70, avgRating: 4.5, experience: 3 },
  91. { id: 'li', name: '李四', skills: ['新中式', '宋式风格'], workload: 45, avgRating: 4.8, experience: 5 },
  92. { id: 'wang', name: '王五', skills: ['北欧风格', '日式风格'], workload: 85, avgRating: 4.2, experience: 2 },
  93. { id: 'zhao', name: '赵六', skills: ['现代风格', '轻奢风格'], workload: 30, avgRating: 4.6, experience: 4 }
  94. ];
  95. // 10个项目阶段
  96. projectStages: ProjectStage[] = [
  97. { id: 'pendingApproval', name: '待确认', order: 1 },
  98. { id: 'pendingAssignment', name: '待分配', order: 2 },
  99. { id: 'requirement', name: '需求沟通', order: 3 },
  100. { id: 'planning', name: '方案规划', order: 4 },
  101. { id: 'modeling', name: '建模阶段', order: 5 },
  102. { id: 'rendering', name: '渲染阶段', order: 6 },
  103. { id: 'postProduction', name: '后期处理', order: 7 },
  104. { id: 'review', name: '方案评审', order: 8 },
  105. { id: 'revision', name: '方案修改', order: 9 },
  106. { id: 'delivery', name: '交付完成', order: 10 }
  107. ];
  108. // 5大核心阶段(聚合展示)
  109. corePhases: ProjectStage[] = [
  110. { id: 'order', name: '订单创建', order: 1 }, // 待确认、待分配
  111. { id: 'requirements', name: '确认需求', order: 2 }, // 需求沟通、方案规划
  112. { id: 'delivery', name: '交付执行', order: 3 }, // 建模、渲染、后期/评审/修改
  113. { id: 'aftercare', name: '售后', order: 4 } // 交付完成 → 售后
  114. ];
  115. // 甘特视图开关与实例引用
  116. showGanttView: boolean = false;
  117. private ganttChart: any | null = null;
  118. @ViewChild('ganttChartRef', { static: false }) ganttChartRef!: ElementRef<HTMLDivElement>;
  119. // 新增:工作量概览图表引用与实例
  120. @ViewChild('workloadChartRef', { static: false }) workloadChartRef!: ElementRef<HTMLDivElement>;
  121. private workloadChart: any | null = null;
  122. workloadDimension: 'designer' | 'member' = 'designer';
  123. // 甘特时间尺度:仅周/月
  124. ganttScale: 'week' | 'month' = 'week';
  125. // 新增:甘特模式(项目 / 设计师排班)
  126. ganttMode: 'project' | 'designer' = 'project';
  127. constructor(private projectService: ProjectService, private router: Router) {}
  128. ngOnInit(): void {
  129. this.loadProjects();
  130. this.loadTodoTasks();
  131. // 首次微任务后尝试初始化一次,确保容器已渲染
  132. setTimeout(() => this.updateWorkloadChart(), 0);
  133. }
  134. loadProjects(): void {
  135. // 模拟数据加载 - 增强数据结构,添加currentStage
  136. this.projects = [
  137. {
  138. id: 'proj-001',
  139. name: '现代风格客厅设计',
  140. type: 'soft',
  141. memberType: 'vip',
  142. designerName: '张三',
  143. status: '进行中',
  144. expectedEndDate: new Date(2023, 9, 15),
  145. deadline: new Date(2023, 9, 15),
  146. isOverdue: true,
  147. overdueDays: 2,
  148. dueSoon: false,
  149. urgency: 'high',
  150. currentStage: 'rendering',
  151. phases: [
  152. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  153. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: true },
  154. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  155. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  156. ]
  157. },
  158. {
  159. id: 'proj-002',
  160. name: '北欧风格卧室设计',
  161. type: 'soft',
  162. memberType: 'normal',
  163. designerName: '李四',
  164. status: '进行中',
  165. expectedEndDate: new Date(2023, 9, 20),
  166. deadline: new Date(2023, 9, 20),
  167. isOverdue: false,
  168. overdueDays: 0,
  169. dueSoon: false,
  170. urgency: 'medium',
  171. currentStage: 'postProduction',
  172. phases: [
  173. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  174. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  175. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: true },
  176. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  177. ]
  178. },
  179. {
  180. id: 'proj-003',
  181. name: '新中式餐厅设计',
  182. type: 'hard',
  183. memberType: 'normal',
  184. designerName: '王五',
  185. status: '进行中',
  186. expectedEndDate: new Date(2023, 9, 25),
  187. deadline: new Date(2023, 9, 25),
  188. isOverdue: false,
  189. overdueDays: 0,
  190. dueSoon: false,
  191. urgency: 'low',
  192. currentStage: 'modeling',
  193. phases: [
  194. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: false, isCurrent: true },
  195. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: false },
  196. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  197. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  198. ]
  199. },
  200. {
  201. id: 'proj-004',
  202. name: '工业风办公室设计',
  203. type: 'hard',
  204. memberType: 'normal',
  205. designerName: '赵六',
  206. status: '进行中',
  207. expectedEndDate: new Date(2023, 9, 10),
  208. deadline: new Date(2023, 9, 10),
  209. isOverdue: true,
  210. overdueDays: 7,
  211. dueSoon: false,
  212. urgency: 'high',
  213. currentStage: 'review',
  214. phases: [
  215. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  216. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  217. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  218. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  219. ]
  220. },
  221. // 添加更多不同阶段的项目
  222. {
  223. id: 'proj-005',
  224. name: '现代简约厨房设计',
  225. type: 'soft',
  226. memberType: 'normal',
  227. designerName: '',
  228. status: '待分配',
  229. expectedEndDate: new Date(2023, 10, 5),
  230. deadline: new Date(2023, 10, 5),
  231. isOverdue: false,
  232. overdueDays: 0,
  233. dueSoon: false,
  234. urgency: 'medium',
  235. currentStage: 'pendingAssignment',
  236. phases: []
  237. },
  238. {
  239. id: 'proj-006',
  240. name: '日式风格书房设计',
  241. type: 'hard',
  242. memberType: 'normal',
  243. designerName: '',
  244. status: '待确认',
  245. expectedEndDate: new Date(2023, 10, 10),
  246. deadline: new Date(2023, 10, 10),
  247. isOverdue: false,
  248. overdueDays: 0,
  249. dueSoon: false,
  250. urgency: 'low',
  251. currentStage: 'pendingApproval',
  252. phases: []
  253. },
  254. {
  255. id: 'proj-007',
  256. name: '轻奢风格浴室设计',
  257. type: 'soft',
  258. memberType: 'normal',
  259. designerName: '钱七',
  260. status: '已完成',
  261. expectedEndDate: new Date(2023, 9, 5),
  262. deadline: new Date(2023, 9, 5),
  263. isOverdue: false,
  264. overdueDays: 0,
  265. dueSoon: false,
  266. urgency: 'medium',
  267. currentStage: 'delivery',
  268. phases: []
  269. }
  270. ];
  271. // ===== 追加生成示例数据:保证总量达到100条 =====
  272. const stageIds = this.projectStages.map(s => s.id);
  273. const designers = ['张三','李四','王五','赵六','钱七','孙八','周九','吴十','郑一','冯二','陈三','褚四'];
  274. const statusMap: Record<string, string> = {
  275. pendingApproval: '待确认',
  276. pendingAssignment: '待分配',
  277. requirement: '进行中',
  278. planning: '进行中',
  279. modeling: '进行中',
  280. rendering: '进行中',
  281. postProduction: '进行中',
  282. review: '进行中',
  283. revision: '进行中',
  284. delivery: '已完成'
  285. };
  286. for (let i = 8; i <= 100; i++) {
  287. const stageIndex = (i - 1) % stageIds.length;
  288. const currentStage = stageIds[stageIndex];
  289. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  290. const urgency: 'high' | 'medium' | 'low' = i % 5 === 0 ? 'high' : (i % 3 === 0 ? 'medium' : 'low');
  291. const isOverdue = ['planning','modeling','rendering','postProduction','review','revision','delivery'].includes(currentStage) ? i % 7 === 0 : false;
  292. const overdueDays = isOverdue ? (i % 10) + 1 : 0;
  293. const hasDesigner = !['pendingApproval', 'pendingAssignment'].includes(currentStage);
  294. const designerName = hasDesigner ? designers[i % designers.length] : '';
  295. const status = statusMap[currentStage] || '进行中';
  296. const expectedEndDate = new Date();
  297. const daysOffset = isOverdue ? - (overdueDays + (i % 5)) : ((i % 20) + 3);
  298. expectedEndDate.setDate(expectedEndDate.getDate() + daysOffset);
  299. const daysToDeadline = Math.ceil((expectedEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
  300. const dueSoon = !isOverdue && daysToDeadline >= 0 && daysToDeadline <= 3;
  301. const memberType: 'vip' | 'normal' = i % 4 === 0 ? 'vip' : 'normal';
  302. this.projects.push({
  303. id: `proj-${String(i).padStart(3, '0')}`,
  304. name: `${type === 'soft' ? '软装' : '硬装'}示例项目 ${i}`,
  305. type,
  306. memberType,
  307. designerName,
  308. status,
  309. expectedEndDate,
  310. deadline: expectedEndDate,
  311. isOverdue,
  312. overdueDays,
  313. dueSoon,
  314. urgency,
  315. currentStage,
  316. phases: []
  317. });
  318. }
  319. // ===== 示例数据生成结束 =====
  320. // 统一补齐真实时间字段(deadline/createdAt),以真实字段贯通筛选与甘特
  321. const DAY = 24 * 60 * 60 * 1000;
  322. this.projects = this.projects.map(p => {
  323. const deadline = p.deadline || p.expectedEndDate;
  324. const baseDays = p.type === 'hard' ? 30 : 14;
  325. const createdAt = p.createdAt || new Date(new Date(deadline).getTime() - baseDays * DAY);
  326. return { ...p, deadline, createdAt } as Project;
  327. });
  328. // 筛选超期与临期项目
  329. this.overdueProjects = this.projects.filter(project => project.isOverdue);
  330. this.dueSoonProjects = this.projects.filter(project => project.dueSoon && !project.isOverdue);
  331. this.filteredProjects = [...this.projects];
  332. // 供筛选用的设计师列表
  333. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  334. // 显示超期提醒
  335. if (this.overdueProjects.length > 0) {
  336. this.showAlert = true;
  337. }
  338. }
  339. loadTodoTasks(): void {
  340. // 模拟待办任务数据
  341. this.todoTasks = [
  342. {
  343. id: 'todo-001',
  344. title: '待评审效果图',
  345. description: '现代风格客厅设计项目需要进行效果图评审',
  346. deadline: new Date(2023, 9, 18, 15, 0),
  347. priority: 'high',
  348. type: 'review',
  349. targetId: 'proj-001'
  350. },
  351. {
  352. id: 'todo-002',
  353. title: '待分配项目',
  354. description: '新中式厨房设计项目需要分配给合适的设计师',
  355. deadline: new Date(2023, 9, 19, 10, 0),
  356. priority: 'high',
  357. type: 'assign',
  358. targetId: 'proj-new'
  359. },
  360. {
  361. id: 'todo-003',
  362. title: '待确认绩效',
  363. description: '9月份团队绩效需要进行审核确认',
  364. deadline: new Date(2023, 9, 22, 18, 0),
  365. priority: 'medium',
  366. type: 'performance',
  367. targetId: 'sep-2023'
  368. },
  369. {
  370. id: 'todo-004',
  371. title: '待处理客户反馈',
  372. description: '北欧风格卧室设计项目有客户反馈需要处理',
  373. deadline: new Date(2023, 9, 20, 14, 0),
  374. priority: 'medium',
  375. type: 'review',
  376. targetId: 'proj-002'
  377. },
  378. {
  379. id: 'todo-005',
  380. title: '团队会议',
  381. description: '每周团队进度沟通会议',
  382. deadline: new Date(2023, 9, 18, 10, 0),
  383. priority: 'low',
  384. type: 'performance',
  385. targetId: 'weekly-meeting'
  386. }
  387. ];
  388. // 按优先级排序:紧急且重要 > 重要不紧急 > 紧急不重要
  389. this.todoTasks.sort((a, b) => {
  390. const priorityOrder = {
  391. 'high': 3,
  392. 'medium': 2,
  393. 'low': 1
  394. };
  395. return priorityOrder[b.priority] - priorityOrder[a.priority];
  396. });
  397. }
  398. // 筛选项目类型
  399. filterProjects(event: Event): void {
  400. const target = event.target as HTMLSelectElement;
  401. this.selectedType = (target && target.value ? target.value : 'all') as any;
  402. this.applyFilters();
  403. }
  404. // 筛选紧急程度
  405. filterByUrgency(event: Event): void {
  406. const target = event.target as HTMLSelectElement;
  407. this.selectedUrgency = (target && target.value ? target.value : 'all') as any;
  408. this.applyFilters();
  409. }
  410. // 筛选项目状态
  411. filterByStatus(status: string): void {
  412. // 点击同一状态时,切换回“全部”,以便恢复全量项目列表
  413. const next = (this.selectedStatus === status) ? 'all' : (status && status.length ? status : 'all');
  414. this.selectedStatus = next as any;
  415. this.applyFilters();
  416. }
  417. // 处理状态筛选下拉框变化
  418. onStatusChange(event: Event): void {
  419. const target = event.target as HTMLSelectElement;
  420. this.selectedStatus = (target && target.value ? target.value : 'all') as any;
  421. this.applyFilters();
  422. }
  423. // 新增:设计师筛选下拉事件处理
  424. onDesignerChange(event: Event): void {
  425. const target = event.target as HTMLSelectElement;
  426. this.selectedDesigner = (target && target.value ? target.value : 'all');
  427. this.applyFilters();
  428. }
  429. // 新增:会员类型筛选下拉事件处理
  430. onMemberTypeChange(event: Event): void {
  431. const select = event.target as HTMLSelectElement;
  432. this.selectedMemberType = select.value as any;
  433. this.applyFilters();
  434. }
  435. // 新增:四大板块改变
  436. onCorePhaseChange(event: Event): void {
  437. const select = event.target as HTMLSelectElement;
  438. this.selectedCorePhase = select.value as any;
  439. this.applyFilters();
  440. }
  441. // 时间窗快捷筛选(供UI按钮触发)
  442. filterByTimeWindow(timeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays'): void {
  443. this.selectedTimeWindow = timeWindow;
  444. this.applyFilters();
  445. }
  446. // 新增:搜索输入变化
  447. onSearchChange(): void {
  448. if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
  449. this.searchDebounceTimer = setTimeout(() => {
  450. this.updateSearchSuggestions();
  451. this.applyFilters();
  452. }, this.SEARCH_DEBOUNCE_MS);
  453. }
  454. // 新增:搜索框聚焦/失焦控制建议显隐
  455. onSearchFocus(): void {
  456. if (this.hideSuggestionsTimer) clearTimeout(this.hideSuggestionsTimer);
  457. this.isSearchFocused = true;
  458. this.updateSearchSuggestions();
  459. }
  460. onSearchBlur(): void {
  461. // 延迟隐藏以允许选择项的 mousedown 触发
  462. this.isSearchFocused = false;
  463. this.hideSuggestionsTimer = setTimeout(() => {
  464. this.showSuggestions = false;
  465. }, 150);
  466. }
  467. // 新增:更新搜索建议(不叠加其它筛选,仅基于关键字)
  468. private updateSearchSuggestions(): void {
  469. const q = (this.searchTerm || '').trim().toLowerCase();
  470. if (q.length < this.MIN_SEARCH_LEN) {
  471. this.searchSuggestions = [];
  472. this.showSuggestions = false;
  473. return;
  474. }
  475. const scored = this.projects
  476. .filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q))
  477. .map(p => {
  478. const dl = p.deadline || p.expectedEndDate;
  479. const dlTime = dl ? new Date(dl).getTime() : NaN;
  480. const daysToDl = Math.ceil(((isNaN(dlTime) ? 0 : dlTime) - Date.now()) / (1000 * 60 * 60 * 24));
  481. const urgencyScore = p.urgency === 'high' ? 3 : p.urgency === 'medium' ? 2 : 1;
  482. const overdueScore = p.isOverdue ? 10 : 0;
  483. const score = overdueScore + (4 - urgencyScore) * 2 - (isNaN(daysToDl) ? 0 : daysToDl);
  484. return { p, score };
  485. })
  486. .sort((a, b) => b.score - a.score)
  487. .slice(0, this.MAX_SUGGESTIONS)
  488. .map(x => x.p);
  489. this.searchSuggestions = scored;
  490. this.showSuggestions = this.isSearchFocused && this.searchSuggestions.length > 0;
  491. }
  492. // 新增:选择建议项
  493. selectSuggestion(project: Project): void {
  494. this.searchTerm = project.name;
  495. this.showSuggestions = false;
  496. this.viewProjectDetails(project.id);
  497. }
  498. // 统一筛选
  499. private applyFilters(): void {
  500. let result = [...this.projects];
  501. // 新增:关键词搜索(项目名 / 设计师名 / 含风格关键词的项目名)
  502. const q = (this.searchTerm || '').trim().toLowerCase();
  503. if (q) {
  504. result = result.filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q));
  505. }
  506. // 类型筛选
  507. if (this.selectedType !== 'all') {
  508. result = result.filter(p => p.type === this.selectedType);
  509. }
  510. // 紧急程度筛选
  511. if (this.selectedUrgency !== 'all') {
  512. result = result.filter(p => p.urgency === this.selectedUrgency);
  513. }
  514. // 项目状态筛选
  515. if (this.selectedStatus !== 'all') {
  516. if (this.selectedStatus === 'overdue') {
  517. result = result.filter(p => p.isOverdue);
  518. } else if (this.selectedStatus === 'dueSoon') {
  519. result = result.filter(p => p.dueSoon && !p.isOverdue);
  520. } else if (this.selectedStatus === 'pendingApproval') {
  521. result = result.filter(p => p.currentStage === 'pendingApproval');
  522. } else if (this.selectedStatus === 'pendingAssignment') {
  523. result = result.filter(p => p.currentStage === 'pendingAssignment');
  524. } else if (this.selectedStatus === 'progress') {
  525. const progressStages = ['requirement','planning','modeling','rendering','postProduction','review','revision'];
  526. result = result.filter(p => progressStages.includes(p.currentStage));
  527. } else if (this.selectedStatus === 'completed') {
  528. result = result.filter(p => p.currentStage === 'delivery');
  529. }
  530. }
  531. // 新增:四大板块筛选
  532. if (this.selectedCorePhase !== 'all') {
  533. result = result.filter(p => this.mapStageToCorePhase(p.currentStage) === this.selectedCorePhase);
  534. }
  535. // 设计师筛选
  536. if (this.selectedDesigner !== 'all') {
  537. result = result.filter(p => p.designerName === this.selectedDesigner);
  538. }
  539. // 会员类型筛选
  540. if (this.selectedMemberType !== 'all') {
  541. result = result.filter(p => p.memberType === this.selectedMemberType);
  542. }
  543. // 新增:时间窗筛选
  544. if (this.selectedTimeWindow !== 'all') {
  545. const now = new Date();
  546. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  547. result = result.filter(p => {
  548. const projectDeadline = new Date(p.deadline);
  549. const timeDiff = projectDeadline.getTime() - today.getTime();
  550. const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
  551. switch (this.selectedTimeWindow) {
  552. case 'today':
  553. return daysDiff <= 1 && daysDiff >= 0;
  554. case 'threeDays':
  555. return daysDiff <= 3 && daysDiff >= 0;
  556. case 'sevenDays':
  557. return daysDiff <= 7 && daysDiff >= 0;
  558. default:
  559. return true;
  560. }
  561. });
  562. }
  563. this.filteredProjects = result;
  564. // 新增:计算紧急任务固定区(超期 + 高紧急),与筛选联动
  565. this.urgentPinnedProjects = this.filteredProjects
  566. .filter(p => p.isOverdue && p.urgency === 'high')
  567. .sort((a, b) => (b.overdueDays - a.overdueDays) || a.name.localeCompare(b.name, 'zh-Hans-CN'));
  568. // 当显示甘特卡片时,同步刷新甘特图
  569. if (this.showGanttView) {
  570. this.updateGantt();
  571. }
  572. // 同步刷新工作量概览图
  573. this.updateWorkloadChart();
  574. }
  575. // 切换项目看板/负载日历(甘特)视图
  576. toggleView(): void {
  577. this.showGanttView = !this.showGanttView;
  578. if (this.showGanttView) {
  579. setTimeout(() => this.initOrUpdateGantt(), 0);
  580. } else {
  581. if (this.ganttChart) {
  582. this.ganttChart.dispose();
  583. this.ganttChart = null;
  584. }
  585. if (this.workloadChart) {
  586. this.workloadChart.dispose();
  587. this.workloadChart = null;
  588. }
  589. }
  590. }
  591. // 设置甘特时间尺度
  592. setGanttScale(scale: 'week' | 'month'): void {
  593. if (this.ganttScale !== scale) {
  594. this.ganttScale = scale;
  595. this.updateGantt();
  596. }
  597. }
  598. // 新增:切换甘特模式
  599. setGanttMode(mode: 'project' | 'designer'): void {
  600. if (this.ganttMode !== mode) {
  601. this.ganttMode = mode;
  602. this.updateGantt();
  603. }
  604. }
  605. private initOrUpdateGantt(): void {
  606. if (!this.ganttChartRef) return;
  607. const el = this.ganttChartRef.nativeElement;
  608. if (!this.ganttChart) {
  609. this.ganttChart = echarts.init(el);
  610. window.addEventListener('resize', () => {
  611. this.ganttChart && this.ganttChart.resize();
  612. });
  613. }
  614. this.updateGantt();
  615. }
  616. private updateGantt(): void {
  617. if (!this.ganttChart) return;
  618. if (this.ganttMode === 'designer') {
  619. this.updateGanttDesigner();
  620. return;
  621. }
  622. // 按紧急程度从上到下排序(高->中->低),同级按到期时间升序
  623. const urgencyRank: Record<'high'|'medium'|'low', number> = { high: 0, medium: 1, low: 2 };
  624. const projects = [...this.filteredProjects]
  625. .sort((a, b) => {
  626. const u = urgencyRank[a.urgency] - urgencyRank[b.urgency];
  627. if (u !== 0) return u;
  628. // 二级排序:临期优先(到期更近)> 已分配人员 > VIP客户 > 其他
  629. const endDiff = new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
  630. if (endDiff !== 0) return endDiff;
  631. const assignedA = !!a.designerName;
  632. const assignedB = !!b.designerName;
  633. if (assignedA !== assignedB) return assignedA ? -1 : 1; // 已分配在前
  634. const vipA = a.memberType === 'vip';
  635. const vipB = b.memberType === 'vip';
  636. if (vipA !== vipB) return vipA ? -1 : 1; // VIP在前
  637. return a.name.localeCompare(b.name, 'zh-CN');
  638. });
  639. const categories = projects.map(p => p.name);
  640. const urgencyMap: Record<string, 'high'|'medium'|'low'> = Object.fromEntries(projects.map(p => [p.name, p.urgency])) as any;
  641. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  642. high: '#ef4444',
  643. medium: '#f59e0b',
  644. low: '#22c55e'
  645. } as const;
  646. const DAY = 24 * 60 * 60 * 1000;
  647. const data = projects.map((p, idx) => {
  648. const end = new Date(p.deadline).getTime();
  649. const baseDays = p.type === 'hard' ? 30 : 14;
  650. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  651. const color = colorByUrgency[p.urgency] || '#60a5fa';
  652. return {
  653. name: p.name,
  654. value: [idx, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
  655. itemStyle: { color }
  656. };
  657. });
  658. // 计算时间范围(仅周/月)
  659. const now = new Date();
  660. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  661. const todayTs = today.getTime();
  662. let xMin: number;
  663. let xMax: number;
  664. let xSplitNumber: number;
  665. let xLabelFormatter: (value: number) => string;
  666. if (this.ganttScale === 'week') {
  667. const day = today.getDay(); // 0=周日
  668. const diffToMonday = (day === 0 ? 6 : day - 1);
  669. const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
  670. const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
  671. xMin = startOfWeek.getTime();
  672. xMax = endOfWeek.getTime();
  673. xSplitNumber = 7;
  674. const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
  675. xLabelFormatter = (val) => {
  676. const d = new Date(val);
  677. return WEEK_LABELS[d.getDay()];
  678. };
  679. } else { // month
  680. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  681. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
  682. xMin = startOfMonth.getTime();
  683. xMax = endOfMonth.getTime();
  684. xSplitNumber = 4;
  685. xLabelFormatter = (val) => {
  686. const d = new Date(val);
  687. const weekOfMonth = Math.ceil(d.getDate() / 7);
  688. return `第${weekOfMonth}周`;
  689. };
  690. }
  691. // 计算默认可视区,并尝试保留上一次的滚动/缩放位置
  692. const total = categories.length;
  693. const visible = Math.min(total, 15); // 默认首屏展开15条
  694. const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
  695. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  696. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  697. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  698. const option = {
  699. backgroundColor: 'transparent',
  700. tooltip: {
  701. trigger: 'item',
  702. formatter: (params: any) => {
  703. const v = params.value;
  704. const start = new Date(v[1]);
  705. const end = new Date(v[2]);
  706. return `项目:${params.name}<br/>负责人:${v[3] || '未分配'}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
  707. }
  708. },
  709. grid: { left: 100, right: 64, top: 30, bottom: 30 },
  710. xAxis: {
  711. type: 'time',
  712. min: xMin,
  713. max: xMax,
  714. splitNumber: xSplitNumber,
  715. axisLine: { lineStyle: { color: '#e5e7eb' } },
  716. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  717. splitLine: { lineStyle: { color: '#f1f5f9' } }
  718. },
  719. yAxis: {
  720. type: 'category',
  721. data: categories,
  722. inverse: true,
  723. axisLabel: {
  724. color: '#374151',
  725. margin: 8,
  726. formatter: (val: string) => {
  727. const u = urgencyMap[val] || 'low';
  728. const text = val.length > 16 ? val.slice(0, 16) + '…' : val;
  729. return `{${u}Dot|●} ${text}`;
  730. },
  731. rich: {
  732. highDot: { color: '#ef4444' },
  733. mediumDot: { color: '#f59e0b' },
  734. lowDot: { color: '#22c55e' }
  735. }
  736. },
  737. axisTick: { show: false },
  738. axisLine: { lineStyle: { color: '#e5e7eb' } }
  739. },
  740. dataZoom: [
  741. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, width: 14, start: preservedStart, end: preservedEnd, zoomLock: false },
  742. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  743. ],
  744. series: [
  745. {
  746. type: 'custom',
  747. renderItem: (params: any, api: any) => {
  748. const categoryIndex = api.value(0);
  749. const start = api.coord([api.value(1), categoryIndex]);
  750. const end = api.coord([api.value(2), categoryIndex]);
  751. const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
  752. const rectShape = echarts.graphic.clipRectByRect({
  753. x: start[0],
  754. y: start[1] - height / 2,
  755. width: Math.max(end[0] - start[0], 2),
  756. height
  757. }, {
  758. x: params.coordSys.x,
  759. y: params.coordSys.y,
  760. width: params.coordSys.width,
  761. height: params.coordSys.height
  762. });
  763. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  764. },
  765. encode: { x: [1, 2], y: 0 },
  766. data,
  767. itemStyle: { borderRadius: 4 },
  768. emphasis: { focus: 'self' },
  769. markLine: {
  770. silent: true,
  771. symbol: 'none',
  772. lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
  773. label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
  774. data: [ { xAxis: todayTs } ]
  775. }
  776. }
  777. ]
  778. };
  779. // 强制刷新,避免缓存导致坐标轴不更新
  780. this.ganttChart.clear();
  781. this.ganttChart.setOption(option, true);
  782. this.ganttChart.resize();
  783. }
  784. // 新增:设计师排班甘特
  785. private updateGanttDesigner(): void {
  786. if (!this.ganttChart) return;
  787. const DAY = 24 * 60 * 60 * 1000;
  788. const now = new Date();
  789. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  790. const todayTs = today.getTime();
  791. // 时间轴按当前周/月
  792. let xMin: number;
  793. let xMax: number;
  794. let xSplitNumber: number;
  795. let xLabelFormatter: (value: number) => string;
  796. if (this.ganttScale === 'week') {
  797. const day = today.getDay();
  798. const diffToMonday = (day === 0 ? 6 : day - 1);
  799. const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
  800. const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
  801. xMin = startOfWeek.getTime();
  802. xMax = endOfWeek.getTime();
  803. xSplitNumber = 7;
  804. const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
  805. xLabelFormatter = (val) => WEEK_LABELS[new Date(val).getDay()];
  806. } else {
  807. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  808. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
  809. xMin = startOfMonth.getTime();
  810. xMax = endOfMonth.getTime();
  811. xSplitNumber = 4;
  812. xLabelFormatter = (val) => `第${Math.ceil(new Date(val).getDate() / 7)}周`;
  813. }
  814. // 仅统计已分配项目
  815. const assigned = this.filteredProjects.filter(p => !!p.designerName);
  816. const designers = Array.from(new Set(assigned.map(p => p.designerName)));
  817. const byDesigner: Record<string, typeof assigned> = {} as any;
  818. designers.forEach(n => byDesigner[n] = [] as any);
  819. assigned.forEach(p => byDesigner[p.designerName].push(p));
  820. const busyCountMap: Record<string, number> = Object.fromEntries(designers.map(n => [n, byDesigner[n].length]));
  821. const sortedDesigners = designers.sort((a, b) => {
  822. const diff = (busyCountMap[b] || 0) - (busyCountMap[a] || 0);
  823. return diff !== 0 ? diff : a.localeCompare(b, 'zh-CN');
  824. });
  825. const categories = sortedDesigners;
  826. // 工作量等级(用于左侧小圆点颜色)
  827. const workloadLevelMap: Record<string, 'high'|'medium'|'low'> = {} as any;
  828. categories.forEach(name => {
  829. const cnt = busyCountMap[name] || 0;
  830. workloadLevelMap[name] = cnt >= 5 ? 'high' : (cnt >= 3 ? 'medium' : 'low');
  831. });
  832. // 条形颜色仍按项目紧急度
  833. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  834. high: '#ef4444',
  835. medium: '#f59e0b',
  836. low: '#22c55e'
  837. } as const;
  838. const data = assigned.flatMap(p => {
  839. const end = new Date(p.deadline).getTime();
  840. const baseDays = p.type === 'hard' ? 30 : 14;
  841. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  842. const yIndex = categories.indexOf(p.designerName);
  843. if (yIndex === -1) return [] as any[];
  844. const color = colorByUrgency[p.urgency] || '#60a5fa';
  845. return [{
  846. name: p.name,
  847. value: [yIndex, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
  848. itemStyle: { color }
  849. }];
  850. });
  851. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  852. const total = categories.length || 1;
  853. const visible = Math.min(total, 30);
  854. const defaultEndPercent = Math.min(100, (visible / total) * 100);
  855. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  856. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  857. const option = {
  858. backgroundColor: 'transparent',
  859. tooltip: {
  860. trigger: 'item',
  861. formatter: (params: any) => {
  862. const v = params.value;
  863. const start = new Date(v[1]);
  864. const end = new Date(v[2]);
  865. return `项目:${params.name}<br/>设计师:${v[3]}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
  866. }
  867. },
  868. grid: { left: 110, right: 64, top: 30, bottom: 30 },
  869. xAxis: {
  870. type: 'time',
  871. min: xMin,
  872. max: xMax,
  873. splitNumber: xSplitNumber,
  874. axisLine: { lineStyle: { color: '#e5e7eb' } },
  875. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  876. splitLine: { lineStyle: { color: '#f1f5f9' } }
  877. },
  878. yAxis: {
  879. type: 'category',
  880. data: categories,
  881. inverse: true,
  882. axisLabel: {
  883. color: '#374151',
  884. margin: 8,
  885. formatter: (val: string) => {
  886. const lvl = workloadLevelMap[val] || 'low';
  887. const count = busyCountMap[val] || 0;
  888. const text = val.length > 8 ? val.slice(0, 8) + '…' : val;
  889. return `{${lvl}Dot|●} ${text}(${count}项)`;
  890. },
  891. rich: {
  892. highDot: { color: '#ef4444' },
  893. mediumDot: { color: '#f59e0b' },
  894. lowDot: { color: '#22c55e' }
  895. }
  896. },
  897. axisTick: { show: false },
  898. axisLine: { lineStyle: { color: '#e5e7eb' } }
  899. },
  900. dataZoom: [
  901. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, start: preservedStart, end: preservedEnd, zoomLock: false },
  902. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  903. ],
  904. series: [
  905. {
  906. type: 'custom',
  907. renderItem: (params: any, api: any) => {
  908. const categoryIndex = api.value(0);
  909. const start = api.coord([api.value(1), categoryIndex]);
  910. const end = api.coord([api.value(2), categoryIndex]);
  911. const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
  912. const rectShape = echarts.graphic.clipRectByRect({
  913. x: start[0],
  914. y: start[1] - height / 2,
  915. width: Math.max(end[0] - start[0], 2),
  916. height
  917. }, {
  918. x: params.coordSys.x,
  919. y: params.coordSys.y,
  920. width: params.coordSys.width,
  921. height: params.coordSys.height
  922. });
  923. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  924. },
  925. encode: { x: [1, 2], y: 0 },
  926. data,
  927. itemStyle: { borderRadius: 4 },
  928. emphasis: { focus: 'self' },
  929. markLine: {
  930. silent: true,
  931. symbol: 'none',
  932. lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
  933. label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
  934. data: [ { xAxis: todayTs } ]
  935. }
  936. }
  937. ]
  938. } as any;
  939. this.ganttChart.clear();
  940. this.ganttChart.setOption(option, true);
  941. this.ganttChart.resize();
  942. }
  943. ngOnDestroy(): void {
  944. if (this.ganttChart) {
  945. this.ganttChart.dispose();
  946. this.ganttChart = null;
  947. }
  948. if (this.workloadChart) {
  949. this.workloadChart.dispose();
  950. this.workloadChart = null;
  951. }
  952. }
  953. // 选择单个项目
  954. selectProject(): void {
  955. if (this.selectedProjectId) {
  956. this.router.navigate(['/team-leader/project-detail', this.selectedProjectId]);
  957. }
  958. }
  959. // 获取特定阶段的项目
  960. getProjectsByStage(stageId: string): Project[] {
  961. return this.filteredProjects.filter(project => project.currentStage === stageId);
  962. }
  963. // 新增:阶段到核心阶段的映射
  964. private mapStageToCorePhase(stageId: string): 'order' | 'requirements' | 'delivery' | 'aftercare' {
  965. // 订单创建:立项初期
  966. if (stageId === 'pendingApproval' || stageId === 'pendingAssignment') return 'order';
  967. // 确认需求:需求沟通 + 方案规划
  968. if (stageId === 'requirement' || stageId === 'planning') return 'requirements';
  969. // 交付执行:制作与评审修订过程
  970. if (stageId === 'modeling' || stageId === 'rendering' || stageId === 'postProduction' || stageId === 'review' || stageId === 'revision') return 'delivery';
  971. // 售后:交付完成后的跟进(当前数据以交付完成代表进入售后)
  972. return 'aftercare';
  973. }
  974. // 新增:获取核心阶段的项目
  975. getProjectsByCorePhase(coreId: string): Project[] {
  976. return this.filteredProjects.filter(p => this.mapStageToCorePhase(p.currentStage) === coreId);
  977. }
  978. // 新增:获取核心阶段的项目数量
  979. getProjectCountByCorePhase(coreId: string): number {
  980. return this.getProjectsByCorePhase(coreId).length;
  981. }
  982. // 获取特定阶段的项目数量
  983. getProjectCountByStage(stageId: string): number {
  984. return this.getProjectsByStage(stageId).length;
  985. }
  986. // 待审批项目:currentStage === 'pendingApproval'
  987. get pendingApprovalProjects(): Project[] {
  988. const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  989. return src.filter(p => p.currentStage === 'pendingApproval');
  990. }
  991. // 待指派项目:currentStage === 'pendingAssignment'
  992. get pendingAssignmentProjects(): Project[] {
  993. const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  994. return src.filter(p => p.currentStage === 'pendingAssignment');
  995. }
  996. // 获取紧急程度标签
  997. getUrgencyLabel(urgency: string): string {
  998. const labels = {
  999. high: '紧急',
  1000. medium: '一般',
  1001. low: '普通'
  1002. };
  1003. return labels[urgency as keyof typeof labels] || urgency;
  1004. }
  1005. // 智能推荐设计师
  1006. private getRecommendedDesigner(projectType: 'soft' | 'hard') {
  1007. if (!this.designerProfiles || !this.designerProfiles.length) return null;
  1008. const scoreOf = (p: any) => {
  1009. const workloadScore = 100 - (p.workload ?? 0); // 负载越低越好
  1010. const ratingScore = (p.avgRating ?? 0) * 10; // 评分越高越好
  1011. const expScore = (p.experience ?? 0) * 5; // 经验越高越好
  1012. return workloadScore * 0.5 + ratingScore * 0.3 + expScore * 0.2;
  1013. };
  1014. const sorted = [...this.designerProfiles].sort((a, b) => scoreOf(b) - scoreOf(a));
  1015. return sorted[0] || null;
  1016. }
  1017. // 质量评审
  1018. reviewProjectQuality(projectId: string, rating: 'excellent' | 'qualified' | 'unqualified'): void {
  1019. const project = this.projects.find(p => p.id === projectId);
  1020. if (!project) return;
  1021. project.qualityRating = rating;
  1022. if (rating === 'unqualified') {
  1023. // 不合格:回退到修改阶段
  1024. project.currentStage = 'revision';
  1025. }
  1026. this.applyFilters();
  1027. alert('质量评审已提交');
  1028. }
  1029. // 查看绩效预警(占位:跳转到团队管理)
  1030. viewPerformanceDetails(): void {
  1031. this.router.navigate(['/team-leader/team-management']);
  1032. }
  1033. // 打开负载日历(占位:跳转到团队管理)
  1034. navigateToWorkloadCalendar(): void {
  1035. this.router.navigate(['/team-leader/workload-calendar']);
  1036. }
  1037. // 查看项目详情
  1038. viewProjectDetails(projectId: string): void {
  1039. // 改为跳转到复用的项目详情(组长上下文,具备审核权限)
  1040. this.router.navigate(['/team-leader/project-detail', projectId]);
  1041. }
  1042. // 快速分配项目(增强:加入智能推荐)
  1043. quickAssignProject(projectId: string): void {
  1044. const project = this.projects.find(p => p.id === projectId);
  1045. if (!project) {
  1046. alert('未找到对应项目');
  1047. return;
  1048. }
  1049. const recommended = this.getRecommendedDesigner(project.type);
  1050. if (recommended) {
  1051. const reassigning = !!project.designerName;
  1052. const message = `推荐设计师:${recommended.name}(工作负载:${recommended.workload}%,评分:${recommended.avgRating}分)` +
  1053. (reassigning ? `\n\n该项目当前已由「${project.designerName}」负责,是否改为分配给「${recommended.name}」?` : '\n\n是否确认分配?');
  1054. const confirmAssign = confirm(message);
  1055. if (confirmAssign) {
  1056. project.designerName = recommended.name;
  1057. if (project.currentStage === 'pendingAssignment' || project.currentStage === 'pendingApproval') {
  1058. project.currentStage = 'requirement';
  1059. }
  1060. project.status = '进行中';
  1061. // 更新设计师筛选列表
  1062. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  1063. this.applyFilters();
  1064. alert(`项目已${reassigning ? '重新' : ''}分配给 ${recommended.name}`);
  1065. return;
  1066. }
  1067. }
  1068. // 无推荐或用户取消,跳转到详细分配页面
  1069. // 改为跳转到复用详情(组长视图),通过 query 参数标记 assign 模式
  1070. this.router.navigate(['/team-leader/project-detail', projectId], { queryParams: { mode: 'assign' } });
  1071. }
  1072. // 导航到待办任务
  1073. navigateToTask(task: TodoTask): void {
  1074. switch (task.type) {
  1075. case 'review':
  1076. this.router.navigate(['team-leader/quality-management', task.targetId]);
  1077. break;
  1078. case 'assign':
  1079. this.router.navigate(['/team-leader/dashboard']);
  1080. break;
  1081. case 'performance':
  1082. this.router.navigate(['team-leader/team-management']);
  1083. break;
  1084. }
  1085. }
  1086. // 获取优先级标签
  1087. getPriorityLabel(priority: 'high' | 'medium' | 'low'): string {
  1088. const labels: Record<'high' | 'medium' | 'low', string> = {
  1089. 'high': '紧急且重要',
  1090. 'medium': '重要不紧急',
  1091. 'low': '紧急不重要'
  1092. };
  1093. return labels[priority];
  1094. }
  1095. // 导航到团队管理
  1096. navigateToTeamManagement(): void {
  1097. this.router.navigate(['/team-leader/team-management']);
  1098. }
  1099. // 导航到项目评审
  1100. navigateToProjectReview(): void {
  1101. // 统一入口:跳转到项目列表/看板,而非旧评审页
  1102. this.router.navigate(['/team-leader/dashboard']);
  1103. }
  1104. // 导航到质量管理
  1105. navigateToQualityManagement(): void {
  1106. this.router.navigate(['/team-leader/quality-management']);
  1107. }
  1108. // 打开工作量预估工具(已迁移)
  1109. openWorkloadEstimator(): void {
  1110. // 工具迁移至详情页:引导前往当前选中项目详情
  1111. if (this.selectedProjectId) {
  1112. this.router.navigate(['/team-leader/project-detail', this.selectedProjectId], { queryParams: { tool: 'estimator' } });
  1113. } else {
  1114. this.router.navigate(['/team-leader/dashboard']);
  1115. }
  1116. alert('工作量预估工具已迁移至项目详情页,您可以在建模阶段之前使用该工具进行工作量计算。');
  1117. }
  1118. // 查看所有超期项目
  1119. viewAllOverdueProjects(): void {
  1120. this.filterByStatus('overdue');
  1121. this.closeAlert();
  1122. }
  1123. // 关闭提醒
  1124. closeAlert(): void {
  1125. this.showAlert = false;
  1126. }
  1127. // 维度切换(设计师/会员类型)
  1128. setWorkloadDimension(dim: 'designer' | 'member'): void {
  1129. if (this.workloadDimension !== dim) {
  1130. this.workloadDimension = dim;
  1131. this.updateWorkloadChart();
  1132. }
  1133. }
  1134. // 刷新“工作量概览”图表
  1135. private updateWorkloadChart(): void {
  1136. if (!this.workloadChartRef) { return; }
  1137. const el = this.workloadChartRef.nativeElement;
  1138. if (!el) { return; }
  1139. // 初始化实例(使用 SVG 渲染以获得更佳文本清晰度)
  1140. if (!this.workloadChart) {
  1141. this.workloadChart = echarts.init(el, null, { renderer: 'svg' });
  1142. }
  1143. const data = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
  1144. const byDesigner = this.workloadDimension === 'designer';
  1145. const groupKey = byDesigner ? 'designerName' : 'memberType';
  1146. const labelMap: Record<string, string> = { vip: 'VIP', normal: '普通' };
  1147. const groupSet = new Set<string>();
  1148. data.forEach(p => {
  1149. const val = (p as any)[groupKey] || (byDesigner ? '未分配' : '未知');
  1150. groupSet.add(val);
  1151. });
  1152. const categories = Array.from(groupSet).sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
  1153. const count = (urg: 'high'|'medium'|'low', group: string) =>
  1154. data.filter(p => (((p as any)[groupKey] || (byDesigner ? '未分配' : '未知')) === group) && p.urgency === urg).length;
  1155. const high = categories.map(c => count('high', c));
  1156. const medium = categories.map(c => count('medium', c));
  1157. const low = categories.map(c => count('low', c));
  1158. const option = {
  1159. tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
  1160. legend: { data: ['高', '中', '低'] },
  1161. grid: { left: 12, right: 16, top: 28, bottom: 8, containLabel: true },
  1162. xAxis: { type: 'value', boundaryGap: [0, 0.01] },
  1163. yAxis: {
  1164. type: 'category',
  1165. data: categories.map(c => byDesigner ? c : (labelMap[c] || c))
  1166. },
  1167. series: [
  1168. { name: '高', type: 'bar', stack: 'workload', data: high, itemStyle: { color: '#ef4444' } },
  1169. { name: '中', type: 'bar', stack: 'workload', data: medium, itemStyle: { color: '#f59e0b' } },
  1170. { name: '低', type: 'bar', stack: 'workload', data: low, itemStyle: { color: '#10b981' } }
  1171. ]
  1172. } as any;
  1173. this.workloadChart.clear();
  1174. this.workloadChart.setOption(option, true);
  1175. this.workloadChart.resize();
  1176. }
  1177. resetStatusFilter(): void {
  1178. this.selectedStatus = 'all';
  1179. this.applyFilters();
  1180. }
  1181. }