dashboard.ts 143 KB

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