project-detail.ts 56 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623
  1. import { Component, OnInit, OnDestroy } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { ActivatedRoute, Router } from '@angular/router';
  4. import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
  5. import { ProjectService } from '../../../services/project.service';
  6. import {
  7. Project,
  8. RenderProgress,
  9. ModelCheckItem,
  10. CustomerFeedback,
  11. DesignerChange,
  12. Settlement,
  13. ProjectStage
  14. } from '../../../models/project.model';
  15. import { ConsultationOrderPanelComponent } from '../../../shared/components/consultation-order-panel/consultation-order-panel.component';
  16. import { RequirementsConfirmCardComponent } from '../../../shared/components/requirements-confirm-card/requirements-confirm-card';
  17. import { SettlementCardComponent } from '../../../shared/components/settlement-card/settlement-card';
  18. import { CustomerReviewCardComponent } from '../../../shared/components/customer-review-card/customer-review-card';
  19. import { ComplaintCardComponent } from '../../../shared/components/complaint-card/complaint-card';
  20. import { VerticalNavComponent } from './components/vertical-nav/vertical-nav.component';
  21. interface ExceptionHistory {
  22. id: string;
  23. type: 'failed' | 'stuck' | 'quality' | 'other';
  24. description: string;
  25. submitTime: Date;
  26. status: '待处理' | '处理中' | '已解决';
  27. response?: string;
  28. }
  29. interface ProjectMember {
  30. id: string;
  31. name: string;
  32. role: string;
  33. avatar: string;
  34. skillMatch: number;
  35. progress: number;
  36. contribution: number;
  37. }
  38. interface ProjectFile {
  39. id: string;
  40. name: string;
  41. type: string;
  42. size: string;
  43. date: string;
  44. url: string;
  45. }
  46. interface TimelineEvent {
  47. id: string;
  48. time: string;
  49. title: string;
  50. action: string;
  51. description: string;
  52. }
  53. // 新增:四大板块键类型(顶层声明,供组件与模板共同使用)
  54. type SectionKey = 'order' | 'requirements' | 'delivery' | 'aftercare';
  55. @Component({
  56. selector: 'app-project-detail',
  57. standalone: true,
  58. imports: [CommonModule, FormsModule, ReactiveFormsModule, ConsultationOrderPanelComponent, RequirementsConfirmCardComponent, SettlementCardComponent, CustomerReviewCardComponent, ComplaintCardComponent, VerticalNavComponent],
  59. templateUrl: './project-detail.html',
  60. styleUrls: ['./project-detail.scss', './debug-styles.scss']
  61. })
  62. export class ProjectDetail implements OnInit, OnDestroy {
  63. // 项目基本数据
  64. projectId: string = '';
  65. project: Project | undefined;
  66. renderProgress: RenderProgress | undefined;
  67. modelCheckItems: ModelCheckItem[] = [];
  68. feedbacks: CustomerFeedback[] = [];
  69. designerChanges: DesignerChange[] = [];
  70. settlements: Settlement[] = [];
  71. requirementChecklist: string[] = [];
  72. reminderMessage: string = '';
  73. isLoadingRenderProgress: boolean = false;
  74. errorLoadingRenderProgress: boolean = false;
  75. feedbackTimeoutCountdown: number = 0;
  76. private countdownInterval: any;
  77. projects: {id: string, name: string, status: string}[] = [];
  78. showDropdown: boolean = false;
  79. currentStage: string = '';
  80. // 新增:10阶段顺序(串式流程)
  81. stageOrder: ProjectStage[] = ['订单创建', '需求沟通', '方案确认', '建模', '软装', '渲染', '尾款结算', '客户评价', '投诉处理'];
  82. // 新增:阶段展开状态(默认全部收起,当前阶段在数据加载后自动展开)
  83. expandedStages: Partial<Record<ProjectStage, boolean>> = {
  84. '订单创建': false,
  85. '需求沟通': false,
  86. '方案确认': false,
  87. '建模': false,
  88. '软装': false,
  89. '渲染': false,
  90. '后期': false,
  91. '尾款结算': false,
  92. '客户评价': false,
  93. '投诉处理': false,
  94. };
  95. // 新增:四大板块定义与展开状态
  96. sections: Array<{ key: SectionKey; label: string; stages: ProjectStage[] }> = [
  97. { key: 'order', label: '订单创建', stages: ['订单创建'] },
  98. { key: 'requirements', label: '确认需求', stages: ['需求沟通', '方案确认'] },
  99. { key: 'delivery', label: '交付执行', stages: ['建模', '软装', '渲染'] },
  100. { key: 'aftercare', label: '售后', stages: ['尾款结算', '客户评价', '投诉处理'] }
  101. ];
  102. expandedSection: SectionKey | null = null;
  103. // 渲染异常反馈相关属性
  104. exceptionType: 'failed' | 'stuck' | 'quality' | 'other' = 'failed';
  105. exceptionDescription: string = '';
  106. exceptionScreenshotUrl: string | null = null;
  107. exceptionHistories: ExceptionHistory[] = [];
  108. isSubmittingFeedback: boolean = false;
  109. selectedScreenshot: File | null = null;
  110. screenshotPreview: string | null = null;
  111. showExceptionForm: boolean = false;
  112. // 标签页相关
  113. activeTab: 'progress' | 'members' | 'files' = 'progress';
  114. tabs = [
  115. { id: 'progress', name: '项目进度' },
  116. { id: 'members', name: '项目成员' },
  117. { id: 'files', name: '项目文件' }
  118. ];
  119. // 标准化阶段(视图层映射)
  120. standardPhases: Array<'待分配' | '需求方案' | '项目执行' | '收尾验收' | '归档'> = ['待分配', '需求方案', '项目执行', '收尾验收', '归档'];
  121. // 文件上传(通用)
  122. acceptedFileTypes: string = '.doc,.docx,.pdf,.jpg,.jpeg,.png,.zip,.rar,.max,.obj';
  123. isUploadingFile: boolean = false;
  124. projectMembers: ProjectMember[] = [];
  125. // 项目文件数据
  126. projectFiles: ProjectFile[] = [];
  127. // 团队协作时间轴
  128. timelineEvents: TimelineEvent[] = [];
  129. // ============ 阶段图片上传状态(新增) ============
  130. allowedImageTypes: string = '.jpg,.jpeg,.png';
  131. // 增加审核状态reviewStatus与是否已同步synced标记(仅由组长操作)
  132. whiteModelImages: Array<{ id: string; name: string; url: string; size?: string; reviewStatus?: 'pending' | 'approved' | 'rejected'; synced?: boolean }> = [];
  133. softDecorImages: Array<{ id: string; name: string; url: string; size?: string; reviewStatus?: 'pending' | 'approved' | 'rejected'; synced?: boolean }> = [];
  134. renderLargeImages: Array<{ id: string; name: string; url: string; size?: string; reviewStatus?: 'pending' | 'approved' | 'rejected'; synced?: boolean }> = [];
  135. showRenderUploadModal: boolean = false;
  136. pendingRenderLargeItems: Array<{ id: string; name: string; url: string; file: File }> = [];
  137. // 视图上下文:根据路由前缀识别角色视角(客服/设计师/组长)
  138. private roleContext: 'customer-service' | 'designer' | 'team-leader' = 'designer';
  139. constructor(
  140. private route: ActivatedRoute,
  141. private projectService: ProjectService,
  142. private router: Router,
  143. private fb: FormBuilder,
  144. ) {}
  145. // 切换标签页
  146. switchTab(tabId: 'progress' | 'members' | 'files') {
  147. this.activeTab = tabId;
  148. }
  149. // 类型安全的标签页检查方法
  150. isActiveTab(tabId: 'progress' | 'members' | 'files'): boolean {
  151. return this.activeTab === tabId;
  152. }
  153. // 根据事件类型获取作者名称
  154. getEventAuthor(action: string): string {
  155. // 根据不同的action类型返回对应的作者名称
  156. switch(action) {
  157. case '完成':
  158. case '更新':
  159. case '优化':
  160. return '李设计师';
  161. case '收到':
  162. return '李客服';
  163. case '提交':
  164. return '赵建模师';
  165. default:
  166. return '王组长';
  167. }
  168. }
  169. // 切换项目
  170. switchProject(projectId: string): void {
  171. this.projectId = projectId;
  172. this.loadProjectData();
  173. this.loadProjectMembers();
  174. this.loadProjectFiles();
  175. this.loadTimelineEvents();
  176. // 更新URL但不触发组件重载
  177. this.router.navigate([], { relativeTo: this.route, queryParamsHandling: 'merge', queryParams: { id: projectId } });
  178. }
  179. // 返回工作台
  180. backToWorkbench(): void {
  181. this.router.navigate(['/designer/dashboard']);
  182. }
  183. // 检查阶段是否已完成
  184. isStageCompleted(stage: ProjectStage): boolean {
  185. if (!this.project) return false;
  186. // 定义阶段顺序
  187. const stageOrder = [
  188. '订单创建', '需求沟通', '方案确认', '建模', '软装',
  189. '渲染', '后期', '尾款结算', '客户评价', '投诉处理'
  190. ];
  191. // 获取当前阶段和检查阶段的索引
  192. const currentStageIndex = stageOrder.indexOf(this.project.currentStage);
  193. const checkStageIndex = stageOrder.indexOf(stage);
  194. // 如果检查阶段在当前阶段之前,则已完成
  195. return checkStageIndex < currentStageIndex;
  196. }
  197. // 获取阶段状态:completed/active/pending
  198. getStageStatus(stage: ProjectStage): 'completed' | 'active' | 'pending' {
  199. const order = this.stageOrder;
  200. const current = this.project?.currentStage as ProjectStage | undefined;
  201. const currentIdx = current ? order.indexOf(current) : -1;
  202. const idx = order.indexOf(stage);
  203. if (idx === -1) return 'pending';
  204. if (currentIdx === -1) return 'pending';
  205. if (idx < currentIdx) return 'completed';
  206. if (idx === currentIdx) return 'active';
  207. return 'pending';
  208. }
  209. // 切换阶段展开/收起,并保持单展开
  210. toggleStage(stage: ProjectStage): void {
  211. // 已移除所有展开按钮,本方法保留以兼容模板其它引用,如无需可进一步删除调用点和方法
  212. const exclusivelyOpen = true;
  213. if (exclusivelyOpen) {
  214. Object.keys(this.expandedStages).forEach((key) => (this.expandedStages[key as ProjectStage] = false));
  215. this.expandedStages[stage] = true;
  216. } else {
  217. this.expandedStages[stage] = !this.expandedStages[stage];
  218. }
  219. }
  220. // 查看阶段详情(已不再通过按钮触发,保留以兼容日志或未来调用)
  221. viewStageDetails(stage: ProjectStage): void {
  222. // 以往这里有 alert/导航行为,现清空用户交互,避免误触
  223. return;
  224. }
  225. ngOnInit(): void {
  226. this.route.paramMap.subscribe(params => {
  227. this.projectId = params.get('id') || '';
  228. // 根据当前URL检测视图上下文
  229. this.roleContext = this.detectRoleContextFromUrl();
  230. this.loadProjectData();
  231. this.loadExceptionHistories();
  232. this.loadProjectMembers();
  233. this.loadProjectFiles();
  234. this.loadTimelineEvents();
  235. });
  236. // 新增:监听查询参数,支持通过 activeTab 设置初始标签页
  237. this.route.queryParamMap.subscribe(qp => {
  238. const raw = qp.get('activeTab');
  239. const alias: Record<string, 'progress' | 'members' | 'files'> = {
  240. requirements: 'progress',
  241. overview: 'progress'
  242. };
  243. const tab = raw && (raw in alias ? alias[raw] : raw);
  244. if (tab === 'progress' || tab === 'members' || tab === 'files') {
  245. this.activeTab = tab;
  246. }
  247. });
  248. // 添加点击事件监听器,当点击页面其他位置时关闭下拉菜单
  249. document.addEventListener('click', this.closeDropdownOnClickOutside);
  250. // 初始化客户表单(与客服端保持一致)
  251. this.customerForm = this.fb.group({
  252. name: ['', Validators.required],
  253. phone: ['', [Validators.required, Validators.pattern(/^1[3-9]\d{9}$/)]],
  254. wechat: [''],
  255. customerType: ['新客户'],
  256. source: [''],
  257. remark: [''],
  258. demandType: [''],
  259. followUpStatus: ['']
  260. });
  261. // 自动生成下单时间
  262. this.orderTime = new Date().toLocaleString('zh-CN', {
  263. year: 'numeric', month: '2-digit', day: '2-digit',
  264. hour: '2-digit', minute: '2-digit', second: '2-digit'
  265. });
  266. }
  267. // 在组件销毁时移除事件监听器和清理资源
  268. ngOnDestroy(): void {
  269. if (this.countdownInterval) {
  270. clearInterval(this.countdownInterval);
  271. }
  272. document.removeEventListener('click', this.closeDropdownOnClickOutside);
  273. // 释放所有 blob 预览 URL
  274. const revokeList: string[] = [];
  275. this.whiteModelImages.forEach(i => { if (i.url.startsWith('blob:')) revokeList.push(i.url); });
  276. this.softDecorImages.forEach(i => { if (i.url.startsWith('blob:')) revokeList.push(i.url); });
  277. this.renderLargeImages.forEach(i => { if (i.url.startsWith('blob:')) revokeList.push(i.url); });
  278. this.pendingRenderLargeItems.forEach(i => { if (i.url.startsWith('blob:')) revokeList.push(i.url); });
  279. revokeList.forEach(u => URL.revokeObjectURL(u));
  280. }
  281. // ============ 角色视图与只读控制(新增) ============
  282. private detectRoleContextFromUrl(): 'customer-service' | 'designer' | 'team-leader' {
  283. const url = this.router.url || '';
  284. if (url.includes('/customer-service/')) return 'customer-service';
  285. if (url.includes('/team-leader/')) return 'team-leader';
  286. return 'designer';
  287. }
  288. isDesignerView(): boolean { return this.roleContext === 'designer'; }
  289. isTeamLeaderView(): boolean { return this.roleContext === 'team-leader'; }
  290. isCustomerServiceView(): boolean { return this.roleContext === 'customer-service'; }
  291. // 只读规则:客服视角为只读
  292. isReadOnly(): boolean { return this.isCustomerServiceView(); }
  293. // 计算当前激活板块:优先用户点击的 expandedSection;否则取当前阶段所属板块;再否则回退首个板块
  294. private getActiveSectionKey(): SectionKey {
  295. if (this.expandedSection) return this.expandedSection;
  296. const current = this.project?.currentStage as ProjectStage | undefined;
  297. return current ? this.getSectionKeyForStage(current) : this.sections[0].key;
  298. }
  299. // 返回当前板块的全部阶段(所有角色一致):
  300. // 设计师也可查看 订单创建/确认需求/售后 板块内容
  301. getVisibleStages(): ProjectStage[] {
  302. const activeKey = this.getActiveSectionKey();
  303. const sec = this.sections.find(s => s.key === activeKey);
  304. return sec ? sec.stages : [];
  305. }
  306. // ============ 组长:同步上传与审核(新增,模拟实现) ============
  307. syncUploadedImages(phase: 'white' | 'soft' | 'render'): void {
  308. if (!this.isTeamLeaderView()) return;
  309. const markSynced = (arr: Array<{ reviewStatus?: 'pending'|'approved'|'rejected'; synced?: boolean }>) => {
  310. arr.forEach(img => {
  311. if (!img.synced) img.synced = true;
  312. if (!img.reviewStatus) img.reviewStatus = 'pending';
  313. });
  314. };
  315. if (phase === 'white') markSynced(this.whiteModelImages);
  316. if (phase === 'soft') markSynced(this.softDecorImages);
  317. if (phase === 'render') markSynced(this.renderLargeImages);
  318. alert('已同步该阶段的图片信息(模拟)');
  319. }
  320. reviewImage(imageId: string, phase: 'white' | 'soft' | 'render', status: 'approved' | 'rejected'): void {
  321. if (!this.isTeamLeaderView()) return;
  322. const setStatus = (arr: Array<{ id: string; reviewStatus?: 'pending'|'approved'|'rejected'; synced?: boolean }>) => {
  323. const target = arr.find(i => i.id === imageId);
  324. if (target) {
  325. target.reviewStatus = status;
  326. if (!target.synced) target.synced = true; // 审核时自动视为已同步
  327. }
  328. };
  329. if (phase === 'white') setStatus(this.whiteModelImages);
  330. if (phase === 'soft') setStatus(this.softDecorImages);
  331. if (phase === 'render') setStatus(this.renderLargeImages);
  332. }
  333. getImageReviewStatusText(img: { reviewStatus?: 'pending'|'approved'|'rejected'; synced?: boolean }): string {
  334. const synced = img.synced ? '已同步' : '未同步';
  335. const map: Record<string, string> = {
  336. 'pending': '待审',
  337. 'approved': '已通过',
  338. 'rejected': '已驳回'
  339. };
  340. const st = img.reviewStatus ? map[img.reviewStatus] : '未标记';
  341. return `${st} · ${synced}`;
  342. }
  343. // 点击页面其他位置时关闭下拉菜单
  344. private closeDropdownOnClickOutside = (event: MouseEvent): void => {
  345. const targetElement = event.target as HTMLElement;
  346. const projectSwitcher = targetElement.closest('.project-switcher');
  347. if (!projectSwitcher && this.showDropdown) {
  348. this.showDropdown = false;
  349. }
  350. };
  351. loadProjectData(): void {
  352. if (this.projectId) {
  353. this.loadProjectDetails();
  354. this.loadRenderProgress();
  355. this.loadModelCheckItems();
  356. this.loadCustomerFeedbacks();
  357. this.loadDesignerChanges();
  358. this.loadSettlements();
  359. this.loadRequirementChecklist();
  360. }
  361. // 初始化项目列表数据(模拟)
  362. this.projects = [
  363. { id: '1', name: '现代风格客厅设计', status: '进行中' },
  364. { id: '2', name: '北欧风卧室装修', status: '已完成' },
  365. { id: '3', name: '新中式书房改造', status: '进行中' },
  366. { id: '4', name: '工业风餐厅设计', status: '待处理' }
  367. ];
  368. }
  369. // 加载项目成员数据
  370. loadProjectMembers(): void {
  371. // 模拟API请求获取项目成员数据
  372. setTimeout(() => {
  373. this.projectMembers = [
  374. {
  375. id: '1',
  376. name: '李设计师',
  377. role: '主设计师',
  378. avatar: '李',
  379. skillMatch: 95,
  380. progress: 65,
  381. contribution: 75
  382. },
  383. {
  384. id: '2',
  385. name: '陈设计师',
  386. role: '助理设计师',
  387. avatar: '陈',
  388. skillMatch: 88,
  389. progress: 80,
  390. contribution: 60
  391. },
  392. {
  393. id: '3',
  394. name: '王组长',
  395. role: '项目组长',
  396. avatar: '王',
  397. skillMatch: 92,
  398. progress: 70,
  399. contribution: 70
  400. },
  401. {
  402. id: '4',
  403. name: '赵建模师',
  404. role: '3D建模师',
  405. avatar: '赵',
  406. skillMatch: 90,
  407. progress: 90,
  408. contribution: 85
  409. }
  410. ];
  411. }, 600);
  412. }
  413. // 加载项目文件数据
  414. loadProjectFiles(): void {
  415. // 模拟API请求获取项目文件数据
  416. setTimeout(() => {
  417. this.projectFiles = [
  418. {
  419. id: '1',
  420. name: '客厅设计方案V2.0.pdf',
  421. type: 'pdf',
  422. size: '2.5MB',
  423. date: '2024-02-10',
  424. url: '#'
  425. },
  426. {
  427. id: '2',
  428. name: '材质库集合.rar',
  429. type: 'rar',
  430. size: '45.8MB',
  431. date: '2024-02-08',
  432. url: '#'
  433. },
  434. {
  435. id: '3',
  436. name: '客厅渲染预览1.jpg',
  437. type: 'jpg',
  438. size: '3.2MB',
  439. date: '2024-02-14',
  440. url: '#'
  441. },
  442. {
  443. id: '4',
  444. name: '3D模型文件.max',
  445. type: 'max',
  446. size: '87.5MB',
  447. date: '2024-02-12',
  448. url: '#'
  449. },
  450. {
  451. id: '5',
  452. name: '客户需求文档.docx',
  453. type: 'docx',
  454. size: '1.2MB',
  455. date: '2024-01-15',
  456. url: '#'
  457. },
  458. {
  459. id: '6',
  460. name: '客厅渲染预览2.jpg',
  461. type: 'jpg',
  462. size: '3.8MB',
  463. date: '2024-02-15',
  464. url: '#'
  465. }
  466. ];
  467. }, 700);
  468. }
  469. // 加载团队协作时间轴数据
  470. loadTimelineEvents(): void {
  471. // 模拟API请求获取时间轴数据
  472. setTimeout(() => {
  473. this.timelineEvents = [
  474. {
  475. id: '1',
  476. time: '2024-02-15 14:30',
  477. title: '渲染完成',
  478. action: '完成',
  479. description: '客厅主视角渲染已完成,等待客户确认'
  480. },
  481. {
  482. id: '2',
  483. time: '2024-02-14 10:15',
  484. title: '材质调整',
  485. action: '更新',
  486. description: '根据客户反馈调整了沙发和窗帘材质'
  487. },
  488. {
  489. id: '3',
  490. time: '2024-02-12 16:45',
  491. title: '模型优化',
  492. action: '优化',
  493. description: '优化了模型面数,提高渲染效率'
  494. },
  495. {
  496. id: '4',
  497. time: '2024-02-10 09:30',
  498. title: '客户反馈',
  499. action: '收到',
  500. description: '收到客户关于颜色和储物空间的反馈意见'
  501. },
  502. {
  503. id: '5',
  504. time: '2024-02-08 15:20',
  505. title: '模型提交',
  506. action: '提交',
  507. description: '完成3D模型搭建并提交审核'
  508. }
  509. ];
  510. }, 800);
  511. }
  512. // 加载历史反馈记录
  513. loadExceptionHistories(): void {
  514. this.projectService.getExceptionHistories(this.projectId).subscribe(histories => {
  515. this.exceptionHistories = histories;
  516. });
  517. }
  518. loadProjectDetails(): void {
  519. this.projectService.getProjectById(this.projectId).subscribe(project => {
  520. this.project = project;
  521. // 设置当前阶段
  522. if (project) {
  523. this.currentStage = project.currentStage || '';
  524. // 重置展开状态并默认展开当前阶段
  525. this.stageOrder.forEach(s => this.expandedStages[s] = false);
  526. if (this.stageOrder.includes(project.currentStage)) {
  527. this.expandedStages[project.currentStage] = true;
  528. }
  529. // 新增:根据当前阶段默认展开所属板块
  530. const currentSec = this.getSectionKeyForStage(project.currentStage as ProjectStage);
  531. this.expandedSection = currentSec;
  532. }
  533. // 检查技能匹配度
  534. this.checkSkillMismatch();
  535. });
  536. }
  537. // 整理项目详情
  538. organizeProject(): void {
  539. // 模拟整理项目逻辑
  540. alert('项目详情已整理');
  541. }
  542. // 检查当前阶段是否显示特定卡片
  543. shouldShowCard(cardType: string): boolean {
  544. // 改为始终显示:各阶段详情在看板下方就地展示,不再受当前阶段限制
  545. return true;
  546. }
  547. loadRenderProgress(): void {
  548. this.isLoadingRenderProgress = true;
  549. this.errorLoadingRenderProgress = false;
  550. // 模拟API加载过程
  551. setTimeout(() => {
  552. this.projectService.getRenderProgress(this.projectId).subscribe(progress => {
  553. this.renderProgress = progress;
  554. this.isLoadingRenderProgress = false;
  555. // 模拟API加载失败的情况
  556. if (!progress) {
  557. this.errorLoadingRenderProgress = true;
  558. // 通知技术组长
  559. this.notifyTeamLeader('render-failed');
  560. } else {
  561. // 检查是否需要显示超时预警
  562. this.checkRenderTimeout();
  563. }
  564. });
  565. }, 1000);
  566. }
  567. loadModelCheckItems(): void {
  568. this.projectService.getModelCheckItems().subscribe(items => {
  569. this.modelCheckItems = items;
  570. });
  571. }
  572. loadCustomerFeedbacks(): void {
  573. this.projectService.getCustomerFeedbacks().subscribe(feedbacks => {
  574. this.feedbacks = feedbacks.filter(f => f.projectId === this.projectId);
  575. // 为反馈添加分类标签
  576. this.tagCustomerFeedbacks();
  577. // 检查是否有需要处理的反馈并启动倒计时
  578. this.checkFeedbackTimeout();
  579. });
  580. }
  581. loadDesignerChanges(): void {
  582. // 在实际应用中,这里应该从服务中获取设计师变更记录
  583. // 这里使用模拟数据
  584. this.designerChanges = [
  585. {
  586. id: 'dc1',
  587. projectId: this.projectId,
  588. oldDesignerId: 'designer2',
  589. oldDesignerName: '设计师B',
  590. newDesignerId: 'designer1',
  591. newDesignerName: '设计师A',
  592. changeTime: new Date('2025-09-05'),
  593. acceptanceTime: new Date('2025-09-05'),
  594. historicalAchievements: ['完成初步建模', '确定色彩方案'],
  595. completedWorkload: 30
  596. }
  597. ];
  598. }
  599. loadSettlements(): void {
  600. this.projectService.getSettlements().subscribe(settlements => {
  601. this.settlements = settlements.filter(s => s.projectId === this.projectId);
  602. });
  603. }
  604. loadRequirementChecklist(): void {
  605. this.projectService.generateRequirementChecklist(this.projectId).subscribe(checklist => {
  606. this.requirementChecklist = checklist;
  607. });
  608. }
  609. updateModelCheckItem(itemId: string, isPassed: boolean): void {
  610. this.projectService.updateModelCheckItem(itemId, isPassed).subscribe(() => {
  611. this.loadModelCheckItems(); // 重新加载检查项
  612. });
  613. }
  614. updateFeedbackStatus(feedbackId: string, status: '处理中' | '已解决'): void {
  615. this.projectService.updateFeedbackStatus(feedbackId, status).subscribe(() => {
  616. this.loadCustomerFeedbacks(); // 重新加载反馈
  617. // 清除倒计时
  618. if (this.countdownInterval) {
  619. clearInterval(this.countdownInterval);
  620. this.feedbackTimeoutCountdown = 0;
  621. }
  622. });
  623. }
  624. updateProjectStage(stage: ProjectStage): void {
  625. if (this.project) {
  626. this.projectService.updateProjectStage(this.projectId, stage).subscribe(() => {
  627. this.loadProjectDetails(); // 重新加载项目详情
  628. });
  629. }
  630. }
  631. // 新增:根据给定阶段跳转到下一阶段
  632. advanceToNextStage(afterStage: ProjectStage): void {
  633. const idx = this.stageOrder.indexOf(afterStage);
  634. if (idx >= 0 && idx < this.stageOrder.length - 1) {
  635. const next = this.stageOrder[idx + 1];
  636. this.updateProjectStage(next);
  637. // 可选:更新展开状态,折叠当前、展开下一阶段,提升体验
  638. if (this.expandedStages[afterStage] !== undefined) this.expandedStages[afterStage] = false as any;
  639. if (this.expandedStages[next] !== undefined) this.expandedStages[next] = true as any;
  640. }
  641. }
  642. generateReminderMessage(): void {
  643. this.projectService.generateReminderMessage('stagnation').subscribe(message => {
  644. this.reminderMessage = message;
  645. // 3秒后自动清除提醒
  646. setTimeout(() => {
  647. this.reminderMessage = '';
  648. }, 3000);
  649. });
  650. }
  651. // ============ 新增:标准化阶段映射与紧急程度 ============
  652. // 计算距离截止日期的天数(向下取整)
  653. getDaysToDeadline(): number | null {
  654. if (!this.project?.deadline) return null;
  655. const now = new Date();
  656. const deadline = new Date(this.project.deadline);
  657. const diffMs = deadline.getTime() - now.getTime();
  658. return Math.floor(diffMs / (1000 * 60 * 60 * 24));
  659. }
  660. // 是否延期/临期/提示
  661. getUrgencyBadge(): 'overdue' | 'due_3' | 'due_7' | null {
  662. const d = this.getDaysToDeadline();
  663. if (d === null) return null;
  664. if (d < 0) return 'overdue';
  665. if (d <= 3) return 'due_3';
  666. if (d <= 7) return 'due_7';
  667. return null;
  668. }
  669. // 是否存在不满意或待处理投诉/反馈
  670. hasPendingComplaint(): boolean {
  671. return this.feedbacks.some(f => !f.isSatisfied || f.status === '待处理');
  672. }
  673. // 将现有细分阶段映射为标准化阶段
  674. mapToStandardPhase(stage: ProjectStage): '待分配' | '需求方案' | '项目执行' | '收尾验收' | '归档' {
  675. const mapping: Record<ProjectStage, '待分配' | '需求方案' | '项目执行' | '收尾验收' | '归档'> = {
  676. '订单创建': '待分配',
  677. '需求沟通': '需求方案',
  678. '方案确认': '需求方案',
  679. '建模': '项目执行',
  680. '软装': '项目执行',
  681. '渲染': '项目执行',
  682. '后期': '项目执行',
  683. '尾款结算': '收尾验收',
  684. '客户评价': '收尾验收',
  685. '投诉处理': '收尾验收'
  686. };
  687. return mapping[stage] ?? '待分配';
  688. }
  689. getStandardPhaseIndex(): number {
  690. if (!this.project?.currentStage) return 0;
  691. const phase = this.mapToStandardPhase(this.project.currentStage);
  692. return this.standardPhases.indexOf(phase);
  693. }
  694. isStandardPhaseCompleted(phase: '待分配' | '需求方案' | '项目执行' | '收尾验收' | '归档'): boolean {
  695. return this.standardPhases.indexOf(phase) < this.getStandardPhaseIndex();
  696. }
  697. isStandardPhaseCurrent(phase: '待分配' | '需求方案' | '项目执行' | '收尾验收' | '归档'): boolean {
  698. return this.standardPhases.indexOf(phase) === this.getStandardPhaseIndex();
  699. }
  700. // ============ 新增:项目报告导出 ============
  701. exportProjectReport(): void {
  702. if (!this.project) return;
  703. const lines: string[] = [];
  704. const d = this.getDaysToDeadline();
  705. lines.push(`项目名称: ${this.project.name}`);
  706. lines.push(`当前阶段(细分): ${this.project.currentStage}`);
  707. lines.push(`当前阶段(标准化): ${this.mapToStandardPhase(this.project.currentStage)}`);
  708. if (this.project.deadline) {
  709. lines.push(`截止日期: ${this.formatDate(this.project.deadline)}`);
  710. lines.push(`剩余天数: ${d !== null ? d : '-'}天`);
  711. }
  712. lines.push(`技能需求: ${(this.project.skillsRequired || []).join('、')}`);
  713. lines.push('—— 渲染进度 ——');
  714. lines.push(this.renderProgress ? `状态: ${this.renderProgress.status}, 完成度: ${this.renderProgress.completionRate}%` : '无渲染进度数据');
  715. lines.push('—— 客户反馈 ——');
  716. lines.push(this.feedbacks.length ? `${this.feedbacks.length} 条` : '暂无');
  717. lines.push('—— 设计师变更 ——');
  718. lines.push(this.designerChanges.length ? `${this.designerChanges.length} 条` : '暂无');
  719. lines.push('—— 交付文件 ——');
  720. lines.push(this.projectFiles.length ? this.projectFiles.map(f => `• ${f.name} (${f.type}, ${f.size})`).join('\n') : '暂无');
  721. const content = lines.join('\n');
  722. const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
  723. const url = URL.createObjectURL(blob);
  724. const a = document.createElement('a');
  725. a.href = url;
  726. a.download = `${this.project.name || '项目'}-阶段报告.txt`;
  727. a.click();
  728. URL.revokeObjectURL(url);
  729. }
  730. // ============ 新增:通用文件上传(含4K图片校验) ============
  731. async onGeneralFilesSelected(event: Event): Promise<void> {
  732. const input = event.target as HTMLInputElement;
  733. if (!input.files || input.files.length === 0) return;
  734. const files = Array.from(input.files);
  735. this.isUploadingFile = true;
  736. for (const file of files) {
  737. // 对图片进行4K校验(最大边 >= 4000px)
  738. if (/\.(jpg|jpeg|png)$/i.test(file.name)) {
  739. const ok = await this.validateImage4K(file).catch(() => false);
  740. if (!ok) {
  741. alert(`图片不符合4K标准(最大边需≥4000像素):${file.name}`);
  742. continue;
  743. }
  744. }
  745. // 简化:直接追加到本地列表(实际应上传到服务器)
  746. const fakeType = (file.name.split('.').pop() || '').toLowerCase();
  747. const sizeMB = (file.size / (1024 * 1024)).toFixed(1) + 'MB';
  748. const nowStr = this.formatDate(new Date());
  749. this.projectFiles.unshift({
  750. id: `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
  751. name: file.name,
  752. type: fakeType,
  753. size: sizeMB,
  754. date: nowStr,
  755. url: '#'
  756. });
  757. }
  758. this.isUploadingFile = false;
  759. // 清空选择
  760. input.value = '';
  761. }
  762. validateImage4K(file: File): Promise<boolean> {
  763. return new Promise((resolve, reject) => {
  764. const reader = new FileReader();
  765. reader.onload = () => {
  766. const img = new Image();
  767. img.onload = () => {
  768. const maxSide = Math.max(img.width, img.height);
  769. resolve(maxSide >= 4000);
  770. };
  771. img.onerror = () => reject('image load error');
  772. img.src = reader.result as string;
  773. };
  774. reader.onerror = () => reject('read error');
  775. reader.readAsDataURL(file);
  776. });
  777. }
  778. // 可选:列表 trackBy,优化渲染
  779. trackById(_: number, item: { id: string }): string { return item.id; }
  780. retryLoadRenderProgress(): void {
  781. this.loadRenderProgress();
  782. }
  783. // 检查是否所有模型检查项都已通过
  784. areAllModelChecksPassed(): boolean {
  785. return this.modelCheckItems.every(item => item.isPassed);
  786. }
  787. // 获取技能匹配度警告
  788. getSkillMismatchWarning(): string | null {
  789. if (!this.project) return null;
  790. // 模拟技能匹配度检查
  791. const designerSkills = ['现代风格', '硬装'];
  792. const requiredSkills = this.project.skillsRequired;
  793. const mismatchedSkills = requiredSkills.filter(skill => !designerSkills.includes(skill));
  794. if (mismatchedSkills.length > 0) {
  795. return `警告:您不擅长${mismatchedSkills.join('、')},建议联系组长协调`;
  796. }
  797. return null;
  798. }
  799. // 检查渲染是否超时
  800. checkRenderTimeout(): void {
  801. if (!this.renderProgress || !this.project) return;
  802. // 模拟交付前3小时预警
  803. const deliveryTime = new Date(this.project.deadline);
  804. const currentTime = new Date();
  805. const timeDifference = deliveryTime.getTime() - currentTime.getTime();
  806. const hoursRemaining = Math.floor(timeDifference / (1000 * 60 * 60));
  807. if (hoursRemaining <= 3 && hoursRemaining > 0) {
  808. // 弹窗预警
  809. alert('渲染进度预警:交付前3小时,请关注渲染进度');
  810. }
  811. if (hoursRemaining <= 1 && hoursRemaining > 0) {
  812. // 更严重的预警
  813. alert('渲染进度严重预警:交付前1小时,渲染可能无法按时完成!');
  814. }
  815. }
  816. // 为客户反馈添加分类标签
  817. tagCustomerFeedbacks(): void {
  818. this.feedbacks.forEach(feedback => {
  819. // 添加分类标签
  820. if (feedback.content.includes('色彩') || feedback.problemLocation?.includes('色彩')) {
  821. (feedback as any).tag = '色彩问题';
  822. } else if (feedback.content.includes('家具') || feedback.problemLocation?.includes('家具')) {
  823. (feedback as any).tag = '家具款式问题';
  824. } else if (feedback.content.includes('光线') || feedback.content.includes('照明')) {
  825. (feedback as any).tag = '光线问题';
  826. } else {
  827. (feedback as any).tag = '其他问题';
  828. }
  829. });
  830. }
  831. // 获取反馈标签的辅助方法
  832. getFeedbackTag(feedback: CustomerFeedback): string {
  833. return (feedback as any).tag || '';
  834. }
  835. // 检查反馈超时
  836. checkFeedbackTimeout(): void {
  837. const pendingFeedbacks = this.feedbacks.filter(f => f.status === '待处理');
  838. if (pendingFeedbacks.length > 0) {
  839. // 启动1小时倒计时
  840. this.feedbackTimeoutCountdown = 3600; // 3600秒 = 1小时
  841. this.startCountdown();
  842. }
  843. }
  844. // 启动倒计时
  845. startCountdown(): void {
  846. this.countdownInterval = setInterval(() => {
  847. if (this.feedbackTimeoutCountdown > 0) {
  848. this.feedbackTimeoutCountdown--;
  849. } else {
  850. clearInterval(this.countdownInterval);
  851. // 超时提醒
  852. alert('客户反馈已超过1小时未响应,请立即处理!');
  853. this.notifyTeamLeader('feedback-overdue');
  854. }
  855. }, 1000);
  856. }
  857. // 通知技术组长
  858. notifyTeamLeader(type: 'render-failed' | 'feedback-overdue' | 'skill-mismatch'): void {
  859. // 实际应用中应调用消息服务通知组长
  860. console.log(`通知技术组长:${type} - 项目ID: ${this.projectId}`);
  861. }
  862. // 检查技能匹配度并提示
  863. checkSkillMismatch(): void {
  864. const warning = this.getSkillMismatchWarning();
  865. if (warning) {
  866. // 显示技能不匹配警告
  867. if (confirm(`${warning}\n是否联系技术组长协调支持?`)) {
  868. this.notifyTeamLeader('skill-mismatch');
  869. }
  870. }
  871. }
  872. // 发起设计师变更
  873. initiateDesignerChange(reason: string): void {
  874. // 实际应用中应调用API发起变更
  875. console.log(`发起设计师变更,原因:${reason}`);
  876. alert('已发起设计师变更申请,请等待新设计师承接');
  877. }
  878. // 确认承接变更项目
  879. acceptDesignerChange(changeId: string): void {
  880. // 实际应用中应调用API确认承接
  881. console.log(`确认承接设计师变更:${changeId}`);
  882. alert('已确认承接项目,系统已记录时间戳和责任人');
  883. }
  884. // 格式化倒计时显示
  885. formatCountdown(seconds: number): string {
  886. const hours = Math.floor(seconds / 3600);
  887. const minutes = Math.floor((seconds % 3600) / 60);
  888. const remainingSeconds = seconds % 60;
  889. return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
  890. }
  891. // 提交异常反馈
  892. submitExceptionFeedback(): void {
  893. if (!this.exceptionDescription.trim() || this.isSubmittingFeedback) {
  894. alert('请填写异常类型和描述');
  895. return;
  896. }
  897. this.isSubmittingFeedback = true;
  898. // 模拟提交反馈到服务器
  899. setTimeout(() => {
  900. const newException: ExceptionHistory = {
  901. id: `exception-${Date.now()}`,
  902. type: this.exceptionType,
  903. description: this.exceptionDescription,
  904. submitTime: new Date(),
  905. status: '待处理'
  906. };
  907. // 添加到历史记录中
  908. this.exceptionHistories.unshift(newException);
  909. // 通知客服和技术支持
  910. this.notifyTechnicalSupport(newException);
  911. // 清空表单
  912. this.exceptionDescription = '';
  913. this.clearExceptionScreenshot();
  914. this.showExceptionForm = false;
  915. // 显示成功消息
  916. alert('异常反馈已提交,技术支持将尽快处理');
  917. this.isSubmittingFeedback = false;
  918. }, 1000);
  919. }
  920. // 上传异常截图
  921. uploadExceptionScreenshot(event: Event): void {
  922. const input = event.target as HTMLInputElement;
  923. if (input.files && input.files[0]) {
  924. const file = input.files[0];
  925. // 在实际应用中,这里应该上传文件到服务器
  926. // 这里我们使用FileReader来生成一个预览URL
  927. const reader = new FileReader();
  928. reader.onload = (e) => {
  929. this.exceptionScreenshotUrl = e.target?.result as string;
  930. };
  931. reader.readAsDataURL(file);
  932. }
  933. }
  934. // 清除异常截图
  935. clearExceptionScreenshot(): void {
  936. this.exceptionScreenshotUrl = null;
  937. const input = document.getElementById('screenshot-upload') as HTMLInputElement;
  938. if (input) {
  939. input.value = '';
  940. }
  941. }
  942. // 联系组长
  943. contactTeamLeader() {
  944. alert(`已联系${this.project?.assigneeName || '项目组长'}`);
  945. }
  946. // 处理渲染超时预警
  947. handleRenderTimeout() {
  948. alert('已发送渲染超时预警通知');
  949. }
  950. // 通知技术支持
  951. notifyTechnicalSupport(exception: ExceptionHistory): void {
  952. // 实际应用中应调用消息服务通知技术支持和客服
  953. console.log(`通知技术支持和客服:渲染异常 - 项目ID: ${this.projectId}`);
  954. console.log(`异常类型: ${this.getExceptionTypeText(exception.type)}, 描述: ${exception.description}`);
  955. }
  956. // 获取异常类型文本
  957. getExceptionTypeText(type: string): string {
  958. const typeMap: Record<string, string> = {
  959. 'failed': '渲染失败',
  960. 'stuck': '渲染卡顿',
  961. 'quality': '渲染质量问题',
  962. 'other': '其他问题'
  963. };
  964. return typeMap[type] || type;
  965. }
  966. // 格式化日期
  967. formatDate(date: Date | string): string {
  968. const d = typeof date === 'string' ? new Date(date) : date;
  969. const year = d.getFullYear();
  970. const month = String(d.getMonth() + 1).padStart(2, '0');
  971. const day = String(d.getDate()).padStart(2, '0');
  972. const hours = String(d.getHours()).padStart(2, '0');
  973. const minutes = String(d.getMinutes()).padStart(2, '0');
  974. return `${year}-${month}-${day} ${hours}:${minutes}`;
  975. }
  976. // 将字节格式化为易读尺寸
  977. private formatFileSize(bytes: number): string {
  978. if (bytes < 1024) return `${bytes}B`;
  979. const kb = bytes / 1024;
  980. if (kb < 1024) return `${kb.toFixed(1)}KB`;
  981. const mb = kb / 1024;
  982. if (mb < 1024) return `${mb.toFixed(1)}MB`;
  983. const gb = mb / 1024;
  984. return `${gb.toFixed(2)}GB`;
  985. }
  986. // 生成缩略图条目(并创建本地预览URL)
  987. private makeImageItem(file: File): { id: string; name: string; url: string; size: string } {
  988. const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  989. const url = URL.createObjectURL(file);
  990. return { id, name: file.name, url, size: this.formatFileSize(file.size) };
  991. }
  992. // 释放对象URL
  993. private revokeUrl(url: string): void {
  994. try { if (url && url.startsWith('blob:')) URL.revokeObjectURL(url); } catch {}
  995. }
  996. // =========== 建模阶段:白模上传 ===========
  997. onWhiteModelSelected(event: Event): void {
  998. const input = event.target as HTMLInputElement;
  999. if (!input.files || input.files.length === 0) return;
  1000. const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));
  1001. const items = files.map(f => this.makeImageItem(f));
  1002. this.whiteModelImages.unshift(...items);
  1003. input.value = '';
  1004. }
  1005. removeWhiteModelImage(id: string): void {
  1006. const target = this.whiteModelImages.find(i => i.id === id);
  1007. if (target) this.revokeUrl(target.url);
  1008. this.whiteModelImages = this.whiteModelImages.filter(i => i.id !== id);
  1009. }
  1010. // 新增:建模阶段 确认上传并自动进入下一阶段(软装)
  1011. confirmWhiteModelUpload(): void {
  1012. if (this.whiteModelImages.length === 0) return;
  1013. this.advanceToNextStage('建模');
  1014. }
  1015. // =========== 软装阶段:小图上传(建议≤1MB,不强制) ===========
  1016. onSoftDecorSmallPicsSelected(event: Event): void {
  1017. const input = event.target as HTMLInputElement;
  1018. if (!input.files || input.files.length === 0) return;
  1019. const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));
  1020. const warnOversize = files.filter(f => f.size > 1024 * 1024);
  1021. if (warnOversize.length > 0) {
  1022. // 仅提示,不阻断
  1023. console.warn('软装小图建议≤1MB,以下文件较大:', warnOversize.map(f => f.name));
  1024. }
  1025. const items = files.map(f => this.makeImageItem(f));
  1026. this.softDecorImages.unshift(...items);
  1027. input.value = '';
  1028. }
  1029. // 拖拽上传相关属性
  1030. isDragOver: boolean = false;
  1031. // 图片预览相关属性
  1032. showImagePreview: boolean = false;
  1033. previewImageData: any = null;
  1034. // 图片预览方法
  1035. previewImage(img: any): void {
  1036. this.previewImageData = img;
  1037. this.showImagePreview = true;
  1038. }
  1039. closeImagePreview(): void {
  1040. this.showImagePreview = false;
  1041. this.previewImageData = null;
  1042. }
  1043. downloadImage(img: any): void {
  1044. if (img) {
  1045. const link = document.createElement('a');
  1046. link.href = img.url;
  1047. link.download = img.name;
  1048. link.click();
  1049. }
  1050. }
  1051. removeImageFromPreview(): void {
  1052. if (this.previewImageData) {
  1053. // 根据图片类型调用相应的删除方法
  1054. if (this.whiteModelImages.find(i => i.id === this.previewImageData.id)) {
  1055. this.removeWhiteModelImage(this.previewImageData.id);
  1056. } else if (this.softDecorImages.find(i => i.id === this.previewImageData.id)) {
  1057. this.removeSoftDecorImage(this.previewImageData.id);
  1058. } else if (this.renderLargeImages.find(i => i.id === this.previewImageData.id)) {
  1059. this.removeRenderLargeImage(this.previewImageData.id);
  1060. }
  1061. this.closeImagePreview();
  1062. }
  1063. }
  1064. // 拖拽事件处理
  1065. onDragOver(event: DragEvent): void {
  1066. event.preventDefault();
  1067. event.stopPropagation();
  1068. this.isDragOver = true;
  1069. }
  1070. onDragLeave(event: DragEvent): void {
  1071. event.preventDefault();
  1072. event.stopPropagation();
  1073. this.isDragOver = false;
  1074. }
  1075. onFileDrop(event: DragEvent, type: 'whiteModel' | 'softDecor' | 'render'): void {
  1076. event.preventDefault();
  1077. event.stopPropagation();
  1078. this.isDragOver = false;
  1079. const files = event.dataTransfer?.files;
  1080. if (!files || files.length === 0) return;
  1081. // 创建模拟的input事件
  1082. const mockEvent = {
  1083. target: {
  1084. files: files
  1085. }
  1086. } as any;
  1087. // 根据类型调用相应的处理方法
  1088. switch (type) {
  1089. case 'whiteModel':
  1090. this.onWhiteModelSelected(mockEvent);
  1091. break;
  1092. case 'softDecor':
  1093. this.onSoftDecorSmallPicsSelected(mockEvent);
  1094. break;
  1095. case 'render':
  1096. this.onRenderLargePicsSelected(mockEvent);
  1097. break;
  1098. }
  1099. }
  1100. // 触发文件输入框
  1101. triggerFileInput(type: 'whiteModel' | 'softDecor' | 'render'): void {
  1102. let inputId: string;
  1103. switch (type) {
  1104. case 'whiteModel':
  1105. inputId = 'whiteModelFileInput';
  1106. break;
  1107. case 'softDecor':
  1108. inputId = 'softDecorFileInput';
  1109. break;
  1110. case 'render':
  1111. inputId = 'renderFileInput';
  1112. break;
  1113. }
  1114. const input = document.querySelector(`#${inputId}`) as HTMLInputElement;
  1115. if (input) {
  1116. input.click();
  1117. }
  1118. }
  1119. removeSoftDecorImage(id: string): void {
  1120. const target = this.softDecorImages.find(i => i.id === id);
  1121. if (target) this.revokeUrl(target.url);
  1122. this.softDecorImages = this.softDecorImages.filter(i => i.id !== id);
  1123. }
  1124. // 新增:软装阶段 确认上传并自动进入下一阶段(渲染)
  1125. confirmSoftDecorUpload(): void {
  1126. if (this.softDecorImages.length === 0) return;
  1127. this.advanceToNextStage('软装');
  1128. }
  1129. // =========== 渲染阶段:大图上传(弹窗 + 4K校验) ===========
  1130. openRenderUploadModal(): void {
  1131. this.showRenderUploadModal = true;
  1132. this.pendingRenderLargeItems = [];
  1133. }
  1134. closeRenderUploadModal(): void {
  1135. // 关闭时释放临时预览URL
  1136. this.pendingRenderLargeItems.forEach(i => this.revokeUrl(i.url));
  1137. this.pendingRenderLargeItems = [];
  1138. this.showRenderUploadModal = false;
  1139. }
  1140. async onRenderLargePicsSelected(event: Event): Promise<void> {
  1141. const input = event.target as HTMLInputElement;
  1142. if (!input.files || input.files.length === 0) return;
  1143. const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));
  1144. for (const f of files) {
  1145. const ok = await this.validateImage4K(f).catch(() => false);
  1146. if (!ok) {
  1147. alert(`图片不符合4K标准(最大边需≥4000像素):${f.name}`);
  1148. continue;
  1149. }
  1150. const item = this.makeImageItem(f);
  1151. // 直接添加到正式列表,不再使用待确认列表
  1152. this.renderLargeImages.unshift({
  1153. id: item.id,
  1154. name: item.name,
  1155. url: item.url,
  1156. size: this.formatFileSize(f.size)
  1157. });
  1158. }
  1159. input.value = '';
  1160. }
  1161. confirmRenderUpload(): void {
  1162. // 将待确认的图片加入正式列表(此处模拟上传成功)
  1163. const toAdd = this.pendingRenderLargeItems.map(i => ({ id: i.id, name: i.name, url: i.url, size: this.formatFileSize(i.file.size) }));
  1164. this.renderLargeImages.unshift(...toAdd);
  1165. this.closeRenderUploadModal();
  1166. // 新增:渲染阶段确认后,自动进入下一阶段(后期)
  1167. this.advanceToNextStage('渲染');
  1168. }
  1169. removeRenderLargeImage(id: string): void {
  1170. const target = this.renderLargeImages.find(i => i.id === id);
  1171. if (target) this.revokeUrl(target.url);
  1172. this.renderLargeImages = this.renderLargeImages.filter(i => i.id !== id);
  1173. }
  1174. // 根据阶段映射所属板块
  1175. getSectionKeyForStage(stage: ProjectStage): SectionKey {
  1176. switch (stage) {
  1177. case '订单创建':
  1178. return 'order';
  1179. case '需求沟通':
  1180. case '方案确认':
  1181. return 'requirements';
  1182. case '建模':
  1183. case '软装':
  1184. case '渲染':
  1185. return 'delivery';
  1186. case '尾款结算':
  1187. case '客户评价':
  1188. case '投诉处理':
  1189. return 'aftercare';
  1190. default:
  1191. return 'order';
  1192. }
  1193. }
  1194. // 获取板块状态:completed | 'active' | 'pending'
  1195. getSectionStatus(key: SectionKey): 'completed' | 'active' | 'pending' {
  1196. const current = this.project?.currentStage as ProjectStage | undefined;
  1197. if (!current) return 'pending';
  1198. const currentSection = this.getSectionKeyForStage(current);
  1199. const sectionOrder = this.sections.map(s => s.key);
  1200. const currentIdx = sectionOrder.indexOf(currentSection);
  1201. const idx = sectionOrder.indexOf(key);
  1202. if (idx === -1 || currentIdx === -1) return 'pending';
  1203. if (idx < currentIdx) return 'completed';
  1204. if (idx === currentIdx) return 'active';
  1205. return 'pending';
  1206. }
  1207. // 切换四大板块(单展开)
  1208. toggleSection(key: SectionKey): void {
  1209. this.expandedSection = key;
  1210. // 点击板块按钮时,滚动到该板块的第一个可见阶段卡片
  1211. const sec = this.sections.find(s => s.key === key);
  1212. if (sec) {
  1213. // 设计师仅滚动到可见的三大执行阶段,否则取该板块第一个阶段
  1214. const candidate = this.isDesignerView()
  1215. ? sec.stages.find(st => ['建模', '软装', '渲染'].includes(st)) || sec.stages[0]
  1216. : sec.stages[0];
  1217. this.scrollToStage(candidate);
  1218. }
  1219. }
  1220. // 阶段到锚点的映射
  1221. stageToAnchor(stage: ProjectStage): string {
  1222. const map: Record<ProjectStage, string> = {
  1223. '订单创建': 'order',
  1224. '需求沟通': 'requirements-talk',
  1225. '方案确认': 'proposal-confirm',
  1226. '建模': 'modeling',
  1227. '软装': 'softdecor',
  1228. '渲染': 'render',
  1229. '后期': 'aftercare',
  1230. '尾款结算': 'settlement',
  1231. '客户评价': 'customer-review',
  1232. '投诉处理': 'complaint'
  1233. };
  1234. return `stage-${map[stage] || 'unknown'}`;
  1235. }
  1236. // 平滑滚动到指定阶段卡片
  1237. scrollToStage(stage: ProjectStage): void {
  1238. const anchor = this.stageToAnchor(stage);
  1239. const el = document.getElementById(anchor);
  1240. if (el) {
  1241. el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  1242. }
  1243. }
  1244. // 订单创建阶段:客户信息(迁移自客服端"客户信息"卡片)
  1245. orderCreationMethod: 'miniprogram' | 'manual' = 'miniprogram';
  1246. isSyncing: boolean = false;
  1247. orderTime: string = '';
  1248. customerForm!: FormGroup;
  1249. customerSearchKeyword: string = '';
  1250. customerSearchResults: Array<{ id: string; name: string; phone: string; wechat?: string; avatar?: string; customerType?: string; source?: string; remark?: string }> = [];
  1251. selectedOrderCustomer: { id: string; name: string; phone: string; wechat?: string; avatar?: string; customerType?: string; source?: string; remark?: string } | null = null;
  1252. demandTypes = [
  1253. { value: 'price', label: '价格敏感' },
  1254. { value: 'quality', label: '质量敏感' },
  1255. { value: 'comprehensive', label: '综合要求' }
  1256. ];
  1257. followUpStatus = [
  1258. { value: 'quotation', label: '待报价' },
  1259. { value: 'confirm', label: '待确认需求' },
  1260. { value: 'lost', label: '已失联' }
  1261. ];
  1262. // 需求关键信息同步数据
  1263. requirementKeyInfo = {
  1264. colorAtmosphere: {
  1265. description: '',
  1266. mainColor: '',
  1267. colorTemp: '',
  1268. materials: [] as string[]
  1269. },
  1270. spaceStructure: {
  1271. lineRatio: 0,
  1272. blankRatio: 0,
  1273. flowWidth: 0,
  1274. aspectRatio: 0,
  1275. ceilingHeight: 0
  1276. },
  1277. materialWeights: {
  1278. fabricRatio: 0,
  1279. woodRatio: 0,
  1280. metalRatio: 0,
  1281. smoothness: 0,
  1282. glossiness: 0
  1283. },
  1284. presetAtmosphere: {
  1285. name: '',
  1286. rgb: '',
  1287. colorTemp: '',
  1288. materials: [] as string[]
  1289. }
  1290. };
  1291. // 客户信息:搜索/选择/清空/同步/快速填写 逻辑
  1292. searchCustomer(): void {
  1293. if (this.customerSearchKeyword.trim().length >= 2) {
  1294. this.customerSearchResults = [
  1295. { id: '1', name: '张先生', phone: '138****5678', customerType: '老客户', source: '官网咨询', avatar: "data:image/svg+xml,%3Csvg width='64' height='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='%23E6E6E6'/%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'%3EIMG%3C/text%3E%3C/svg%3E" },
  1296. { id: '2', name: '李女士', phone: '139****1234', customerType: 'VIP客户', source: '推荐介绍', avatar: "data:image/svg+xml,%3Csvg width='65' height='40' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='%23DCDCDC'/%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'%3EIMG%3C/text%3E%3C/svg%3E" }
  1297. ];
  1298. } else {
  1299. this.customerSearchResults = [];
  1300. }
  1301. }
  1302. selectCustomer(customer: { id: string; name: string; phone: string; wechat?: string; avatar?: string; customerType?: string; source?: string; remark?: string }): void {
  1303. this.selectedOrderCustomer = customer;
  1304. this.customerForm.patchValue({
  1305. name: customer.name,
  1306. phone: customer.phone,
  1307. wechat: customer.wechat || '',
  1308. customerType: customer.customerType || '新客户',
  1309. source: customer.source || '',
  1310. remark: customer.remark || ''
  1311. });
  1312. this.customerSearchResults = [];
  1313. this.customerSearchKeyword = '';
  1314. }
  1315. clearSelectedCustomer(): void {
  1316. this.selectedOrderCustomer = null;
  1317. this.customerForm.reset({ customerType: '新客户' });
  1318. }
  1319. quickFillCustomerInfo(keyword: string): void {
  1320. const k = (keyword || '').trim();
  1321. if (!k) return;
  1322. // 模拟:若有搜索结果,选择第一条
  1323. if (this.customerSearchResults.length === 0) this.searchCustomer();
  1324. if (this.customerSearchResults.length > 0) {
  1325. this.selectCustomer(this.customerSearchResults[0]);
  1326. }
  1327. }
  1328. syncMiniprogramCustomerInfo(): void {
  1329. if (this.isSyncing) return;
  1330. this.isSyncing = true;
  1331. setTimeout(() => {
  1332. // 模拟从小程序同步到客户表单
  1333. this.customerForm.patchValue({
  1334. name: '小程序用户',
  1335. phone: '13800001234',
  1336. wechat: 'wx_user_001',
  1337. customerType: '新客户',
  1338. source: '小程序下单'
  1339. });
  1340. this.isSyncing = false;
  1341. }, 1000);
  1342. }
  1343. downloadFile(file: ProjectFile): void {
  1344. // 实现文件下载逻辑
  1345. const link = document.createElement('a');
  1346. link.href = file.url;
  1347. link.download = file.name;
  1348. link.click();
  1349. }
  1350. previewFile(file: ProjectFile): void {
  1351. // 预览文件逻辑
  1352. console.log('预览文件:', file.name);
  1353. }
  1354. // 同步需求关键信息到客户信息卡片
  1355. syncRequirementKeyInfo(requirementData: any): void {
  1356. if (requirementData) {
  1357. // 同步色彩氛围信息
  1358. if (requirementData.colorIndicators) {
  1359. this.requirementKeyInfo.colorAtmosphere = {
  1360. description: requirementData.colorIndicators.colorRange || '',
  1361. mainColor: `rgb(${requirementData.colorIndicators.mainColor?.r || 0}, ${requirementData.colorIndicators.mainColor?.g || 0}, ${requirementData.colorIndicators.mainColor?.b || 0})`,
  1362. colorTemp: `${requirementData.colorIndicators.colorTemperature || 0}K`,
  1363. materials: []
  1364. };
  1365. }
  1366. // 同步空间结构信息
  1367. if (requirementData.spaceIndicators) {
  1368. this.requirementKeyInfo.spaceStructure = {
  1369. lineRatio: requirementData.spaceIndicators.lineRatio || 0,
  1370. blankRatio: requirementData.spaceIndicators.blankRatio || 0,
  1371. flowWidth: requirementData.spaceIndicators.flowWidth || 0,
  1372. aspectRatio: requirementData.spaceIndicators.aspectRatio || 0,
  1373. ceilingHeight: requirementData.spaceIndicators.ceilingHeight || 0
  1374. };
  1375. }
  1376. // 同步材质权重信息
  1377. if (requirementData.materialIndicators) {
  1378. this.requirementKeyInfo.materialWeights = {
  1379. fabricRatio: requirementData.materialIndicators.fabricRatio || 0,
  1380. woodRatio: requirementData.materialIndicators.woodRatio || 0,
  1381. metalRatio: requirementData.materialIndicators.metalRatio || 0,
  1382. smoothness: requirementData.materialIndicators.smoothness || 0,
  1383. glossiness: requirementData.materialIndicators.glossiness || 0
  1384. };
  1385. }
  1386. // 同步预设氛围信息
  1387. if (requirementData.selectedPresetAtmosphere) {
  1388. this.requirementKeyInfo.presetAtmosphere = {
  1389. name: requirementData.selectedPresetAtmosphere.name || '',
  1390. rgb: requirementData.selectedPresetAtmosphere.rgb || '',
  1391. colorTemp: requirementData.selectedPresetAtmosphere.colorTemp || '',
  1392. materials: requirementData.selectedPresetAtmosphere.materials || []
  1393. };
  1394. }
  1395. console.log('需求关键信息已同步:', this.requirementKeyInfo);
  1396. } else {
  1397. // 模拟数据用于演示
  1398. this.requirementKeyInfo = {
  1399. colorAtmosphere: {
  1400. description: '温馨暖调',
  1401. mainColor: 'rgb(255, 230, 180)',
  1402. colorTemp: '2700K',
  1403. materials: ['木质', '布艺']
  1404. },
  1405. spaceStructure: {
  1406. lineRatio: 60,
  1407. blankRatio: 30,
  1408. flowWidth: 0.9,
  1409. aspectRatio: 1.6,
  1410. ceilingHeight: 2.8
  1411. },
  1412. materialWeights: {
  1413. fabricRatio: 50,
  1414. woodRatio: 30,
  1415. metalRatio: 20,
  1416. smoothness: 7,
  1417. glossiness: 4
  1418. },
  1419. presetAtmosphere: {
  1420. name: '现代简约',
  1421. rgb: '200,220,240',
  1422. colorTemp: '5000K',
  1423. materials: ['金属', '玻璃']
  1424. }
  1425. };
  1426. }
  1427. }
  1428. // 获取同步的关键信息摘要
  1429. getRequirementSummary(): string[] {
  1430. const summary: string[] = [];
  1431. if (this.requirementKeyInfo.colorAtmosphere.description) {
  1432. summary.push(`色彩氛围: ${this.requirementKeyInfo.colorAtmosphere.description}`);
  1433. }
  1434. if (this.requirementKeyInfo.spaceStructure.aspectRatio > 0) {
  1435. summary.push(`空间比例: ${this.requirementKeyInfo.spaceStructure.aspectRatio.toFixed(1)}`);
  1436. }
  1437. if (this.requirementKeyInfo.materialWeights.woodRatio > 0) {
  1438. summary.push(`木质占比: ${this.requirementKeyInfo.materialWeights.woodRatio}%`);
  1439. }
  1440. if (this.requirementKeyInfo.presetAtmosphere.name) {
  1441. summary.push(`预设氛围: ${this.requirementKeyInfo.presetAtmosphere.name}`);
  1442. }
  1443. return summary;
  1444. }
  1445. // 处理咨询订单表单提交
  1446. onConsultationOrderSubmit(formData: any): void {
  1447. console.log('咨询订单表单提交:', formData);
  1448. // 这里可以添加处理表单提交的逻辑
  1449. // 例如:保存订单信息、更新项目状态等
  1450. }
  1451. }