dashboard.ts 138 KB

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