dashboard.ts 132 KB

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