dashboard.ts 151 KB

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