dashboard.ts 73 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935
  1. import { CommonModule } from '@angular/common';
  2. import { FormsModule } from '@angular/forms';
  3. import { Router, RouterModule } from '@angular/router';
  4. import { Component, OnInit, OnDestroy, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
  5. import { ProjectService } from '../../../services/project.service';
  6. import { DesignerService } from '../services/designer.service';
  7. import { WxworkAuth } from 'fmode-ng/core';
  8. import { ProjectIssueService, ProjectIssue, IssueStatus, IssuePriority, IssueType } from '../../../../modules/project/services/project-issue.service';
  9. import { FmodeParse } from 'fmode-ng/parse';
  10. import { ProjectTimelineComponent } from '../project-timeline';
  11. import { EmployeeDetailPanelComponent } from '../employee-detail-panel';
  12. import { DashboardMetricsComponent } from './components/dashboard-metrics/dashboard-metrics.component';
  13. import { WorkloadGanttComponent } from './components/workload-gantt/workload-gantt.component';
  14. import { TodoSectionComponent } from './components/todo-section/todo-section.component';
  15. import { SmartMatchModalComponent } from './components/smart-match-modal/smart-match-modal.component';
  16. import { DashboardFilterBarComponent } from './components/dashboard-filter-bar/dashboard-filter-bar.component';
  17. import { ProjectKanbanComponent } from './components/project-kanban/project-kanban.component';
  18. import { DashboardAlertsComponent } from './components/dashboard-alerts/dashboard-alerts.component';
  19. import type { ProjectTimeline } from '../project-timeline/project-timeline';
  20. import { normalizeDateInput, addDays } from '../../../utils/date-utils';
  21. import { generatePhaseDeadlines } from '../../../utils/phase-deadline.utils';
  22. import { PhaseDeadlines, PhaseName } from '../../../models/project-phase.model';
  23. import {
  24. ProjectStage,
  25. ProjectPhase,
  26. Project,
  27. TodoTaskFromIssue,
  28. UrgentEvent,
  29. LeaveRecord,
  30. EmployeeDetail,
  31. EmployeeCalendarData,
  32. EmployeeCalendarDay
  33. } from './dashboard.model';
  34. @Component({
  35. selector: 'app-dashboard',
  36. standalone: true,
  37. imports: [
  38. CommonModule,
  39. FormsModule,
  40. RouterModule,
  41. ProjectTimelineComponent,
  42. EmployeeDetailPanelComponent,
  43. DashboardMetricsComponent,
  44. WorkloadGanttComponent,
  45. TodoSectionComponent,
  46. SmartMatchModalComponent,
  47. DashboardFilterBarComponent,
  48. ProjectKanbanComponent,
  49. DashboardAlertsComponent
  50. ],
  51. templateUrl: './dashboard.html',
  52. styleUrl: './dashboard.scss',
  53. changeDetection: ChangeDetectionStrategy.OnPush
  54. })
  55. export class Dashboard implements OnInit, OnDestroy {
  56. // 暴露 Array 给模板使用
  57. Array = Array;
  58. projects: Project[] = [];
  59. filteredProjects: Project[] = [];
  60. urgentPinnedProjects: Project[] = [];
  61. showAlert: boolean = false;
  62. selectedProjectId: string = '';
  63. // 待办任务数据(交给子组件处理显示)
  64. todoTasksFromIssues: TodoTaskFromIssue[] = [];
  65. loadingTodoTasks: boolean = false;
  66. todoTaskError: string = '';
  67. private todoTaskRefreshTimer: any;
  68. // 紧急事件数据(交给子组件处理显示)
  69. urgentEvents: UrgentEvent[] = [];
  70. loadingUrgentEvents: boolean = false;
  71. handledUrgentEventIds: Set<string> = new Set();
  72. mutedUrgentEventIds: Set<string> = new Set();
  73. // 新增:当前用户信息
  74. currentUser = {
  75. name: '组长',
  76. avatar: "data:image/svg+xml,%3Csvg width='40' height='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='%23CCFFCC'/%3E%3Ctext x='50%25' y='50%25' font-family='Arial' font-size='13.333333333333334' font-weight='bold' text-anchor='middle' fill='%23555555' dy='0.3em'%3E组长%3C/text%3E%3C/svg%3E",
  77. roleName: '组长'
  78. };
  79. currentDate = new Date();
  80. // 真实设计师数据(从fmode-ng获取)
  81. realDesigners: any[] = [];
  82. // 设计师工作量映射(从 ProjectTeam 表)
  83. designerWorkloadMap: Map<string, any[]> = new Map(); // designerId/name -> projects[]
  84. // 智能推荐相关
  85. showSmartMatch: boolean = false;
  86. selectedProject: any = null;
  87. recommendations: any[] = [];
  88. // 新增:关键词搜索
  89. searchTerm: string = '';
  90. // 临期项目与筛选状态
  91. selectedType: 'all' | 'soft' | 'hard' = 'all';
  92. selectedUrgency: 'all' | 'high' | 'medium' | 'low' = 'all';
  93. selectedStatus: 'all' | 'progress' | 'completed' | 'overdue' | 'pendingApproval' | 'pendingAssignment' | 'dueSoon' = 'all';
  94. selectedDesigner: string = 'all';
  95. selectedMemberType: 'all' | 'vip' | 'normal' = 'all';
  96. // 新增:时间窗筛选
  97. selectedTimeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays' = 'all';
  98. designers: string[] = [];
  99. // 新增:四大板块筛选
  100. selectedCorePhase: 'all' | 'order' | 'requirements' | 'delivery' | 'aftercare' = 'all';
  101. // 设计师画像(从fmode-ng动态获取,保留此字段作为兼容)
  102. designerProfiles: any[] = [];
  103. // 10个项目阶段
  104. projectStages: ProjectStage[] = [
  105. { id: 'pendingApproval', name: '待确认', order: 1 },
  106. { id: 'pendingAssignment', name: '待分配', order: 2 },
  107. { id: 'requirement', name: '需求沟通', order: 3 },
  108. { id: 'planning', name: '方案规划', order: 4 },
  109. { id: 'modeling', name: '建模阶段', order: 5 },
  110. { id: 'rendering', name: '渲染阶段', order: 6 },
  111. { id: 'postProduction', name: '后期处理', order: 7 },
  112. { id: 'review', name: '方案评审', order: 8 },
  113. { id: 'revision', name: '方案修改', order: 9 },
  114. { id: 'delivery', name: '交付完成', order: 10 }
  115. ];
  116. // 5大核心阶段(聚合展示)
  117. corePhases: ProjectStage[] = [
  118. { id: 'order', name: '订单分配', order: 1 }, // 待确认、待分配
  119. { id: 'requirements', name: '确认需求', order: 2 }, // 需求沟通、方案规划
  120. { id: 'delivery', name: '交付执行', order: 3 }, // 建模、渲染、后期/评审/修改
  121. { id: 'aftercare', name: '售后', order: 4 } // 交付完成 → 售后
  122. ];
  123. // 视图开关
  124. showGanttView: boolean = true;
  125. // 个人详情面板相关属性
  126. showEmployeeDetailPanel: boolean = false;
  127. selectedEmployeeName: string = '';
  128. selectedEmployeeDetail: any | null = null;
  129. selectedEmployeeProjects: any[] = [];
  130. // 项目时间轴数据
  131. projectTimelineData: ProjectTimeline[] = [];
  132. private timelineDataCache: ProjectTimeline[] = [];
  133. private lastDesignerWorkloadMapSize: number = 0;
  134. // 员工请假数据
  135. // private leaveRecords: LeaveRecord[] = [];
  136. constructor(
  137. private projectService: ProjectService,
  138. private router: Router,
  139. private designerService: DesignerService,
  140. private issueService: ProjectIssueService,
  141. private cdr: ChangeDetectorRef
  142. ) {}
  143. async ngOnInit(): Promise<void> {
  144. // 新增:加载用户Profile信息
  145. await this.loadUserProfile();
  146. await this.loadProjects();
  147. await this.loadDesigners();
  148. // 加载待办任务(从问题板块)
  149. await this.loadTodoTasksFromIssues();
  150. // 🆕 计算紧急事件
  151. this.calculateUrgentEvents();
  152. // 启动自动刷新
  153. this.startAutoRefresh();
  154. }
  155. /**
  156. * 从fmode-ng加载真实设计师数据
  157. */
  158. async loadDesigners(): Promise<void> {
  159. try {
  160. this.realDesigners = await this.designerService.getDesigners();
  161. // 更新设计师列表(用于筛选下拉框)
  162. this.designers = this.realDesigners.map(d => d.name);
  163. // 同时更新designerProfiles以保持兼容性
  164. this.designerProfiles = this.realDesigners.map(d => ({
  165. id: d.id,
  166. name: d.name,
  167. skills: d.tags.expertise.styles || [],
  168. workload: 0, // 后续动态计算
  169. avgRating: d.tags.history.avgRating || 0,
  170. experience: 0 // 暂无此字段
  171. }));
  172. // 加载设计师的实际工作量
  173. await this.loadDesignerWorkload();
  174. } catch (error) {
  175. console.error('加载设计师数据失败:', error);
  176. }
  177. }
  178. /**
  179. * 🔧 从 ProjectTeam 表加载每个设计师的实际工作量
  180. */
  181. async loadDesignerWorkload(): Promise<void> {
  182. try {
  183. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  184. // 查询所有 ProjectTeam 记录
  185. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  186. // 先查询当前公司的所有项目
  187. const projectQuery = new Parse.Query('Project');
  188. projectQuery.equalTo('company', cid);
  189. projectQuery.notEqualTo('isDeleted', true);
  190. // 查询当前公司项目的 ProjectTeam
  191. const teamQuery = new Parse.Query('ProjectTeam');
  192. teamQuery.matchesQuery('project', projectQuery);
  193. teamQuery.notEqualTo('isDeleted', true);
  194. teamQuery.include('project');
  195. teamQuery.include('profile');
  196. teamQuery.limit(1000);
  197. const teamRecords = await teamQuery.find();
  198. // 如果 ProjectTeam 表为空,使用降级方案
  199. if (teamRecords.length === 0) {
  200. await this.loadDesignerWorkloadFromProjects();
  201. return;
  202. }
  203. // 构建设计师工作量映射
  204. this.designerWorkloadMap.clear();
  205. teamRecords.forEach((record: any) => {
  206. const profile = record.get('profile');
  207. const project = record.get('project');
  208. if (!profile || !project) {
  209. return;
  210. }
  211. const profileId = profile.id;
  212. const profileName = profile.get('name') || profile.get('user')?.get?.('name') || `设计师-${profileId.slice(-4)}`;
  213. // 提取项目信息
  214. // 优先获取各个日期字段
  215. const createdAtValue = project.get('createdAt');
  216. const updatedAtValue = project.get('updatedAt');
  217. const deadlineValue = project.get('deadline');
  218. const deliveryDateValue = project.get('deliveryDate');
  219. const expectedDeliveryDateValue = project.get('expectedDeliveryDate');
  220. const demodayValue = project.get('demoday'); // 🆕 小图对图日期
  221. // 🔧 如果 get() 方法都返回假值,尝试从 createdAt/updatedAt 属性直接获取
  222. // Parse 对象的 createdAt/updatedAt 是内置属性
  223. let finalCreatedAt = createdAtValue || updatedAtValue;
  224. if (!finalCreatedAt && project.createdAt) {
  225. finalCreatedAt = project.createdAt; // Parse 内置属性
  226. }
  227. if (!finalCreatedAt && project.updatedAt) {
  228. finalCreatedAt = project.updatedAt; // Parse 内置属性
  229. }
  230. // ✅ 应用方案:获取项目的 data 字段(包含 phaseDeadlines, deliveryStageStatus 等)
  231. const projectDataField = project.get('data') || {};
  232. const projectData = {
  233. id: project.id,
  234. name: project.get('title') || '未命名项目',
  235. status: project.get('status') || '进行中',
  236. currentStage: project.get('currentStage') || '未知阶段',
  237. deadline: deadlineValue || deliveryDateValue || expectedDeliveryDateValue,
  238. demoday: demodayValue, // 🆕 小图对图日期
  239. createdAt: finalCreatedAt,
  240. updatedAt: updatedAtValue || project.updatedAt, // ✅ 添加 updatedAt
  241. designerName: profileName,
  242. designerId: profileId, // ✅ 添加 designerId
  243. data: projectDataField, // ✅ 添加 data 字段
  244. contact: project.get('contact'), // ✅ 添加客户信息
  245. space: projectDataField.quotation?.spaces?.[0]?.name || '' // ✅ 添加空间信息
  246. };
  247. // 添加到映射 (by ID)
  248. if (!this.designerWorkloadMap.has(profileId)) {
  249. this.designerWorkloadMap.set(profileId, []);
  250. }
  251. this.designerWorkloadMap.get(profileId)!.push(projectData);
  252. // 同时建立 name -> projects 的映射(用于甘特图)
  253. if (!this.designerWorkloadMap.has(profileName)) {
  254. this.designerWorkloadMap.set(profileName, []);
  255. }
  256. this.designerWorkloadMap.get(profileName)!.push(projectData);
  257. });
  258. // 更新项目时间轴数据
  259. this.convertToProjectTimeline();
  260. } catch (error) {
  261. console.error('加载设计师工作量失败:', error);
  262. }
  263. }
  264. /**
  265. * 🔧 降级方案:从 Project.assignee 统计工作量
  266. * 当 ProjectTeam 表为空时使用
  267. */
  268. async loadDesignerWorkloadFromProjects(): Promise<void> {
  269. try {
  270. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  271. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  272. // 查询所有项目
  273. const projectQuery = new Parse.Query('Project');
  274. projectQuery.equalTo('company', cid);
  275. projectQuery.equalTo('isDeleted', false);
  276. projectQuery.include('assignee');
  277. projectQuery.include('department');
  278. projectQuery.limit(1000);
  279. const projects = await projectQuery.find();
  280. // 构建设计师工作量映射
  281. this.designerWorkloadMap.clear();
  282. projects.forEach((project: any) => {
  283. const assignee = project.get('assignee');
  284. if (!assignee) return;
  285. // 只统计组员角色的项目
  286. const assigneeRole = assignee.get('roleName');
  287. if (assigneeRole !== '组员') {
  288. return;
  289. }
  290. const assigneeName = assignee.get('name') || assignee.get('user')?.get?.('name') || `设计师-${assignee.id.slice(-4)}`;
  291. // 提取项目信息
  292. // 优先获取各个日期字段
  293. const createdAtValue = project.get('createdAt');
  294. const updatedAtValue = project.get('updatedAt');
  295. const deadlineValue = project.get('deadline');
  296. const deliveryDateValue = project.get('deliveryDate');
  297. const expectedDeliveryDateValue = project.get('expectedDeliveryDate');
  298. const demodayValue = project.get('demoday'); // 🆕 小图对图日期
  299. // 🔧 如果 get() 方法都返回假值,尝试从 createdAt/updatedAt 属性直接获取
  300. let finalCreatedAt = createdAtValue || updatedAtValue;
  301. if (!finalCreatedAt && project.createdAt) {
  302. finalCreatedAt = project.createdAt;
  303. }
  304. if (!finalCreatedAt && project.updatedAt) {
  305. finalCreatedAt = project.updatedAt;
  306. }
  307. // ✅ 应用方案:获取项目的 data 字段(包含 phaseDeadlines, deliveryStageStatus 等)
  308. const projectDataField = project.get('data') || {};
  309. const projectData = {
  310. id: project.id,
  311. name: project.get('title') || '未命名项目',
  312. status: project.get('status') || '进行中',
  313. currentStage: project.get('currentStage') || '未知阶段',
  314. deadline: deadlineValue || deliveryDateValue || expectedDeliveryDateValue,
  315. demoday: demodayValue, // 🆕 小图对图日期
  316. createdAt: finalCreatedAt,
  317. updatedAt: updatedAtValue || project.updatedAt, // ✅ 添加 updatedAt
  318. designerName: assigneeName,
  319. designerId: assignee.id, // ✅ 添加 designerId
  320. data: projectDataField, // ✅ 添加 data 字段
  321. contact: project.get('contact'), // ✅ 添加客户信息
  322. space: projectDataField.quotation?.spaces?.[0]?.name || '' // ✅ 添加空间信息
  323. };
  324. // 添加到映射
  325. if (!this.designerWorkloadMap.has(assigneeName)) {
  326. this.designerWorkloadMap.set(assigneeName, []);
  327. }
  328. this.designerWorkloadMap.get(assigneeName)!.push(projectData);
  329. });
  330. // ✅ 修复:加载完数据后,转换为时间轴格式
  331. console.log(`📊 [降级方案] 加载了 ${projects.length} 个项目,填充到 ${this.designerWorkloadMap.size} 个设计师的工作量映射`);
  332. this.convertToProjectTimeline();
  333. } catch (error) {
  334. console.error('[降级方案] 加载工作量失败:', error);
  335. }
  336. }
  337. /**
  338. * 从fmode-ng加载真实项目数据
  339. */
  340. async loadProjects(): Promise<void> {
  341. try {
  342. const realProjects = await this.designerService.getProjects();
  343. // 如果有真实数据,使用真实数据
  344. if (realProjects && realProjects.length > 0) {
  345. this.projects = realProjects;
  346. } else {
  347. this.projects = [];
  348. }
  349. } catch (error) {
  350. console.error('加载项目数据失败:', error);
  351. this.projects = [];
  352. }
  353. // 应用筛选
  354. this.applyFilters();
  355. }
  356. /**
  357. * 将项目数据转换为ProjectTimeline格式(带缓存优化 + 去重)
  358. */
  359. private convertToProjectTimeline(): void {
  360. // 🔧 不去重,保留所有项目-设计师关联关系(一个项目可能有多个设计师)
  361. const allDesignerProjects: any[] = [];
  362. // 统计项目数量
  363. let totalProjectsInMap = 0;
  364. this.designerWorkloadMap.forEach((projects, key) => {
  365. totalProjectsInMap += projects.length;
  366. console.log(`📊 设计师 "${key}": ${projects.length} 个项目`);
  367. });
  368. console.log(`📊 总计 ${totalProjectsInMap} 个项目分布在 ${this.designerWorkloadMap.size} 个设计师中`);
  369. this.designerWorkloadMap.forEach((projects, designerKey) => {
  370. // 🔧 改进判断逻辑:跳过明显的 ID 格式(Parse objectId 是10位字母数字组合)
  371. // 只要包含中文字符,就认为是设计师名称
  372. const isParseId = typeof designerKey === 'string'
  373. && designerKey.length === 10
  374. && /^[a-zA-Z0-9]{10}$/.test(designerKey); // Parse ID 格式:10位字母数字
  375. const isDesignerName = !isParseId && typeof designerKey === 'string' && /[\u4e00-\u9fa5]/.test(designerKey);
  376. if (isDesignerName) {
  377. projects.forEach(proj => {
  378. // ✅ 不去重,保留每个设计师-项目的关联
  379. const projectWithDesigner = {
  380. ...proj,
  381. designerName: designerKey // 使用当前的设计师名称
  382. };
  383. allDesignerProjects.push(projectWithDesigner);
  384. });
  385. }
  386. });
  387. console.log(`📊 开始转换 ${allDesignerProjects.length} 个项目为时间轴格式`);
  388. this.projectTimelineData = allDesignerProjects.map((project, index) => {
  389. const now = new Date();
  390. // ✅ 应用方案:使用真实字段数据
  391. const projectData = project.data || {};
  392. // 1. 获取真实的项目开始时间
  393. const realStartDate = normalizeDateInput(
  394. projectData.phaseDeadlines?.modeling?.startDate ||
  395. projectData.requirementsConfirmedAt ||
  396. project.createdAt,
  397. new Date()
  398. );
  399. // 2. 获取真实的交付日期
  400. // ✅ 修复:确保 deadline 是未来的日期(不使用过去的初始值或未初始化的值)
  401. let proposedEndDate = project.deadline || projectData.phaseDeadlines?.postProcessing?.deadline;
  402. let realEndDate: Date;
  403. // 如果提议的结束日期在过去,或者日期无效,使用默认值
  404. if (proposedEndDate) {
  405. const proposed = normalizeDateInput(proposedEndDate, realStartDate);
  406. // 只有当提议的日期在未来时才使用它
  407. if (proposed.getTime() > now.getTime()) {
  408. realEndDate = proposed;
  409. } else {
  410. // 日期在过去,使用默认值(从开始日期起30天)
  411. realEndDate = addDays(realStartDate, 30);
  412. }
  413. } else {
  414. // 没有提议的日期,使用默认值
  415. realEndDate = addDays(realStartDate, 30);
  416. }
  417. // 3. 获取真实的对图时间(小图对图)
  418. // ✅ 逻辑:优先使用 project.demoday,否则在软装截止时间后半天
  419. let realReviewDate: Date;
  420. let reviewDateSource = 'default';
  421. if (project.demoday) {
  422. // 如果有显式设置的小图对图日期,使用它
  423. realReviewDate = normalizeDateInput(project.demoday, new Date());
  424. reviewDateSource = 'demoday';
  425. } else if (projectData.phaseDeadlines?.softDecor?.deadline) {
  426. // 软装截止时间后半天作为小图对图时间
  427. const softDecorDeadline = normalizeDateInput(projectData.phaseDeadlines.softDecor.deadline, new Date());
  428. realReviewDate = new Date(softDecorDeadline.getTime() + 12 * 60 * 60 * 1000); // 加12小时
  429. reviewDateSource = 'softDecor + 12h';
  430. } else {
  431. // 默认值:项目进度的60%位置,下午2点
  432. const defaultReviewPoint = new Date(
  433. realStartDate.getTime() + (realEndDate.getTime() - realStartDate.getTime()) * 0.6
  434. );
  435. defaultReviewPoint.setHours(14, 0, 0, 0);
  436. realReviewDate = defaultReviewPoint;
  437. reviewDateSource = 'default 60%';
  438. }
  439. // 调试日志
  440. if (project.name?.includes('紫云') || project.name?.includes('自建')) {
  441. console.log(`📸 [${project.name}] 小图对图时间计算:`, {
  442. source: reviewDateSource,
  443. reviewDate: realReviewDate.toLocaleString('zh-CN'),
  444. demoday: project.demoday,
  445. softDecorDeadline: projectData.phaseDeadlines?.softDecor?.deadline,
  446. hasPhaseDeadlines: !!projectData.phaseDeadlines
  447. });
  448. }
  449. // 4. 计算距离交付还有几天(使用真实日期)
  450. const daysUntilDeadline = Math.ceil((realEndDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  451. // 5. 计算项目状态
  452. let status: 'normal' | 'warning' | 'urgent' | 'overdue' = 'normal';
  453. if (daysUntilDeadline < 0) {
  454. status = 'overdue';
  455. } else if (daysUntilDeadline <= 1) {
  456. status = 'urgent';
  457. } else if (daysUntilDeadline <= 3) {
  458. status = 'warning';
  459. }
  460. // 6. 映射阶段
  461. const stageMap: Record<string, 'plan' | 'model' | 'decoration' | 'render' | 'delivery'> = {
  462. '方案设计': 'plan',
  463. '方案规划': 'plan',
  464. '建模': 'model',
  465. '建模阶段': 'model',
  466. '软装': 'decoration',
  467. '软装设计': 'decoration',
  468. '渲染': 'render',
  469. '渲染阶段': 'render',
  470. '后期': 'render',
  471. '交付': 'delivery',
  472. '已完成': 'delivery'
  473. };
  474. const currentStage = stageMap[project.currentStage || '建模阶段'] || 'model';
  475. const stageName = project.currentStage || '建模阶段';
  476. // 7. 🆕 阶段任务完成度(由时间轴组件的 getProjectCompletionRate 计算)
  477. // ✅ 重要变更:进度条现在表示"任务完成度"而不是"时间百分比"
  478. // - 时间轴组件会优先使用交付物完成率(overallCompletionRate)
  479. // - 若无交付物数据,则根据 phaseDeadlines.status 推断任务完成度
  480. // - stageProgress 保留作为兼容字段,但已弃用
  481. let stageProgress = 50; // 默认兼容值(实际进度由时间轴组件计算)
  482. // 8. 检查是否停滞(基于 updatedAt)
  483. let isStalled = false;
  484. let stalledDays = 0;
  485. if (project.updatedAt) {
  486. const updatedAt = project.updatedAt instanceof Date ? project.updatedAt : new Date(project.updatedAt);
  487. const daysSinceUpdate = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
  488. // 如果超过7天未更新,认为停滞
  489. isStalled = daysSinceUpdate > 7;
  490. stalledDays = daysSinceUpdate;
  491. }
  492. // 9. 催办次数(基于状态和历史记录)
  493. const urgentCount = status === 'overdue' ? 2 : status === 'urgent' ? 1 : 0;
  494. // 10. 优先级
  495. let priority: 'low' | 'medium' | 'high' | 'critical' = 'medium';
  496. if (status === 'overdue') {
  497. priority = 'critical';
  498. } else if (status === 'urgent') {
  499. priority = 'high';
  500. } else if (status === 'warning') {
  501. priority = 'medium';
  502. } else {
  503. priority = 'low';
  504. }
  505. // 11. 获取或生成阶段截止时间数据
  506. let phaseDeadlines: PhaseDeadlines | undefined = projectData.phaseDeadlines;
  507. if (!phaseDeadlines) {
  508. phaseDeadlines = generatePhaseDeadlines(realStartDate, realEndDate);
  509. }
  510. if (phaseDeadlines) {
  511. (Object.keys(phaseDeadlines) as PhaseName[]).forEach((phaseKey) => {
  512. const info = phaseDeadlines![phaseKey];
  513. if (!info) return;
  514. const phaseStart = normalizeDateInput(info.startDate, realStartDate);
  515. const phaseEnd = normalizeDateInput(info.deadline, realEndDate);
  516. if (now >= phaseEnd) {
  517. info.status = 'completed';
  518. } else if (now >= phaseStart) {
  519. info.status = 'in_progress';
  520. } else {
  521. info.status = info.status || 'not_started';
  522. }
  523. });
  524. }
  525. // 12. 获取空间和客户信息
  526. const spaceName = project.space || projectData.quotation?.spaces?.[0]?.name || '';
  527. const customerName = project.customer || project.contact?.name || '';
  528. return {
  529. projectId: project.id || `proj-${Math.random().toString(36).slice(2, 9)}`,
  530. projectName: project.name || '未命名项目',
  531. designerId: project.designerId || project.designerName || '未分配',
  532. designerName: project.designerName || '未分配',
  533. startDate: realStartDate, // ✅ 使用真实开始时间
  534. endDate: realEndDate, // ✅ 使用真实结束时间
  535. deliveryDate: realEndDate, // ✅ 使用真实交付日期
  536. reviewDate: realReviewDate, // ✅ 使用真实对图时间
  537. currentStage,
  538. stageName,
  539. stageProgress: Math.round(stageProgress), // ✅ 使用计算的真实进度
  540. status, // ✅ 基于真实日期计算的状态
  541. isStalled, // ✅ 基于 updatedAt 计算的停滞状态
  542. stalledDays, // ✅ 真实的停滞天数
  543. urgentCount,
  544. priority,
  545. spaceName, // ✅ 从项目数据获取
  546. customerName, // ✅ 从项目数据获取
  547. phaseDeadlines: phaseDeadlines, // ✅ 使用真实或计算的阶段截止时间
  548. data: projectData // ✅ 保留原始数据,供后续使用
  549. };
  550. });
  551. // 更新缓存
  552. this.timelineDataCache = this.projectTimelineData;
  553. this.lastDesignerWorkloadMapSize = totalProjectsInMap;
  554. console.log(`📊 convertToProjectTimeline 完成: 转换了 ${this.projectTimelineData.length} 个项目`);
  555. if (this.projectTimelineData.length > 0) {
  556. const now = new Date();
  557. const sampleProject = this.projectTimelineData[0];
  558. const daysFromStart = Math.floor((now.getTime() - sampleProject.startDate.getTime()) / (1000 * 60 * 60 * 24));
  559. const daysToEnd = Math.floor((sampleProject.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  560. console.log(`📊 第一个项目示例:`, {
  561. projectId: sampleProject.projectId,
  562. projectName: sampleProject.projectName,
  563. designerName: sampleProject.designerName,
  564. startDate: sampleProject.startDate.toLocaleString('zh-CN'),
  565. endDate: sampleProject.endDate.toLocaleString('zh-CN'),
  566. startDateFromNow: `${daysFromStart} 天前`,
  567. endDateFromNow: daysToEnd >= 0 ? `${daysToEnd} 天后` : `${Math.abs(daysToEnd)} 天前`,
  568. isEndDateInPast: daysToEnd < 0,
  569. hasPhaseDeadlines: !!sampleProject.phaseDeadlines
  570. });
  571. // ✅ 检查日期问题:统计有多少项目的结束日期在过去
  572. const projectsWithPastEndDate = this.projectTimelineData.filter(p => {
  573. const daysToEnd = Math.floor((p.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  574. return daysToEnd < 0;
  575. });
  576. if (projectsWithPastEndDate.length > 0) {
  577. console.warn(`⚠️ 发现 ${projectsWithPastEndDate.length} 个项目的结束日期在过去,这些项目在时间轴上可能显示为一个点`);
  578. console.log(`⚠️ 示例项目:`, projectsWithPastEndDate.slice(0, 3).map(p => ({
  579. name: p.projectName,
  580. endDate: p.endDate.toLocaleString('zh-CN'),
  581. daysAgo: Math.abs(Math.floor((p.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)))
  582. })));
  583. }
  584. }
  585. }
  586. /**
  587. * 处理项目点击事件
  588. */
  589. onProjectTimelineClick(projectId: string): void {
  590. if (!projectId) {
  591. return;
  592. }
  593. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  594. try {
  595. localStorage.setItem('enterAsTeamLeader', '1');
  596. localStorage.setItem('teamLeaderMode', 'true');
  597. // 🔥 关键:清除客服端标记,避免冲突
  598. localStorage.removeItem('enterFromCustomerService');
  599. localStorage.removeItem('customerServiceMode');
  600. console.log('✅ 已标记从组长看板进入,启用组长模式');
  601. } catch (e) {
  602. console.warn('无法设置 localStorage 标记:', e);
  603. }
  604. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  605. const project = this.projects.find(p => p.id === projectId);
  606. const currentStage = project?.currentStage || '订单分配';
  607. // 阶段映射:项目阶段 → 路由路径
  608. const stageRouteMap: Record<string, string> = {
  609. '订单分配': 'order',
  610. '确认需求': 'requirements',
  611. '方案深化': 'requirements',
  612. '建模': 'requirements',
  613. '软装': 'requirements',
  614. '渲染': 'requirements',
  615. '后期': 'requirements',
  616. '交付执行': 'delivery',
  617. '交付': 'delivery',
  618. '售后归档': 'aftercare',
  619. '已完成': 'aftercare'
  620. };
  621. const stagePath = stageRouteMap[currentStage] || 'order';
  622. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  623. // 获取公司ID(与 viewProjectDetails 保持一致)
  624. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  625. // 跳转到对应阶段,带上组长标识
  626. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  627. queryParams: { roleName: 'team-leader' }
  628. });
  629. }
  630. /**
  631. * 构建搜索索引(如果需要)
  632. */
  633. private buildSearchIndexes(): void {
  634. this.projects.forEach(p => {
  635. if (!p.searchIndex) {
  636. p.searchIndex = `${p.name}|${p.designerName}`.toLowerCase();
  637. }
  638. });
  639. }
  640. // 筛选状态改变(由子组件触发)
  641. // 重置状态筛选
  642. resetStatusFilter(): void {
  643. this.selectedStatus = 'all';
  644. this.applyFilters();
  645. }
  646. onFilterChange(filterState: any): void {
  647. // 更新本地状态
  648. this.searchTerm = filterState.searchTerm;
  649. this.selectedType = filterState.type;
  650. this.selectedUrgency = filterState.urgency;
  651. this.selectedStatus = filterState.status;
  652. this.selectedDesigner = filterState.designer;
  653. this.selectedMemberType = filterState.memberType;
  654. this.selectedCorePhase = filterState.corePhase;
  655. this.selectedProjectId = filterState.projectId;
  656. this.selectedTimeWindow = filterState.timeWindow;
  657. // 应用筛选
  658. this.applyFilters();
  659. }
  660. // 状态筛选(由指标卡片点击触发)
  661. filterByStatus(status: string): void {
  662. this.selectedStatus = status as any;
  663. this.applyFilters();
  664. }
  665. // 阶段映射(简化版,用于筛选)
  666. private mapStageToCorePhase(stageId: string): string {
  667. const orderStages = ['pendingApproval', 'pendingAssignment'];
  668. const requirementStages = ['requirement', 'planning'];
  669. const deliveryStages = ['modeling', 'rendering', 'postProduction', 'review', 'revision'];
  670. const aftercareStages = ['delivery'];
  671. if (orderStages.includes(stageId)) return 'order';
  672. if (requirementStages.includes(stageId)) return 'requirements';
  673. if (deliveryStages.includes(stageId)) return 'delivery';
  674. if (aftercareStages.includes(stageId)) return 'aftercare';
  675. return '';
  676. }
  677. // 统一筛选
  678. applyFilters(): void {
  679. let result = [...this.projects];
  680. // 新增:关键词搜索(项目名 / 设计师名 / 含风格关键词的项目名)
  681. const q = (this.searchTerm || '').trim().toLowerCase();
  682. if (q) {
  683. result = result.filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q));
  684. }
  685. // 类型筛选
  686. if (this.selectedType !== 'all') {
  687. result = result.filter(p => p.type === this.selectedType);
  688. }
  689. // 紧急程度筛选
  690. if (this.selectedUrgency !== 'all') {
  691. result = result.filter(p => p.urgency === this.selectedUrgency);
  692. }
  693. // 项目状态筛选
  694. if (this.selectedStatus !== 'all') {
  695. if (this.selectedStatus === 'overdue') {
  696. result = result.filter(p => p.isOverdue);
  697. } else if (this.selectedStatus === 'dueSoon') {
  698. result = result.filter(p => p.dueSoon && !p.isOverdue);
  699. } else if (this.selectedStatus === 'pendingApproval') {
  700. result = result.filter(p => p.currentStage === 'pendingApproval');
  701. } else if (this.selectedStatus === 'pendingAssignment') {
  702. result = result.filter(p => p.currentStage === 'pendingAssignment');
  703. } else if (this.selectedStatus === 'progress') {
  704. const progressStages = ['requirement','planning','modeling','rendering','postProduction','review','revision'];
  705. result = result.filter(p => progressStages.includes(p.currentStage));
  706. } else if (this.selectedStatus === 'completed') {
  707. result = result.filter(p => p.currentStage === 'delivery');
  708. }
  709. }
  710. // 新增:四大板块筛选
  711. if (this.selectedCorePhase !== 'all') {
  712. result = result.filter(p => this.mapStageToCorePhase(p.currentStage) === this.selectedCorePhase);
  713. }
  714. // 设计师筛选
  715. if (this.selectedDesigner !== 'all') {
  716. result = result.filter(p => p.designerName === this.selectedDesigner);
  717. }
  718. // 会员类型筛选
  719. if (this.selectedMemberType !== 'all') {
  720. result = result.filter(p => p.memberType === this.selectedMemberType);
  721. }
  722. // 新增:时间窗筛选
  723. if (this.selectedTimeWindow !== 'all') {
  724. const now = new Date();
  725. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  726. result = result.filter(p => {
  727. const projectDeadline = new Date(p.deadline);
  728. const timeDiff = projectDeadline.getTime() - today.getTime();
  729. const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
  730. switch (this.selectedTimeWindow) {
  731. case 'today':
  732. return daysDiff <= 1 && daysDiff >= 0;
  733. case 'threeDays':
  734. return daysDiff <= 3 && daysDiff >= 0;
  735. case 'sevenDays':
  736. return daysDiff <= 7 && daysDiff >= 0;
  737. default:
  738. return true;
  739. }
  740. });
  741. }
  742. this.filteredProjects = result;
  743. // 计算紧急任务固定区(超期 + 高紧急)
  744. this.urgentPinnedProjects = this.filteredProjects
  745. .filter(p => p.isOverdue && p.urgency === 'high')
  746. .sort((a, b) => (b.overdueDays - a.overdueDays) || a.name.localeCompare(b.name, 'zh-Hans-CN'));
  747. // 当显示甘特卡片时,同步刷新时间轴
  748. if (this.showGanttView) {
  749. this.convertToProjectTimeline();
  750. }
  751. }
  752. /**
  753. * 计算项目加权值
  754. */
  755. calculateWorkloadWeight(project: any): number {
  756. return this.designerService.calculateProjectWeight(project);
  757. }
  758. /**
  759. * 获取设计师加权工作量
  760. */
  761. getDesignerWeightedWorkload(designerName: string): {
  762. weightedTotal: number;
  763. projectCount: number;
  764. overdueCount: number;
  765. loadRate: number;
  766. } {
  767. const designerProjects = this.filteredProjects.filter(p => p.designerName === designerName);
  768. const weightedTotal = designerProjects.reduce((sum, p) => sum + this.calculateWorkloadWeight(p), 0);
  769. const overdueCount = designerProjects.filter(p => p.isOverdue).length;
  770. // 从realDesigners获取设计师的单周处理量
  771. const designer = this.realDesigners.find(d => d.name === designerName);
  772. const weeklyCapacity = designer?.tags?.capacity?.weeklyProjects || 3;
  773. const loadRate = weeklyCapacity > 0 ? (weightedTotal / weeklyCapacity) * 100 : 0;
  774. return {
  775. weightedTotal,
  776. projectCount: designerProjects.length,
  777. overdueCount,
  778. loadRate
  779. };
  780. }
  781. /**
  782. * 工作量卡片数据(替代ECharts)
  783. */
  784. get designerWorkloadCards(): Array<{
  785. name: string;
  786. loadRate: number;
  787. weightedValue: number;
  788. projectCount: number;
  789. overdueCount: number;
  790. status: 'overload' | 'busy' | 'idle';
  791. }> {
  792. const designers = Array.from(new Set(this.filteredProjects.map(p => p.designerName).filter(n => n && n !== '未分配')));
  793. return designers.map(name => {
  794. const workload = this.getDesignerWeightedWorkload(name);
  795. let status: 'overload' | 'busy' | 'idle' = 'idle';
  796. if (workload.loadRate > 80) status = 'overload';
  797. else if (workload.loadRate > 50) status = 'busy';
  798. return {
  799. name,
  800. loadRate: workload.loadRate,
  801. weightedValue: workload.weightedTotal,
  802. projectCount: workload.projectCount,
  803. overdueCount: workload.overdueCount,
  804. status
  805. };
  806. }).sort((a, b) => b.loadRate - a.loadRate); // 按负载率降序
  807. }
  808. /**
  809. * 获取超负荷设计师数量
  810. */
  811. get overloadedDesignersCount(): number {
  812. return this.designerWorkloadCards.filter(d => d.status === 'overload').length;
  813. }
  814. /**
  815. * 获取平均负载率
  816. */
  817. get averageWorkloadRate(): number {
  818. const cards = this.designerWorkloadCards;
  819. if (cards.length === 0) return 0;
  820. const sum = cards.reduce((acc, card) => acc + card.loadRate, 0);
  821. return sum / cards.length;
  822. }
  823. /**
  824. * 获取预警汇总数据
  825. */
  826. getAlertSummary(): {
  827. totalAlerts: number;
  828. overdueHighRisk: Project[];
  829. overloadedDesigners: any[];
  830. dueSoonProjects: Project[];
  831. } {
  832. // 1. 超期高危项目(超期>=5天 或 高紧急度超期)
  833. const overdueHighRisk = this.filteredProjects
  834. .filter(p => p.isOverdue && (p.overdueDays >= 5 || p.urgency === 'high'))
  835. .sort((a, b) => b.overdueDays - a.overdueDays)
  836. .slice(0, 5);
  837. // 2. 超负荷设计师
  838. const overloadedDesigners = this.designerWorkloadCards
  839. .filter(d => d.loadRate > 80)
  840. .sort((a, b) => b.loadRate - a.loadRate)
  841. .slice(0, 5);
  842. // 3. 即将到期项目(1-2天内)
  843. const now = new Date();
  844. const dueSoonProjects = this.filteredProjects
  845. .filter(p => {
  846. if (p.isOverdue) return false;
  847. const daysLeft = Math.ceil((p.deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  848. return daysLeft >= 1 && daysLeft <= 2;
  849. })
  850. .sort((a, b) => a.deadline.getTime() - b.deadline.getTime())
  851. .slice(0, 5);
  852. const totalAlerts = overdueHighRisk.length + overloadedDesigners.length + dueSoonProjects.length;
  853. return {
  854. totalAlerts,
  855. overdueHighRisk,
  856. overloadedDesigners,
  857. dueSoonProjects
  858. };
  859. }
  860. /**
  861. * 打开智能推荐弹窗
  862. */
  863. async openSmartMatch(project: any): Promise<void> {
  864. this.selectedProject = project;
  865. this.showSmartMatch = true;
  866. try {
  867. this.recommendations = await this.designerService.getRecommendedDesigners(project, this.realDesigners);
  868. } catch (error) {
  869. console.error('智能推荐失败:', error);
  870. this.recommendations = [];
  871. }
  872. }
  873. /**
  874. * 关闭智能推荐弹窗
  875. */
  876. closeSmartMatch(): void {
  877. this.showSmartMatch = false;
  878. this.selectedProject = null;
  879. this.recommendations = [];
  880. }
  881. /**
  882. * 分配项目给设计师
  883. */
  884. async assignToDesigner(designerId: string): Promise<void> {
  885. if (!this.selectedProject) return;
  886. try {
  887. const success = await this.designerService.assignProject(this.selectedProject.id, designerId);
  888. if (success) {
  889. this.closeSmartMatch();
  890. await this.loadProjects(); // 重新加载项目数据
  891. }
  892. } catch (error) {
  893. console.error('❌ 分配项目失败:', error);
  894. window?.fmode?.alert('分配失败,请重试');
  895. }
  896. }
  897. // 切换视图
  898. toggleView(): void {
  899. this.showGanttView = !this.showGanttView;
  900. if (this.showGanttView) {
  901. this.convertToProjectTimeline();
  902. }
  903. }
  904. ngOnDestroy(): void {
  905. // 清理待办任务自动刷新定时器
  906. if (this.todoTaskRefreshTimer) {
  907. clearInterval(this.todoTaskRefreshTimer);
  908. }
  909. }
  910. // 🔥 已延期项目
  911. get overdueProjects(): Project[] {
  912. return this.projects.filter(p => p.isOverdue);
  913. }
  914. // ⏳ 临期项目(3天内)
  915. get dueSoonProjects(): Project[] {
  916. return this.projects.filter(p => p.dueSoon && !p.isOverdue);
  917. }
  918. // 📋 待审批项目(支持中文和英文阶段名称)
  919. get pendingApprovalProjects(): Project[] {
  920. const pending = this.projects.filter(p => {
  921. const stage = (p.currentStage || '').trim();
  922. const data = (p as any).data || {};
  923. const approvalStatus = data.approvalStatus;
  924. // 1. 阶段为"订单分配"且审批状态为 pending
  925. // 2. 或者阶段为"待确认"/"待审批"(兼容旧数据)
  926. return (stage === '订单分配' && approvalStatus === 'pending') ||
  927. stage === '待审批' ||
  928. stage === '待确认';
  929. });
  930. return pending;
  931. }
  932. // 检查项目是否待审批
  933. isPendingApproval(project: Project): boolean {
  934. const stage = (project.currentStage || '').trim();
  935. const stageEn = stage.toLowerCase();
  936. const data: any = (project as any).data || {};
  937. // 🔥 新增:检查顶层的 pendingApproval 字段(备用方案)
  938. const topLevelPending = (project as any).pendingApproval === true && (project as any).approvalStage === '订单分配';
  939. return (stage === '订单分配' && (data.approvalStatus === 'pending' || topLevelPending)) ||
  940. ((stage === '交付执行' || stageEn === 'delivery') &&
  941. (data.deliveryApproval?.status === 'pending' ||
  942. (Array.isArray(data.pendingDeliveryApprovals) && data.pendingDeliveryApprovals.some((x: any) => x?.status === 'pending'))));
  943. }
  944. // 🎯 待分配项目(支持中文和英文阶段名称)
  945. get pendingAssignmentProjects(): Project[] {
  946. return this.projects.filter(p => {
  947. const stage = (p.currentStage || '').trim().toLowerCase();
  948. return stage === 'pendingassignment' ||
  949. stage === '待分配' ||
  950. stage === '订单分配';
  951. });
  952. }
  953. // 智能推荐设计师
  954. private getRecommendedDesigner(projectType: 'soft' | 'hard') {
  955. if (!this.designerProfiles || !this.designerProfiles.length) return null;
  956. const scoreOf = (p: any) => {
  957. const workloadScore = 100 - (p.workload ?? 0); // 负载越低越好
  958. const ratingScore = (p.avgRating ?? 0) * 10; // 评分越高越好
  959. const expScore = (p.experience ?? 0) * 5; // 经验越高越好
  960. return workloadScore * 0.5 + ratingScore * 0.3 + expScore * 0.2;
  961. };
  962. const sorted = [...this.designerProfiles].sort((a, b) => scoreOf(b) - scoreOf(a));
  963. return sorted[0] || null;
  964. }
  965. // 项目质量评审(由子组件触发)
  966. reviewProjectQuality(data: {projectId: string, rating: 'excellent' | 'qualified' | 'unqualified'}): void {
  967. const project = this.projects.find(p => p.id === data.projectId);
  968. if (!project) return;
  969. project.qualityRating = data.rating;
  970. if (data.rating === 'unqualified') {
  971. project.currentStage = 'revision';
  972. }
  973. this.applyFilters();
  974. window?.fmode?.alert('质量评审已提交');
  975. }
  976. /**
  977. * 根据看板列跳转到项目详情(参考客服板块实现)
  978. * @param projectId 项目ID
  979. * @param corePhaseId 核心阶段ID (order/requirements/delivery/aftercare)
  980. */
  981. viewProjectDetailsByPhase(projectId: string, corePhaseId: string): void {
  982. if (!projectId) {
  983. return;
  984. }
  985. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  986. try {
  987. localStorage.setItem('enterAsTeamLeader', '1');
  988. localStorage.setItem('teamLeaderMode', 'true');
  989. // 🔥 关键:清除客服端标记,避免冲突
  990. localStorage.removeItem('enterFromCustomerService');
  991. localStorage.removeItem('customerServiceMode');
  992. console.log('✅ 已标记从组长看板进入,启用组长模式');
  993. } catch (e) {
  994. console.warn('无法设置 localStorage 标记:', e);
  995. }
  996. // 获取公司ID
  997. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  998. // 🔥 根据看板列ID直接映射到路由路径(与客服板块保持一致)
  999. // corePhaseId已经是路由路径格式:order, requirements, delivery, aftercare
  1000. const stagePath = corePhaseId;
  1001. console.log(`🎯 从看板列「${this.corePhases.find(c => c.id === corePhaseId)?.name}」进入项目,跳转到: ${stagePath}`);
  1002. // ✅ 跳转到对应阶段,并通过查询参数标识为组长视角
  1003. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  1004. queryParams: { roleName: 'team-leader' }
  1005. });
  1006. }
  1007. // 查看项目详情 - 跳转到纯净的项目详情页(无管理端UI)
  1008. viewProjectDetails(projectId: string): void {
  1009. if (!projectId) {
  1010. return;
  1011. }
  1012. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  1013. try {
  1014. localStorage.setItem('enterAsTeamLeader', '1');
  1015. localStorage.setItem('teamLeaderMode', 'true');
  1016. // 🔥 关键:清除客服端标记,避免冲突
  1017. localStorage.removeItem('enterFromCustomerService');
  1018. localStorage.removeItem('customerServiceMode');
  1019. console.log('✅ 已标记从组长看板进入,启用组长模式');
  1020. } catch (e) {
  1021. console.warn('无法设置 localStorage 标记:', e);
  1022. }
  1023. // 获取公司ID
  1024. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  1025. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  1026. const project = this.projects.find(p => p.id === projectId);
  1027. const currentStage = project?.currentStage || '订单分配';
  1028. // 阶段映射:项目阶段 → 路由路径
  1029. const stageRouteMap: Record<string, string> = {
  1030. '订单分配': 'order',
  1031. '确认需求': 'requirements',
  1032. '方案深化': 'requirements',
  1033. '建模': 'requirements',
  1034. '软装': 'requirements',
  1035. '渲染': 'requirements',
  1036. '后期': 'requirements',
  1037. '交付执行': 'delivery',
  1038. '交付': 'delivery',
  1039. '售后归档': 'aftercare',
  1040. '已完成': 'aftercare'
  1041. };
  1042. const stagePath = stageRouteMap[currentStage] || 'order';
  1043. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  1044. // ✅ 跳转到对应阶段,并通过查询参数标识为组长视角
  1045. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  1046. queryParams: { roleName: 'team-leader' }
  1047. });
  1048. }
  1049. // 快速分配项目(增强:加入智能推荐)
  1050. async quickAssignProject(projectId: string): Promise<void> {
  1051. const project = this.projects.find(p => p.id === projectId);
  1052. if (!project) {
  1053. window?.fmode?.alert('未找到对应项目');
  1054. return;
  1055. }
  1056. const recommended = this.getRecommendedDesigner(project.type);
  1057. if (recommended) {
  1058. const reassigning = !!project.designerName;
  1059. const message = `推荐设计师:${recommended.name}(工作负载:${recommended.workload}%,评分:${recommended.avgRating}分)` +
  1060. (reassigning ? `\n\n该项目当前已由「${project.designerName}」负责,是否改为分配给「${recommended.name}」?` : '\n\n是否确认分配?');
  1061. const confirmAssign = await window?.fmode?.confirm(message);
  1062. if (confirmAssign) {
  1063. project.designerName = recommended.name;
  1064. if (project.currentStage === 'pendingAssignment' || project.currentStage === 'pendingApproval') {
  1065. project.currentStage = 'requirement';
  1066. }
  1067. project.status = '进行中';
  1068. // 更新设计师筛选列表
  1069. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  1070. this.applyFilters();
  1071. window?.fmode?.alert(`项目已${reassigning ? '重新' : ''}分配给 ${recommended.name}`);
  1072. return;
  1073. }
  1074. }
  1075. // 无推荐或用户取消,跳转到详细分配页面
  1076. // 跳转到项目详情页
  1077. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  1078. // 动态跳转到项目当前阶段,并标记组长身份
  1079. const current = this.projects.find(p => p.id === projectId)?.currentStage || '订单分配';
  1080. const stageRouteMap: Record<string, string> = {
  1081. '订单分配': 'order',
  1082. '确认需求': 'requirements',
  1083. '方案深化': 'requirements',
  1084. '建模': 'requirements',
  1085. '软装': 'requirements',
  1086. '渲染': 'requirements',
  1087. '后期': 'requirements',
  1088. '交付执行': 'delivery',
  1089. '交付': 'delivery',
  1090. '售后归档': 'aftercare',
  1091. '已完成': 'aftercare'
  1092. };
  1093. const stagePath = stageRouteMap[current] || 'order';
  1094. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  1095. queryParams: { roleName: 'team-leader' }
  1096. });
  1097. }
  1098. // 预警相关操作(由子组件触发)
  1099. viewAllOverdueProjects(): void {
  1100. this.filterByStatus('overdue');
  1101. this.showAlert = false;
  1102. }
  1103. closeAlert(): void {
  1104. this.showAlert = false;
  1105. }
  1106. // 处理甘特图员工点击事件
  1107. async onEmployeeClick(employeeName: string): Promise<void> {
  1108. if (!employeeName || employeeName === '未分配') {
  1109. return;
  1110. }
  1111. this.selectedEmployeeName = employeeName;
  1112. this.selectedEmployeeProjects = this.designerWorkloadMap.get(employeeName) || [];
  1113. this.showEmployeeDetailPanel = true;
  1114. // 🔥 修复:手动触发变更检测,确保弹窗立即显示
  1115. this.cdr.markForCheck();
  1116. }
  1117. // 关闭员工详情面板
  1118. closeEmployeeDetailPanel(): void {
  1119. this.showEmployeeDetailPanel = false;
  1120. this.selectedEmployeeName = '';
  1121. this.selectedEmployeeProjects = [];
  1122. this.selectedEmployeeDetail = null;
  1123. }
  1124. // 员工详情面板:日历月份切换
  1125. changeEmployeeCalendarMonth(direction: number): void {
  1126. // 由 EmployeeDetailPanelComponent 内部处理
  1127. console.log('日历月份切换:', direction);
  1128. }
  1129. // 员工详情面板:日历日期点击
  1130. onCalendarDayClick(day: any): void {
  1131. // 由 EmployeeDetailPanelComponent 内部处理
  1132. console.log('日历日期点击:', day);
  1133. }
  1134. // 员工详情面板:刷新问卷
  1135. refreshEmployeeSurvey(): void {
  1136. // 由 EmployeeDetailPanelComponent 内部处理
  1137. console.log('刷新员工问卷');
  1138. }
  1139. // 从员工详情面板跳转到项目详情
  1140. navigateToProjectFromPanel(projectId: string): void {
  1141. if (!projectId) {
  1142. return;
  1143. }
  1144. // 关闭员工详情面板
  1145. this.closeEmployeeDetailPanel();
  1146. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  1147. try {
  1148. localStorage.setItem('enterAsTeamLeader', '1');
  1149. localStorage.setItem('teamLeaderMode', 'true');
  1150. // 🔥 关键:清除客服端标记,避免冲突
  1151. localStorage.removeItem('enterFromCustomerService');
  1152. localStorage.removeItem('customerServiceMode');
  1153. console.log('✅ 已标记从组长看板进入,启用组长模式');
  1154. } catch (e) {
  1155. console.warn('无法设置 localStorage 标记:', e);
  1156. }
  1157. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  1158. const project = this.projects.find(p => p.id === projectId);
  1159. const currentStage = project?.currentStage || '订单分配';
  1160. // 阶段映射:项目阶段 → 路由路径
  1161. const stageRouteMap: Record<string, string> = {
  1162. '订单分配': 'order',
  1163. '确认需求': 'requirements',
  1164. '方案深化': 'requirements',
  1165. '建模': 'requirements',
  1166. '软装': 'requirements',
  1167. '渲染': 'requirements',
  1168. '后期': 'requirements',
  1169. '交付执行': 'delivery',
  1170. '交付': 'delivery',
  1171. '售后归档': 'aftercare',
  1172. '已完成': 'aftercare'
  1173. };
  1174. const stagePath = stageRouteMap[currentStage] || 'order';
  1175. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  1176. // 跳转到对应阶段,通过查询参数标识为组长视角
  1177. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  1178. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  1179. queryParams: { roleName: 'team-leader' }
  1180. });
  1181. }
  1182. /**
  1183. * 加载用户Profile信息
  1184. */
  1185. async loadUserProfile(): Promise<void> {
  1186. try {
  1187. const cid = localStorage.getItem("company");
  1188. if (!cid) {
  1189. console.warn('未找到公司ID,使用默认用户信息');
  1190. return;
  1191. }
  1192. const wwAuth = new WxworkAuth({ cid });
  1193. const profile = await wwAuth.currentProfile();
  1194. if (profile) {
  1195. const name = profile.get("name") || profile.get("mobile") || '组长';
  1196. const avatar = profile.get("avatar");
  1197. const roleName = profile.get("roleName") || '组长';
  1198. this.currentUser = {
  1199. name,
  1200. avatar: avatar || this.generateDefaultAvatar(name),
  1201. roleName
  1202. };
  1203. console.log('用户Profile加载成功:', this.currentUser);
  1204. }
  1205. } catch (error) {
  1206. console.error('加载用户Profile失败:', error);
  1207. // 保持默认值
  1208. }
  1209. }
  1210. /**
  1211. * 生成默认头像(SVG格式)
  1212. * @param name 用户名
  1213. * @returns Base64编码的SVG数据URL
  1214. */
  1215. generateDefaultAvatar(name: string): string {
  1216. const initial = name ? name.substring(0, 2).toUpperCase() : '组长';
  1217. const bgColor = '#CCFFCC';
  1218. const textColor = '#555555';
  1219. const svg = `<svg width='40' height='40' xmlns='http://www.w3.org/2000/svg'>
  1220. <rect width='100%' height='100%' fill='${bgColor}'/>
  1221. <text x='50%' y='50%' font-family='Arial' font-size='13.333333333333334' font-weight='bold' text-anchor='middle' fill='${textColor}' dy='0.3em'>${initial}</text>
  1222. </svg>`;
  1223. return `data:image/svg+xml,${encodeURIComponent(svg)}`;
  1224. }
  1225. // ==================== 新增:待办任务相关方法 ====================
  1226. /**
  1227. * 从问题板块加载待办任务
  1228. */
  1229. async loadTodoTasksFromIssues(): Promise<void> {
  1230. this.loadingTodoTasks = true;
  1231. this.todoTaskError = '';
  1232. try {
  1233. const Parse: any = FmodeParse.with('nova');
  1234. const query = new Parse.Query('ProjectIssue');
  1235. // 筛选条件:待处理 + 处理中
  1236. query.containedIn('status', ['待处理', '处理中']);
  1237. query.notEqualTo('isDeleted', true);
  1238. // 关联数据
  1239. query.include(['project', 'creator', 'assignee']);
  1240. // 排序:更新时间倒序
  1241. query.descending('updatedAt');
  1242. // 限制数量
  1243. query.limit(50);
  1244. const results = await query.find();
  1245. console.log(`📥 查询到 ${results.length} 条问题记录`);
  1246. // 数据转换(异步处理以支持 fetch)
  1247. const tasks = await Promise.all(results.map(async (obj: any) => {
  1248. let project = obj.get('project');
  1249. const assignee = obj.get('assignee');
  1250. const creator = obj.get('creator');
  1251. const data = obj.get('data') || {};
  1252. let projectName = '未知项目';
  1253. let projectId = '';
  1254. // 如果 project 存在,尝试获取完整数据
  1255. if (project) {
  1256. projectId = project.id;
  1257. // 尝试从已加载的对象获取 name
  1258. projectName = project.get('name');
  1259. // 如果 name 为空,使用 Parse.Query 查询项目
  1260. if (!projectName && projectId) {
  1261. try {
  1262. console.log(`🔄 查询项目数据: ${projectId}`);
  1263. const projectQuery = new Parse.Query('Project');
  1264. const fetchedProject = await projectQuery.get(projectId);
  1265. projectName = fetchedProject.get('name') || fetchedProject.get('title') || '未知项目';
  1266. console.log(`✅ 项目名称: ${projectName}`);
  1267. } catch (error) {
  1268. console.warn(`⚠️ 无法加载项目 ${projectId}:`, error);
  1269. projectName = `项目-${projectId.slice(0, 6)}`;
  1270. }
  1271. }
  1272. } else {
  1273. console.warn('⚠️ 问题缺少关联项目:', {
  1274. issueId: obj.id,
  1275. title: obj.get('title')
  1276. });
  1277. }
  1278. return {
  1279. id: obj.id,
  1280. title: obj.get('title') || obj.get('description')?.slice(0, 40) || '未命名问题',
  1281. description: obj.get('description'),
  1282. priority: obj.get('priority') as IssuePriority || 'medium',
  1283. type: obj.get('issueType') as IssueType || 'task',
  1284. status: this.zh2enStatus(obj.get('status')) as IssueStatus,
  1285. projectId,
  1286. projectName,
  1287. relatedSpace: obj.get('relatedSpace') || data.relatedSpace,
  1288. relatedStage: obj.get('relatedStage') || data.relatedStage,
  1289. assigneeName: assignee?.get('name') || assignee?.get('realname') || '未指派',
  1290. creatorName: creator?.get('name') || creator?.get('realname') || '未知',
  1291. createdAt: obj.createdAt || new Date(),
  1292. updatedAt: obj.updatedAt || new Date(),
  1293. dueDate: obj.get('dueDate'),
  1294. tags: (data.tags || []) as string[]
  1295. };
  1296. }));
  1297. this.todoTasksFromIssues = tasks;
  1298. // 排序:优先级 -> 时间
  1299. this.todoTasksFromIssues.sort((a, b) => {
  1300. const priorityA = this.getPriorityOrder(a.priority);
  1301. const priorityB = this.getPriorityOrder(b.priority);
  1302. if (priorityA !== priorityB) {
  1303. return priorityA - priorityB;
  1304. }
  1305. return +new Date(b.updatedAt) - +new Date(a.updatedAt);
  1306. });
  1307. console.log(`✅ 加载待办任务成功,共 ${this.todoTasksFromIssues.length} 条`);
  1308. } catch (error) {
  1309. console.error('❌ 加载待办任务失败:', error);
  1310. this.todoTaskError = '加载失败,请稍后重试';
  1311. } finally {
  1312. this.loadingTodoTasks = false;
  1313. }
  1314. }
  1315. /**
  1316. * 启动自动刷新(每5分钟)
  1317. */
  1318. startAutoRefresh(): void {
  1319. this.todoTaskRefreshTimer = setInterval(() => {
  1320. console.log('🔄 自动刷新待办任务...');
  1321. this.loadTodoTasksFromIssues();
  1322. }, 5 * 60 * 1000); // 5分钟
  1323. }
  1324. /**
  1325. * 手动刷新待办任务
  1326. */
  1327. refreshTodoTasks(): void {
  1328. console.log('🔄 手动刷新待办任务...');
  1329. this.loadTodoTasksFromIssues();
  1330. this.calculateUrgentEvents(); // 🆕 同时刷新紧急事件
  1331. }
  1332. /**
  1333. * 🆕 从项目时间轴数据计算紧急事件
  1334. * 识别截止时间已到或即将到达但未完成的关键节点
  1335. */
  1336. calculateUrgentEvents(): void {
  1337. this.loadingUrgentEvents = true;
  1338. const events: UrgentEvent[] = [];
  1339. const now = new Date();
  1340. const oneDayMs = 24 * 60 * 60 * 1000;
  1341. const handledSet = this.handledUrgentEventIds;
  1342. const mutedSet = this.mutedUrgentEventIds;
  1343. const projectEventCount = new Map<string, number>(); // 追踪每个项目生成的事件数
  1344. const MAX_EVENTS_PER_PROJECT = 2; // 每个项目最多生成2个最紧急的事件
  1345. const resolveCategory = (
  1346. eventType: UrgentEvent['eventType'],
  1347. category?: 'customer' | 'phase' | 'review' | 'delivery'
  1348. ): 'customer' | 'phase' | 'review' | 'delivery' => {
  1349. if (category) return category;
  1350. switch (eventType) {
  1351. case 'phase_deadline':
  1352. return 'phase';
  1353. case 'delivery':
  1354. return 'delivery';
  1355. case 'customer_alert':
  1356. return 'customer';
  1357. default:
  1358. return 'review';
  1359. }
  1360. };
  1361. // 🆕 辅助方法:获取逾期原因
  1362. const getOverdueReason = (daysDiff: number, stalledDays: number = 0) => {
  1363. if (daysDiff >= 0) return ''; // 未逾期
  1364. // 如果项目超过3天未更新/无反馈,推测为客户原因
  1365. if (stalledDays >= 3) {
  1366. return '原因:客户未跟进反馈导致逾期';
  1367. }
  1368. // 否则推测为设计师进度原因
  1369. return '原因:设计师进度原因导致逾期';
  1370. };
  1371. const addEvent = (
  1372. partial: Omit<UrgentEvent, 'category' | 'statusType' | 'labels' | 'allowConfirmOnTime' | 'allowMarkHandled' | 'allowCreateTodo' | 'followUpNeeded'> &
  1373. Partial<UrgentEvent>
  1374. ) => {
  1375. // 检查该项目是否已达到事件数量上限
  1376. const currentCount = projectEventCount.get(partial.projectId) || 0;
  1377. if (currentCount >= MAX_EVENTS_PER_PROJECT) {
  1378. return; // 跳过,避免单个项目产生过多事件
  1379. }
  1380. const category = resolveCategory(partial.eventType, partial.category);
  1381. const statusType: UrgentEvent['statusType'] =
  1382. partial.statusType || (partial.overdueDays && partial.overdueDays > 0 ? 'overdue' : 'dueSoon');
  1383. // 简化描述,避免过长字符串
  1384. const description = partial.description?.substring(0, 100) || '';
  1385. const event: UrgentEvent = {
  1386. ...partial,
  1387. description,
  1388. category,
  1389. statusType,
  1390. labels: partial.labels ?? [],
  1391. followUpNeeded: partial.followUpNeeded ?? false,
  1392. allowConfirmOnTime:
  1393. partial.allowConfirmOnTime ?? (category !== 'customer' && statusType === 'dueSoon'),
  1394. allowMarkHandled: partial.allowMarkHandled ?? true,
  1395. allowCreateTodo: partial.allowCreateTodo ?? category === 'customer'
  1396. };
  1397. events.push(event);
  1398. projectEventCount.set(partial.projectId, currentCount + 1);
  1399. };
  1400. try {
  1401. // 从 projectTimelineData 中提取数据
  1402. this.projectTimelineData.forEach(project => {
  1403. // 1. 检查小图对图事件
  1404. if (project.reviewDate) {
  1405. const reviewTime = project.reviewDate.getTime();
  1406. const timeDiff = reviewTime - now.getTime();
  1407. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  1408. // 如果小图对图已经到期或即将到期(1天内),且不在已完成状态
  1409. if (daysDiff <= 1 && project.currentStage !== 'delivery') {
  1410. const reason = getOverdueReason(daysDiff, project.stalledDays);
  1411. const descSuffix = reason ? `,${reason}` : '';
  1412. addEvent({
  1413. id: `${project.projectId}-review`,
  1414. title: `小图对图截止`,
  1415. description: `项目「${project.projectName}」的小图对图时间已${daysDiff < 0 ? '逾期' : '临近'}${descSuffix}`,
  1416. eventType: 'review',
  1417. deadline: project.reviewDate,
  1418. projectId: project.projectId,
  1419. projectName: project.projectName,
  1420. designerName: project.designerName,
  1421. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  1422. overdueDays: -daysDiff,
  1423. labels: daysDiff < 0 ? ['逾期'] : ['临近'],
  1424. followUpNeeded: (project.stageName || '').includes('图') || project.status === 'warning'
  1425. });
  1426. }
  1427. }
  1428. // 2. 检查交付事件
  1429. if (project.deliveryDate) {
  1430. const deliveryTime = project.deliveryDate.getTime();
  1431. const timeDiff = deliveryTime - now.getTime();
  1432. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  1433. // 如果交付已经到期或即将到期(1天内),且不在已完成状态
  1434. if (daysDiff <= 1 && project.currentStage !== 'delivery') {
  1435. const summary = project.spaceDeliverableSummary;
  1436. const completionRate = summary?.overallCompletionRate || 0;
  1437. const reason = getOverdueReason(daysDiff, project.stalledDays);
  1438. const descSuffix = reason ? `,${reason}` : '';
  1439. addEvent({
  1440. id: `${project.projectId}-delivery`,
  1441. title: `项目交付截止`,
  1442. description: `项目「${project.projectName}」需要在 ${project.deliveryDate.toLocaleDateString()} 交付(当前完成率 ${completionRate}%)${descSuffix}`,
  1443. eventType: 'delivery',
  1444. deadline: project.deliveryDate,
  1445. projectId: project.projectId,
  1446. projectName: project.projectName,
  1447. designerName: project.designerName,
  1448. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  1449. overdueDays: -daysDiff,
  1450. completionRate,
  1451. labels: daysDiff < 0 ? ['逾期'] : ['临近']
  1452. });
  1453. }
  1454. }
  1455. // 3. 检查各阶段截止时间
  1456. if (project.phaseDeadlines) {
  1457. const phaseMap = {
  1458. modeling: '建模',
  1459. softDecor: '软装',
  1460. rendering: '渲染',
  1461. postProcessing: '后期'
  1462. };
  1463. Object.entries(project.phaseDeadlines).forEach(([key, phaseInfo]: [string, any]) => {
  1464. if (phaseInfo && phaseInfo.deadline) {
  1465. const deadline = new Date(phaseInfo.deadline);
  1466. const phaseTime = deadline.getTime();
  1467. const timeDiff = phaseTime - now.getTime();
  1468. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  1469. // 如果阶段已经到期或即将到期(1天内),且状态不是已完成
  1470. if (daysDiff <= 1 && phaseInfo.status !== 'completed') {
  1471. const phaseName = phaseMap[key as keyof typeof phaseMap] || key;
  1472. // 获取该阶段的完成率
  1473. const summary = project.spaceDeliverableSummary;
  1474. let completionRate = 0;
  1475. if (summary && summary.phaseProgress) {
  1476. const phaseProgress = summary.phaseProgress[key as keyof typeof summary.phaseProgress];
  1477. completionRate = phaseProgress?.completionRate || 0;
  1478. }
  1479. const reason = getOverdueReason(daysDiff, project.stalledDays);
  1480. const descSuffix = reason ? `,${reason}` : '';
  1481. addEvent({
  1482. id: `${project.projectId}-phase-${key}`,
  1483. title: `${phaseName}阶段截止`,
  1484. description: `项目「${project.projectName}」的${phaseName}阶段截止时间已${daysDiff < 0 ? '逾期' : '临近'}(完成率 ${completionRate}%)${descSuffix}`,
  1485. eventType: 'phase_deadline',
  1486. phaseName,
  1487. deadline,
  1488. projectId: project.projectId,
  1489. projectName: project.projectName,
  1490. designerName: project.designerName,
  1491. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  1492. overdueDays: -daysDiff,
  1493. completionRate,
  1494. labels: daysDiff < 0 ? ['逾期'] : ['临近']
  1495. });
  1496. }
  1497. }
  1498. });
  1499. }
  1500. if (project.stalledDays && project.stalledDays >= 7) {
  1501. addEvent({
  1502. id: `${project.projectId}-stagnant`,
  1503. title: project.stalledDays >= 14 ? '客户停滞预警' : '停滞期提醒',
  1504. description: `项目「${project.projectName}」已有 ${project.stalledDays} 天未收到客户反馈,请主动跟进。`,
  1505. eventType: 'customer_alert',
  1506. deadline: new Date(),
  1507. projectId: project.projectId,
  1508. projectName: project.projectName,
  1509. designerName: project.designerName,
  1510. urgencyLevel: project.stalledDays >= 14 ? 'critical' : 'high',
  1511. statusType: 'stagnant',
  1512. stagnationDays: project.stalledDays,
  1513. labels: ['停滞期'],
  1514. followUpNeeded: true,
  1515. allowCreateTodo: true,
  1516. allowConfirmOnTime: false,
  1517. category: 'customer'
  1518. });
  1519. }
  1520. const inReviewStage = (project.stageName || '').includes('图') || (project.currentStage || '').includes('图');
  1521. if (inReviewStage && project.status === 'warning') {
  1522. addEvent({
  1523. id: `${project.projectId}-review-followup`,
  1524. title: '对图反馈待跟进',
  1525. description: `项目「${project.projectName}」客户反馈尚未处理,请尽快跟进。`,
  1526. eventType: 'customer_alert',
  1527. deadline: project.reviewDate || new Date(),
  1528. projectId: project.projectId,
  1529. projectName: project.projectName,
  1530. designerName: project.designerName,
  1531. urgencyLevel: 'high',
  1532. statusType: project.reviewDate && project.reviewDate < now ? 'overdue' : 'dueSoon',
  1533. labels: ['对图期'],
  1534. followUpNeeded: true,
  1535. allowCreateTodo: true,
  1536. customerIssueType: 'feedback_pending',
  1537. category: 'customer'
  1538. });
  1539. }
  1540. if (project.priority === 'critical') {
  1541. addEvent({
  1542. id: `${project.projectId}-customer-alert`,
  1543. title: '客户服务预警',
  1544. description: `项目「${project.projectName}」存在客户不满或抱怨,需要立即处理并记录。`,
  1545. eventType: 'customer_alert',
  1546. deadline: new Date(),
  1547. projectId: project.projectId,
  1548. projectName: project.projectName,
  1549. designerName: project.designerName,
  1550. urgencyLevel: 'critical',
  1551. statusType: 'dueSoon',
  1552. labels: ['客户预警'],
  1553. followUpNeeded: true,
  1554. allowCreateTodo: true,
  1555. customerIssueType: 'complaint',
  1556. category: 'customer'
  1557. });
  1558. }
  1559. });
  1560. // 按紧急程度和时间排序
  1561. events.sort((a, b) => {
  1562. // 首先按紧急程度排序
  1563. const urgencyOrder = { critical: 0, high: 1, medium: 2 };
  1564. const urgencyDiff = urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
  1565. if (urgencyDiff !== 0) return urgencyDiff;
  1566. // 相同紧急程度,按截止时间排序(越早越靠前)
  1567. return a.deadline.getTime() - b.deadline.getTime();
  1568. });
  1569. // 过滤已处理和静音的事件
  1570. const filteredEvents = events.filter(event => !handledSet.has(event.id) && !mutedSet.has(event.id));
  1571. // 🔥 限制最大显示数量,避免渲染过多 DOM 导致卡顿和 RangeError
  1572. const MAX_URGENT_EVENTS = 50;
  1573. this.urgentEvents = filteredEvents.slice(0, MAX_URGENT_EVENTS);
  1574. if (filteredEvents.length > MAX_URGENT_EVENTS) {
  1575. console.warn(`⚠️ 紧急事件过多(${filteredEvents.length}个),已限制为前 ${MAX_URGENT_EVENTS} 个最紧急的事件`);
  1576. }
  1577. console.log(`✅ 计算紧急事件完成,共 ${this.urgentEvents.length} 个紧急事件(原始${events.length}个)`);
  1578. } catch (error) {
  1579. console.error('❌ 计算紧急事件失败:', error);
  1580. // 发生错误时清空列表,避免渲染异常数据
  1581. this.urgentEvents = [];
  1582. } finally {
  1583. this.loadingUrgentEvents = false;
  1584. // 确保触发变更检测
  1585. this.cdr.markForCheck();
  1586. }
  1587. }
  1588. // 紧急事件处理方法(由子组件触发)
  1589. confirmEventOnTime(event: UrgentEvent): void {
  1590. this.mutedUrgentEventIds.add(event.id);
  1591. this.urgentEvents = this.urgentEvents.filter(e => e.id !== event.id);
  1592. this.cdr.markForCheck();
  1593. }
  1594. resolveUrgentEvent(event: UrgentEvent): void {
  1595. this.handledUrgentEventIds.add(event.id);
  1596. this.urgentEvents = this.urgentEvents.filter(e => e.id !== event.id);
  1597. this.cdr.markForCheck();
  1598. }
  1599. markEventAsStagnant(event: UrgentEvent): void {
  1600. this.urgentEvents = this.urgentEvents.map(item => {
  1601. if (item.id !== event.id) return item;
  1602. const labels = new Set(item.labels || []);
  1603. labels.add('停滞期');
  1604. return {
  1605. ...item,
  1606. category: 'customer' as const,
  1607. statusType: 'stagnant' as const,
  1608. stagnationDays: item.stagnationDays || 7,
  1609. labels: Array.from(labels),
  1610. followUpNeeded: true
  1611. };
  1612. });
  1613. this.cdr.markForCheck();
  1614. }
  1615. createTodoFromEvent(event: UrgentEvent): void {
  1616. const now = new Date();
  1617. const newTask: TodoTaskFromIssue = {
  1618. id: `urgent-todo-${event.id}-${now.getTime()}`,
  1619. title: `【紧急】${event.title}`,
  1620. description: event.description,
  1621. priority: event.urgencyLevel === 'critical' ? 'urgent' : event.urgencyLevel === 'high' ? 'high' : 'medium',
  1622. type: 'feedback',
  1623. status: 'open',
  1624. projectId: event.projectId,
  1625. projectName: event.projectName,
  1626. relatedStage: event.phaseName,
  1627. assigneeName: event.designerName || '待分配',
  1628. creatorName: this.currentUser.name,
  1629. createdAt: now,
  1630. updatedAt: now,
  1631. dueDate: event.deadline,
  1632. tags: [...(event.labels || []), '来自紧急事件']
  1633. };
  1634. this.todoTasksFromIssues = [newTask, ...this.todoTasksFromIssues];
  1635. this.resolveUrgentEvent(event);
  1636. }
  1637. // 待办任务操作(由子组件触发)
  1638. navigateToIssue(task: TodoTaskFromIssue): void {
  1639. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  1640. this.router.navigate(
  1641. ['/wxwork', cid, 'project', task.projectId, 'order'],
  1642. {
  1643. queryParams: {
  1644. openIssues: 'true',
  1645. highlightIssue: task.id,
  1646. roleName: 'team-leader'
  1647. }
  1648. }
  1649. );
  1650. }
  1651. markAsRead(task: TodoTaskFromIssue): void {
  1652. this.todoTasksFromIssues = this.todoTasksFromIssues.filter(t => t.id !== task.id);
  1653. console.log(`✅ 标记问题为已读: ${task.title}`);
  1654. }
  1655. // 状态映射辅助方法
  1656. private zh2enStatus(status: string): IssueStatus {
  1657. const map: Record<string, IssueStatus> = {
  1658. '待处理': 'open',
  1659. '处理中': 'in_progress',
  1660. '已解决': 'resolved',
  1661. '已关闭': 'closed'
  1662. };
  1663. return map[status] || 'open';
  1664. }
  1665. private getPriorityOrder(priority: IssuePriority): number {
  1666. const config: Record<IssuePriority, number> = {
  1667. urgent: 0,
  1668. critical: 0,
  1669. high: 1,
  1670. medium: 2,
  1671. low: 3
  1672. };
  1673. return config[priority] || 2;
  1674. }
  1675. }