Răsfoiți Sursa

Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project

徐福静0235668 1 zi în urmă
părinte
comite
751e4479cd

+ 0 - 0
src/modules/project/pages/project-detail/stages/stage-delivery-new.component.html → src/modules/project/pages/project-detail/stages/components/stage-delivery-execution/stage-delivery-execution.component.html


+ 0 - 0
src/modules/project/pages/project-detail/stages/stage-delivery-new.component.scss → src/modules/project/pages/project-detail/stages/components/stage-delivery-execution/stage-delivery-execution.component.scss


+ 862 - 0
src/modules/project/pages/project-detail/stages/components/stage-delivery-execution/stage-delivery-execution.component.ts

@@ -0,0 +1,862 @@
+import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FmodeObject, FmodeParse } from 'fmode-ng/parse';
+import { NovaFile } from 'fmode-ng/core';
+import { DragUploadModalComponent, UploadResult } from '../../../../../components/drag-upload-modal/drag-upload-modal.component';
+import { RevisionTaskModalComponent } from '../../../../../components/revision-task-modal/revision-task-modal.component';
+import { RevisionTaskListComponent } from '../../../../../components/revision-task-list/revision-task-list.component';
+import { DeliveryMessageModalComponent } from '../delivery-message-modal/delivery-message-modal.component';
+import { DeliveryMessageService, MESSAGE_TEMPLATES } from '../../../../../../../app/pages/services/delivery-message.service';
+import { ProjectFileService } from '../../../../../services/project-file.service';
+import { ProductSpaceService, Project } from '../../../../../services/product-space.service';
+import { WxworkSDKService } from '../../../../../services/wxwork-sdk.service';
+import { ImageAnalysisService } from '../../../../../services/image-analysis.service';
+import { PhaseDeadlines, PhaseName } from '../../../../../../../app/models/project-phase.model';
+import { ensurePhaseDeadlines, mapDeliveryTypeToPhase, markPhaseStatus, updatePhaseOnSubmission } from '../../../../../../../app/utils/phase-deadline.utils';
+import { addDays, normalizeDateInput } from '../../../../../../../app/utils/date-utils';
+
+const Parse = FmodeParse.with('nova');
+
+/**
+ * 审批状态类型
+ */
+export type ApprovalStatus = 'pending' | 'approved' | 'rejected' | 'unverified';
+
+/**
+ * 空间确认记录接口
+ */
+export interface SpaceConfirmation {
+  confirmedBy: string;
+  confirmedByName: string;
+  confirmedByRole: string;
+  confirmedAt: Date;
+  spaceId: string;
+  filesSnapshot: string[];
+}
+
+/**
+ * 交付文件接口
+ */
+export interface DeliveryFile {
+  id: string;
+  url: string;
+  name: string;
+  size: number;
+  uploadTime: Date;
+  uploadedBy: string;
+  productId: string;
+  deliveryType: 'white_model' | 'soft_decor' | 'rendering' | 'post_process';
+  stage: string;
+  projectFile?: NovaFile | FmodeObject | any;
+  approvalStatus: ApprovalStatus;
+  approvedBy?: string;
+  approvedAt?: Date;
+  rejectionReason?: string;
+}
+
+@Component({
+  selector: 'app-stage-delivery-execution',
+  standalone: true,
+  imports: [
+    CommonModule, 
+    FormsModule, 
+    DragUploadModalComponent, 
+    RevisionTaskModalComponent, 
+    RevisionTaskListComponent,
+    DeliveryMessageModalComponent
+  ],
+  templateUrl: './stage-delivery-execution.component.html',
+  styleUrls: ['./stage-delivery-execution.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class StageDeliveryExecutionComponent implements OnChanges {
+  @Input() project: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() canEdit: boolean = true;
+  @Input() isTeamLeader: boolean = false;
+  @Input() isFromCustomerService: boolean = false;
+  @Input() loading: boolean = false;
+  
+  @Input() projectProducts: Project[] = [];
+  @Input() deliveryFiles: {
+    [productId: string]: {
+      white_model: DeliveryFile[];
+      soft_decor: DeliveryFile[];
+      rendering: DeliveryFile[];
+      post_process: DeliveryFile[];
+    };
+  } = {};
+  @Input() revisionTaskCount: number = 0;
+  @Input() cid: string = '';
+
+  @Output() refreshData = new EventEmitter<void>();
+  @Output() fileUploaded = new EventEmitter<{ productId: string, deliveryType: string, fileCount: number }>();
+
+  // 交付类型定义
+  deliveryTypes = [
+    {
+      id: 'white_model',
+      name: '白模',
+      icon: 'cube-outline',
+      color: 'primary',
+      description: '空间结构建模、基础框架搭建'
+    },
+    {
+      id: 'soft_decor',
+      name: '软装',
+      icon: 'color-palette-outline',
+      color: 'secondary',
+      description: '家具配置、材质选择、色彩搭配'
+    },
+    {
+      id: 'rendering',
+      name: '渲染',
+      icon: 'image-outline',
+      color: 'tertiary',
+      description: '高清效果图、全景图、细节特写'
+    },
+    {
+      id: 'post_process',
+      name: '后期',
+      icon: 'color-wand-outline',
+      color: 'success',
+      description: '色彩调整、效果优化、最终成品'
+    }
+  ];
+
+  // 空间展开/收起状态管理
+  expandedSpaces: Set<string> = new Set();
+  
+  // 当前选中的空间和阶段(用于查看图片)
+  selectedSpaceId: string = '';
+  selectedStageType: string = '';
+  
+  // 改图工单相关
+  showRevisionTaskModal: boolean = false;
+  showRevisionTaskList: boolean = false;
+  
+  // 缓存的空间和阶段列表(用于DragUploadModal)
+  cachedAvailableSpaces: Array<{ id: string; name: string }> = [];
+  cachedAvailableStages: Array<{ id: string; name: string }> = [];
+  cachedRevisionSpaces: Array<{ id: string; name: string; selected: boolean }> = [];
+  
+  // 消息发送相关
+  showMessageModal: boolean = false;
+  messageModalConfig: {
+    projectId: string;
+    spaceName: string;
+    stage: string;
+    stageName: string;
+    imageUrls: string[];
+  } | null = null;
+  sendingMessage: boolean = false;
+  messageTemplates = MESSAGE_TEMPLATES;
+
+  // 上传进度
+  uploadingDeliveryFiles: boolean = false;
+  saving: boolean = false;
+
+  // 拖拽上传相关状态
+  isDragOver: boolean = false;
+  showDragUploadModal: boolean = false;
+  dragUploadFiles: File[] = [];
+  dragUploadSpaceId: string = '';
+  dragUploadStageType: string = '';
+  dragUploadSpaceName: string = '';
+  dragUploadStageName: string = '';
+
+  // 阶段图片库相关状态
+  showStageGalleryModal: boolean = false;
+  currentStageGallery: {
+    spaceId: string;
+    spaceName: string;
+    stageId: string;
+    stageName: string;
+    files: DeliveryFile[];
+  } | null = null;
+
+  // 图片占位
+  private readonly fallbackImageDataUrl: string =
+    'data:image/svg+xml;utf8,' +
+    encodeURIComponent(
+      '<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300">' +
+      '<defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop stop-color="#eef2ff"/><stop offset="1" stop-color="#e9ecff"/></linearGradient></defs>' +
+      '<rect width="400" height="300" fill="url(#g)"/>' +
+      '<g fill="#667eea" opacity="0.7"><circle cx="70" cy="60" r="6"/><circle cx="120" cy="80" r="4"/><circle cx="340" cy="210" r="5"/></g>' +
+      '<text x="200" y="160" text-anchor="middle" font-size="18" fill="#64748b">暂无预览</text>' +
+      '</svg>'
+    );
+
+  constructor(
+    private cdr: ChangeDetectorRef,
+    private projectFileService: ProjectFileService,
+    private productSpaceService: ProductSpaceService,
+    public deliveryMessageService: DeliveryMessageService,
+    private wxworkSDKService: WxworkSDKService,
+    private imageAnalysisService: ImageAnalysisService
+  ) {}
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['projectProducts']) {
+      this.updateCachedLists();
+      // 默认展开第一个空间
+      if (this.projectProducts.length > 0 && this.expandedSpaces.size === 0) {
+        this.expandedSpaces.add(this.projectProducts[0].id);
+      }
+    }
+  }
+
+  updateCachedLists() {
+    if (!this.projectProducts) return;
+    
+    this.cachedAvailableSpaces = this.projectProducts.map(p => ({
+      id: p.id,
+      name: this.getSpaceDisplayName(p)
+    }));
+    
+    this.cachedAvailableStages = this.deliveryTypes.map(t => ({
+      id: t.id,
+      name: t.name
+    }));
+    
+    this.cachedRevisionSpaces = this.projectProducts.map(p => ({
+      id: p.id,
+      name: this.getSpaceDisplayName(p),
+      selected: false
+    }));
+  }
+
+  getSpaceDisplayName(space: Project): string {
+    if (space.quotation && space.quotation.name) {
+      return space.quotation.name;
+    }
+    return space.name || '未命名空间';
+  }
+
+  toggleSpaceExpansion(spaceId: string) {
+    if (this.expandedSpaces.has(spaceId)) {
+      this.expandedSpaces.delete(spaceId);
+    } else {
+      this.expandedSpaces.add(spaceId);
+    }
+  }
+
+  isSpaceExpanded(spaceId: string): boolean {
+    return this.expandedSpaces.has(spaceId);
+  }
+
+  getCompletedStagesCount(spaceId: string): number {
+    if (!this.deliveryFiles[spaceId]) return 0;
+    let count = 0;
+    if (this.deliveryFiles[spaceId].white_model?.length > 0) count++;
+    if (this.deliveryFiles[spaceId].soft_decor?.length > 0) count++;
+    if (this.deliveryFiles[spaceId].rendering?.length > 0) count++;
+    if (this.deliveryFiles[spaceId].post_process?.length > 0) count++;
+    return count;
+  }
+
+  getSpaceStageFileCount(spaceId: string, stageType: string): number {
+    if (!this.deliveryFiles[spaceId]) return 0;
+    const type = stageType as keyof typeof this.deliveryFiles[typeof spaceId];
+    return this.deliveryFiles[spaceId][type]?.length || 0;
+  }
+  
+  getSpaceTotalFileCount(spaceId: string): number {
+    if (!this.deliveryFiles[spaceId]) return 0;
+    let count = 0;
+    count += this.deliveryFiles[spaceId].white_model?.length || 0;
+    count += this.deliveryFiles[spaceId].soft_decor?.length || 0;
+    count += this.deliveryFiles[spaceId].rendering?.length || 0;
+    count += this.deliveryFiles[spaceId].post_process?.length || 0;
+    return count;
+  }
+
+  getProductDeliveryFiles(spaceId: string, stageType: string): DeliveryFile[] {
+    if (!this.deliveryFiles[spaceId]) return [];
+    const type = stageType as keyof typeof this.deliveryFiles[typeof spaceId];
+    return this.deliveryFiles[spaceId][type] || [];
+  }
+
+  isImageFile(filename: string): boolean {
+    if (!filename) return false;
+    const ext = filename.split('.').pop()?.toLowerCase() || '';
+    return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(ext);
+  }
+
+  previewFile(file: DeliveryFile) {
+    if (file.url) {
+      window.open(file.url, '_blank');
+    }
+  }
+
+  onImageError(event: Event): void {
+    const img = event.target as HTMLImageElement;
+    if (img && img.src !== this.fallbackImageDataUrl) {
+      img.src = this.fallbackImageDataUrl;
+    }
+  }
+
+  // ============ Drag & Drop ============
+
+  onSpaceAreaDragOver(event: DragEvent, spaceId: string) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = true;
+    this.selectedSpaceId = spaceId; // Use selectedSpaceId to track drop target
+  }
+
+  onSpaceAreaDragLeave(event: DragEvent) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = false;
+    this.selectedSpaceId = '';
+  }
+
+  onSpaceAreaDrop(event: DragEvent, spaceId: string) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = false;
+    this.selectedSpaceId = '';
+    
+    const files = event.dataTransfer?.files;
+    if (files && files.length > 0) {
+      this.handleDrop(files, spaceId, ''); // Stage determined by modal or AI
+    }
+  }
+
+  onDragOver(event: DragEvent, spaceId: string, stageType: string) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = true;
+    this.selectedSpaceId = spaceId;
+    this.selectedStageType = stageType;
+  }
+
+  onDragLeave(event: DragEvent) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = false;
+    this.selectedSpaceId = '';
+    this.selectedStageType = '';
+  }
+
+  onDrop(event: DragEvent, spaceId: string, stageType: string) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.isDragOver = false;
+    this.selectedSpaceId = '';
+    this.selectedStageType = '';
+    
+    const files = event.dataTransfer?.files;
+    if (files && files.length > 0) {
+      this.handleDrop(files, spaceId, stageType);
+    }
+  }
+
+  handleDrop(files: FileList, spaceId: string, stageType: string) {
+    if (!this.canEdit) {
+      alert('您没有上传权限');
+      return;
+    }
+
+    this.dragUploadFiles = Array.from(files);
+    this.dragUploadSpaceId = spaceId;
+    this.dragUploadStageType = stageType;
+    
+    const space = this.projectProducts.find(p => p.id === spaceId);
+    this.dragUploadSpaceName = space ? this.getSpaceDisplayName(space) : '';
+    
+    const stage = this.deliveryTypes.find(t => t.id === stageType);
+    this.dragUploadStageName = stage ? stage.name : '';
+    
+    this.showDragUploadModal = true;
+    this.cdr.detectChanges();
+  }
+
+  closeDragUploadModal() {
+    this.showDragUploadModal = false;
+    this.dragUploadFiles = [];
+    this.dragUploadSpaceId = '';
+    this.dragUploadStageType = '';
+  }
+
+  async confirmDragUpload(result: UploadResult) {
+    this.closeDragUploadModal();
+    
+    if (!result.files || result.files.length === 0) return;
+
+    try {
+      this.uploadingDeliveryFiles = true;
+      this.cdr.markForCheck();
+
+      const targetProjectId = this.project?.id;
+      if (!targetProjectId) return;
+
+      const uploadPromises = result.files.map(item => {
+        // UploadFile interface wrapper contains the actual File object in item.file.file
+        const file = item.file.file;
+        
+        return this.projectFileService.uploadProjectFileWithRecord(
+          file,
+          targetProjectId,
+          `delivery_${item.stageType}`,
+          item.spaceId,
+          'delivery',
+          {
+            deliveryType: item.stageType,
+            productId: item.spaceId,
+            spaceId: item.spaceId,
+            uploadedFor: 'delivery_execution',
+            approvalStatus: 'unverified',
+            uploadedByName: this.currentUser?.get('name') || '',
+            uploadedById: this.currentUser?.id || '',
+            uploadStage: 'delivery',
+            // Add AI analysis results if available
+            analysisResult: item.analysisResult,
+            submittedAt: item.submittedAt,
+            submittedBy: item.submittedBy,
+            submittedByName: item.submittedByName,
+            deliveryListId: item.deliveryListId
+          }
+        ).then(() => {
+           // Emit single file uploaded event for each file to update UI/Notifications
+           this.fileUploaded.emit({ 
+             productId: item.spaceId, 
+             deliveryType: item.stageType, 
+             fileCount: 1 
+           });
+        });
+      });
+
+      await Promise.all(uploadPromises);
+      
+      // Refresh data
+      this.refreshData.emit();
+
+    } catch (error) {
+      console.error('上传失败', error);
+      alert('上传失败,请重试');
+    } finally {
+      this.uploadingDeliveryFiles = false;
+      this.cdr.markForCheck();
+    }
+  }
+
+  cancelDragUpload() {
+    this.closeDragUploadModal();
+  }
+
+  // ============ File Operations ============
+
+  async uploadDeliveryFile(event: any, productId: string, deliveryType: string, silentMode: boolean = false): Promise<void> {
+    const files = event.target.files;
+    if (!files || files.length === 0) return;
+    
+    if (!this.canEdit) {
+      if (!silentMode) alert('您没有上传文件的权限');
+      return;
+    }
+
+    try {
+      this.uploadingDeliveryFiles = true;
+      this.cdr.markForCheck();
+
+      const targetProjectId = this.project?.id;
+      if (!targetProjectId) return;
+
+      const uploadPromises = [];
+      for (let i = 0; i < files.length; i++) {
+        const file = files[i];
+        uploadPromises.push(
+          this.projectFileService.uploadProjectFileWithRecord(
+            file,
+            targetProjectId,
+            `delivery_${deliveryType}`,
+            productId,
+            'delivery',
+            {
+              deliveryType: deliveryType,
+              productId: productId,
+              spaceId: productId,
+              uploadedFor: 'delivery_execution',
+              approvalStatus: 'unverified',
+              uploadedByName: this.currentUser?.get('name') || '',
+              uploadedById: this.currentUser?.id || '',
+              uploadStage: 'delivery'
+            }
+          )
+        );
+      }
+
+      await Promise.all(uploadPromises);
+      
+      if (!silentMode) {
+        // Use a simple alert or toast if available. Assuming window.fmode.alert exists from original code
+        // window.fmode?.alert('文件上传成功');
+      }
+      
+      // Clear input
+      if (event.target) {
+        event.target.value = '';
+      }
+      
+      // Refresh data
+      this.refreshData.emit();
+      this.fileUploaded.emit({ productId, deliveryType, fileCount: files.length });
+      
+      // Update gallery if open
+      if (this.showStageGalleryModal && this.currentStageGallery) {
+        if (this.currentStageGallery.spaceId === productId && this.currentStageGallery.stageId === deliveryType) {
+           // Ideally we should reload gallery content, but refreshData will trigger parent to reload deliveryFiles
+           // We need to wait for parent to update deliveryFiles and then update gallery
+           // For now, just closing gallery or simple refresh might be enough, 
+           // but let's rely on ngOnChanges to update currentStageGallery if we want it reactive
+        }
+      }
+
+    } catch (error) {
+      console.error('上传失败', error);
+      alert('上传失败,请重试');
+    } finally {
+      this.uploadingDeliveryFiles = false;
+      this.cdr.markForCheck();
+    }
+  }
+
+  async deleteDeliveryFile(spaceId: string, stageType: string, fileId: string) {
+    if (!confirm('确定要删除这个文件吗?')) return;
+    
+    try {
+      await this.projectFileService.deleteProjectFile(fileId);
+      this.refreshData.emit();
+      
+      // Update gallery local state if open
+      if (this.showStageGalleryModal && this.currentStageGallery) {
+        this.currentStageGallery.files = this.currentStageGallery.files.filter(f => f.id !== fileId);
+        this.cdr.markForCheck();
+      }
+    } catch (error) {
+      console.error('删除失败', error);
+      alert('删除失败,请重试');
+    }
+  }
+
+  // ============ Gallery ============
+
+  openStageGallery(spaceId: string, stageId: string, event?: Event) {
+    if (event) {
+      event.stopPropagation();
+    }
+    
+    const space = this.projectProducts.find(p => p.id === spaceId);
+    const stage = this.deliveryTypes.find(t => t.id === stageId);
+    
+    if (!space || !stage) return;
+    
+    const files = this.getProductDeliveryFiles(spaceId, stageId);
+    
+    this.currentStageGallery = {
+      spaceId,
+      spaceName: this.getSpaceDisplayName(space),
+      stageId,
+      stageName: stage.name,
+      files: [...files] // Copy
+    };
+    
+    this.showStageGalleryModal = true;
+  }
+
+  closeStageGallery() {
+    this.showStageGalleryModal = false;
+    this.currentStageGallery = null;
+  }
+
+  // ============ Revision Tasks ============
+
+  openRevisionTaskModal() {
+    this.showRevisionTaskModal = true;
+  }
+
+  closeRevisionTaskModal() {
+    this.showRevisionTaskModal = false;
+  }
+
+  onRevisionTaskCreated(task: any) {
+    this.closeRevisionTaskModal();
+    this.refreshData.emit();
+  }
+
+  openRevisionTaskList() {
+    this.showRevisionTaskList = true;
+  }
+
+  closeRevisionTaskList() {
+    this.showRevisionTaskList = false;
+  }
+
+  onRevisionTaskListRefresh() {
+    this.refreshData.emit();
+  }
+
+  // ============ Space Confirmation ============
+
+  getSpaceConfirmation(spaceId: string): SpaceConfirmation | null {
+    if (!this.project) return null;
+    const data = this.project.get('data') || {};
+    return data.spaceConfirmations?.[spaceId] || null;
+  }
+
+  isSpaceConfirmed(spaceId: string): boolean {
+    return this.getSpaceConfirmation(spaceId) !== null;
+  }
+
+  hasSpaceFilesChanged(spaceId: string): boolean {
+    const confirmation = this.getSpaceConfirmation(spaceId);
+    if (!confirmation) return false;
+
+    const currentFileIds: string[] = [];
+    this.deliveryTypes.forEach(type => {
+      const files = this.getProductDeliveryFiles(spaceId, type.id);
+      files.forEach(file => currentFileIds.push(file.id));
+    });
+
+    const snapshotIds = confirmation.filesSnapshot || [];
+    
+    if (currentFileIds.length !== snapshotIds.length) return true;
+
+    const currentSet = new Set(currentFileIds);
+    const snapshotSet = new Set(snapshotIds);
+    
+    for (const id of currentFileIds) {
+      if (!snapshotSet.has(id)) return true;
+    }
+    
+    return false;
+  }
+
+  getSpaceConfirmationText(spaceId: string): string {
+    const confirmation = this.getSpaceConfirmation(spaceId);
+    if (!confirmation) return '';
+    
+    const date = new Date(confirmation.confirmedAt);
+    const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
+    
+    return `${confirmation.confirmedByName} (${confirmation.confirmedByRole}) - ${dateStr}`;
+  }
+
+  async confirmSpace(spaceId: string) {
+    if (!this.project || !this.currentUser) {
+      console.warn('❌ 无法确认:缺少项目或用户信息');
+      return;
+    }
+
+    const totalFiles = this.getSpaceTotalFileCount(spaceId);
+    if (totalFiles === 0) {
+      alert('该空间暂无文件,无法确认');
+      return;
+    }
+
+    const spaceName = this.projectProducts.find(p => p.id === spaceId)?.name || '空间';
+    const completedStages = this.getCompletedStagesCount(spaceId);
+
+    if (!confirm(`确认【${spaceName}】的交付执行清单?\n\n已完成 ${completedStages}/4 个阶段,共 ${totalFiles} 个文件。\n\n确认后如有文件变更,需要重新确认。`)) return;
+
+    try {
+      this.saving = true;
+      this.cdr.markForCheck();
+
+      const data = this.project.get('data') || {};
+      
+      if (!data.spaceConfirmations) {
+        data.spaceConfirmations = {};
+      }
+
+      const filesSnapshot: string[] = [];
+      this.deliveryTypes.forEach(type => {
+        const files = this.getProductDeliveryFiles(spaceId, type.id);
+        files.forEach(file => filesSnapshot.push(file.id));
+      });
+
+      const confirmation: SpaceConfirmation = {
+        confirmedBy: this.currentUser.id || '',
+        confirmedByName: this.currentUser.get('name') || '',
+        confirmedByRole: this.currentUser.get('roleName') || '',
+        confirmedAt: new Date(),
+        spaceId: spaceId,
+        filesSnapshot: filesSnapshot
+      };
+
+      data.spaceConfirmations[spaceId] = confirmation;
+
+      this.project.set('data', data);
+      await this.project.save();
+
+      this.refreshData.emit();
+      
+      await this.checkAndAutoCompleteDelivery();
+
+    } catch (error) {
+      console.error('确认空间失败:', error);
+      alert('确认失败,请重试');
+    } finally {
+      this.saving = false;
+      this.cdr.markForCheck();
+    }
+  }
+
+  private async checkAndAutoCompleteDelivery(): Promise<void> {
+    if (!this.project || !this.currentUser) return;
+
+    const allSpacesConfirmed = this.projectProducts.every(space => this.isSpaceConfirmed(space.id));
+    
+    if (!allSpacesConfirmed) return;
+
+    if (!confirm(`🎉 恭喜!所有空间的交付执行清单已确认完成。\n\n确认空间数:${this.projectProducts.length} 个\n交付文件总数:${this.getSpaceTotalFileCount('')} 个\n\n是否立即进入售后归档阶段?`)) return;
+
+    try {
+      this.saving = true;
+      this.cdr.markForCheck();
+
+      this.project.set('currentStage', '售后归档');
+      
+      const data = this.project.get('data') || {};
+      
+      data.deliveryCompletedAt = new Date();
+      data.deliveryCompletedBy = this.currentUser.get('name') || '';
+      data.deliveryCompletedById = this.currentUser.id || '';
+      data.deliveryCompletedByRole = this.currentUser.get('roleName') || '';
+      data.deliveryFileCount = this.getTotalFileCount();
+      data.deliveryConfirmedSpacesCount = this.projectProducts.length;
+      
+      data.deliveryFinalConfirmations = this.projectProducts.map(space => {
+        const confirmation = this.getSpaceConfirmation(space.id);
+        return {
+          spaceId: space.id,
+          spaceName: space.name,
+          confirmedBy: confirmation?.confirmedByName || '',
+          confirmedByRole: confirmation?.confirmedByRole || '',
+          confirmedAt: confirmation?.confirmedAt || new Date(),
+          fileCount: this.getSpaceTotalFileCount(space.id)
+        };
+      });
+
+      this.project.set('data', data);
+      await this.project.save();
+
+      alert('✅ 交付执行完成!项目已自动进入售后归档阶段');
+      
+      // Emit event
+      this.refreshData.emit();
+      
+    } catch (err) {
+      console.error('自动完成交付失败:', err);
+      alert('自动进入售后归档失败,请稍后重试');
+    } finally {
+      this.saving = false;
+      this.cdr.markForCheck();
+    }
+  }
+  
+  private getTotalFileCount(): number {
+    let count = 0;
+    for (const product of this.projectProducts) {
+      count += this.getSpaceTotalFileCount(product.id);
+    }
+    return count;
+  }
+
+
+  // ============ Message ============
+
+  openMessageModal(config: any) {
+    this.messageModalConfig = config;
+    this.showMessageModal = true;
+  }
+
+  closeMessageModal() {
+    this.showMessageModal = false;
+    this.messageModalConfig = null;
+  }
+
+  async onSendMessage(event: { text: string; imageUrls: string[] }) {
+    if (!this.messageModalConfig || !this.project) return;
+    
+    this.sendingMessage = true;
+    try {
+        if (event.imageUrls.length > 0) {
+            await this.deliveryMessageService.createImageMessage(
+                this.project.id!,
+                this.messageModalConfig.stage,
+                event.imageUrls,
+                event.text,
+                this.currentUser
+            );
+        } else {
+             await this.deliveryMessageService.createTextMessage(
+                this.project.id!,
+                this.messageModalConfig.stage,
+                event.text,
+                this.currentUser
+            );
+        }
+
+        alert('消息已发送');
+        this.closeMessageModal();
+    } catch (e) {
+        console.error(e);
+        alert('发送失败');
+    } finally {
+        this.sendingMessage = false;
+        this.cdr.markForCheck();
+    }
+  }
+
+  openMessageModalWithFiles(spaceId: string, stageId: string) {
+    const space = this.projectProducts.find(p => p.id === spaceId);
+    const stage = this.deliveryTypes.find(t => t.id === stageId);
+
+    if (!space || !stage) return;
+
+    const files = this.getProductDeliveryFiles(spaceId, stageId);
+    const imageUrls = files
+      .filter(f => this.isImageFile(f.name))
+      .map(f => f.url);
+
+    this.openMessageModal({
+      projectId: this.project?.id || '',
+      spaceName: this.getSpaceDisplayName(space),
+      stage: stageId,
+      stageName: stage.name,
+      imageUrls: imageUrls
+    });
+  }
+
+  sendSpaceAllImages(spaceId: string) {
+    const space = this.projectProducts.find(p => p.id === spaceId);
+    if (!space) return;
+
+    const allFiles: DeliveryFile[] = [];
+    this.deliveryTypes.forEach(type => {
+      const files = this.getProductDeliveryFiles(spaceId, type.id);
+      allFiles.push(...files);
+    });
+
+    const imageUrls = allFiles
+      .filter(f => this.isImageFile(f.name))
+      .map(f => f.url);
+
+    if (imageUrls.length === 0) {
+        alert('该空间暂无图片文件');
+        return;
+    }
+
+    this.openMessageModal({
+      projectId: this.project?.id || '',
+      spaceName: this.getSpaceDisplayName(space),
+      stage: 'delivery_list',
+      stageName: '交付清单',
+      imageUrls: imageUrls
+    });
+  }
+}

Fișier diff suprimat deoarece este prea mare
+ 171 - 769
src/modules/project/pages/project-detail/stages/stage-delivery.component.ts


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff