dashboard.ts 150 KB

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