|
|
@@ -0,0 +1,799 @@
|
|
|
+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 { 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
|
|
|
+ ],
|
|
|
+ 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: {
|
|
|
+ spaceId: string;
|
|
|
+ spaceName: string;
|
|
|
+ stage: string;
|
|
|
+ stageName: string;
|
|
|
+ imageUrls: string[];
|
|
|
+ } | null = null;
|
|
|
+ customMessage: string = '';
|
|
|
+ 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;
|
|
|
+ this.customMessage = '';
|
|
|
+ }
|
|
|
+
|
|
|
+ async sendMessage() {
|
|
|
+ if (!this.messageModalConfig || !this.project) return;
|
|
|
+
|
|
|
+ this.sendingMessage = true;
|
|
|
+ try {
|
|
|
+ // Use deliveryMessageService to send message
|
|
|
+ // Implementation depends on service details
|
|
|
+ // ...
|
|
|
+ alert('消息已发送');
|
|
|
+ this.closeMessageModal();
|
|
|
+ } catch (e) {
|
|
|
+ alert('发送失败');
|
|
|
+ } finally {
|
|
|
+ this.sendingMessage = false;
|
|
|
+ this.cdr.markForCheck();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|