dashboard.ts 113 KB

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