dashboard.ts 154 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201
  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. 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 type { ProjectTimeline } from '../project-timeline/project-timeline';
  12. import { EmployeeDetailPanelComponent } from '../employee-detail-panel';
  13. import { normalizeDateInput, addDays } from '../../../utils/date-utils';
  14. import { generatePhaseDeadlines } from '../../../utils/phase-deadline.utils';
  15. import { PhaseDeadlines, PhaseName } from '../../../models/project-phase.model';
  16. // 项目阶段定义
  17. interface ProjectStage {
  18. id: string;
  19. name: string;
  20. order: number;
  21. }
  22. interface ProjectPhase {
  23. name: string;
  24. percentage: number;
  25. startPercentage: number;
  26. isCompleted: boolean;
  27. isCurrent: boolean;
  28. }
  29. interface Project {
  30. id: string;
  31. name: string;
  32. type: 'soft' | 'hard';
  33. memberType: 'vip' | 'normal';
  34. designerName: string;
  35. status: string;
  36. expectedEndDate: Date; // TODO: 兼容旧字段,后续可移除
  37. deadline: Date; // 真实截止时间字段
  38. createdAt?: Date; // 真实开始时间字段(可选)
  39. isOverdue: boolean;
  40. overdueDays: number;
  41. dueSoon: boolean;
  42. urgency: 'high' | 'medium' | 'low';
  43. phases: ProjectPhase[];
  44. currentStage: string; // 新增:当前项目阶段
  45. // 新增:质量评级
  46. qualityRating?: 'excellent' | 'qualified' | 'unqualified' | 'pending';
  47. lastCustomerFeedback?: string;
  48. // 预构建的搜索索引,减少重复 toLowerCase 与拼接
  49. searchIndex?: string;
  50. }
  51. interface TodoTask {
  52. id: string;
  53. title: string;
  54. description: string;
  55. deadline: Date;
  56. priority: 'high' | 'medium' | 'low';
  57. type: 'review' | 'assign' | 'performance';
  58. targetId: string;
  59. }
  60. // 新增:从问题板块映射的待办任务
  61. interface TodoTaskFromIssue {
  62. id: string;
  63. title: string;
  64. description?: string;
  65. priority: IssuePriority;
  66. type: IssueType;
  67. status: IssueStatus;
  68. projectId: string;
  69. projectName: string;
  70. relatedSpace?: string;
  71. relatedStage?: string;
  72. assigneeName?: string;
  73. creatorName?: string;
  74. createdAt: Date;
  75. updatedAt: Date;
  76. dueDate?: Date;
  77. tags?: string[];
  78. }
  79. /**
  80. * 🆕 紧急事件接口
  81. * 从项目时间轴自动计算,表示截止时间到了但未完成的事件
  82. */
  83. interface UrgentEvent {
  84. id: string;
  85. title: string;
  86. description: string;
  87. eventType: 'review' | 'delivery' | 'phase_deadline'; // 事件类型
  88. phaseName?: string; // 阶段名称(如果是阶段截止)
  89. deadline: Date; // 截止时间
  90. projectId: string;
  91. projectName: string;
  92. designerName?: string;
  93. urgencyLevel: 'critical' | 'high' | 'medium'; // 紧急程度
  94. overdueDays?: number; // 逾期天数(负数表示还有几天)
  95. completionRate?: number; // 完成率(0-100)
  96. }
  97. // 员工请假记录接口
  98. interface LeaveRecord {
  99. id: string;
  100. employeeName: string;
  101. date: string; // YYYY-MM-DD 格式
  102. isLeave: boolean;
  103. leaveType?: 'sick' | 'personal' | 'annual' | 'other'; // 请假类型
  104. reason?: string; // 请假原因
  105. }
  106. // 员工详情面板数据接口
  107. interface EmployeeDetail {
  108. name: string;
  109. currentProjects: number; // 当前负责项目数
  110. projectNames: string[]; // 项目名称列表(用于显示)
  111. projectData: Array<{ id: string; name: string }>; // 项目数据(包含ID和名称,用于跳转)
  112. leaveRecords: LeaveRecord[]; // 未来7天请假记录
  113. redMarkExplanation: string; // 红色标记说明
  114. calendarData?: EmployeeCalendarData; // 负载日历数据
  115. // 新增:问卷相关
  116. surveyCompleted?: boolean; // 是否完成问卷
  117. surveyData?: any; // 问卷答案数据
  118. profileId?: string; // Profile ID
  119. }
  120. // 员工日历数据接口
  121. interface EmployeeCalendarData {
  122. currentMonth: Date;
  123. days: EmployeeCalendarDay[];
  124. }
  125. // 日历日期数据
  126. interface EmployeeCalendarDay {
  127. date: Date;
  128. projectCount: number; // 当天项目数量
  129. projects: Array<{ id: string; name: string; deadline?: Date }>; // 项目列表
  130. isToday: boolean;
  131. isCurrentMonth: boolean;
  132. }
  133. declare const echarts: any;
  134. @Component({
  135. selector: 'app-dashboard',
  136. standalone: true,
  137. imports: [CommonModule, FormsModule, RouterModule, ProjectTimelineComponent, EmployeeDetailPanelComponent],
  138. templateUrl: './dashboard.html',
  139. styleUrl: './dashboard.scss'
  140. })
  141. export class Dashboard implements OnInit, OnDestroy {
  142. // 暴露 Array 给模板使用
  143. Array = Array;
  144. projects: Project[] = [];
  145. filteredProjects: Project[] = [];
  146. todoTasks: TodoTask[] = [];
  147. urgentPinnedProjects: Project[] = [];
  148. showAlert: boolean = false;
  149. selectedProjectId: string = '';
  150. // 新增:从问题板块加载的待办任务
  151. todoTasksFromIssues: TodoTaskFromIssue[] = [];
  152. loadingTodoTasks: boolean = false;
  153. todoTaskError: string = '';
  154. private todoTaskRefreshTimer: any;
  155. // 🆕 紧急事件(从项目时间轴自动计算)
  156. urgentEvents: UrgentEvent[] = [];
  157. loadingUrgentEvents: boolean = false;
  158. // 新增:当前用户信息
  159. currentUser = {
  160. name: '组长',
  161. 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",
  162. roleName: '组长'
  163. };
  164. currentDate = new Date();
  165. // 真实设计师数据(从fmode-ng获取)
  166. realDesigners: any[] = [];
  167. // 设计师工作量映射(从 ProjectTeam 表)
  168. designerWorkloadMap: Map<string, any[]> = new Map(); // designerId/name -> projects[]
  169. // 智能推荐相关
  170. showSmartMatch: boolean = false;
  171. selectedProject: any = null;
  172. recommendations: any[] = [];
  173. // 新增:关键词搜索
  174. searchTerm: string = '';
  175. searchSuggestions: Project[] = [];
  176. showSuggestions: boolean = false;
  177. private hideSuggestionsTimer: any;
  178. // 搜索性能与交互控制
  179. private searchDebounceTimer: any;
  180. private readonly SEARCH_DEBOUNCE_MS = 200; // 防抖时长(毫秒)
  181. private readonly MIN_SEARCH_LEN = 2; // 最小触发建议长度
  182. private readonly MAX_SUGGESTIONS = 8; // 建议最大条数
  183. private isSearchFocused: boolean = false; // 是否处于输入聚焦态
  184. // 新增:临期项目与筛选状态
  185. selectedType: 'all' | 'soft' | 'hard' = 'all';
  186. selectedUrgency: 'all' | 'high' | 'medium' | 'low' = 'all';
  187. selectedStatus: 'all' | 'progress' | 'completed' | 'overdue' | 'pendingApproval' | 'pendingAssignment' | 'dueSoon' = 'all';
  188. selectedDesigner: string = 'all';
  189. selectedMemberType: 'all' | 'vip' | 'normal' = 'all';
  190. // 新增:时间窗筛选
  191. selectedTimeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays' = 'all';
  192. designers: string[] = [];
  193. // 新增:四大板块筛选
  194. selectedCorePhase: 'all' | 'order' | 'requirements' | 'delivery' | 'aftercare' = 'all';
  195. // 设计师画像(从fmode-ng动态获取,保留此字段作为兼容)
  196. designerProfiles: any[] = [];
  197. // 10个项目阶段
  198. projectStages: ProjectStage[] = [
  199. { id: 'pendingApproval', name: '待确认', order: 1 },
  200. { id: 'pendingAssignment', name: '待分配', order: 2 },
  201. { id: 'requirement', name: '需求沟通', order: 3 },
  202. { id: 'planning', name: '方案规划', order: 4 },
  203. { id: 'modeling', name: '建模阶段', order: 5 },
  204. { id: 'rendering', name: '渲染阶段', order: 6 },
  205. { id: 'postProduction', name: '后期处理', order: 7 },
  206. { id: 'review', name: '方案评审', order: 8 },
  207. { id: 'revision', name: '方案修改', order: 9 },
  208. { id: 'delivery', name: '交付完成', order: 10 }
  209. ];
  210. // 5大核心阶段(聚合展示)
  211. corePhases: ProjectStage[] = [
  212. { id: 'order', name: '订单分配', order: 1 }, // 待确认、待分配
  213. { id: 'requirements', name: '确认需求', order: 2 }, // 需求沟通、方案规划
  214. { id: 'delivery', name: '交付执行', order: 3 }, // 建模、渲染、后期/评审/修改
  215. { id: 'aftercare', name: '售后', order: 4 } // 交付完成 → 售后
  216. ];
  217. // 甘特视图开关与实例引用(默认显示时间轴视图)
  218. showGanttView: boolean = true;
  219. private ganttChart: any | null = null;
  220. @ViewChild('ganttChartRef', { static: false }) ganttChartRef!: ElementRef<HTMLDivElement>;
  221. // 工作负载甘特图引用
  222. @ViewChild('workloadGanttContainer', { static: false }) workloadGanttContainer!: ElementRef<HTMLDivElement>;
  223. private workloadGanttChart: any | null = null;
  224. workloadGanttScale: 'week' | 'month' = 'week';
  225. // 甘特时间尺度:仅周/月
  226. ganttScale: 'day' | 'week' | 'month' = 'week';
  227. // 新增:甘特模式(项目 / 设计师排班)
  228. ganttMode: 'project' | 'designer' = 'project';
  229. // 个人详情面板相关属性
  230. showEmployeeDetailPanel: boolean = false;
  231. selectedEmployeeDetail: EmployeeDetail | null = null;
  232. refreshingSurvey: boolean = false; // 新增:刷新问卷状态
  233. showFullSurvey: boolean = false; // 新增:是否显示完整问卷
  234. // 日历项目列表弹窗
  235. showCalendarProjectList: boolean = false;
  236. selectedDayProjects: Array<{ id: string; name: string; deadline?: Date }> = [];
  237. selectedDate: Date | null = null;
  238. // 当前员工日历相关数据(用于切换月份)
  239. private currentEmployeeName: string = '';
  240. private currentEmployeeProjects: any[] = [];
  241. // 项目时间轴数据
  242. projectTimelineData: ProjectTimeline[] = [];
  243. private timelineDataCache: ProjectTimeline[] = [];
  244. private lastDesignerWorkloadMapSize: number = 0;
  245. // 员工请假数据(模拟数据)
  246. private leaveRecords: LeaveRecord[] = [
  247. { id: '1', employeeName: '张三', date: '2024-01-20', isLeave: true, leaveType: 'personal', reason: '事假' },
  248. { id: '2', employeeName: '张三', date: '2024-01-21', isLeave: false },
  249. { id: '3', employeeName: '张三', date: '2024-01-22', isLeave: false },
  250. { id: '4', employeeName: '张三', date: '2024-01-23', isLeave: false },
  251. { id: '5', employeeName: '张三', date: '2024-01-24', isLeave: false },
  252. { id: '6', employeeName: '张三', date: '2024-01-25', isLeave: false },
  253. { id: '7', employeeName: '张三', date: '2024-01-26', isLeave: false },
  254. { id: '8', employeeName: '李四', date: '2024-01-20', isLeave: false },
  255. { id: '9', employeeName: '李四', date: '2024-01-21', isLeave: true, leaveType: 'sick', reason: '病假' },
  256. { id: '10', employeeName: '李四', date: '2024-01-22', isLeave: true, leaveType: 'sick', reason: '病假' },
  257. { id: '11', employeeName: '李四', date: '2024-01-23', isLeave: false },
  258. { id: '12', employeeName: '李四', date: '2024-01-24', isLeave: false },
  259. { id: '13', employeeName: '李四', date: '2024-01-25', isLeave: false },
  260. { id: '14', employeeName: '李四', date: '2024-01-26', isLeave: false },
  261. { id: '15', employeeName: '王五', date: '2024-01-20', isLeave: false },
  262. { id: '16', employeeName: '王五', date: '2024-01-21', isLeave: false },
  263. { id: '17', employeeName: '王五', date: '2024-01-22', isLeave: false },
  264. { id: '18', employeeName: '王五', date: '2024-01-23', isLeave: true, leaveType: 'annual', reason: '年假' },
  265. { id: '19', employeeName: '王五', date: '2024-01-24', isLeave: false },
  266. { id: '20', employeeName: '王五', date: '2024-01-25', isLeave: false },
  267. { id: '21', employeeName: '王五', date: '2024-01-26', isLeave: false },
  268. { id: '22', employeeName: '赵六', date: '2024-01-20', isLeave: false },
  269. { id: '23', employeeName: '赵六', date: '2024-01-21', isLeave: false },
  270. { id: '24', employeeName: '赵六', date: '2024-01-22', isLeave: false },
  271. { id: '25', employeeName: '赵六', date: '2024-01-23', isLeave: false },
  272. { id: '26', employeeName: '赵六', date: '2024-01-24', isLeave: false },
  273. { id: '27', employeeName: '赵六', date: '2024-01-25', isLeave: false },
  274. { id: '28', employeeName: '赵六', date: '2024-01-26', isLeave: false }
  275. ];
  276. constructor(
  277. private projectService: ProjectService,
  278. private router: Router,
  279. private designerService: DesignerService,
  280. private issueService: ProjectIssueService
  281. ) {}
  282. async ngOnInit(): Promise<void> {
  283. // 新增:加载用户Profile信息
  284. await this.loadUserProfile();
  285. await this.loadProjects();
  286. await this.loadDesigners();
  287. this.loadTodoTasks();
  288. // 首次微任务后尝试初始化一次,确保容器已渲染
  289. setTimeout(() => this.updateWorkloadGantt(), 0);
  290. // 新增:加载待办任务(从问题板块)
  291. await this.loadTodoTasksFromIssues();
  292. // 🆕 计算紧急事件
  293. this.calculateUrgentEvents();
  294. // 启动自动刷新
  295. this.startAutoRefresh();
  296. }
  297. /**
  298. * 从fmode-ng加载真实设计师数据
  299. */
  300. async loadDesigners(): Promise<void> {
  301. try {
  302. this.realDesigners = await this.designerService.getDesigners();
  303. // 更新设计师列表(用于筛选下拉框)
  304. this.designers = this.realDesigners.map(d => d.name);
  305. // 同时更新designerProfiles以保持兼容性
  306. this.designerProfiles = this.realDesigners.map(d => ({
  307. id: d.id,
  308. name: d.name,
  309. skills: d.tags.expertise.styles || [],
  310. workload: 0, // 后续动态计算
  311. avgRating: d.tags.history.avgRating || 0,
  312. experience: 0 // 暂无此字段
  313. }));
  314. // 加载设计师的实际工作量
  315. await this.loadDesignerWorkload();
  316. } catch (error) {
  317. console.error('加载设计师数据失败:', error);
  318. }
  319. }
  320. /**
  321. * 🔧 从 ProjectTeam 表加载每个设计师的实际工作量
  322. */
  323. async loadDesignerWorkload(): Promise<void> {
  324. try {
  325. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  326. // 查询所有 ProjectTeam 记录
  327. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  328. // 先查询当前公司的所有项目
  329. const projectQuery = new Parse.Query('Project');
  330. projectQuery.equalTo('company', cid);
  331. projectQuery.notEqualTo('isDeleted', true);
  332. // 查询当前公司项目的 ProjectTeam
  333. const teamQuery = new Parse.Query('ProjectTeam');
  334. teamQuery.matchesQuery('project', projectQuery);
  335. teamQuery.notEqualTo('isDeleted', true);
  336. teamQuery.include('project');
  337. teamQuery.include('profile');
  338. teamQuery.limit(1000);
  339. const teamRecords = await teamQuery.find();
  340. // 如果 ProjectTeam 表为空,使用降级方案
  341. if (teamRecords.length === 0) {
  342. await this.loadDesignerWorkloadFromProjects();
  343. return;
  344. }
  345. // 构建设计师工作量映射
  346. this.designerWorkloadMap.clear();
  347. teamRecords.forEach((record: any) => {
  348. const profile = record.get('profile');
  349. const project = record.get('project');
  350. if (!profile || !project) {
  351. return;
  352. }
  353. const profileId = profile.id;
  354. const profileName = profile.get('name') || profile.get('user')?.get?.('name') || `设计师-${profileId.slice(-4)}`;
  355. // 提取项目信息
  356. // 优先获取各个日期字段
  357. const createdAtValue = project.get('createdAt');
  358. const updatedAtValue = project.get('updatedAt');
  359. const deadlineValue = project.get('deadline');
  360. const deliveryDateValue = project.get('deliveryDate');
  361. const expectedDeliveryDateValue = project.get('expectedDeliveryDate');
  362. const demodayValue = project.get('demoday'); // 🆕 小图对图日期
  363. // 🔧 如果 get() 方法都返回假值,尝试从 createdAt/updatedAt 属性直接获取
  364. // Parse 对象的 createdAt/updatedAt 是内置属性
  365. let finalCreatedAt = createdAtValue || updatedAtValue;
  366. if (!finalCreatedAt && project.createdAt) {
  367. finalCreatedAt = project.createdAt; // Parse 内置属性
  368. }
  369. if (!finalCreatedAt && project.updatedAt) {
  370. finalCreatedAt = project.updatedAt; // Parse 内置属性
  371. }
  372. // ✅ 应用方案:获取项目的 data 字段(包含 phaseDeadlines, deliveryStageStatus 等)
  373. const projectDataField = project.get('data') || {};
  374. const projectData = {
  375. id: project.id,
  376. name: project.get('title') || '未命名项目',
  377. status: project.get('status') || '进行中',
  378. currentStage: project.get('currentStage') || '未知阶段',
  379. deadline: deadlineValue || deliveryDateValue || expectedDeliveryDateValue,
  380. demoday: demodayValue, // 🆕 小图对图日期
  381. createdAt: finalCreatedAt,
  382. updatedAt: updatedAtValue || project.updatedAt, // ✅ 添加 updatedAt
  383. designerName: profileName,
  384. designerId: profileId, // ✅ 添加 designerId
  385. data: projectDataField, // ✅ 添加 data 字段
  386. contact: project.get('contact'), // ✅ 添加客户信息
  387. space: projectDataField.quotation?.spaces?.[0]?.name || '' // ✅ 添加空间信息
  388. };
  389. // 添加到映射 (by ID)
  390. if (!this.designerWorkloadMap.has(profileId)) {
  391. this.designerWorkloadMap.set(profileId, []);
  392. }
  393. this.designerWorkloadMap.get(profileId)!.push(projectData);
  394. // 同时建立 name -> projects 的映射(用于甘特图)
  395. if (!this.designerWorkloadMap.has(profileName)) {
  396. this.designerWorkloadMap.set(profileName, []);
  397. }
  398. this.designerWorkloadMap.get(profileName)!.push(projectData);
  399. });
  400. // 更新项目时间轴数据
  401. this.convertToProjectTimeline();
  402. } catch (error) {
  403. console.error('加载设计师工作量失败:', error);
  404. }
  405. }
  406. /**
  407. * 🔧 降级方案:从 Project.assignee 统计工作量
  408. * 当 ProjectTeam 表为空时使用
  409. */
  410. async loadDesignerWorkloadFromProjects(): Promise<void> {
  411. try {
  412. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  413. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  414. // 查询所有项目
  415. const projectQuery = new Parse.Query('Project');
  416. projectQuery.equalTo('company', cid);
  417. projectQuery.equalTo('isDeleted', false);
  418. projectQuery.include('assignee');
  419. projectQuery.include('department');
  420. projectQuery.limit(1000);
  421. const projects = await projectQuery.find();
  422. // 构建设计师工作量映射
  423. this.designerWorkloadMap.clear();
  424. projects.forEach((project: any) => {
  425. const assignee = project.get('assignee');
  426. if (!assignee) return;
  427. // 只统计组员角色的项目
  428. const assigneeRole = assignee.get('roleName');
  429. if (assigneeRole !== '组员') {
  430. return;
  431. }
  432. const assigneeName = assignee.get('name') || assignee.get('user')?.get?.('name') || `设计师-${assignee.id.slice(-4)}`;
  433. // 提取项目信息
  434. // 优先获取各个日期字段
  435. const createdAtValue = project.get('createdAt');
  436. const updatedAtValue = project.get('updatedAt');
  437. const deadlineValue = project.get('deadline');
  438. const deliveryDateValue = project.get('deliveryDate');
  439. const expectedDeliveryDateValue = project.get('expectedDeliveryDate');
  440. const demodayValue = project.get('demoday'); // 🆕 小图对图日期
  441. // 🔧 如果 get() 方法都返回假值,尝试从 createdAt/updatedAt 属性直接获取
  442. let finalCreatedAt = createdAtValue || updatedAtValue;
  443. if (!finalCreatedAt && project.createdAt) {
  444. finalCreatedAt = project.createdAt;
  445. }
  446. if (!finalCreatedAt && project.updatedAt) {
  447. finalCreatedAt = project.updatedAt;
  448. }
  449. // ✅ 应用方案:获取项目的 data 字段(包含 phaseDeadlines, deliveryStageStatus 等)
  450. const projectDataField = project.get('data') || {};
  451. const projectData = {
  452. id: project.id,
  453. name: project.get('title') || '未命名项目',
  454. status: project.get('status') || '进行中',
  455. currentStage: project.get('currentStage') || '未知阶段',
  456. deadline: deadlineValue || deliveryDateValue || expectedDeliveryDateValue,
  457. demoday: demodayValue, // 🆕 小图对图日期
  458. createdAt: finalCreatedAt,
  459. updatedAt: updatedAtValue || project.updatedAt, // ✅ 添加 updatedAt
  460. designerName: assigneeName,
  461. designerId: assignee.id, // ✅ 添加 designerId
  462. data: projectDataField, // ✅ 添加 data 字段
  463. contact: project.get('contact'), // ✅ 添加客户信息
  464. space: projectDataField.quotation?.spaces?.[0]?.name || '' // ✅ 添加空间信息
  465. };
  466. // 添加到映射
  467. if (!this.designerWorkloadMap.has(assigneeName)) {
  468. this.designerWorkloadMap.set(assigneeName, []);
  469. }
  470. this.designerWorkloadMap.get(assigneeName)!.push(projectData);
  471. });
  472. // ✅ 修复:加载完数据后,转换为时间轴格式
  473. console.log(`📊 [降级方案] 加载了 ${projects.length} 个项目,填充到 ${this.designerWorkloadMap.size} 个设计师的工作量映射`);
  474. this.convertToProjectTimeline();
  475. } catch (error) {
  476. console.error('[降级方案] 加载工作量失败:', error);
  477. }
  478. }
  479. /**
  480. * 从fmode-ng加载真实项目数据
  481. */
  482. async loadProjects(): Promise<void> {
  483. try {
  484. const realProjects = await this.designerService.getProjects();
  485. // 如果有真实数据,使用真实数据
  486. if (realProjects && realProjects.length > 0) {
  487. this.projects = realProjects;
  488. } else {
  489. // 如果没有真实数据,使用模拟数据
  490. this.projects = this.getMockProjects();
  491. }
  492. } catch (error) {
  493. console.error('加载项目数据失败:', error);
  494. this.projects = this.getMockProjects();
  495. }
  496. // 应用筛选
  497. this.applyFilters();
  498. }
  499. /**
  500. * 将项目数据转换为ProjectTimeline格式(带缓存优化 + 去重)
  501. */
  502. private convertToProjectTimeline(): void {
  503. // 🔧 不去重,保留所有项目-设计师关联关系(一个项目可能有多个设计师)
  504. const allDesignerProjects: any[] = [];
  505. // 统计项目数量
  506. let totalProjectsInMap = 0;
  507. this.designerWorkloadMap.forEach((projects, key) => {
  508. totalProjectsInMap += projects.length;
  509. console.log(`📊 设计师 "${key}": ${projects.length} 个项目`);
  510. });
  511. console.log(`📊 总计 ${totalProjectsInMap} 个项目分布在 ${this.designerWorkloadMap.size} 个设计师中`);
  512. this.designerWorkloadMap.forEach((projects, designerKey) => {
  513. // 🔧 改进判断逻辑:跳过明显的 ID 格式(Parse objectId 是10位字母数字组合)
  514. // 只要包含中文字符,就认为是设计师名称
  515. const isParseId = typeof designerKey === 'string'
  516. && designerKey.length === 10
  517. && /^[a-zA-Z0-9]{10}$/.test(designerKey); // Parse ID 格式:10位字母数字
  518. const isDesignerName = !isParseId && typeof designerKey === 'string' && /[\u4e00-\u9fa5]/.test(designerKey);
  519. if (isDesignerName) {
  520. projects.forEach(proj => {
  521. // ✅ 不去重,保留每个设计师-项目的关联
  522. const projectWithDesigner = {
  523. ...proj,
  524. designerName: designerKey // 使用当前的设计师名称
  525. };
  526. allDesignerProjects.push(projectWithDesigner);
  527. });
  528. }
  529. });
  530. console.log(`📊 开始转换 ${allDesignerProjects.length} 个项目为时间轴格式`);
  531. this.projectTimelineData = allDesignerProjects.map((project, index) => {
  532. const now = new Date();
  533. // ✅ 应用方案:使用真实字段数据
  534. const projectData = project.data || {};
  535. // 1. 获取真实的项目开始时间
  536. const realStartDate = normalizeDateInput(
  537. projectData.phaseDeadlines?.modeling?.startDate ||
  538. projectData.requirementsConfirmedAt ||
  539. project.createdAt,
  540. new Date()
  541. );
  542. // 2. 获取真实的交付日期
  543. // ✅ 修复:确保 deadline 是未来的日期(不使用过去的初始值或未初始化的值)
  544. let proposedEndDate = project.deadline || projectData.phaseDeadlines?.postProcessing?.deadline;
  545. let realEndDate: Date;
  546. // 如果提议的结束日期在过去,或者日期无效,使用默认值
  547. if (proposedEndDate) {
  548. const proposed = normalizeDateInput(proposedEndDate, realStartDate);
  549. // 只有当提议的日期在未来时才使用它
  550. if (proposed.getTime() > now.getTime()) {
  551. realEndDate = proposed;
  552. } else {
  553. // 日期在过去,使用默认值(从开始日期起30天)
  554. realEndDate = addDays(realStartDate, 30);
  555. }
  556. } else {
  557. // 没有提议的日期,使用默认值
  558. realEndDate = addDays(realStartDate, 30);
  559. }
  560. // 3. 获取真实的对图时间(小图对图)
  561. // ✅ 逻辑:优先使用 project.demoday,否则在软装截止时间后半天
  562. let realReviewDate: Date;
  563. let reviewDateSource = 'default';
  564. if (project.demoday) {
  565. // 如果有显式设置的小图对图日期,使用它
  566. realReviewDate = normalizeDateInput(project.demoday, new Date());
  567. reviewDateSource = 'demoday';
  568. } else if (projectData.phaseDeadlines?.softDecor?.deadline) {
  569. // 软装截止时间后半天作为小图对图时间
  570. const softDecorDeadline = normalizeDateInput(projectData.phaseDeadlines.softDecor.deadline, new Date());
  571. realReviewDate = new Date(softDecorDeadline.getTime() + 12 * 60 * 60 * 1000); // 加12小时
  572. reviewDateSource = 'softDecor + 12h';
  573. } else {
  574. // 默认值:项目进度的60%位置,下午2点
  575. const defaultReviewPoint = new Date(
  576. realStartDate.getTime() + (realEndDate.getTime() - realStartDate.getTime()) * 0.6
  577. );
  578. defaultReviewPoint.setHours(14, 0, 0, 0);
  579. realReviewDate = defaultReviewPoint;
  580. reviewDateSource = 'default 60%';
  581. }
  582. // 调试日志
  583. if (project.name?.includes('紫云') || project.name?.includes('自建')) {
  584. console.log(`📸 [${project.name}] 小图对图时间计算:`, {
  585. source: reviewDateSource,
  586. reviewDate: realReviewDate.toLocaleString('zh-CN'),
  587. demoday: project.demoday,
  588. softDecorDeadline: projectData.phaseDeadlines?.softDecor?.deadline,
  589. hasPhaseDeadlines: !!projectData.phaseDeadlines
  590. });
  591. }
  592. // 4. 计算距离交付还有几天(使用真实日期)
  593. const daysUntilDeadline = Math.ceil((realEndDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  594. // 5. 计算项目状态
  595. let status: 'normal' | 'warning' | 'urgent' | 'overdue' = 'normal';
  596. if (daysUntilDeadline < 0) {
  597. status = 'overdue';
  598. } else if (daysUntilDeadline <= 1) {
  599. status = 'urgent';
  600. } else if (daysUntilDeadline <= 3) {
  601. status = 'warning';
  602. }
  603. // 6. 映射阶段
  604. const stageMap: Record<string, 'plan' | 'model' | 'decoration' | 'render' | 'delivery'> = {
  605. '方案设计': 'plan',
  606. '方案规划': 'plan',
  607. '建模': 'model',
  608. '建模阶段': 'model',
  609. '软装': 'decoration',
  610. '软装设计': 'decoration',
  611. '渲染': 'render',
  612. '渲染阶段': 'render',
  613. '后期': 'render',
  614. '交付': 'delivery',
  615. '已完成': 'delivery'
  616. };
  617. const currentStage = stageMap[project.currentStage || '建模阶段'] || 'model';
  618. const stageName = project.currentStage || '建模阶段';
  619. // 7. 🆕 阶段任务完成度(由时间轴组件的 getProjectCompletionRate 计算)
  620. // ✅ 重要变更:进度条现在表示"任务完成度"而不是"时间百分比"
  621. // - 时间轴组件会优先使用交付物完成率(overallCompletionRate)
  622. // - 若无交付物数据,则根据 phaseDeadlines.status 推断任务完成度
  623. // - stageProgress 保留作为兼容字段,但已弃用
  624. let stageProgress = 50; // 默认兼容值(实际进度由时间轴组件计算)
  625. // 8. 检查是否停滞(基于 updatedAt)
  626. let isStalled = false;
  627. let stalledDays = 0;
  628. if (project.updatedAt) {
  629. const updatedAt = project.updatedAt instanceof Date ? project.updatedAt : new Date(project.updatedAt);
  630. const daysSinceUpdate = Math.floor((now.getTime() - updatedAt.getTime()) / (1000 * 60 * 60 * 24));
  631. // 如果超过7天未更新,认为停滞
  632. isStalled = daysSinceUpdate > 7;
  633. stalledDays = isStalled ? daysSinceUpdate : 0;
  634. }
  635. // 9. 催办次数(基于状态和历史记录)
  636. const urgentCount = status === 'overdue' ? 2 : status === 'urgent' ? 1 : 0;
  637. // 10. 优先级
  638. let priority: 'low' | 'medium' | 'high' | 'critical' = 'medium';
  639. if (status === 'overdue') {
  640. priority = 'critical';
  641. } else if (status === 'urgent') {
  642. priority = 'high';
  643. } else if (status === 'warning') {
  644. priority = 'medium';
  645. } else {
  646. priority = 'low';
  647. }
  648. // 11. 获取或生成阶段截止时间数据
  649. let phaseDeadlines: PhaseDeadlines | undefined = projectData.phaseDeadlines;
  650. if (!phaseDeadlines) {
  651. phaseDeadlines = generatePhaseDeadlines(realStartDate, realEndDate);
  652. }
  653. if (phaseDeadlines) {
  654. (Object.keys(phaseDeadlines) as PhaseName[]).forEach((phaseKey) => {
  655. const info = phaseDeadlines![phaseKey];
  656. if (!info) return;
  657. const phaseStart = normalizeDateInput(info.startDate, realStartDate);
  658. const phaseEnd = normalizeDateInput(info.deadline, realEndDate);
  659. if (now >= phaseEnd) {
  660. info.status = 'completed';
  661. } else if (now >= phaseStart) {
  662. info.status = 'in_progress';
  663. } else {
  664. info.status = info.status || 'not_started';
  665. }
  666. });
  667. }
  668. // 12. 获取空间和客户信息
  669. const spaceName = project.space || projectData.quotation?.spaces?.[0]?.name || '';
  670. const customerName = project.customer || project.contact?.name || '';
  671. return {
  672. projectId: project.id || `proj-${Math.random().toString(36).slice(2, 9)}`,
  673. projectName: project.name || '未命名项目',
  674. designerId: project.designerId || project.designerName || '未分配',
  675. designerName: project.designerName || '未分配',
  676. startDate: realStartDate, // ✅ 使用真实开始时间
  677. endDate: realEndDate, // ✅ 使用真实结束时间
  678. deliveryDate: realEndDate, // ✅ 使用真实交付日期
  679. reviewDate: realReviewDate, // ✅ 使用真实对图时间
  680. currentStage,
  681. stageName,
  682. stageProgress: Math.round(stageProgress), // ✅ 使用计算的真实进度
  683. status, // ✅ 基于真实日期计算的状态
  684. isStalled, // ✅ 基于 updatedAt 计算的停滞状态
  685. stalledDays, // ✅ 真实的停滞天数
  686. urgentCount,
  687. priority,
  688. spaceName, // ✅ 从项目数据获取
  689. customerName, // ✅ 从项目数据获取
  690. phaseDeadlines: phaseDeadlines, // ✅ 使用真实或计算的阶段截止时间
  691. data: projectData // ✅ 保留原始数据,供后续使用
  692. };
  693. });
  694. // 更新缓存
  695. this.timelineDataCache = this.projectTimelineData;
  696. this.lastDesignerWorkloadMapSize = totalProjectsInMap;
  697. console.log(`📊 convertToProjectTimeline 完成: 转换了 ${this.projectTimelineData.length} 个项目`);
  698. if (this.projectTimelineData.length > 0) {
  699. const now = new Date();
  700. const sampleProject = this.projectTimelineData[0];
  701. const daysFromStart = Math.floor((now.getTime() - sampleProject.startDate.getTime()) / (1000 * 60 * 60 * 24));
  702. const daysToEnd = Math.floor((sampleProject.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  703. console.log(`📊 第一个项目示例:`, {
  704. projectId: sampleProject.projectId,
  705. projectName: sampleProject.projectName,
  706. designerName: sampleProject.designerName,
  707. startDate: sampleProject.startDate.toLocaleString('zh-CN'),
  708. endDate: sampleProject.endDate.toLocaleString('zh-CN'),
  709. startDateFromNow: `${daysFromStart} 天前`,
  710. endDateFromNow: daysToEnd >= 0 ? `${daysToEnd} 天后` : `${Math.abs(daysToEnd)} 天前`,
  711. isEndDateInPast: daysToEnd < 0,
  712. hasPhaseDeadlines: !!sampleProject.phaseDeadlines
  713. });
  714. // ✅ 检查日期问题:统计有多少项目的结束日期在过去
  715. const projectsWithPastEndDate = this.projectTimelineData.filter(p => {
  716. const daysToEnd = Math.floor((p.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  717. return daysToEnd < 0;
  718. });
  719. if (projectsWithPastEndDate.length > 0) {
  720. console.warn(`⚠️ 发现 ${projectsWithPastEndDate.length} 个项目的结束日期在过去,这些项目在时间轴上可能显示为一个点`);
  721. console.log(`⚠️ 示例项目:`, projectsWithPastEndDate.slice(0, 3).map(p => ({
  722. name: p.projectName,
  723. endDate: p.endDate.toLocaleString('zh-CN'),
  724. daysAgo: Math.abs(Math.floor((p.endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)))
  725. })));
  726. }
  727. }
  728. }
  729. /**
  730. * 处理项目点击事件
  731. */
  732. onProjectTimelineClick(projectId: string): void {
  733. if (!projectId) {
  734. return;
  735. }
  736. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  737. try {
  738. localStorage.setItem('enterAsTeamLeader', '1');
  739. localStorage.setItem('teamLeaderMode', 'true');
  740. // 🔥 关键:清除客服端标记,避免冲突
  741. localStorage.removeItem('enterFromCustomerService');
  742. localStorage.removeItem('customerServiceMode');
  743. console.log('✅ 已标记从组长看板进入,启用组长模式');
  744. } catch (e) {
  745. console.warn('无法设置 localStorage 标记:', e);
  746. }
  747. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  748. const project = this.projects.find(p => p.id === projectId);
  749. const currentStage = project?.currentStage || '订单分配';
  750. // 阶段映射:项目阶段 → 路由路径
  751. const stageRouteMap: Record<string, string> = {
  752. '订单分配': 'order',
  753. '确认需求': 'requirements',
  754. '方案深化': 'requirements',
  755. '建模': 'requirements',
  756. '软装': 'requirements',
  757. '渲染': 'requirements',
  758. '后期': 'requirements',
  759. '交付执行': 'delivery',
  760. '交付': 'delivery',
  761. '售后归档': 'aftercare',
  762. '已完成': 'aftercare'
  763. };
  764. const stagePath = stageRouteMap[currentStage] || 'order';
  765. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  766. // 获取公司ID(与 viewProjectDetails 保持一致)
  767. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  768. // 跳转到对应阶段,带上组长标识
  769. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  770. queryParams: { roleName: 'team-leader' }
  771. });
  772. }
  773. /**
  774. * 构建搜索索引(如果需要)
  775. */
  776. private buildSearchIndexes(): void {
  777. this.projects.forEach(p => {
  778. if (!p.searchIndex) {
  779. p.searchIndex = `${p.name}|${p.designerName}`.toLowerCase();
  780. }
  781. });
  782. }
  783. /**
  784. * 模拟项目数据(作为备用)
  785. */
  786. private getMockProjects(): Project[] {
  787. return [
  788. {
  789. id: 'proj-001',
  790. name: '现代风格客厅设计',
  791. type: 'soft',
  792. memberType: 'vip',
  793. designerName: '张三',
  794. status: '进行中',
  795. expectedEndDate: new Date(2023, 9, 15),
  796. deadline: new Date(2023, 9, 15),
  797. isOverdue: true,
  798. overdueDays: 2,
  799. dueSoon: false,
  800. urgency: 'high',
  801. currentStage: 'rendering',
  802. phases: [
  803. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  804. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: true },
  805. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  806. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  807. ]
  808. },
  809. {
  810. id: 'proj-002',
  811. name: '北欧风格卧室设计',
  812. type: 'soft',
  813. memberType: 'normal',
  814. designerName: '李四',
  815. status: '进行中',
  816. expectedEndDate: new Date(2023, 9, 20),
  817. deadline: new Date(2023, 9, 20),
  818. isOverdue: false,
  819. overdueDays: 0,
  820. dueSoon: false,
  821. urgency: 'medium',
  822. currentStage: 'postProduction',
  823. phases: [
  824. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  825. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  826. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: true },
  827. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  828. ]
  829. },
  830. {
  831. id: 'proj-003',
  832. name: '新中式餐厅设计',
  833. type: 'hard',
  834. memberType: 'normal',
  835. designerName: '王五',
  836. status: '进行中',
  837. expectedEndDate: new Date(2023, 9, 25),
  838. deadline: new Date(2023, 9, 25),
  839. isOverdue: false,
  840. overdueDays: 0,
  841. dueSoon: false,
  842. urgency: 'low',
  843. currentStage: 'modeling',
  844. phases: [
  845. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: false, isCurrent: true },
  846. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: false, isCurrent: false },
  847. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  848. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  849. ]
  850. },
  851. {
  852. id: 'proj-004',
  853. name: '工业风办公室设计',
  854. type: 'hard',
  855. memberType: 'normal',
  856. designerName: '赵六',
  857. status: '进行中',
  858. expectedEndDate: new Date(2023, 9, 10),
  859. deadline: new Date(2023, 9, 10),
  860. isOverdue: true,
  861. overdueDays: 7,
  862. dueSoon: false,
  863. urgency: 'high',
  864. currentStage: 'review',
  865. phases: [
  866. { name: '建模', percentage: 15, startPercentage: 0, isCompleted: true, isCurrent: false },
  867. { name: '渲染', percentage: 20, startPercentage: 15, isCompleted: true, isCurrent: false },
  868. { name: '后期', percentage: 15, startPercentage: 35, isCompleted: false, isCurrent: false },
  869. { name: '交付', percentage: 50, startPercentage: 50, isCompleted: false, isCurrent: false }
  870. ]
  871. },
  872. // 添加更多不同阶段的项目
  873. {
  874. id: 'proj-005',
  875. name: '现代简约厨房设计',
  876. type: 'soft',
  877. memberType: 'normal',
  878. designerName: '',
  879. status: '待分配',
  880. expectedEndDate: new Date(2023, 10, 5),
  881. deadline: new Date(2023, 10, 5),
  882. isOverdue: false,
  883. overdueDays: 0,
  884. dueSoon: false,
  885. urgency: 'medium',
  886. currentStage: 'pendingAssignment',
  887. phases: []
  888. },
  889. {
  890. id: 'proj-006',
  891. name: '日式风格书房设计',
  892. type: 'hard',
  893. memberType: 'normal',
  894. designerName: '',
  895. status: '待确认',
  896. expectedEndDate: new Date(2023, 10, 10),
  897. deadline: new Date(2023, 10, 10),
  898. isOverdue: false,
  899. overdueDays: 0,
  900. dueSoon: false,
  901. urgency: 'low',
  902. currentStage: 'pendingApproval',
  903. phases: []
  904. },
  905. {
  906. id: 'proj-007',
  907. name: '轻奢风格浴室设计',
  908. type: 'soft',
  909. memberType: 'normal',
  910. designerName: '钱七',
  911. status: '已完成',
  912. expectedEndDate: new Date(2023, 9, 5),
  913. deadline: new Date(2023, 9, 5),
  914. isOverdue: false,
  915. overdueDays: 0,
  916. dueSoon: false,
  917. urgency: 'medium',
  918. currentStage: 'delivery',
  919. phases: []
  920. }
  921. ];
  922. // ===== 追加生成示例数据:保证总量达到100条 =====
  923. const stageIds = this.projectStages.map(s => s.id);
  924. const designers = ['张三','李四','王五','赵六','孙七','周八','吴九','陈十'];
  925. const statusMap: Record<string, string> = {
  926. pendingApproval: '待确认',
  927. pendingAssignment: '待分配',
  928. requirement: '进行中',
  929. planning: '进行中',
  930. modeling: '进行中',
  931. rendering: '进行中',
  932. postProduction: '进行中',
  933. review: '进行中',
  934. revision: '进行中',
  935. delivery: '已完成'
  936. };
  937. // 为有项目的设计师分配项目
  938. const busyDesigners = ['张三', '王五', '吴九']; // 高负荷设计师
  939. const moderateDesigners = ['孙七']; // 中等负荷设计师
  940. const idleDesigners = ['李四', '赵六', '周八', '陈十']; // 空闲设计师
  941. // 为忙碌的设计师分配更多项目
  942. for (let i = 8; i <= 30; i++) {
  943. const designerIndex = (i - 8) % busyDesigners.length;
  944. const designerName = busyDesigners[designerIndex];
  945. const stageIndex = (i - 1) % 7 + 3; // 主要在进行中的阶段
  946. const currentStage = stageIds[stageIndex];
  947. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  948. const urgency: 'high' | 'medium' | 'low' = i % 4 === 0 ? 'high' : (i % 3 === 0 ? 'medium' : 'low');
  949. const isOverdue = i % 8 === 0;
  950. const overdueDays = isOverdue ? (i % 5) + 1 : 0;
  951. const status = statusMap[currentStage] || '进行中';
  952. const expectedEndDate = new Date();
  953. const daysOffset = isOverdue ? -(overdueDays + (i % 3)) : ((i % 15) + 5);
  954. expectedEndDate.setDate(expectedEndDate.getDate() + daysOffset);
  955. const daysToDeadline = Math.ceil((expectedEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
  956. const dueSoon = !isOverdue && daysToDeadline >= 0 && daysToDeadline <= 3;
  957. const memberType: 'vip' | 'normal' = i % 5 === 0 ? 'vip' : 'normal';
  958. this.projects.push({
  959. id: `proj-${String(i).padStart(3, '0')}`,
  960. name: `${designerName}负责的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  961. type,
  962. memberType,
  963. designerName,
  964. status,
  965. expectedEndDate,
  966. deadline: expectedEndDate,
  967. isOverdue,
  968. overdueDays,
  969. dueSoon,
  970. urgency,
  971. currentStage,
  972. phases: []
  973. });
  974. }
  975. // 为中等负荷设计师分配少量项目
  976. for (let i = 31; i <= 35; i++) {
  977. const designerName = moderateDesigners[0];
  978. const stageIndex = (i - 1) % 5 + 4; // 中间阶段
  979. const currentStage = stageIds[stageIndex];
  980. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  981. const urgency: 'high' | 'medium' | 'low' = 'medium';
  982. const status = statusMap[currentStage] || '进行中';
  983. const expectedEndDate = new Date();
  984. expectedEndDate.setDate(expectedEndDate.getDate() + (i % 10) + 7);
  985. const memberType: 'vip' | 'normal' = 'normal';
  986. this.projects.push({
  987. id: `proj-${String(i).padStart(3, '0')}`,
  988. name: `${designerName}负责的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  989. type,
  990. memberType,
  991. designerName,
  992. status,
  993. expectedEndDate,
  994. deadline: expectedEndDate,
  995. isOverdue: false,
  996. overdueDays: 0,
  997. dueSoon: false,
  998. urgency,
  999. currentStage,
  1000. phases: []
  1001. });
  1002. }
  1003. // 空闲设计师不分配项目,或只分配很少的已完成项目
  1004. for (let i = 36; i <= 40; i++) {
  1005. const designerIndex = (i - 36) % idleDesigners.length;
  1006. const designerName = idleDesigners[designerIndex];
  1007. const currentStage = 'delivery'; // 已完成的项目
  1008. const type: 'soft' | 'hard' = i % 2 === 0 ? 'soft' : 'hard';
  1009. const urgency: 'high' | 'medium' | 'low' = 'low';
  1010. const status = '已完成';
  1011. const expectedEndDate = new Date();
  1012. expectedEndDate.setDate(expectedEndDate.getDate() - (i % 10) - 5); // 过去的日期
  1013. const memberType: 'vip' | 'normal' = 'normal';
  1014. this.projects.push({
  1015. id: `proj-${String(i).padStart(3, '0')}`,
  1016. name: `${designerName}已完成的${type === 'soft' ? '软装' : '硬装'}项目 ${i}`,
  1017. type,
  1018. memberType,
  1019. designerName,
  1020. status,
  1021. expectedEndDate,
  1022. deadline: expectedEndDate,
  1023. isOverdue: false,
  1024. overdueDays: 0,
  1025. dueSoon: false,
  1026. urgency,
  1027. currentStage,
  1028. phases: []
  1029. });
  1030. }
  1031. // ===== 示例数据生成结束 =====
  1032. // 统一补齐真实时间字段(deadline/createdAt),以真实字段贯通筛选与甘特
  1033. const DAY = 24 * 60 * 60 * 1000;
  1034. this.projects = this.projects.map(p => {
  1035. const deadline = p.deadline || p.expectedEndDate;
  1036. const baseDays = p.type === 'hard' ? 30 : 14;
  1037. const createdAt = p.createdAt || new Date(new Date(deadline).getTime() - baseDays * DAY);
  1038. return { ...p, deadline, createdAt } as Project;
  1039. });
  1040. // 筛选结果初始化为全部项目
  1041. this.filteredProjects = [...this.projects];
  1042. // 供筛选用的设计师列表
  1043. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  1044. // 显示超期提醒(使用 getter)
  1045. if (this.overdueProjects.length > 0) {
  1046. this.showAlert = true;
  1047. }
  1048. }
  1049. loadTodoTasks(): void {
  1050. // 模拟待办任务数据
  1051. this.todoTasks = [
  1052. {
  1053. id: 'todo-001',
  1054. title: '待评审效果图',
  1055. description: '现代风格客厅设计项目需要进行效果图评审',
  1056. deadline: new Date(2023, 9, 18, 15, 0),
  1057. priority: 'high',
  1058. type: 'review',
  1059. targetId: 'proj-001'
  1060. },
  1061. {
  1062. id: 'todo-002',
  1063. title: '待分配项目',
  1064. description: '新中式厨房设计项目需要分配给合适的设计师',
  1065. deadline: new Date(2023, 9, 19, 10, 0),
  1066. priority: 'high',
  1067. type: 'assign',
  1068. targetId: 'proj-new'
  1069. },
  1070. {
  1071. id: 'todo-003',
  1072. title: '待确认绩效',
  1073. description: '9月份团队绩效需要进行审核确认',
  1074. deadline: new Date(2023, 9, 22, 18, 0),
  1075. priority: 'medium',
  1076. type: 'performance',
  1077. targetId: 'sep-2023'
  1078. },
  1079. {
  1080. id: 'todo-004',
  1081. title: '待处理客户反馈',
  1082. description: '北欧风格卧室设计项目有客户反馈需要处理',
  1083. deadline: new Date(2023, 9, 20, 14, 0),
  1084. priority: 'medium',
  1085. type: 'review',
  1086. targetId: 'proj-002'
  1087. },
  1088. {
  1089. id: 'todo-005',
  1090. title: '团队会议',
  1091. description: '每周团队进度沟通会议',
  1092. deadline: new Date(2023, 9, 18, 10, 0),
  1093. priority: 'low',
  1094. type: 'performance',
  1095. targetId: 'weekly-meeting'
  1096. }
  1097. ];
  1098. // 按优先级排序:紧急且重要 > 重要不紧急 > 紧急不重要
  1099. this.todoTasks.sort((a, b) => {
  1100. const priorityOrder = {
  1101. 'high': 3,
  1102. 'medium': 2,
  1103. 'low': 1
  1104. };
  1105. return priorityOrder[b.priority] - priorityOrder[a.priority];
  1106. });
  1107. }
  1108. // 筛选项目类型
  1109. filterProjects(event: Event): void {
  1110. const target = event.target as HTMLSelectElement;
  1111. this.selectedType = (target && target.value ? target.value : 'all') as any;
  1112. this.applyFilters();
  1113. }
  1114. // 筛选紧急程度
  1115. filterByUrgency(event: Event): void {
  1116. const target = event.target as HTMLSelectElement;
  1117. this.selectedUrgency = (target && target.value ? target.value : 'all') as any;
  1118. this.applyFilters();
  1119. }
  1120. // 筛选项目状态
  1121. filterByStatus(status: string): void {
  1122. // 点击同一状态时,切换回“全部”,以便恢复全量项目列表
  1123. const next = (this.selectedStatus === status) ? 'all' : (status && status.length ? status : 'all');
  1124. this.selectedStatus = next as any;
  1125. this.applyFilters();
  1126. }
  1127. // 处理状态筛选下拉框变化
  1128. onStatusChange(event: Event): void {
  1129. const target = event.target as HTMLSelectElement;
  1130. this.selectedStatus = (target && target.value ? target.value : 'all') as any;
  1131. this.applyFilters();
  1132. }
  1133. // 新增:设计师筛选下拉事件处理
  1134. onDesignerChange(event: Event): void {
  1135. const target = event.target as HTMLSelectElement;
  1136. this.selectedDesigner = (target && target.value ? target.value : 'all');
  1137. this.applyFilters();
  1138. }
  1139. // 新增:会员类型筛选下拉事件处理
  1140. onMemberTypeChange(event: Event): void {
  1141. const select = event.target as HTMLSelectElement;
  1142. this.selectedMemberType = select.value as any;
  1143. this.applyFilters();
  1144. }
  1145. // 新增:四大板块改变
  1146. onCorePhaseChange(event: Event): void {
  1147. const select = event.target as HTMLSelectElement;
  1148. this.selectedCorePhase = select.value as any;
  1149. this.applyFilters();
  1150. }
  1151. // 时间窗快捷筛选(供UI按钮触发)
  1152. filterByTimeWindow(timeWindow: 'all' | 'today' | 'threeDays' | 'sevenDays'): void {
  1153. this.selectedTimeWindow = timeWindow;
  1154. this.applyFilters();
  1155. }
  1156. // 新增:搜索输入变化
  1157. onSearchChange(): void {
  1158. if (this.searchDebounceTimer) clearTimeout(this.searchDebounceTimer);
  1159. this.searchDebounceTimer = setTimeout(() => {
  1160. this.updateSearchSuggestions();
  1161. this.applyFilters();
  1162. }, this.SEARCH_DEBOUNCE_MS);
  1163. }
  1164. // 新增:搜索框聚焦/失焦控制建议显隐
  1165. onSearchFocus(): void {
  1166. if (this.hideSuggestionsTimer) clearTimeout(this.hideSuggestionsTimer);
  1167. this.isSearchFocused = true;
  1168. this.updateSearchSuggestions();
  1169. }
  1170. onSearchBlur(): void {
  1171. // 延迟隐藏以允许选择项的 mousedown 触发
  1172. this.isSearchFocused = false;
  1173. this.hideSuggestionsTimer = setTimeout(() => {
  1174. this.showSuggestions = false;
  1175. }, 150);
  1176. }
  1177. // 新增:更新搜索建议(不叠加其它筛选,仅基于关键字)
  1178. private updateSearchSuggestions(): void {
  1179. const q = (this.searchTerm || '').trim().toLowerCase();
  1180. if (q.length < this.MIN_SEARCH_LEN) {
  1181. this.searchSuggestions = [];
  1182. this.showSuggestions = false;
  1183. return;
  1184. }
  1185. const scored = this.projects
  1186. .filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q))
  1187. .map(p => {
  1188. const dl = p.deadline || p.expectedEndDate;
  1189. const dlTime = dl ? new Date(dl).getTime() : NaN;
  1190. const daysToDl = Math.ceil(((isNaN(dlTime) ? 0 : dlTime) - Date.now()) / (1000 * 60 * 60 * 24));
  1191. const urgencyScore = p.urgency === 'high' ? 3 : p.urgency === 'medium' ? 2 : 1;
  1192. const overdueScore = p.isOverdue ? 10 : 0;
  1193. const score = overdueScore + (4 - urgencyScore) * 2 - (isNaN(daysToDl) ? 0 : daysToDl);
  1194. return { p, score };
  1195. })
  1196. .sort((a, b) => b.score - a.score)
  1197. .slice(0, this.MAX_SUGGESTIONS)
  1198. .map(x => x.p);
  1199. this.searchSuggestions = scored;
  1200. this.showSuggestions = this.isSearchFocused && this.searchSuggestions.length > 0;
  1201. }
  1202. // 新增:选择建议项
  1203. selectSuggestion(project: Project): void {
  1204. this.searchTerm = project.name;
  1205. this.showSuggestions = false;
  1206. this.viewProjectDetails(project.id);
  1207. }
  1208. // 统一筛选
  1209. private applyFilters(): void {
  1210. let result = [...this.projects];
  1211. // 新增:关键词搜索(项目名 / 设计师名 / 含风格关键词的项目名)
  1212. const q = (this.searchTerm || '').trim().toLowerCase();
  1213. if (q) {
  1214. result = result.filter(p => (p.searchIndex || `${(p.name || '')}|${(p.designerName || '')}`.toLowerCase()).includes(q));
  1215. }
  1216. // 类型筛选
  1217. if (this.selectedType !== 'all') {
  1218. result = result.filter(p => p.type === this.selectedType);
  1219. }
  1220. // 紧急程度筛选
  1221. if (this.selectedUrgency !== 'all') {
  1222. result = result.filter(p => p.urgency === this.selectedUrgency);
  1223. }
  1224. // 项目状态筛选
  1225. if (this.selectedStatus !== 'all') {
  1226. if (this.selectedStatus === 'overdue') {
  1227. result = result.filter(p => p.isOverdue);
  1228. } else if (this.selectedStatus === 'dueSoon') {
  1229. result = result.filter(p => p.dueSoon && !p.isOverdue);
  1230. } else if (this.selectedStatus === 'pendingApproval') {
  1231. result = result.filter(p => p.currentStage === 'pendingApproval');
  1232. } else if (this.selectedStatus === 'pendingAssignment') {
  1233. result = result.filter(p => p.currentStage === 'pendingAssignment');
  1234. } else if (this.selectedStatus === 'progress') {
  1235. const progressStages = ['requirement','planning','modeling','rendering','postProduction','review','revision'];
  1236. result = result.filter(p => progressStages.includes(p.currentStage));
  1237. } else if (this.selectedStatus === 'completed') {
  1238. result = result.filter(p => p.currentStage === 'delivery');
  1239. }
  1240. }
  1241. // 新增:四大板块筛选
  1242. if (this.selectedCorePhase !== 'all') {
  1243. result = result.filter(p => this.mapStageToCorePhase(p.currentStage) === this.selectedCorePhase);
  1244. }
  1245. // 设计师筛选
  1246. if (this.selectedDesigner !== 'all') {
  1247. result = result.filter(p => p.designerName === this.selectedDesigner);
  1248. }
  1249. // 会员类型筛选
  1250. if (this.selectedMemberType !== 'all') {
  1251. result = result.filter(p => p.memberType === this.selectedMemberType);
  1252. }
  1253. // 新增:时间窗筛选
  1254. if (this.selectedTimeWindow !== 'all') {
  1255. const now = new Date();
  1256. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  1257. result = result.filter(p => {
  1258. const projectDeadline = new Date(p.deadline);
  1259. const timeDiff = projectDeadline.getTime() - today.getTime();
  1260. const daysDiff = Math.ceil(timeDiff / (1000 * 60 * 60 * 24));
  1261. switch (this.selectedTimeWindow) {
  1262. case 'today':
  1263. return daysDiff <= 1 && daysDiff >= 0;
  1264. case 'threeDays':
  1265. return daysDiff <= 3 && daysDiff >= 0;
  1266. case 'sevenDays':
  1267. return daysDiff <= 7 && daysDiff >= 0;
  1268. default:
  1269. return true;
  1270. }
  1271. });
  1272. }
  1273. this.filteredProjects = result;
  1274. // 新增:计算紧急任务固定区(超期 + 高紧急),与筛选联动
  1275. this.urgentPinnedProjects = this.filteredProjects
  1276. .filter(p => p.isOverdue && p.urgency === 'high')
  1277. .sort((a, b) => (b.overdueDays - a.overdueDays) || a.name.localeCompare(b.name, 'zh-Hans-CN'));
  1278. // 当显示甘特卡片时,同步刷新甘特图
  1279. if (this.showGanttView) {
  1280. this.updateGantt();
  1281. }
  1282. // 同步刷新工作负载甘特图
  1283. setTimeout(() => this.updateWorkloadGantt(), 0);
  1284. }
  1285. /**
  1286. * 计算项目加权值
  1287. */
  1288. calculateWorkloadWeight(project: any): number {
  1289. return this.designerService.calculateProjectWeight(project);
  1290. }
  1291. /**
  1292. * 获取设计师加权工作量
  1293. */
  1294. getDesignerWeightedWorkload(designerName: string): {
  1295. weightedTotal: number;
  1296. projectCount: number;
  1297. overdueCount: number;
  1298. loadRate: number;
  1299. } {
  1300. const designerProjects = this.filteredProjects.filter(p => p.designerName === designerName);
  1301. const weightedTotal = designerProjects.reduce((sum, p) => sum + this.calculateWorkloadWeight(p), 0);
  1302. const overdueCount = designerProjects.filter(p => p.isOverdue).length;
  1303. // 从realDesigners获取设计师的单周处理量
  1304. const designer = this.realDesigners.find(d => d.name === designerName);
  1305. const weeklyCapacity = designer?.tags?.capacity?.weeklyProjects || 3;
  1306. const loadRate = weeklyCapacity > 0 ? (weightedTotal / weeklyCapacity) * 100 : 0;
  1307. return {
  1308. weightedTotal,
  1309. projectCount: designerProjects.length,
  1310. overdueCount,
  1311. loadRate
  1312. };
  1313. }
  1314. /**
  1315. * 工作量卡片数据(替代ECharts)
  1316. */
  1317. get designerWorkloadCards(): Array<{
  1318. name: string;
  1319. loadRate: number;
  1320. weightedValue: number;
  1321. projectCount: number;
  1322. overdueCount: number;
  1323. status: 'overload' | 'busy' | 'idle';
  1324. }> {
  1325. const designers = Array.from(new Set(this.filteredProjects.map(p => p.designerName).filter(n => n && n !== '未分配')));
  1326. return designers.map(name => {
  1327. const workload = this.getDesignerWeightedWorkload(name);
  1328. let status: 'overload' | 'busy' | 'idle' = 'idle';
  1329. if (workload.loadRate > 80) status = 'overload';
  1330. else if (workload.loadRate > 50) status = 'busy';
  1331. return {
  1332. name,
  1333. loadRate: workload.loadRate,
  1334. weightedValue: workload.weightedTotal,
  1335. projectCount: workload.projectCount,
  1336. overdueCount: workload.overdueCount,
  1337. status
  1338. };
  1339. }).sort((a, b) => b.loadRate - a.loadRate); // 按负载率降序
  1340. }
  1341. /**
  1342. * 获取超负荷设计师数量
  1343. */
  1344. get overloadedDesignersCount(): number {
  1345. return this.designerWorkloadCards.filter(d => d.status === 'overload').length;
  1346. }
  1347. /**
  1348. * 获取平均负载率
  1349. */
  1350. get averageWorkloadRate(): number {
  1351. const cards = this.designerWorkloadCards;
  1352. if (cards.length === 0) return 0;
  1353. const sum = cards.reduce((acc, card) => acc + card.loadRate, 0);
  1354. return sum / cards.length;
  1355. }
  1356. /**
  1357. * 获取预警汇总数据
  1358. */
  1359. getAlertSummary(): {
  1360. totalAlerts: number;
  1361. overdueHighRisk: Project[];
  1362. overloadedDesigners: any[];
  1363. dueSoonProjects: Project[];
  1364. } {
  1365. // 1. 超期高危项目(超期>=5天 或 高紧急度超期)
  1366. const overdueHighRisk = this.filteredProjects
  1367. .filter(p => p.isOverdue && (p.overdueDays >= 5 || p.urgency === 'high'))
  1368. .sort((a, b) => b.overdueDays - a.overdueDays)
  1369. .slice(0, 5);
  1370. // 2. 超负荷设计师
  1371. const overloadedDesigners = this.designerWorkloadCards
  1372. .filter(d => d.loadRate > 80)
  1373. .sort((a, b) => b.loadRate - a.loadRate)
  1374. .slice(0, 5);
  1375. // 3. 即将到期项目(1-2天内)
  1376. const now = new Date();
  1377. const dueSoonProjects = this.filteredProjects
  1378. .filter(p => {
  1379. if (p.isOverdue) return false;
  1380. const daysLeft = Math.ceil((p.deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  1381. return daysLeft >= 1 && daysLeft <= 2;
  1382. })
  1383. .sort((a, b) => a.deadline.getTime() - b.deadline.getTime())
  1384. .slice(0, 5);
  1385. const totalAlerts = overdueHighRisk.length + overloadedDesigners.length + dueSoonProjects.length;
  1386. return {
  1387. totalAlerts,
  1388. overdueHighRisk,
  1389. overloadedDesigners,
  1390. dueSoonProjects
  1391. };
  1392. }
  1393. /**
  1394. * 打开智能推荐弹窗
  1395. */
  1396. async openSmartMatch(project: any): Promise<void> {
  1397. this.selectedProject = project;
  1398. this.showSmartMatch = true;
  1399. try {
  1400. this.recommendations = await this.designerService.getRecommendedDesigners(project, this.realDesigners);
  1401. } catch (error) {
  1402. console.error('智能推荐失败:', error);
  1403. this.recommendations = [];
  1404. }
  1405. }
  1406. /**
  1407. * 关闭智能推荐弹窗
  1408. */
  1409. closeSmartMatch(): void {
  1410. this.showSmartMatch = false;
  1411. this.selectedProject = null;
  1412. this.recommendations = [];
  1413. }
  1414. /**
  1415. * 分配项目给设计师
  1416. */
  1417. async assignToDesigner(designerId: string): Promise<void> {
  1418. if (!this.selectedProject) return;
  1419. try {
  1420. const success = await this.designerService.assignProject(this.selectedProject.id, designerId);
  1421. if (success) {
  1422. this.closeSmartMatch();
  1423. await this.loadProjects(); // 重新加载项目数据
  1424. }
  1425. } catch (error) {
  1426. console.error('❌ 分配项目失败:', error);
  1427. window?.fmode?.alert('分配失败,请重试');
  1428. }
  1429. }
  1430. /**
  1431. * 获取紧急度标签
  1432. */
  1433. getUrgencyLabel(urgency: string): string {
  1434. const labels: Record<string, string> = {
  1435. 'high': '高',
  1436. 'medium': '中',
  1437. 'low': '低'
  1438. };
  1439. return labels[urgency] || '未知';
  1440. }
  1441. // 切换项目看板/负载日历(甘特)视图
  1442. toggleView(): void {
  1443. this.showGanttView = !this.showGanttView;
  1444. if (this.showGanttView) {
  1445. // 切换到时间轴视图时,延迟加载数据(性能优化)
  1446. setTimeout(() => {
  1447. this.convertToProjectTimeline();
  1448. }, 0);
  1449. } else {
  1450. if (this.ganttChart) {
  1451. this.ganttChart.dispose();
  1452. this.ganttChart = null;
  1453. }
  1454. }
  1455. }
  1456. // 设置甘特时间尺度
  1457. setGanttScale(scale: 'day' | 'week' | 'month'): void {
  1458. if (this.ganttScale !== scale) {
  1459. this.ganttScale = scale;
  1460. this.updateGantt();
  1461. }
  1462. }
  1463. // 工作负载甘特图时间尺度切换
  1464. setWorkloadGanttScale(scale: 'week' | 'month'): void {
  1465. if (this.workloadGanttScale !== scale) {
  1466. this.workloadGanttScale = scale;
  1467. this.updateWorkloadGantt();
  1468. }
  1469. }
  1470. // 新增:切换甘特模式
  1471. setGanttMode(mode: 'project' | 'designer'): void {
  1472. if (this.ganttMode !== mode) {
  1473. this.ganttMode = mode;
  1474. this.updateGantt();
  1475. }
  1476. }
  1477. private initOrUpdateGantt(): void {
  1478. if (!this.ganttChartRef) return;
  1479. const el = this.ganttChartRef.nativeElement;
  1480. if (!this.ganttChart) {
  1481. this.ganttChart = echarts.init(el);
  1482. // 添加点击事件监听器
  1483. this.ganttChart.on('click', (params: any) => {
  1484. if (params.componentType === 'series' && params.seriesType === 'custom') {
  1485. // 获取点击的员工名称(从y轴类目数据中获取)
  1486. const yAxisData = this.ganttChart.getOption().yAxis[0].data;
  1487. if (yAxisData && params.dataIndex !== undefined) {
  1488. const employeeName = yAxisData[params.value[0]];
  1489. if (employeeName && employeeName !== '未分配') {
  1490. this.onEmployeeClick(employeeName);
  1491. }
  1492. }
  1493. }
  1494. });
  1495. window.addEventListener('resize', () => {
  1496. this.ganttChart && this.ganttChart.resize();
  1497. });
  1498. }
  1499. this.updateGantt();
  1500. }
  1501. private updateGantt(): void {
  1502. if (!this.ganttChart) return;
  1503. if (this.ganttMode === 'designer') {
  1504. this.updateGanttDesigner();
  1505. return;
  1506. }
  1507. // 按紧急程度从上到下排序(高->中->低),同级按到期时间升序
  1508. const urgencyRank: Record<'high'|'medium'|'low', number> = { high: 0, medium: 1, low: 2 };
  1509. const projects = [...this.filteredProjects]
  1510. .sort((a, b) => {
  1511. const u = urgencyRank[a.urgency] - urgencyRank[b.urgency];
  1512. if (u !== 0) return u;
  1513. // 二级排序:临期优先(到期更近)> 已分配人员 > VIP客户 > 其他
  1514. const endDiff = new Date(a.deadline).getTime() - new Date(b.deadline).getTime();
  1515. if (endDiff !== 0) return endDiff;
  1516. const assignedA = !!a.designerName;
  1517. const assignedB = !!b.designerName;
  1518. if (assignedA !== assignedB) return assignedA ? -1 : 1; // 已分配在前
  1519. const vipA = a.memberType === 'vip';
  1520. const vipB = b.memberType === 'vip';
  1521. if (vipA !== vipB) return vipA ? -1 : 1; // VIP在前
  1522. return a.name.localeCompare(b.name, 'zh-CN');
  1523. });
  1524. const categories = projects.map(p => p.name);
  1525. const urgencyMap: Record<string, 'high'|'medium'|'low'> = Object.fromEntries(projects.map(p => [p.name, p.urgency])) as any;
  1526. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  1527. high: '#ef4444',
  1528. medium: '#f59e0b',
  1529. low: '#22c55e'
  1530. } as const;
  1531. const DAY = 24 * 60 * 60 * 1000;
  1532. const data = projects.map((p, idx) => {
  1533. const end = new Date(p.deadline).getTime();
  1534. const baseDays = p.type === 'hard' ? 30 : 14;
  1535. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  1536. const color = colorByUrgency[p.urgency] || '#60a5fa';
  1537. return {
  1538. name: p.name,
  1539. value: [idx, start, end, p.designerName, p.urgency, p.memberType, p.currentStage],
  1540. itemStyle: { color }
  1541. };
  1542. });
  1543. // 计算时间范围(仅周/月)
  1544. const now = new Date();
  1545. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  1546. const todayTs = today.getTime();
  1547. let xMin: number;
  1548. let xMax: number;
  1549. let xSplitNumber: number;
  1550. let xLabelFormatter: (value: number) => string;
  1551. if (this.ganttScale === 'week') {
  1552. const day = today.getDay(); // 0=周日
  1553. const diffToMonday = (day === 0 ? 6 : day - 1);
  1554. const startOfWeek = new Date(today.getTime() - diffToMonday * DAY);
  1555. const endOfWeek = new Date(startOfWeek.getTime() + 7 * DAY - 1);
  1556. xMin = startOfWeek.getTime();
  1557. xMax = endOfWeek.getTime();
  1558. xSplitNumber = 7;
  1559. const WEEK_LABELS = ['周日','周一','周二','周三','周四','周五','周六'];
  1560. xLabelFormatter = (val) => {
  1561. const d = new Date(val);
  1562. return WEEK_LABELS[d.getDay()];
  1563. };
  1564. } else { // month
  1565. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  1566. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0, 23, 59, 59, 999);
  1567. xMin = startOfMonth.getTime();
  1568. xMax = endOfMonth.getTime();
  1569. xSplitNumber = 4;
  1570. xLabelFormatter = (val) => {
  1571. const d = new Date(val);
  1572. const weekOfMonth = Math.ceil(d.getDate() / 7);
  1573. return `第${weekOfMonth}周`;
  1574. };
  1575. }
  1576. // 计算默认可视区,并尝试保留上一次的滚动/缩放位置
  1577. const total = categories.length;
  1578. const visible = Math.min(total, 15); // 默认首屏展开15条
  1579. const defaultEndPercent = total > 0 ? Math.min(100, (visible / total) * 100) : 100;
  1580. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  1581. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  1582. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  1583. // 生成请假覆盖层数据
  1584. const leaveOverlayData = this.generateLeaveOverlayData(categories, xMin, xMax);
  1585. const option = {
  1586. backgroundColor: 'transparent',
  1587. tooltip: {
  1588. trigger: 'item',
  1589. formatter: (params: any) => {
  1590. const v = params.value;
  1591. const start = new Date(v[1]);
  1592. const end = new Date(v[2]);
  1593. return `项目:${params.name}<br/>负责人:${v[3] || '未分配'}<br/>阶段:${v[6]}<br/>起止:${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}`;
  1594. }
  1595. },
  1596. grid: { left: 100, right: 64, top: 30, bottom: 30 },
  1597. xAxis: {
  1598. type: 'time',
  1599. min: xMin,
  1600. max: xMax,
  1601. splitNumber: xSplitNumber,
  1602. axisLine: { lineStyle: { color: '#e5e7eb' } },
  1603. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  1604. splitLine: { lineStyle: { color: '#f1f5f9' } }
  1605. },
  1606. yAxis: {
  1607. type: 'category',
  1608. data: categories,
  1609. inverse: true,
  1610. axisLabel: {
  1611. color: '#374151',
  1612. margin: 8,
  1613. formatter: (val: string) => {
  1614. const u = urgencyMap[val] || 'low';
  1615. const text = val.length > 16 ? val.slice(0, 16) + '…' : val;
  1616. return `{${u}Dot|●} ${text}`;
  1617. },
  1618. rich: {
  1619. highDot: { color: '#ef4444' },
  1620. mediumDot: { color: '#f59e0b' },
  1621. lowDot: { color: '#22c55e' }
  1622. }
  1623. },
  1624. axisTick: { show: false },
  1625. axisLine: { lineStyle: { color: '#e5e7eb' } }
  1626. },
  1627. dataZoom: [
  1628. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, width: 14, start: preservedStart, end: preservedEnd, zoomLock: false },
  1629. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  1630. ],
  1631. series: [
  1632. // 项目条形图系列
  1633. {
  1634. type: 'custom',
  1635. name: '项目进度',
  1636. renderItem: (params: any, api: any) => {
  1637. const categoryIndex = api.value(0);
  1638. const start = api.coord([api.value(1), categoryIndex]);
  1639. const end = api.coord([api.value(2), categoryIndex]);
  1640. const height = Math.max(api.size([0, 1])[1] * 0.5, 8);
  1641. const rectShape = echarts.graphic.clipRectByRect({
  1642. x: start[0],
  1643. y: start[1] - height / 2,
  1644. width: Math.max(end[0] - start[0], 2),
  1645. height
  1646. }, {
  1647. x: params.coordSys.x,
  1648. y: params.coordSys.y,
  1649. width: params.coordSys.width,
  1650. height: params.coordSys.height
  1651. });
  1652. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  1653. },
  1654. encode: { x: [1, 2], y: 0 },
  1655. data,
  1656. itemStyle: { borderRadius: 4 },
  1657. emphasis: { focus: 'self' },
  1658. markLine: {
  1659. silent: true,
  1660. symbol: 'none',
  1661. lineStyle: { color: '#ef4444', type: 'dashed', width: 1 },
  1662. label: { formatter: '今日', color: '#ef4444', fontSize: 10, position: 'end' },
  1663. data: [ { xAxis: todayTs } ]
  1664. }
  1665. },
  1666. // 请假覆盖层系列
  1667. {
  1668. type: 'custom',
  1669. name: '请假/繁忙标记',
  1670. renderItem: (params: any, api: any) => {
  1671. const categoryIndex = api.value(0);
  1672. const start = api.coord([api.value(1), categoryIndex]);
  1673. const end = api.coord([api.value(2), categoryIndex]);
  1674. const height = Math.max(api.size([0, 1])[1] * 0.8, 12); // 稍微高一点,覆盖项目条
  1675. const rectShape = echarts.graphic.clipRectByRect({
  1676. x: start[0],
  1677. y: start[1] - height / 2,
  1678. width: Math.max(end[0] - start[0], 2),
  1679. height
  1680. }, {
  1681. x: params.coordSys.x,
  1682. y: params.coordSys.y,
  1683. width: params.coordSys.width,
  1684. height: params.coordSys.height
  1685. });
  1686. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  1687. },
  1688. encode: { x: [1, 2], y: 0 },
  1689. data: leaveOverlayData,
  1690. itemStyle: { borderRadius: 4 },
  1691. emphasis: { focus: 'self' },
  1692. z: 10 // 确保覆盖层在项目条之上
  1693. }
  1694. ]
  1695. };
  1696. // 强制刷新,避免缓存导致坐标轴不更新
  1697. this.ganttChart.clear();
  1698. this.ganttChart.setOption(option, true);
  1699. this.ganttChart.resize();
  1700. }
  1701. // 新增:设计师排班甘特
  1702. private updateGanttDesigner(): void {
  1703. if (!this.ganttChart) return;
  1704. const DAY = 24 * 60 * 60 * 1000;
  1705. const now = new Date();
  1706. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  1707. const todayTs = today.getTime();
  1708. // 时间轴按当前周/月/日
  1709. let xMin: number;
  1710. let xMax: number;
  1711. let xSplitNumber: number;
  1712. let xLabelFormatter: (value: number) => string;
  1713. if (this.ganttScale === 'day') {
  1714. // 日视图:显示今日24小时
  1715. const startOfDay = new Date(today.getTime());
  1716. const endOfDay = new Date(today.getTime() + DAY - 1);
  1717. xMin = startOfDay.getTime();
  1718. xMax = endOfDay.getTime();
  1719. xSplitNumber = 24;
  1720. xLabelFormatter = (val) => `${new Date(val).getHours()}:00`;
  1721. } else if (this.ganttScale === 'week') {
  1722. // 周视图:从今天开始显示未来7天的具体日期
  1723. const startOfWeek = new Date(today.getTime());
  1724. const endOfWeek = new Date(today.getTime() + 7 * DAY - 1);
  1725. xMin = startOfWeek.getTime();
  1726. xMax = endOfWeek.getTime();
  1727. xSplitNumber = 7;
  1728. xLabelFormatter = (val) => {
  1729. const date = new Date(val);
  1730. const month = date.getMonth() + 1;
  1731. const day = date.getDate();
  1732. return `${month}月${day}日`;
  1733. };
  1734. } else {
  1735. // 月视图:从当前月份开始显示未来几个月
  1736. const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
  1737. const endOfMonth = new Date(today.getFullYear(), today.getMonth() + 3, 0, 23, 59, 59, 999); // 显示未来3个月
  1738. xMin = startOfMonth.getTime();
  1739. xMax = endOfMonth.getTime();
  1740. xSplitNumber = 3;
  1741. xLabelFormatter = (val) => {
  1742. const date = new Date(val);
  1743. const year = date.getFullYear();
  1744. const month = date.getMonth() + 1;
  1745. return `${year}年${month}月`;
  1746. };
  1747. }
  1748. // 仅统计已分配项目
  1749. const assigned = this.filteredProjects.filter(p => !!p.designerName);
  1750. const designers = Array.from(new Set(assigned.map(p => p.designerName)));
  1751. const byDesigner: Record<string, typeof assigned> = {} as any;
  1752. designers.forEach(n => byDesigner[n] = [] as any);
  1753. assigned.forEach(p => byDesigner[p.designerName].push(p));
  1754. const busyCountMap: Record<string, number> = Object.fromEntries(designers.map(n => [n, byDesigner[n].length]));
  1755. const sortedDesigners = designers.sort((a, b) => {
  1756. const diff = (busyCountMap[b] || 0) - (busyCountMap[a] || 0);
  1757. return diff !== 0 ? diff : a.localeCompare(b, 'zh-CN');
  1758. });
  1759. const categories = sortedDesigners;
  1760. // 工作量等级(用于左侧小圆点颜色和负荷状态判断)
  1761. const workloadLevelMap: Record<string, 'high'|'medium'|'low'> = {} as any;
  1762. const workloadStatusMap: Record<string, 'overloaded'|'busy'|'available'> = {} as any;
  1763. categories.forEach(name => {
  1764. const cnt = busyCountMap[name] || 0;
  1765. if (cnt >= 5) {
  1766. workloadLevelMap[name] = 'high';
  1767. workloadStatusMap[name] = 'overloaded'; // 不宜派单
  1768. } else if (cnt >= 3) {
  1769. workloadLevelMap[name] = 'medium';
  1770. workloadStatusMap[name] = 'busy'; // 适度忙碌
  1771. } else {
  1772. workloadLevelMap[name] = 'low';
  1773. workloadStatusMap[name] = 'available'; // 可接单
  1774. }
  1775. });
  1776. // 条形颜色按项目紧急度,增强高负荷时段的视觉效果
  1777. const colorByUrgency: Record<'high'|'medium'|'low', string> = {
  1778. high: '#dc2626', // 更深的红色,突出高紧急度
  1779. medium: '#ea580c', // 更深的橙色
  1780. low: '#16a34a' // 更深的绿色
  1781. } as const;
  1782. const data = assigned.flatMap(p => {
  1783. const end = new Date(p.deadline).getTime();
  1784. const baseDays = p.type === 'hard' ? 30 : 14;
  1785. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  1786. const yIndex = categories.indexOf(p.designerName);
  1787. if (yIndex === -1) return [] as any[];
  1788. // 根据设计师工作负荷状态调整项目条的视觉效果
  1789. const workloadStatus = workloadStatusMap[p.designerName];
  1790. let color = colorByUrgency[p.urgency] || '#60a5fa';
  1791. let borderWidth = 1;
  1792. let borderColor = 'transparent';
  1793. // 高负荷时段增强视觉效果
  1794. if (workloadStatus === 'overloaded') {
  1795. borderWidth = 3;
  1796. borderColor = '#991b1b'; // 深红色边框
  1797. // 对于超负荷状态,使用更深的红色调
  1798. if (p.urgency === 'high') {
  1799. color = '#7f1d1d'; // 深红色
  1800. } else if (p.urgency === 'medium') {
  1801. color = '#c2410c'; // 深橙色
  1802. } else {
  1803. color = '#dc2626'; // 红色(即使是低紧急度也用红色表示超负荷)
  1804. }
  1805. }
  1806. return [{
  1807. name: p.name,
  1808. value: [yIndex, start, end, p.designerName, p.urgency, p.memberType, p.currentStage, workloadStatus],
  1809. itemStyle: {
  1810. color,
  1811. borderWidth,
  1812. borderColor,
  1813. opacity: workloadStatus === 'overloaded' ? 0.9 : 1.0
  1814. }
  1815. }];
  1816. });
  1817. // 生成空闲时段背景数据 - 只在真正空闲的时间段显示
  1818. const idleBackgroundData: any[] = [];
  1819. categories.forEach((designerName, yIndex) => {
  1820. const designerProjects = byDesigner[designerName] || [];
  1821. const workloadStatus = workloadStatusMap[designerName];
  1822. // 获取该设计师的所有项目时间段
  1823. const projectTimeRanges = designerProjects.map(p => {
  1824. const end = new Date(p.deadline).getTime();
  1825. const baseDays = p.type === 'hard' ? 30 : 14;
  1826. const start = p.createdAt ? new Date(p.createdAt).getTime() : end - baseDays * DAY;
  1827. return { start, end };
  1828. }).sort((a, b) => a.start - b.start);
  1829. // 找出空闲时间段
  1830. const idleTimeRanges: { start: number; end: number }[] = [];
  1831. if (projectTimeRanges.length === 0) {
  1832. // 完全没有项目,整个时间轴都是空闲
  1833. idleTimeRanges.push({ start: xMin, end: xMax });
  1834. } else {
  1835. // 检查项目之间的空隙
  1836. let currentTime = xMin;
  1837. for (const range of projectTimeRanges) {
  1838. if (currentTime < range.start) {
  1839. // 在项目开始前有空闲时间
  1840. idleTimeRanges.push({ start: currentTime, end: range.start });
  1841. }
  1842. currentTime = Math.max(currentTime, range.end);
  1843. }
  1844. // 检查最后一个项目后是否还有空闲时间
  1845. if (currentTime < xMax) {
  1846. idleTimeRanges.push({ start: currentTime, end: xMax });
  1847. }
  1848. }
  1849. // 为每个空闲时间段创建背景数据
  1850. idleTimeRanges.forEach((idleRange, index) => {
  1851. // 只有当空闲时间段足够长时才显示(至少1天)
  1852. if (idleRange.end - idleRange.start >= DAY) {
  1853. let backgroundColor = 'transparent';
  1854. if (workloadStatus === 'available') {
  1855. backgroundColor = 'rgba(34, 197, 94, 0.15)'; // 淡绿色背景表示空闲可接单
  1856. } else if (workloadStatus === 'overloaded') {
  1857. backgroundColor = 'rgba(239, 68, 68, 0.1)'; // 淡红色背景表示超负荷
  1858. }
  1859. if (backgroundColor !== 'transparent') {
  1860. idleBackgroundData.push({
  1861. name: `${designerName}-空闲${index + 1}`,
  1862. value: [yIndex, idleRange.start, idleRange.end, designerName, 'background', workloadStatus],
  1863. itemStyle: {
  1864. color: backgroundColor,
  1865. borderWidth: 0
  1866. }
  1867. });
  1868. }
  1869. }
  1870. });
  1871. });
  1872. const prevOpt: any = (this.ganttChart as any).getOption ? (this.ganttChart as any).getOption() : null;
  1873. const total = categories.length || 1;
  1874. const visible = Math.min(total, 30);
  1875. const defaultEndPercent = Math.min(100, (visible / total) * 100);
  1876. const preservedStart = typeof prevOpt?.dataZoom?.[0]?.start === 'number' ? prevOpt.dataZoom[0].start : 0;
  1877. const preservedEnd = typeof prevOpt?.dataZoom?.[0]?.end === 'number' ? prevOpt.dataZoom[0].end : defaultEndPercent;
  1878. const option = {
  1879. backgroundColor: 'transparent',
  1880. tooltip: {
  1881. trigger: 'item',
  1882. backgroundColor: 'rgba(255, 255, 255, 0.95)',
  1883. borderColor: '#e5e7eb',
  1884. borderWidth: 1,
  1885. padding: [12, 16],
  1886. textStyle: { color: '#374151', fontSize: 13 },
  1887. formatter: (params: any) => {
  1888. const v = params.value;
  1889. if (v[4] === 'background') {
  1890. const workloadStatus = v[5];
  1891. const statusText = workloadStatus === 'available' ? '空闲可接单' :
  1892. workloadStatus === 'overloaded' ? '超负荷不宜派单' : '适度忙碌';
  1893. return `<div style="padding: 4px 0;">
  1894. <div style="font-weight: 600; margin-bottom: 6px;">👤 ${v[3]}</div>
  1895. <div style="color: #6b7280;">状态:${statusText}</div>
  1896. </div>`;
  1897. }
  1898. const start = new Date(v[1]);
  1899. const end = new Date(v[2]);
  1900. const urgency = v[4];
  1901. const memberType = v[5];
  1902. const currentStage = v[6];
  1903. const workloadStatus = v[7];
  1904. // 紧急度标识
  1905. const urgencyBadge = urgency === 'high' ? '<span style="background:#dc2626;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">🔥 高紧急</span>' :
  1906. urgency === 'medium' ? '<span style="background:#ea580c;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">⚡ 中紧急</span>' :
  1907. '<span style="background:#16a34a;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">✓ 正常</span>';
  1908. // VIP标识
  1909. const vipBadge = memberType === 'vip' ? '<span style="background:#f59e0b;color:white;padding:2px 6px;border-radius:3px;font-size:11px;margin-left:4px;">⭐ VIP</span>' : '';
  1910. // 负载状态
  1911. const statusIcon = workloadStatus === 'available' ? '🟢' :
  1912. workloadStatus === 'overloaded' ? '🔴' : '🟡';
  1913. const statusText = workloadStatus === 'available' ? '可接单' :
  1914. workloadStatus === 'overloaded' ? '超负荷' : '适度忙碌';
  1915. // 计算项目持续天数
  1916. const durationDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
  1917. // 剩余天数
  1918. const now = new Date();
  1919. const remainingDays = Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
  1920. const remainingText = remainingDays > 0 ? `剩余${remainingDays}天` :
  1921. remainingDays === 0 ? '今天截止' :
  1922. `已超期${Math.abs(remainingDays)}天`;
  1923. const remainingColor = remainingDays > 7 ? '#16a34a' :
  1924. remainingDays > 0 ? '#ea580c' : '#dc2626';
  1925. return `<div style="min-width: 280px;">
  1926. <div style="font-weight: 600; margin-bottom: 8px; font-size: 14px; display: flex; align-items: center; gap: 6px;">
  1927. 🎨 ${params.name}
  1928. </div>
  1929. <div style="display: flex; gap: 4px; margin-bottom: 8px;">
  1930. ${urgencyBadge}${vipBadge}
  1931. </div>
  1932. <div style="border-top: 1px solid #e5e7eb; padding-top: 8px; margin-top: 4px;">
  1933. <div style="margin-bottom: 4px;">👤 设计师:<strong>${v[3]}</strong> ${statusIcon} <span style="color: #6b7280;">${statusText}</span></div>
  1934. <div style="margin-bottom: 4px;">📋 阶段:<span style="color: #6b7280;">${currentStage}</span></div>
  1935. <div style="margin-bottom: 4px;">📅 周期:<span style="color: #6b7280;">${start.toLocaleDateString()} ~ ${end.toLocaleDateString()}</span> (${durationDays}天)</div>
  1936. <div style="margin-bottom: 4px;">⏱️ 状态:<span style="color: ${remainingColor}; font-weight: 600;">${remainingText}</span></div>
  1937. </div>
  1938. <div style="border-top: 1px solid #e5e7eb; padding-top: 6px; margin-top: 6px; color: #9ca3af; font-size: 11px;">
  1939. 💡 点击条形可查看项目详情
  1940. </div>
  1941. </div>`;
  1942. }
  1943. },
  1944. title: {
  1945. text: this.ganttScale === 'week' ? '本周项目排期' : '本月项目排期',
  1946. subtext: '每个条形代表一个项目,颜色越深紧急度越高',
  1947. left: 'center',
  1948. top: 10,
  1949. textStyle: { fontSize: 15, color: '#374151', fontWeight: 600 },
  1950. subtextStyle: { fontSize: 12, color: '#6b7280' }
  1951. },
  1952. legend: {
  1953. data: ['🔥 高紧急', '⚡ 中紧急', '✓ 正常', '🟢 可接单', '🟡 忙碌', '🔴 超负荷'],
  1954. bottom: 10,
  1955. itemGap: 20,
  1956. textStyle: { fontSize: 12, color: '#6b7280' }
  1957. },
  1958. grid: { left: 150, right: 70, top: 60, bottom: 70 },
  1959. xAxis: {
  1960. type: 'time',
  1961. min: xMin,
  1962. max: xMax,
  1963. splitNumber: xSplitNumber,
  1964. axisLine: { lineStyle: { color: '#e5e7eb' } },
  1965. axisLabel: { color: '#6b7280', formatter: (value: number) => xLabelFormatter(value) },
  1966. splitLine: { lineStyle: { color: '#f1f5f9' } }
  1967. },
  1968. yAxis: {
  1969. type: 'category',
  1970. data: categories,
  1971. inverse: true,
  1972. axisLabel: {
  1973. color: '#374151',
  1974. margin: 10,
  1975. fontSize: 13,
  1976. fontWeight: 500,
  1977. formatter: (val: string) => {
  1978. const lvl = workloadLevelMap[val] || 'low';
  1979. const count = busyCountMap[val] || 0;
  1980. const status = workloadStatusMap[val] || 'available';
  1981. const text = val.length > 6 ? val.slice(0, 6) + '…' : val;
  1982. // 根据负载状态选择图标和颜色
  1983. const statusIcon = status === 'available' ? '○' :
  1984. status === 'overloaded' ? '🔥' : '⚡';
  1985. // 项目数量的视觉强化
  1986. const countDisplay = count >= 5 ? `{highCount|${count}}` :
  1987. count >= 3 ? `{mediumCount|${count}}` :
  1988. count >= 1 ? `{lowCount|${count}}` :
  1989. `{idleCount|${count}}`;
  1990. return `${statusIcon} {name|${text}} ${countDisplay}`;
  1991. },
  1992. rich: {
  1993. name: {
  1994. color: '#374151',
  1995. fontSize: 13,
  1996. fontWeight: 500,
  1997. padding: [0, 4, 0, 2]
  1998. },
  1999. highCount: {
  2000. color: '#dc2626',
  2001. fontSize: 12,
  2002. fontWeight: 700,
  2003. backgroundColor: '#fee2e2',
  2004. padding: [2, 6],
  2005. borderRadius: 3
  2006. },
  2007. mediumCount: {
  2008. color: '#ea580c',
  2009. fontSize: 12,
  2010. fontWeight: 700,
  2011. backgroundColor: '#ffedd5',
  2012. padding: [2, 6],
  2013. borderRadius: 3
  2014. },
  2015. lowCount: {
  2016. color: '#16a34a',
  2017. fontSize: 12,
  2018. fontWeight: 600,
  2019. backgroundColor: '#dcfce7',
  2020. padding: [2, 6],
  2021. borderRadius: 3
  2022. },
  2023. idleCount: {
  2024. color: '#9ca3af',
  2025. fontSize: 12,
  2026. fontWeight: 500,
  2027. backgroundColor: '#f3f4f6',
  2028. padding: [2, 6],
  2029. borderRadius: 3
  2030. }
  2031. }
  2032. },
  2033. axisTick: { show: false },
  2034. axisLine: { lineStyle: { color: '#e5e7eb' } }
  2035. },
  2036. dataZoom: [
  2037. { type: 'slider', yAxisIndex: 0, orient: 'vertical', right: 6, start: preservedStart, end: preservedEnd, zoomLock: false },
  2038. { type: 'inside', yAxisIndex: 0, zoomOnMouseWheel: true, moveOnMouseMove: true, moveOnMouseWheel: true }
  2039. ],
  2040. series: [
  2041. // 背景层 - 显示空闲时段
  2042. {
  2043. type: 'custom',
  2044. name: '工作负荷背景',
  2045. renderItem: (params: any, api: any) => {
  2046. const categoryIndex = api.value(0);
  2047. const start = api.coord([api.value(1), categoryIndex]);
  2048. const end = api.coord([api.value(2), categoryIndex]);
  2049. const height = api.size([0, 1])[1] * 0.8;
  2050. const rectShape = echarts.graphic.clipRectByRect({
  2051. x: start[0],
  2052. y: start[1] - height / 2,
  2053. width: Math.max(end[0] - start[0], 2),
  2054. height
  2055. }, {
  2056. x: params.coordSys.x,
  2057. y: params.coordSys.y,
  2058. width: params.coordSys.width,
  2059. height: params.coordSys.height
  2060. });
  2061. return rectShape ? { type: 'rect', shape: rectShape, style: api.style() } : undefined;
  2062. },
  2063. encode: { x: [1, 2], y: 0 },
  2064. data: idleBackgroundData,
  2065. z: 1
  2066. },
  2067. // 项目条层
  2068. {
  2069. type: 'custom',
  2070. name: '项目进度',
  2071. renderItem: (params: any, api: any) => {
  2072. const categoryIndex = api.value(0);
  2073. const start = api.coord([api.value(1), categoryIndex]);
  2074. const end = api.coord([api.value(2), categoryIndex]);
  2075. // 增加条形高度,让项目更明显
  2076. const height = Math.max(api.size([0, 1])[1] * 0.6, 16);
  2077. const width = Math.max(end[0] - start[0], 2);
  2078. const rectShape = echarts.graphic.clipRectByRect({
  2079. x: start[0],
  2080. y: start[1] - height / 2,
  2081. width,
  2082. height
  2083. }, {
  2084. x: params.coordSys.x,
  2085. y: params.coordSys.y,
  2086. width: params.coordSys.width,
  2087. height: params.coordSys.height
  2088. });
  2089. if (!rectShape) return undefined;
  2090. // 获取项目数据
  2091. const urgency = api.value(4);
  2092. const workloadStatus = api.value(7);
  2093. // 基础矩形样式
  2094. const rectStyle = api.style();
  2095. // 根据负载状态添加额外的视觉效果
  2096. if (workloadStatus === 'overloaded') {
  2097. rectStyle.shadowBlur = 8;
  2098. rectStyle.shadowColor = 'rgba(220, 38, 38, 0.4)';
  2099. rectStyle.shadowOffsetY = 2;
  2100. }
  2101. const rect = {
  2102. type: 'rect',
  2103. shape: rectShape,
  2104. style: rectStyle
  2105. };
  2106. // 项目名称和紧急度标识
  2107. const projectName = params.name || '';
  2108. const minWidthForText = 50; // 降低最小宽度要求
  2109. if (width >= minWidthForText && projectName) {
  2110. // 紧急度图标
  2111. const urgencyIcon = urgency === 'high' ? '🔥' :
  2112. urgency === 'medium' ? '⚡' : '✓';
  2113. // 截断过长的项目名称
  2114. const maxChars = Math.floor(width / 9); // 估算能显示的字符数
  2115. const displayName = projectName.length > maxChars ?
  2116. projectName.slice(0, maxChars - 2) + '…' :
  2117. projectName;
  2118. const fullText = `${urgencyIcon} ${displayName}`;
  2119. // 返回组合图形:矩形 + 文本
  2120. return {
  2121. type: 'group',
  2122. children: [
  2123. rect,
  2124. {
  2125. type: 'text',
  2126. style: {
  2127. text: fullText,
  2128. x: rectShape.x + 8,
  2129. y: rectShape.y + rectShape.height / 2,
  2130. textVerticalAlign: 'middle',
  2131. fontSize: 12,
  2132. fontWeight: 600,
  2133. fill: '#ffffff',
  2134. stroke: 'rgba(0, 0, 0, 0.4)',
  2135. lineWidth: 0.8,
  2136. textShadowColor: 'rgba(0, 0, 0, 0.5)',
  2137. textShadowBlur: 3,
  2138. textShadowOffsetX: 0,
  2139. textShadowOffsetY: 1
  2140. }
  2141. }
  2142. ]
  2143. };
  2144. } else if (width >= 30) {
  2145. // 如果空间太小,只显示紧急度图标
  2146. const urgencyIcon = urgency === 'high' ? '🔥' :
  2147. urgency === 'medium' ? '⚡' : '✓';
  2148. return {
  2149. type: 'group',
  2150. children: [
  2151. rect,
  2152. {
  2153. type: 'text',
  2154. style: {
  2155. text: urgencyIcon,
  2156. x: rectShape.x + width / 2,
  2157. y: rectShape.y + rectShape.height / 2,
  2158. textAlign: 'center',
  2159. textVerticalAlign: 'middle',
  2160. fontSize: 12
  2161. }
  2162. }
  2163. ]
  2164. };
  2165. }
  2166. return rect;
  2167. },
  2168. encode: { x: [1, 2], y: 0 },
  2169. data,
  2170. itemStyle: { borderRadius: 4 },
  2171. emphasis: {
  2172. focus: 'self',
  2173. itemStyle: {
  2174. borderWidth: 2,
  2175. borderColor: '#374151',
  2176. shadowBlur: 8,
  2177. shadowColor: 'rgba(0, 0, 0, 0.3)'
  2178. }
  2179. },
  2180. z: 2,
  2181. markLine: {
  2182. silent: true,
  2183. symbol: 'none',
  2184. lineStyle: { color: '#ef4444', type: 'dashed', width: 2 },
  2185. label: {
  2186. formatter: '今日',
  2187. color: '#ef4444',
  2188. fontSize: 11,
  2189. fontWeight: 600,
  2190. position: 'end',
  2191. backgroundColor: '#ffffff',
  2192. padding: [2, 6],
  2193. borderRadius: 3
  2194. },
  2195. data: [ { xAxis: todayTs } ]
  2196. }
  2197. }
  2198. ]
  2199. } as any;
  2200. this.ganttChart.clear();
  2201. this.ganttChart.setOption(option, true);
  2202. this.ganttChart.resize();
  2203. }
  2204. /**
  2205. * 工作负载甘特图:显示设计师在周/月内的工作状态
  2206. */
  2207. private updateWorkloadGantt(): void {
  2208. if (!this.workloadGanttContainer?.nativeElement) {
  2209. setTimeout(() => this.updateWorkloadGantt(), 100);
  2210. return;
  2211. }
  2212. if (!this.workloadGanttChart) {
  2213. this.workloadGanttChart = echarts.init(this.workloadGanttContainer.nativeElement);
  2214. }
  2215. const DAY = 24 * 60 * 60 * 1000;
  2216. const now = new Date();
  2217. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  2218. const todayTs = today.getTime();
  2219. // 时间范围
  2220. let xMin: number;
  2221. let xMax: number;
  2222. let xSplitNumber: number;
  2223. let xLabelFormatter: (value: number) => string;
  2224. if (this.workloadGanttScale === 'week') {
  2225. // 周视图:显示未来7天
  2226. xMin = todayTs;
  2227. xMax = todayTs + 7 * DAY;
  2228. xSplitNumber = 7;
  2229. xLabelFormatter = (val: any) => {
  2230. const date = new Date(val);
  2231. const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
  2232. return `${date.getMonth() + 1}/${date.getDate()}\n${weekDays[date.getDay()]}`;
  2233. };
  2234. } else {
  2235. // 月视图:显示未来30天
  2236. xMin = todayTs;
  2237. xMax = todayTs + 30 * DAY;
  2238. xSplitNumber = 30;
  2239. xLabelFormatter = (val: any) => {
  2240. const date = new Date(val);
  2241. return `${date.getMonth() + 1}/${date.getDate()}`;
  2242. };
  2243. }
  2244. // 获取所有真实设计师
  2245. let designers: string[] = [];
  2246. if (this.realDesigners && this.realDesigners.length > 0) {
  2247. designers = this.realDesigners.map(d => d.name);
  2248. } else {
  2249. // 降级:从已分配的项目中提取设计师
  2250. const assigned = this.filteredProjects.filter(p => p.designerName && p.designerName !== '未分配');
  2251. designers = Array.from(new Set(assigned.map(p => p.designerName)));
  2252. }
  2253. if (designers.length === 0) {
  2254. // 没有设计师数据,显示空状态
  2255. const emptyOption = {
  2256. title: {
  2257. text: '暂无组员数据',
  2258. subtext: '请先在系统中添加设计师(组员角色)',
  2259. left: 'center',
  2260. top: 'center',
  2261. textStyle: { fontSize: 16, color: '#9ca3af' },
  2262. subtextStyle: { fontSize: 13, color: '#d1d5db' }
  2263. }
  2264. };
  2265. this.workloadGanttChart.setOption(emptyOption, true);
  2266. return;
  2267. }
  2268. // 🔧 使用 ProjectTeam 表的数据(实际执行人)
  2269. const workloadByDesigner: Record<string, any[]> = {};
  2270. designers.forEach(name => {
  2271. workloadByDesigner[name] = [];
  2272. });
  2273. // 计算每个设计师的总负载(用于排序)
  2274. const designerTotalLoad: Record<string, number> = {};
  2275. designers.forEach(name => {
  2276. const projects = this.designerWorkloadMap.get(name) || [];
  2277. designerTotalLoad[name] = projects.length;
  2278. });
  2279. // 按总负载从高到低排序设计师
  2280. const sortedDesigners = designers.sort((a, b) => {
  2281. return designerTotalLoad[b] - designerTotalLoad[a];
  2282. });
  2283. // 为每个设计师生成时间段数据
  2284. sortedDesigners.forEach((designerName, yIndex) => {
  2285. const designerProjects = this.designerWorkloadMap.get(designerName) || [];
  2286. // 计算每一天的状态
  2287. const days = this.workloadGanttScale === 'week' ? 7 : 30;
  2288. for (let i = 0; i < days; i++) {
  2289. const dayStart = todayTs + i * DAY;
  2290. const dayEnd = dayStart + DAY - 1;
  2291. // 查找该天有哪些项目
  2292. const dayProjects = designerProjects.filter(p => {
  2293. const isCompleted = p.status === '已完成' || p.status === '已交付';
  2294. // 🔧 已完成的项目不计入未来负载
  2295. if (isCompleted) {
  2296. return false;
  2297. }
  2298. // 如果项目没有 deadline,则认为项目一直在进行中
  2299. if (!p.deadline) {
  2300. return true; // 没有截止日期的项目始终显示
  2301. }
  2302. const pEnd = new Date(p.deadline).getTime();
  2303. // 检查时间是否有效
  2304. if (isNaN(pEnd)) {
  2305. return true; // 如果截止日期无效,认为项目在进行中
  2306. }
  2307. // 🔧 关键修复:项目只在其截止日期之前的日期显示
  2308. // 如果当前查询的日期(dayStart)已经超过了项目的截止日期(pEnd),则不计入负载
  2309. if (dayStart > pEnd) {
  2310. return false; // 截止日期已过的项目不计入该天的负载
  2311. }
  2312. // 项目开始时间
  2313. const pStart = p.createdAt ? new Date(p.createdAt).getTime() : todayTs;
  2314. // 项目在该天的时间范围内
  2315. return !(pEnd < dayStart || pStart > dayEnd);
  2316. });
  2317. let status: 'idle' | 'busy' | 'overload' | 'leave' = 'idle';
  2318. let color = '#d1fae5'; // 空闲-浅绿色
  2319. const projectCount = dayProjects.length;
  2320. // TODO: 检查请假记录,如果该天请假则标记为leave
  2321. // const isOnLeave = this.checkLeave(designerName, dayStart, dayEnd);
  2322. // if (isOnLeave) {
  2323. // status = 'leave';
  2324. // color = '#e5e7eb'; // 请假-灰色
  2325. // }
  2326. if (projectCount === 0) {
  2327. status = 'idle';
  2328. color = '#d1fae5'; // 空闲-浅绿色(0个项目)
  2329. } else if (projectCount >= 3) {
  2330. status = 'overload';
  2331. color = '#fecaca'; // 超负荷-浅红色(≥3个项目)
  2332. } else {
  2333. status = 'busy';
  2334. color = '#bfdbfe'; // 忙碌-浅蓝色(1-2个项目)
  2335. }
  2336. workloadByDesigner[designerName].push({
  2337. name: `${designerName}-${i}`,
  2338. value: [yIndex, dayStart, dayEnd, designerName, status, projectCount, dayProjects.map(p => p.name)],
  2339. itemStyle: { color }
  2340. });
  2341. }
  2342. });
  2343. // 合并所有数据
  2344. const data = Object.values(workloadByDesigner).flat();
  2345. const option = {
  2346. backgroundColor: '#fff',
  2347. title: {
  2348. text: this.workloadGanttScale === 'week' ? '未来7天工作状态' : '未来30天工作状态',
  2349. subtext: '🟢空闲 🔵忙碌 🔴超负荷',
  2350. left: 'center',
  2351. textStyle: { fontSize: 14, color: '#374151', fontWeight: 600 },
  2352. subtextStyle: { fontSize: 12, color: '#6b7280' }
  2353. },
  2354. tooltip: {
  2355. formatter: (params: any) => {
  2356. const [yIndex, start, end, name, status, projectCount, projectNames = []] = params.value;
  2357. const startDate = new Date(start);
  2358. const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
  2359. let statusText = '';
  2360. let statusColor = '';
  2361. let statusBadge = '';
  2362. if (status === 'leave') {
  2363. statusText = '请假';
  2364. statusColor = '#6b7280';
  2365. statusBadge = '<span style="background:#e5e7eb;color:#374151;padding:2px 8px;border-radius:4px;font-size:11px;">请假</span>';
  2366. } else if (projectCount === 0) {
  2367. statusText = '空闲';
  2368. statusColor = '#10b981';
  2369. statusBadge = '<span style="background:#d1fae5;color:#059669;padding:2px 8px;border-radius:4px;font-size:11px;">🟢 空闲</span>';
  2370. } else if (projectCount >= 3) {
  2371. statusText = '超负荷';
  2372. statusColor = '#dc2626';
  2373. statusBadge = '<span style="background:#fecaca;color:#dc2626;padding:2px 8px;border-radius:4px;font-size:11px;">🔴 超负荷</span>';
  2374. } else {
  2375. statusText = '忙碌';
  2376. statusColor = '#3b82f6';
  2377. statusBadge = '<span style="background:#bfdbfe;color:#1d4ed8;padding:2px 8px;border-radius:4px;font-size:11px;">🔵 忙碌</span>';
  2378. }
  2379. let projectListHtml = '';
  2380. if (projectNames && projectNames.length > 0) {
  2381. projectListHtml = `
  2382. <div style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed #e5e7eb;">
  2383. <div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">项目列表:</div>
  2384. ${projectNames.slice(0, 5).map((pName: string, idx: number) =>
  2385. `<div style="font-size: 12px; color: #374151; margin-left: 8px;">
  2386. ${idx + 1}. ${pName.length > 20 ? pName.substring(0, 20) + '...' : pName}
  2387. </div>`
  2388. ).join('')}
  2389. ${projectNames.length > 5 ? `<div style="font-size: 12px; color: #6b7280; margin-left: 8px;">...及${projectNames.length - 5}个其他项目</div>` : ''}
  2390. </div>
  2391. `;
  2392. }
  2393. return `<div style="padding: 12px; min-width: 220px;">
  2394. <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
  2395. <strong style="font-size: 15px; color: #1f2937;">${name}</strong>
  2396. ${statusBadge}
  2397. </div>
  2398. <div style="color: #6b7280; font-size: 13px;">
  2399. 📅 ${startDate.getMonth() + 1}月${startDate.getDate()}日 ${weekDays[startDate.getDay()]}<br/>
  2400. 📊 项目数量: <span style="font-weight: 600; color: ${statusColor};">${projectCount}个</span>
  2401. </div>
  2402. ${projectListHtml}
  2403. <div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid #e5e7eb; color: #6b7280; font-size: 11px; text-align: center;">
  2404. 💡 点击查看设计师详细信息
  2405. </div>
  2406. </div>`;
  2407. }
  2408. },
  2409. grid: {
  2410. left: 100,
  2411. right: 50,
  2412. top: 60,
  2413. bottom: 60
  2414. },
  2415. xAxis: {
  2416. type: 'time',
  2417. min: xMin,
  2418. max: xMax,
  2419. boundaryGap: false,
  2420. axisLine: { lineStyle: { color: '#e5e7eb' } },
  2421. axisLabel: {
  2422. color: '#6b7280',
  2423. formatter: xLabelFormatter,
  2424. interval: 0,
  2425. rotate: this.workloadGanttScale === 'week' ? 0 : 45,
  2426. showMinLabel: true,
  2427. showMaxLabel: true
  2428. },
  2429. axisTick: {
  2430. alignWithLabel: true,
  2431. interval: 0
  2432. },
  2433. splitLine: {
  2434. show: true,
  2435. lineStyle: { color: '#f1f5f9' }
  2436. },
  2437. splitNumber: xSplitNumber,
  2438. minInterval: DAY
  2439. },
  2440. yAxis: {
  2441. type: 'category',
  2442. data: sortedDesigners,
  2443. inverse: true,
  2444. axisLabel: {
  2445. color: '#374151',
  2446. margin: 8,
  2447. fontSize: 13,
  2448. fontWeight: 500,
  2449. formatter: (value: string) => {
  2450. const totalProjects = designerTotalLoad[value] || 0;
  2451. const icon = totalProjects >= 3 ? '🔥' : totalProjects >= 2 ? '⚡' : totalProjects >= 1 ? '✓' : '○';
  2452. return `${icon} ${value} (${totalProjects})`;
  2453. }
  2454. },
  2455. axisTick: { show: false },
  2456. axisLine: { lineStyle: { color: '#e5e7eb' } }
  2457. },
  2458. series: [
  2459. {
  2460. type: 'custom',
  2461. name: '工作负载',
  2462. renderItem: (params: any, api: any) => {
  2463. const categoryIndex = api.value(0);
  2464. const start = api.coord([api.value(1), categoryIndex]);
  2465. const end = api.coord([api.value(2), categoryIndex]);
  2466. const height = api.size([0, 1])[1] * 0.6;
  2467. const rectShape = echarts.graphic.clipRectByRect({
  2468. x: start[0],
  2469. y: start[1] - height / 2,
  2470. width: Math.max(end[0] - start[0], 2),
  2471. height
  2472. }, {
  2473. x: params.coordSys.x,
  2474. y: params.coordSys.y,
  2475. width: params.coordSys.width,
  2476. height: params.coordSys.height
  2477. });
  2478. return rectShape ? {
  2479. type: 'rect',
  2480. shape: rectShape,
  2481. style: api.style()
  2482. } : undefined;
  2483. },
  2484. encode: { x: [1, 2], y: 0 },
  2485. data,
  2486. z: 2
  2487. }
  2488. ]
  2489. } as any;
  2490. this.workloadGanttChart.setOption(option, true);
  2491. // 添加点击事件:点击设计师行时显示详情
  2492. this.workloadGanttChart.on('click', (params: any) => {
  2493. if (params.componentType === 'series' && params.seriesType === 'custom') {
  2494. const designerName = params.value[3]; // value[3]是设计师名称
  2495. if (designerName && designerName !== '未分配') {
  2496. this.onEmployeeClick(designerName);
  2497. }
  2498. }
  2499. });
  2500. }
  2501. ngOnDestroy(): void {
  2502. if (this.ganttChart) {
  2503. this.ganttChart.dispose();
  2504. this.ganttChart = null;
  2505. }
  2506. if (this.workloadGanttChart) {
  2507. this.workloadGanttChart.dispose();
  2508. this.workloadGanttChart = null;
  2509. }
  2510. // 清理待办任务自动刷新定时器
  2511. if (this.todoTaskRefreshTimer) {
  2512. clearInterval(this.todoTaskRefreshTimer);
  2513. }
  2514. }
  2515. // 选择单个项目
  2516. selectProject(): void {
  2517. if (this.selectedProjectId) {
  2518. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  2519. // 根据项目当前阶段跳转到对应阶段页面,并标记组长身份
  2520. const project = this.projects.find(p => p.id === this.selectedProjectId);
  2521. const currentStage = project?.currentStage || '订单分配';
  2522. const stageRouteMap: Record<string, string> = {
  2523. '订单分配': 'order',
  2524. '确认需求': 'requirements',
  2525. '方案深化': 'requirements',
  2526. '建模': 'requirements',
  2527. '软装': 'requirements',
  2528. '渲染': 'requirements',
  2529. '后期': 'requirements',
  2530. '交付执行': 'delivery',
  2531. '交付': 'delivery',
  2532. '售后归档': 'aftercare',
  2533. '已完成': 'aftercare'
  2534. };
  2535. const stagePath = stageRouteMap[currentStage] || 'order';
  2536. this.router.navigate(['/wxwork', cid, 'project', this.selectedProjectId, stagePath], {
  2537. queryParams: { roleName: 'team-leader' }
  2538. });
  2539. }
  2540. }
  2541. // 获取特定阶段的项目
  2542. getProjectsByStage(stageId: string): Project[] {
  2543. return this.filteredProjects.filter(project => project.currentStage === stageId);
  2544. }
  2545. // 新增:阶段到核心阶段的映射
  2546. private mapStageToCorePhase(stageId: string): 'order' | 'requirements' | 'delivery' | 'aftercare' {
  2547. if (!stageId) return 'order'; // 空值默认为订单分配
  2548. // 标准化阶段名称(去除空格,转小写)
  2549. const normalizedStage = stageId.trim().toLowerCase();
  2550. // 1. 订单分配阶段(英文ID + 中文名称)
  2551. if (normalizedStage === 'order' ||
  2552. normalizedStage === 'pendingapproval' ||
  2553. normalizedStage === 'pendingassignment' ||
  2554. normalizedStage === '订单分配' ||
  2555. normalizedStage === '待审批' ||
  2556. normalizedStage === '待分配') {
  2557. return 'order';
  2558. }
  2559. // 2. 确认需求阶段(英文ID + 中文名称)
  2560. if (normalizedStage === 'requirements' ||
  2561. normalizedStage === 'requirement' ||
  2562. normalizedStage === 'planning' ||
  2563. normalizedStage === '确认需求' ||
  2564. normalizedStage === '需求沟通' ||
  2565. normalizedStage === '方案规划') {
  2566. return 'requirements';
  2567. }
  2568. // 3. 交付执行阶段(英文ID + 中文名称 + 🔥 新增子阶段)
  2569. if (normalizedStage === 'delivery' ||
  2570. normalizedStage === 'modeling' ||
  2571. normalizedStage === 'rendering' ||
  2572. normalizedStage === 'postproduction' ||
  2573. normalizedStage === 'review' ||
  2574. normalizedStage === 'revision' ||
  2575. normalizedStage === '交付执行' ||
  2576. normalizedStage === '建模' ||
  2577. normalizedStage === '建模阶段' ||
  2578. normalizedStage === '渲染' ||
  2579. normalizedStage === '渲染阶段' ||
  2580. normalizedStage === '后期制作' ||
  2581. normalizedStage === '评审' ||
  2582. normalizedStage === '修改' ||
  2583. normalizedStage === '修订' ||
  2584. normalizedStage === '白模' || // 🔥 新增:交付执行子阶段
  2585. normalizedStage === '软装' || // 🔥 新增:交付执行子阶段
  2586. normalizedStage === '后期') { // 🔥 新增:交付执行子阶段
  2587. return 'delivery';
  2588. }
  2589. // 4. 售后归档阶段(英文ID + 中文名称)
  2590. if (normalizedStage === 'aftercare' ||
  2591. normalizedStage === 'completed' ||
  2592. normalizedStage === 'archived' ||
  2593. normalizedStage === '售后归档' ||
  2594. normalizedStage === '售后' ||
  2595. normalizedStage === '归档' ||
  2596. normalizedStage === '已完成' ||
  2597. normalizedStage === '已交付') {
  2598. return 'aftercare';
  2599. }
  2600. // 未匹配的阶段:默认为交付执行(因为大部分时间项目都在执行中)
  2601. console.warn(`⚠️ 未识别的阶段: "${stageId}" → 默认归类为交付执行`);
  2602. return 'delivery';
  2603. }
  2604. // 新增:获取核心阶段的项目
  2605. getProjectsByCorePhase(coreId: string): Project[] {
  2606. return this.filteredProjects.filter(p => this.mapStageToCorePhase(p.currentStage) === coreId);
  2607. }
  2608. // 新增:获取核心阶段的项目数量
  2609. getProjectCountByCorePhase(coreId: string): number {
  2610. return this.getProjectsByCorePhase(coreId).length;
  2611. }
  2612. // 获取特定阶段的项目数量
  2613. getProjectCountByStage(stageId: string): number {
  2614. return this.getProjectsByStage(stageId).length;
  2615. }
  2616. // 🔥 已延期项目
  2617. get overdueProjects(): Project[] {
  2618. return this.projects.filter(p => p.isOverdue);
  2619. }
  2620. // ⏳ 临期项目(3天内)
  2621. get dueSoonProjects(): Project[] {
  2622. return this.projects.filter(p => p.dueSoon && !p.isOverdue);
  2623. }
  2624. // 📋 待审批项目(支持中文和英文阶段名称)
  2625. get pendingApprovalProjects(): Project[] {
  2626. const pending = this.projects.filter(p => {
  2627. const stage = (p.currentStage || '').trim();
  2628. const data = (p as any).data || {};
  2629. const approvalStatus = data.approvalStatus;
  2630. // 1. 阶段为"订单分配"且审批状态为 pending
  2631. // 2. 或者阶段为"待确认"/"待审批"(兼容旧数据)
  2632. return (stage === '订单分配' && approvalStatus === 'pending') ||
  2633. stage === '待审批' ||
  2634. stage === '待确认';
  2635. });
  2636. return pending;
  2637. }
  2638. // 检查项目是否待审批
  2639. isPendingApproval(project: Project): boolean {
  2640. const stage = (project.currentStage || '').trim();
  2641. const stageEn = stage.toLowerCase();
  2642. const data: any = (project as any).data || {};
  2643. // 🔥 新增:检查顶层的 pendingApproval 字段(备用方案)
  2644. const topLevelPending = (project as any).pendingApproval === true && (project as any).approvalStage === '订单分配';
  2645. return (stage === '订单分配' && (data.approvalStatus === 'pending' || topLevelPending)) ||
  2646. ((stage === '交付执行' || stageEn === 'delivery') &&
  2647. (data.deliveryApproval?.status === 'pending' ||
  2648. (Array.isArray(data.pendingDeliveryApprovals) && data.pendingDeliveryApprovals.some((x: any) => x?.status === 'pending'))));
  2649. }
  2650. // 🎯 待分配项目(支持中文和英文阶段名称)
  2651. get pendingAssignmentProjects(): Project[] {
  2652. return this.projects.filter(p => {
  2653. const stage = (p.currentStage || '').trim().toLowerCase();
  2654. return stage === 'pendingassignment' ||
  2655. stage === '待分配' ||
  2656. stage === '订单分配';
  2657. });
  2658. }
  2659. // 智能推荐设计师
  2660. private getRecommendedDesigner(projectType: 'soft' | 'hard') {
  2661. if (!this.designerProfiles || !this.designerProfiles.length) return null;
  2662. const scoreOf = (p: any) => {
  2663. const workloadScore = 100 - (p.workload ?? 0); // 负载越低越好
  2664. const ratingScore = (p.avgRating ?? 0) * 10; // 评分越高越好
  2665. const expScore = (p.experience ?? 0) * 5; // 经验越高越好
  2666. return workloadScore * 0.5 + ratingScore * 0.3 + expScore * 0.2;
  2667. };
  2668. const sorted = [...this.designerProfiles].sort((a, b) => scoreOf(b) - scoreOf(a));
  2669. return sorted[0] || null;
  2670. }
  2671. // 质量评审
  2672. reviewProjectQuality(projectId: string, rating: 'excellent' | 'qualified' | 'unqualified'): void {
  2673. const project = this.projects.find(p => p.id === projectId);
  2674. if (!project) return;
  2675. project.qualityRating = rating;
  2676. if (rating === 'unqualified') {
  2677. // 不合格:回退到修改阶段
  2678. project.currentStage = 'revision';
  2679. }
  2680. this.applyFilters();
  2681. window?.fmode?.alert('质量评审已提交');
  2682. }
  2683. // 查看绩效预警(占位:跳转到团队管理)
  2684. viewPerformanceDetails(): void {
  2685. this.router.navigate(['/team-leader/team-management']);
  2686. }
  2687. // 打开负载日历(占位:跳转到团队管理)
  2688. navigateToWorkloadCalendar(): void {
  2689. this.router.navigate(['/team-leader/workload-calendar']);
  2690. }
  2691. /**
  2692. * 根据看板列跳转到项目详情(参考客服板块实现)
  2693. * @param projectId 项目ID
  2694. * @param corePhaseId 核心阶段ID (order/requirements/delivery/aftercare)
  2695. */
  2696. viewProjectDetailsByPhase(projectId: string, corePhaseId: string): void {
  2697. if (!projectId) {
  2698. return;
  2699. }
  2700. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  2701. try {
  2702. localStorage.setItem('enterAsTeamLeader', '1');
  2703. localStorage.setItem('teamLeaderMode', 'true');
  2704. // 🔥 关键:清除客服端标记,避免冲突
  2705. localStorage.removeItem('enterFromCustomerService');
  2706. localStorage.removeItem('customerServiceMode');
  2707. console.log('✅ 已标记从组长看板进入,启用组长模式');
  2708. } catch (e) {
  2709. console.warn('无法设置 localStorage 标记:', e);
  2710. }
  2711. // 获取公司ID
  2712. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  2713. // 🔥 根据看板列ID直接映射到路由路径(与客服板块保持一致)
  2714. // corePhaseId已经是路由路径格式:order, requirements, delivery, aftercare
  2715. const stagePath = corePhaseId;
  2716. console.log(`🎯 从看板列「${this.corePhases.find(c => c.id === corePhaseId)?.name}」进入项目,跳转到: ${stagePath}`);
  2717. // ✅ 跳转到对应阶段,并通过查询参数标识为组长视角
  2718. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  2719. queryParams: { roleName: 'team-leader' }
  2720. });
  2721. }
  2722. // 查看项目详情 - 跳转到纯净的项目详情页(无管理端UI)
  2723. viewProjectDetails(projectId: string): void {
  2724. if (!projectId) {
  2725. return;
  2726. }
  2727. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  2728. try {
  2729. localStorage.setItem('enterAsTeamLeader', '1');
  2730. localStorage.setItem('teamLeaderMode', 'true');
  2731. // 🔥 关键:清除客服端标记,避免冲突
  2732. localStorage.removeItem('enterFromCustomerService');
  2733. localStorage.removeItem('customerServiceMode');
  2734. console.log('✅ 已标记从组长看板进入,启用组长模式');
  2735. } catch (e) {
  2736. console.warn('无法设置 localStorage 标记:', e);
  2737. }
  2738. // 获取公司ID
  2739. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  2740. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  2741. const project = this.projects.find(p => p.id === projectId);
  2742. const currentStage = project?.currentStage || '订单分配';
  2743. // 阶段映射:项目阶段 → 路由路径
  2744. const stageRouteMap: Record<string, string> = {
  2745. '订单分配': 'order',
  2746. '确认需求': 'requirements',
  2747. '方案深化': 'requirements',
  2748. '建模': 'requirements',
  2749. '软装': 'requirements',
  2750. '渲染': 'requirements',
  2751. '后期': 'requirements',
  2752. '交付执行': 'delivery',
  2753. '交付': 'delivery',
  2754. '售后归档': 'aftercare',
  2755. '已完成': 'aftercare'
  2756. };
  2757. const stagePath = stageRouteMap[currentStage] || 'order';
  2758. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  2759. // ✅ 跳转到对应阶段,并通过查询参数标识为组长视角
  2760. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  2761. queryParams: { roleName: 'team-leader' }
  2762. });
  2763. }
  2764. // 快速分配项目(增强:加入智能推荐)
  2765. async quickAssignProject(projectId: string): Promise<void> {
  2766. const project = this.projects.find(p => p.id === projectId);
  2767. if (!project) {
  2768. window?.fmode?.alert('未找到对应项目');
  2769. return;
  2770. }
  2771. const recommended = this.getRecommendedDesigner(project.type);
  2772. if (recommended) {
  2773. const reassigning = !!project.designerName;
  2774. const message = `推荐设计师:${recommended.name}(工作负载:${recommended.workload}%,评分:${recommended.avgRating}分)` +
  2775. (reassigning ? `\n\n该项目当前已由「${project.designerName}」负责,是否改为分配给「${recommended.name}」?` : '\n\n是否确认分配?');
  2776. const confirmAssign = await window?.fmode?.confirm(message);
  2777. if (confirmAssign) {
  2778. project.designerName = recommended.name;
  2779. if (project.currentStage === 'pendingAssignment' || project.currentStage === 'pendingApproval') {
  2780. project.currentStage = 'requirement';
  2781. }
  2782. project.status = '进行中';
  2783. // 更新设计师筛选列表
  2784. this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
  2785. this.applyFilters();
  2786. window?.fmode?.alert(`项目已${reassigning ? '重新' : ''}分配给 ${recommended.name}`);
  2787. return;
  2788. }
  2789. }
  2790. // 无推荐或用户取消,跳转到详细分配页面
  2791. // 跳转到项目详情页
  2792. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  2793. // 动态跳转到项目当前阶段,并标记组长身份
  2794. const current = this.projects.find(p => p.id === projectId)?.currentStage || '订单分配';
  2795. const stageRouteMap: Record<string, string> = {
  2796. '订单分配': 'order',
  2797. '确认需求': 'requirements',
  2798. '方案深化': 'requirements',
  2799. '建模': 'requirements',
  2800. '软装': 'requirements',
  2801. '渲染': 'requirements',
  2802. '后期': 'requirements',
  2803. '交付执行': 'delivery',
  2804. '交付': 'delivery',
  2805. '售后归档': 'aftercare',
  2806. '已完成': 'aftercare'
  2807. };
  2808. const stagePath = stageRouteMap[current] || 'order';
  2809. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  2810. queryParams: { roleName: 'team-leader' }
  2811. });
  2812. }
  2813. // 导航到待办任务
  2814. navigateToTask(task: TodoTask): void {
  2815. switch (task.type) {
  2816. case 'review':
  2817. this.router.navigate(['team-leader/quality-management', task.targetId]);
  2818. break;
  2819. case 'assign':
  2820. this.router.navigate(['/team-leader/dashboard']);
  2821. break;
  2822. case 'performance':
  2823. this.router.navigate(['team-leader/team-management']);
  2824. break;
  2825. }
  2826. }
  2827. // 获取优先级标签
  2828. getPriorityLabel(priority: 'high' | 'medium' | 'low'): string {
  2829. const labels: Record<'high' | 'medium' | 'low', string> = {
  2830. 'high': '紧急且重要',
  2831. 'medium': '重要不紧急',
  2832. 'low': '紧急不重要'
  2833. };
  2834. return labels[priority];
  2835. }
  2836. // 导航到团队管理
  2837. navigateToTeamManagement(): void {
  2838. this.router.navigate(['/team-leader/team-management']);
  2839. }
  2840. // 导航到项目评审
  2841. navigateToProjectReview(): void {
  2842. // 统一入口:跳转到项目列表/看板,而非旧评审页
  2843. this.router.navigate(['/team-leader/dashboard']);
  2844. }
  2845. // 导航到质量管理
  2846. navigateToQualityManagement(): void {
  2847. this.router.navigate(['/team-leader/quality-management']);
  2848. }
  2849. // 打开工作量预估工具(已迁移)
  2850. openWorkloadEstimator(): void {
  2851. // 工具迁移至详情页:引导前往当前选中项目详情
  2852. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  2853. if (this.selectedProjectId) {
  2854. // 跳转到选中项目的当前阶段,并标记组长身份
  2855. const project = this.projects.find(p => p.id === this.selectedProjectId);
  2856. const currentStage = project?.currentStage || '订单分配';
  2857. const stageRouteMap: Record<string, string> = {
  2858. '订单分配': 'order',
  2859. '确认需求': 'requirements',
  2860. '方案深化': 'requirements',
  2861. '建模': 'requirements',
  2862. '软装': 'requirements',
  2863. '渲染': 'requirements',
  2864. '后期': 'requirements',
  2865. '交付执行': 'delivery',
  2866. '交付': 'delivery',
  2867. '售后归档': 'aftercare',
  2868. '已完成': 'aftercare'
  2869. };
  2870. const stagePath = stageRouteMap[currentStage] || 'order';
  2871. this.router.navigate(['/wxwork', cid, 'project', this.selectedProjectId, stagePath], {
  2872. queryParams: { roleName: 'team-leader' }
  2873. });
  2874. } else {
  2875. this.router.navigate(['/wxwork', cid, 'team-leader']);
  2876. }
  2877. window?.fmode?.alert('工作量预估工具已迁移至项目详情页,您可以在建模阶段之前使用该工具进行工作量计算。');
  2878. }
  2879. // 查看所有超期项目
  2880. viewAllOverdueProjects(): void {
  2881. this.filterByStatus('overdue');
  2882. this.closeAlert();
  2883. }
  2884. // 关闭提醒
  2885. closeAlert(): void {
  2886. this.showAlert = false;
  2887. }
  2888. resetStatusFilter(): void {
  2889. this.selectedStatus = 'all';
  2890. this.applyFilters();
  2891. }
  2892. // 处理甘特图员工点击事件
  2893. async onEmployeeClick(employeeName: string): Promise<void> {
  2894. if (!employeeName || employeeName === '未分配') {
  2895. return;
  2896. }
  2897. // 生成员工详情数据
  2898. this.selectedEmployeeDetail = await this.generateEmployeeDetail(employeeName);
  2899. this.showEmployeeDetailPanel = true;
  2900. }
  2901. // 生成员工详情数据
  2902. private async generateEmployeeDetail(employeeName: string): Promise<EmployeeDetail> {
  2903. // 从 ProjectTeam 表获取该员工负责的项目
  2904. const employeeProjects = this.designerWorkloadMap.get(employeeName) || [];
  2905. const currentProjects = employeeProjects.length;
  2906. // 保存完整的项目数据(最多显示3个)
  2907. const projectData = employeeProjects.slice(0, 3).map(p => ({
  2908. id: p.id,
  2909. name: p.name
  2910. }));
  2911. const projectNames = projectData.map(p => p.name); // 项目名称列表
  2912. // 获取该员工的请假记录(未来7天)
  2913. const today = new Date();
  2914. const next7Days = Array.from({ length: 7 }, (_, i) => {
  2915. const date = new Date(today);
  2916. date.setDate(today.getDate() + i);
  2917. return date.toISOString().split('T')[0]; // YYYY-MM-DD 格式
  2918. });
  2919. const employeeLeaveRecords = this.leaveRecords.filter(record =>
  2920. record.employeeName === employeeName && next7Days.includes(record.date)
  2921. );
  2922. // 生成红色标记说明
  2923. const redMarkExplanation = this.generateRedMarkExplanation(employeeName, employeeLeaveRecords, currentProjects);
  2924. // 保存当前员工信息和项目数据(用于切换月份)
  2925. this.currentEmployeeName = employeeName;
  2926. this.currentEmployeeProjects = employeeProjects;
  2927. // 生成日历数据
  2928. const calendarData = this.generateEmployeeCalendar(employeeName, employeeProjects);
  2929. // 新增:加载问卷数据
  2930. let surveyCompleted = false;
  2931. let surveyData = null;
  2932. let profileId = '';
  2933. try {
  2934. const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
  2935. // 通过员工名字查找Profile(同时查询 realname 和 name 字段)
  2936. const realnameQuery = new Parse.Query('Profile');
  2937. realnameQuery.equalTo('realname', employeeName);
  2938. const nameQuery = new Parse.Query('Profile');
  2939. nameQuery.equalTo('name', employeeName);
  2940. // 使用 or 查询
  2941. const profileQuery = Parse.Query.or(realnameQuery, nameQuery);
  2942. profileQuery.limit(1);
  2943. const profileResults = await profileQuery.find();
  2944. console.log(`🔍 查找员工 ${employeeName},找到 ${profileResults.length} 个结果`);
  2945. if (profileResults.length > 0) {
  2946. const profile = profileResults[0];
  2947. profileId = profile.id;
  2948. surveyCompleted = profile.get('surveyCompleted') || false;
  2949. console.log(`📋 Profile ID: ${profileId}, surveyCompleted: ${surveyCompleted}`);
  2950. // 如果已完成问卷,加载问卷答案
  2951. if (surveyCompleted) {
  2952. const surveyQuery = new Parse.Query('SurveyLog');
  2953. surveyQuery.equalTo('profile', profile.toPointer());
  2954. surveyQuery.equalTo('type', 'survey-profile');
  2955. surveyQuery.descending('createdAt');
  2956. surveyQuery.limit(1);
  2957. const surveyResults = await surveyQuery.find();
  2958. console.log(`📝 找到 ${surveyResults.length} 条问卷记录`);
  2959. if (surveyResults.length > 0) {
  2960. const survey = surveyResults[0];
  2961. surveyData = {
  2962. answers: survey.get('answers') || [],
  2963. createdAt: survey.get('createdAt'),
  2964. updatedAt: survey.get('updatedAt')
  2965. };
  2966. console.log(`✅ 加载问卷数据成功,共 ${surveyData.answers.length} 道题`);
  2967. }
  2968. }
  2969. } else {
  2970. console.warn(`⚠️ 未找到员工 ${employeeName} 的 Profile`);
  2971. }
  2972. console.log(`📋 员工 ${employeeName} 问卷状态:`, surveyCompleted ? '已完成' : '未完成');
  2973. } catch (error) {
  2974. console.error(`❌ 加载员工 ${employeeName} 问卷数据失败:`, error);
  2975. }
  2976. return {
  2977. name: employeeName,
  2978. currentProjects,
  2979. projectNames,
  2980. projectData,
  2981. leaveRecords: employeeLeaveRecords,
  2982. redMarkExplanation,
  2983. calendarData,
  2984. // 新增字段
  2985. surveyCompleted,
  2986. surveyData,
  2987. profileId
  2988. };
  2989. }
  2990. /**
  2991. * 生成员工日历数据(支持指定月份)
  2992. */
  2993. private generateEmployeeCalendar(employeeName: string, employeeProjects: any[], targetMonth?: Date): EmployeeCalendarData {
  2994. const currentMonth = targetMonth || new Date();
  2995. const year = currentMonth.getFullYear();
  2996. const month = currentMonth.getMonth();
  2997. // 获取当月天数
  2998. const daysInMonth = new Date(year, month + 1, 0).getDate();
  2999. const days: EmployeeCalendarDay[] = [];
  3000. const today = new Date();
  3001. today.setHours(0, 0, 0, 0);
  3002. // 生成当月每一天的数据
  3003. for (let day = 1; day <= daysInMonth; day++) {
  3004. const date = new Date(year, month, day);
  3005. const dateStr = date.toISOString().split('T')[0];
  3006. // 找出该日期相关的项目(项目进行中且在当天范围内)
  3007. const dayProjects = employeeProjects.filter(p => {
  3008. // 处理 Parse Date 对象:检查是否有 toDate 方法
  3009. const getDate = (dateValue: any) => {
  3010. if (!dateValue) return null;
  3011. if (dateValue.toDate && typeof dateValue.toDate === 'function') {
  3012. return dateValue.toDate(); // Parse Date对象
  3013. }
  3014. if (dateValue instanceof Date) {
  3015. return dateValue;
  3016. }
  3017. return new Date(dateValue); // 字符串或时间戳
  3018. };
  3019. const deadlineDate = getDate(p.deadline);
  3020. const createdDate = p.createdAt ? getDate(p.createdAt) : null;
  3021. // 如果项目既没有 deadline 也没有 createdAt,则跳过
  3022. if (!deadlineDate && !createdDate) {
  3023. return false;
  3024. }
  3025. // 智能处理日期范围
  3026. let startDate: Date;
  3027. let endDate: Date;
  3028. if (deadlineDate && createdDate) {
  3029. // 情况1:两个日期都有
  3030. startDate = createdDate;
  3031. endDate = deadlineDate;
  3032. } else if (deadlineDate) {
  3033. // 情况2:只有deadline,往前推30天
  3034. startDate = new Date(deadlineDate.getTime() - 30 * 24 * 60 * 60 * 1000);
  3035. endDate = deadlineDate;
  3036. } else {
  3037. // 情况3:只有createdAt,往后推30天
  3038. startDate = createdDate!;
  3039. endDate = new Date(createdDate!.getTime() + 30 * 24 * 60 * 60 * 1000);
  3040. }
  3041. startDate.setHours(0, 0, 0, 0);
  3042. endDate.setHours(0, 0, 0, 0);
  3043. const inRange = date >= startDate && date <= endDate;
  3044. return inRange;
  3045. }).map(p => {
  3046. const getDate = (dateValue: any) => {
  3047. if (!dateValue) return undefined;
  3048. if (dateValue.toDate && typeof dateValue.toDate === 'function') {
  3049. return dateValue.toDate();
  3050. }
  3051. if (dateValue instanceof Date) {
  3052. return dateValue;
  3053. }
  3054. return new Date(dateValue);
  3055. };
  3056. return {
  3057. id: p.id,
  3058. name: p.name,
  3059. deadline: getDate(p.deadline)
  3060. };
  3061. });
  3062. days.push({
  3063. date,
  3064. projectCount: dayProjects.length,
  3065. projects: dayProjects,
  3066. isToday: date.getTime() === today.getTime(),
  3067. isCurrentMonth: true
  3068. });
  3069. }
  3070. // 补齐前后的日期(保证从周日开始)
  3071. const firstDay = new Date(year, month, 1);
  3072. const firstDayOfWeek = firstDay.getDay(); // 0=周日
  3073. // 前置补齐(上个月的日期)
  3074. for (let i = firstDayOfWeek - 1; i >= 0; i--) {
  3075. const date = new Date(year, month, -i);
  3076. days.unshift({
  3077. date,
  3078. projectCount: 0,
  3079. projects: [],
  3080. isToday: false,
  3081. isCurrentMonth: false
  3082. });
  3083. }
  3084. // 后置补齐(下个月的日期,保证总数是7的倍数)
  3085. const remainder = days.length % 7;
  3086. if (remainder !== 0) {
  3087. const needed = 7 - remainder;
  3088. for (let i = 1; i <= needed; i++) {
  3089. const date = new Date(year, month + 1, i);
  3090. days.push({
  3091. date,
  3092. projectCount: 0,
  3093. projects: [],
  3094. isToday: false,
  3095. isCurrentMonth: false
  3096. });
  3097. }
  3098. }
  3099. return {
  3100. currentMonth: new Date(year, month, 1),
  3101. days
  3102. };
  3103. }
  3104. /**
  3105. * 处理日历日期点击
  3106. */
  3107. onCalendarDayClick(day: EmployeeCalendarDay): void {
  3108. if (!day.isCurrentMonth || day.projectCount === 0) {
  3109. return;
  3110. }
  3111. this.selectedDate = day.date;
  3112. this.selectedDayProjects = day.projects;
  3113. this.showCalendarProjectList = true;
  3114. }
  3115. /**
  3116. * 切换员工日历月份
  3117. * @param direction -1=上月, 1=下月
  3118. */
  3119. changeEmployeeCalendarMonth(direction: number): void {
  3120. if (!this.selectedEmployeeDetail?.calendarData) {
  3121. return;
  3122. }
  3123. const currentMonth = this.selectedEmployeeDetail.calendarData.currentMonth;
  3124. const newMonth = new Date(currentMonth);
  3125. newMonth.setMonth(newMonth.getMonth() + direction);
  3126. // 重新生成日历数据
  3127. const newCalendarData = this.generateEmployeeCalendar(
  3128. this.currentEmployeeName,
  3129. this.currentEmployeeProjects,
  3130. newMonth
  3131. );
  3132. // 更新员工详情中的日历数据
  3133. this.selectedEmployeeDetail = {
  3134. ...this.selectedEmployeeDetail,
  3135. calendarData: newCalendarData
  3136. };
  3137. }
  3138. /**
  3139. * 关闭项目列表弹窗
  3140. */
  3141. closeCalendarProjectList(): void {
  3142. this.showCalendarProjectList = false;
  3143. this.selectedDate = null;
  3144. this.selectedDayProjects = [];
  3145. }
  3146. // 生成红色标记说明
  3147. private generateRedMarkExplanation(employeeName: string, leaveRecords: LeaveRecord[], projectCount: number): string {
  3148. const explanations: string[] = [];
  3149. // 检查请假情况
  3150. const leaveDays = leaveRecords.filter(record => record.isLeave);
  3151. if (leaveDays.length > 0) {
  3152. leaveDays.forEach(leave => {
  3153. const date = new Date(leave.date);
  3154. const dateStr = `${date.getMonth() + 1}月${date.getDate()}日`;
  3155. explanations.push(`${dateStr}(${leave.reason || '请假'})`);
  3156. });
  3157. }
  3158. // 检查项目繁忙情况
  3159. if (projectCount >= 3) {
  3160. const today = new Date();
  3161. const dateStr = `${today.getMonth() + 1}月${today.getDate()}日`;
  3162. explanations.push(`${dateStr}(${projectCount}个项目繁忙)`);
  3163. }
  3164. if (explanations.length === 0) {
  3165. return '当前无红色标记时段';
  3166. }
  3167. return `甘特图中红色时段说明:${explanations.map((exp, index) => `${index + 1}${exp}`).join(';')}`;
  3168. }
  3169. // 关闭员工详情面板
  3170. closeEmployeeDetailPanel(): void {
  3171. this.showEmployeeDetailPanel = false;
  3172. this.selectedEmployeeDetail = null;
  3173. this.showFullSurvey = false; // 重置问卷显示状态
  3174. }
  3175. /**
  3176. * 刷新员工问卷状态
  3177. */
  3178. async refreshEmployeeSurvey(): Promise<void> {
  3179. if (this.refreshingSurvey || !this.selectedEmployeeDetail) {
  3180. return;
  3181. }
  3182. try {
  3183. this.refreshingSurvey = true;
  3184. console.log('🔄 刷新问卷状态...');
  3185. const employeeName = this.selectedEmployeeDetail.name;
  3186. // 重新加载员工详情数据
  3187. const updatedDetail = await this.generateEmployeeDetail(employeeName);
  3188. // 更新当前显示的员工详情
  3189. this.selectedEmployeeDetail = updatedDetail;
  3190. console.log('✅ 问卷状态刷新成功');
  3191. } catch (error) {
  3192. console.error('❌ 刷新问卷状态失败:', error);
  3193. } finally {
  3194. this.refreshingSurvey = false;
  3195. }
  3196. }
  3197. /**
  3198. * 切换问卷显示模式
  3199. */
  3200. toggleSurveyDisplay(): void {
  3201. this.showFullSurvey = !this.showFullSurvey;
  3202. }
  3203. /**
  3204. * 获取能力画像摘要
  3205. */
  3206. getCapabilitySummary(answers: any[]): any {
  3207. const findAnswer = (questionId: string) => {
  3208. const item = answers.find(a => a.questionId === questionId);
  3209. return item?.answer;
  3210. };
  3211. const formatArray = (value: any): string => {
  3212. if (Array.isArray(value)) {
  3213. return value.join('、');
  3214. }
  3215. return value || '未填写';
  3216. };
  3217. return {
  3218. styles: formatArray(findAnswer('q1_expertise_styles')),
  3219. spaces: formatArray(findAnswer('q2_expertise_spaces')),
  3220. advantages: formatArray(findAnswer('q3_technical_advantages')),
  3221. difficulty: findAnswer('q5_project_difficulty') || '未填写',
  3222. capacity: findAnswer('q7_weekly_capacity') || '未填写',
  3223. urgent: findAnswer('q8_urgent_willingness') || '未填写',
  3224. urgentLimit: findAnswer('q8_urgent_limit') || '',
  3225. feedback: findAnswer('q9_progress_feedback') || '未填写',
  3226. communication: formatArray(findAnswer('q12_communication_methods'))
  3227. };
  3228. }
  3229. // 从员工详情面板跳转到项目详情
  3230. navigateToProjectFromPanel(projectId: string): void {
  3231. if (!projectId) {
  3232. return;
  3233. }
  3234. // 关闭员工详情面板
  3235. this.closeEmployeeDetailPanel();
  3236. // 🔥 标记从组长看板进入(用于跳过激活检查和显示审批按钮)
  3237. try {
  3238. localStorage.setItem('enterAsTeamLeader', '1');
  3239. localStorage.setItem('teamLeaderMode', 'true');
  3240. // 🔥 关键:清除客服端标记,避免冲突
  3241. localStorage.removeItem('enterFromCustomerService');
  3242. localStorage.removeItem('customerServiceMode');
  3243. console.log('✅ 已标记从组长看板进入,启用组长模式');
  3244. } catch (e) {
  3245. console.warn('无法设置 localStorage 标记:', e);
  3246. }
  3247. // 🔥 根据项目当前阶段决定跳转到哪个阶段页面
  3248. const project = this.projects.find(p => p.id === projectId);
  3249. const currentStage = project?.currentStage || '订单分配';
  3250. // 阶段映射:项目阶段 → 路由路径
  3251. const stageRouteMap: Record<string, string> = {
  3252. '订单分配': 'order',
  3253. '确认需求': 'requirements',
  3254. '方案深化': 'requirements',
  3255. '建模': 'requirements',
  3256. '软装': 'requirements',
  3257. '渲染': 'requirements',
  3258. '后期': 'requirements',
  3259. '交付执行': 'delivery',
  3260. '交付': 'delivery',
  3261. '售后归档': 'aftercare',
  3262. '已完成': 'aftercare'
  3263. };
  3264. const stagePath = stageRouteMap[currentStage] || 'order';
  3265. console.log(`🎯 项目当前阶段: ${currentStage},跳转到: ${stagePath}`);
  3266. // 跳转到对应阶段,通过查询参数标识为组长视角
  3267. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  3268. this.router.navigate(['/wxwork', cid, 'project', projectId, stagePath], {
  3269. queryParams: { roleName: 'team-leader' }
  3270. });
  3271. }
  3272. // 获取请假类型显示文本
  3273. getLeaveTypeText(leaveType?: string): string {
  3274. const typeMap: Record<string, string> = {
  3275. 'sick': '病假',
  3276. 'personal': '事假',
  3277. 'annual': '年假',
  3278. 'other': '其他'
  3279. };
  3280. return typeMap[leaveType || ''] || '请假';
  3281. }
  3282. // 生成请假覆盖层数据
  3283. private generateLeaveOverlayData(categories: string[], xMin: number, xMax: number): any[] {
  3284. const DAY = 24 * 60 * 60 * 1000;
  3285. const overlayData: any[] = [];
  3286. categories.forEach((employeeName, yIndex) => {
  3287. // 获取该员工在时间范围内的请假记录
  3288. const employeeLeaves = this.leaveRecords.filter(record => {
  3289. if (record.employeeName !== employeeName || !record.isLeave) {
  3290. return false;
  3291. }
  3292. const recordDate = new Date(record.date).getTime();
  3293. return recordDate >= xMin && recordDate <= xMax;
  3294. });
  3295. // 为每个请假日期创建覆盖层
  3296. employeeLeaves.forEach(leave => {
  3297. const leaveDate = new Date(leave.date);
  3298. const startOfDay = new Date(leaveDate.getFullYear(), leaveDate.getMonth(), leaveDate.getDate()).getTime();
  3299. const endOfDay = startOfDay + DAY - 1;
  3300. overlayData.push({
  3301. name: `${employeeName} - ${this.getLeaveTypeText(leave.leaveType)}`,
  3302. value: [yIndex, startOfDay, endOfDay, employeeName, leave.leaveType, leave.reason],
  3303. itemStyle: {
  3304. color: 'rgba(239, 68, 68, 0.6)', // 半透明红色
  3305. borderColor: '#ef4444',
  3306. borderWidth: 1
  3307. }
  3308. });
  3309. });
  3310. // 检查项目繁忙情况,如果项目数>=3,也添加红色标记
  3311. const employeeProjects = this.designerWorkloadMap.get(employeeName) || [];
  3312. if (employeeProjects.length >= 3) {
  3313. // 在当前日期添加繁忙标记
  3314. const today = new Date();
  3315. const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
  3316. const endOfToday = startOfToday + DAY - 1;
  3317. if (startOfToday >= xMin && startOfToday <= xMax) {
  3318. overlayData.push({
  3319. name: `${employeeName} - 项目繁忙(${employeeProjects.length}个项目)`,
  3320. value: [yIndex, startOfToday, endOfToday, employeeName, 'busy', `负责${employeeProjects.length}个项目`],
  3321. itemStyle: {
  3322. color: 'rgba(239, 68, 68, 0.4)', // 稍微透明的红色
  3323. borderColor: '#ef4444',
  3324. borderWidth: 1,
  3325. borderType: 'dashed' // 虚线边框区分请假和繁忙
  3326. }
  3327. });
  3328. }
  3329. }
  3330. });
  3331. return overlayData;
  3332. }
  3333. /**
  3334. * 加载用户Profile信息
  3335. */
  3336. async loadUserProfile(): Promise<void> {
  3337. try {
  3338. const cid = localStorage.getItem("company");
  3339. if (!cid) {
  3340. console.warn('未找到公司ID,使用默认用户信息');
  3341. return;
  3342. }
  3343. const wwAuth = new WxworkAuth({ cid });
  3344. const profile = await wwAuth.currentProfile();
  3345. if (profile) {
  3346. const name = profile.get("name") || profile.get("mobile") || '组长';
  3347. const avatar = profile.get("avatar");
  3348. const roleName = profile.get("roleName") || '组长';
  3349. this.currentUser = {
  3350. name,
  3351. avatar: avatar || this.generateDefaultAvatar(name),
  3352. roleName
  3353. };
  3354. console.log('用户Profile加载成功:', this.currentUser);
  3355. }
  3356. } catch (error) {
  3357. console.error('加载用户Profile失败:', error);
  3358. // 保持默认值
  3359. }
  3360. }
  3361. /**
  3362. * 生成默认头像(SVG格式)
  3363. * @param name 用户名
  3364. * @returns Base64编码的SVG数据URL
  3365. */
  3366. generateDefaultAvatar(name: string): string {
  3367. const initial = name ? name.substring(0, 2).toUpperCase() : '组长';
  3368. const bgColor = '#CCFFCC';
  3369. const textColor = '#555555';
  3370. const svg = `<svg width='40' height='40' xmlns='http://www.w3.org/2000/svg'>
  3371. <rect width='100%' height='100%' fill='${bgColor}'/>
  3372. <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>
  3373. </svg>`;
  3374. return `data:image/svg+xml,${encodeURIComponent(svg)}`;
  3375. }
  3376. // ==================== 新增:待办任务相关方法 ====================
  3377. /**
  3378. * 从问题板块加载待办任务
  3379. */
  3380. async loadTodoTasksFromIssues(): Promise<void> {
  3381. this.loadingTodoTasks = true;
  3382. this.todoTaskError = '';
  3383. try {
  3384. const Parse: any = FmodeParse.with('nova');
  3385. const query = new Parse.Query('ProjectIssue');
  3386. // 筛选条件:待处理 + 处理中
  3387. query.containedIn('status', ['待处理', '处理中']);
  3388. query.notEqualTo('isDeleted', true);
  3389. // 关联数据
  3390. query.include(['project', 'creator', 'assignee']);
  3391. // 排序:更新时间倒序
  3392. query.descending('updatedAt');
  3393. // 限制数量
  3394. query.limit(50);
  3395. const results = await query.find();
  3396. console.log(`📥 查询到 ${results.length} 条问题记录`);
  3397. // 数据转换(异步处理以支持 fetch)
  3398. const tasks = await Promise.all(results.map(async (obj: any) => {
  3399. let project = obj.get('project');
  3400. const assignee = obj.get('assignee');
  3401. const creator = obj.get('creator');
  3402. const data = obj.get('data') || {};
  3403. let projectName = '未知项目';
  3404. let projectId = '';
  3405. // 如果 project 存在,尝试获取完整数据
  3406. if (project) {
  3407. projectId = project.id;
  3408. // 尝试从已加载的对象获取 name
  3409. projectName = project.get('name');
  3410. // 如果 name 为空,使用 Parse.Query 查询项目
  3411. if (!projectName && projectId) {
  3412. try {
  3413. console.log(`🔄 查询项目数据: ${projectId}`);
  3414. const projectQuery = new Parse.Query('Project');
  3415. const fetchedProject = await projectQuery.get(projectId);
  3416. projectName = fetchedProject.get('name') || fetchedProject.get('title') || '未知项目';
  3417. console.log(`✅ 项目名称: ${projectName}`);
  3418. } catch (error) {
  3419. console.warn(`⚠️ 无法加载项目 ${projectId}:`, error);
  3420. projectName = `项目-${projectId.slice(0, 6)}`;
  3421. }
  3422. }
  3423. } else {
  3424. console.warn('⚠️ 问题缺少关联项目:', {
  3425. issueId: obj.id,
  3426. title: obj.get('title')
  3427. });
  3428. }
  3429. return {
  3430. id: obj.id,
  3431. title: obj.get('title') || obj.get('description')?.slice(0, 40) || '未命名问题',
  3432. description: obj.get('description'),
  3433. priority: obj.get('priority') as IssuePriority || 'medium',
  3434. type: obj.get('issueType') as IssueType || 'task',
  3435. status: this.zh2enStatus(obj.get('status')) as IssueStatus,
  3436. projectId,
  3437. projectName,
  3438. relatedSpace: obj.get('relatedSpace') || data.relatedSpace,
  3439. relatedStage: obj.get('relatedStage') || data.relatedStage,
  3440. assigneeName: assignee?.get('name') || assignee?.get('realname') || '未指派',
  3441. creatorName: creator?.get('name') || creator?.get('realname') || '未知',
  3442. createdAt: obj.createdAt || new Date(),
  3443. updatedAt: obj.updatedAt || new Date(),
  3444. dueDate: obj.get('dueDate'),
  3445. tags: (data.tags || []) as string[]
  3446. };
  3447. }));
  3448. this.todoTasksFromIssues = tasks;
  3449. // 排序:优先级 -> 时间
  3450. this.todoTasksFromIssues.sort((a, b) => {
  3451. const priorityA = this.getPriorityOrder(a.priority);
  3452. const priorityB = this.getPriorityOrder(b.priority);
  3453. if (priorityA !== priorityB) {
  3454. return priorityA - priorityB;
  3455. }
  3456. return +new Date(b.updatedAt) - +new Date(a.updatedAt);
  3457. });
  3458. console.log(`✅ 加载待办任务成功,共 ${this.todoTasksFromIssues.length} 条`);
  3459. } catch (error) {
  3460. console.error('❌ 加载待办任务失败:', error);
  3461. this.todoTaskError = '加载失败,请稍后重试';
  3462. } finally {
  3463. this.loadingTodoTasks = false;
  3464. }
  3465. }
  3466. /**
  3467. * 启动自动刷新(每5分钟)
  3468. */
  3469. startAutoRefresh(): void {
  3470. this.todoTaskRefreshTimer = setInterval(() => {
  3471. console.log('🔄 自动刷新待办任务...');
  3472. this.loadTodoTasksFromIssues();
  3473. }, 5 * 60 * 1000); // 5分钟
  3474. }
  3475. /**
  3476. * 手动刷新待办任务
  3477. */
  3478. refreshTodoTasks(): void {
  3479. console.log('🔄 手动刷新待办任务...');
  3480. this.loadTodoTasksFromIssues();
  3481. this.calculateUrgentEvents(); // 🆕 同时刷新紧急事件
  3482. }
  3483. /**
  3484. * 🆕 从项目时间轴数据计算紧急事件
  3485. * 识别截止时间已到或即将到达但未完成的关键节点
  3486. */
  3487. calculateUrgentEvents(): void {
  3488. this.loadingUrgentEvents = true;
  3489. const events: UrgentEvent[] = [];
  3490. const now = new Date();
  3491. const oneDayMs = 24 * 60 * 60 * 1000;
  3492. try {
  3493. // 从 projectTimelineData 中提取数据
  3494. this.projectTimelineData.forEach(project => {
  3495. // 1. 检查小图对图事件
  3496. if (project.reviewDate) {
  3497. const reviewTime = project.reviewDate.getTime();
  3498. const timeDiff = reviewTime - now.getTime();
  3499. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  3500. // 如果小图对图已经到期或即将到期(1天内),且不在已完成状态
  3501. if (daysDiff <= 1 && project.currentStage !== 'delivery') {
  3502. events.push({
  3503. id: `${project.projectId}-review`,
  3504. title: `小图对图截止`,
  3505. description: `项目「${project.projectName}」的小图对图时间已${daysDiff < 0 ? '逾期' : '临近'}`,
  3506. eventType: 'review',
  3507. deadline: project.reviewDate,
  3508. projectId: project.projectId,
  3509. projectName: project.projectName,
  3510. designerName: project.designerName,
  3511. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  3512. overdueDays: -daysDiff
  3513. });
  3514. }
  3515. }
  3516. // 2. 检查交付事件
  3517. if (project.deliveryDate) {
  3518. const deliveryTime = project.deliveryDate.getTime();
  3519. const timeDiff = deliveryTime - now.getTime();
  3520. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  3521. // 如果交付已经到期或即将到期(1天内),且不在已完成状态
  3522. if (daysDiff <= 1 && project.currentStage !== 'delivery') {
  3523. const summary = project.spaceDeliverableSummary;
  3524. const completionRate = summary?.overallCompletionRate || 0;
  3525. events.push({
  3526. id: `${project.projectId}-delivery`,
  3527. title: `项目交付截止`,
  3528. description: `项目「${project.projectName}」需要在 ${project.deliveryDate.toLocaleDateString()} 交付(当前完成率 ${completionRate}%)`,
  3529. eventType: 'delivery',
  3530. deadline: project.deliveryDate,
  3531. projectId: project.projectId,
  3532. projectName: project.projectName,
  3533. designerName: project.designerName,
  3534. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  3535. overdueDays: -daysDiff,
  3536. completionRate
  3537. });
  3538. }
  3539. }
  3540. // 3. 检查各阶段截止时间
  3541. if (project.phaseDeadlines) {
  3542. const phaseMap = {
  3543. modeling: '建模',
  3544. softDecor: '软装',
  3545. rendering: '渲染',
  3546. postProcessing: '后期'
  3547. };
  3548. Object.entries(project.phaseDeadlines).forEach(([key, phaseInfo]: [string, any]) => {
  3549. if (phaseInfo && phaseInfo.deadline) {
  3550. const deadline = new Date(phaseInfo.deadline);
  3551. const phaseTime = deadline.getTime();
  3552. const timeDiff = phaseTime - now.getTime();
  3553. const daysDiff = Math.ceil(timeDiff / oneDayMs);
  3554. // 如果阶段已经到期或即将到期(1天内),且状态不是已完成
  3555. if (daysDiff <= 1 && phaseInfo.status !== 'completed') {
  3556. const phaseName = phaseMap[key as keyof typeof phaseMap] || key;
  3557. // 获取该阶段的完成率
  3558. const summary = project.spaceDeliverableSummary;
  3559. let completionRate = 0;
  3560. if (summary && summary.phaseProgress) {
  3561. const phaseProgress = summary.phaseProgress[key as keyof typeof summary.phaseProgress];
  3562. completionRate = phaseProgress?.completionRate || 0;
  3563. }
  3564. events.push({
  3565. id: `${project.projectId}-phase-${key}`,
  3566. title: `${phaseName}阶段截止`,
  3567. description: `项目「${project.projectName}」的${phaseName}阶段截止时间已${daysDiff < 0 ? '逾期' : '临近'}(完成率 ${completionRate}%)`,
  3568. eventType: 'phase_deadline',
  3569. phaseName,
  3570. deadline,
  3571. projectId: project.projectId,
  3572. projectName: project.projectName,
  3573. designerName: project.designerName,
  3574. urgencyLevel: daysDiff < 0 ? 'critical' : daysDiff === 0 ? 'high' : 'medium',
  3575. overdueDays: -daysDiff,
  3576. completionRate
  3577. });
  3578. }
  3579. }
  3580. });
  3581. }
  3582. });
  3583. // 按紧急程度和时间排序
  3584. events.sort((a, b) => {
  3585. // 首先按紧急程度排序
  3586. const urgencyOrder = { critical: 0, high: 1, medium: 2 };
  3587. const urgencyDiff = urgencyOrder[a.urgencyLevel] - urgencyOrder[b.urgencyLevel];
  3588. if (urgencyDiff !== 0) return urgencyDiff;
  3589. // 相同紧急程度,按截止时间排序(越早越靠前)
  3590. return a.deadline.getTime() - b.deadline.getTime();
  3591. });
  3592. this.urgentEvents = events;
  3593. console.log(`✅ 计算紧急事件完成,共 ${events.length} 个紧急事件`);
  3594. } catch (error) {
  3595. console.error('❌ 计算紧急事件失败:', error);
  3596. } finally {
  3597. this.loadingUrgentEvents = false;
  3598. }
  3599. }
  3600. /**
  3601. * 跳转到项目问题详情
  3602. */
  3603. navigateToIssue(task: TodoTaskFromIssue): void {
  3604. const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
  3605. // 跳转到项目详情页,并打开问题板块
  3606. this.router.navigate(
  3607. ['/wxwork', cid, 'project', task.projectId, 'order'],
  3608. {
  3609. queryParams: {
  3610. openIssues: 'true',
  3611. highlightIssue: task.id,
  3612. roleName: 'team-leader'
  3613. }
  3614. }
  3615. );
  3616. }
  3617. /**
  3618. * 标记问题为已读
  3619. */
  3620. async markAsRead(task: TodoTaskFromIssue): Promise<void> {
  3621. try {
  3622. // 方式1: 本地隐藏(不修改数据库)
  3623. this.todoTasksFromIssues = this.todoTasksFromIssues.filter(t => t.id !== task.id);
  3624. console.log(`✅ 标记问题为已读: ${task.title}`);
  3625. } catch (error) {
  3626. console.error('❌ 标记已读失败:', error);
  3627. }
  3628. }
  3629. /**
  3630. * 🆕 从紧急事件点击查看项目
  3631. */
  3632. onProjectClick(projectId: string): void {
  3633. if (!projectId) {
  3634. console.warn('⚠️ 项目ID为空');
  3635. return;
  3636. }
  3637. console.log(`🔍 查看紧急事件关联项目: ${projectId}`);
  3638. this.viewProjectDetails(projectId);
  3639. }
  3640. /**
  3641. * 获取优先级配置
  3642. */
  3643. getPriorityConfig(priority: IssuePriority): { label: string; icon: string; color: string; order: number } {
  3644. const config: Record<IssuePriority, { label: string; icon: string; color: string; order: number }> = {
  3645. urgent: { label: '紧急', icon: '🔴', color: '#dc2626', order: 0 },
  3646. critical: { label: '紧急', icon: '🔴', color: '#dc2626', order: 0 },
  3647. high: { label: '高', icon: '🟠', color: '#ea580c', order: 1 },
  3648. medium: { label: '中', icon: '🟡', color: '#ca8a04', order: 2 },
  3649. low: { label: '低', icon: '⚪', color: '#9ca3af', order: 3 }
  3650. };
  3651. return config[priority] || config.medium;
  3652. }
  3653. getPriorityOrder(priority: IssuePriority): number {
  3654. return this.getPriorityConfig(priority).order;
  3655. }
  3656. /**
  3657. * 获取问题类型中文名
  3658. */
  3659. getIssueTypeLabel(type: IssueType): string {
  3660. const map: Record<IssueType, string> = {
  3661. bug: '问题',
  3662. task: '任务',
  3663. feedback: '反馈',
  3664. risk: '风险',
  3665. feature: '需求'
  3666. };
  3667. return map[type] || '任务';
  3668. }
  3669. /**
  3670. * 格式化相对时间(精确到秒)
  3671. */
  3672. formatRelativeTime(date: Date | string): string {
  3673. if (!date) {
  3674. return '未知时间';
  3675. }
  3676. try {
  3677. const targetDate = new Date(date);
  3678. const now = new Date();
  3679. const diff = now.getTime() - targetDate.getTime();
  3680. const seconds = Math.floor(diff / 1000);
  3681. const minutes = Math.floor(seconds / 60);
  3682. const hours = Math.floor(minutes / 60);
  3683. const days = Math.floor(hours / 24);
  3684. if (seconds < 10) {
  3685. return '刚刚';
  3686. } else if (seconds < 60) {
  3687. return `${seconds}秒前`;
  3688. } else if (minutes < 60) {
  3689. return `${minutes}分钟前`;
  3690. } else if (hours < 24) {
  3691. return `${hours}小时前`;
  3692. } else if (days < 7) {
  3693. return `${days}天前`;
  3694. } else {
  3695. return targetDate.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
  3696. }
  3697. } catch (error) {
  3698. console.error('❌ formatRelativeTime 错误:', error, 'date:', date);
  3699. return '时间格式错误';
  3700. }
  3701. }
  3702. /**
  3703. * 格式化精确时间(用于 tooltip)
  3704. * 格式:YYYY-MM-DD HH:mm:ss
  3705. */
  3706. formatExactTime(date: Date | string): string {
  3707. if (!date) {
  3708. return '未知时间';
  3709. }
  3710. try {
  3711. const d = new Date(date);
  3712. const year = d.getFullYear();
  3713. const month = String(d.getMonth() + 1).padStart(2, '0');
  3714. const day = String(d.getDate()).padStart(2, '0');
  3715. const hours = String(d.getHours()).padStart(2, '0');
  3716. const minutes = String(d.getMinutes()).padStart(2, '0');
  3717. const seconds = String(d.getSeconds()).padStart(2, '0');
  3718. return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
  3719. } catch (error) {
  3720. console.error('❌ formatExactTime 错误:', error, 'date:', date);
  3721. return '时间格式错误';
  3722. }
  3723. }
  3724. /**
  3725. * 状态映射(中文 -> 英文)
  3726. */
  3727. private zh2enStatus(status: string): IssueStatus {
  3728. const map: Record<string, IssueStatus> = {
  3729. '待处理': 'open',
  3730. '处理中': 'in_progress',
  3731. '已解决': 'resolved',
  3732. '已关闭': 'closed'
  3733. };
  3734. return map[status] || 'open';
  3735. }
  3736. }