项目-交付执行.md 45 KB

项目管理 - 交付执行阶段 PRD

1. 功能概述

1.1 阶段定位

交付执行阶段是项目管理流程的核心执行环节,包含建模、软装、渲染、后期四个连续子阶段。该阶段负责将设计方案转化为可交付的视觉成果,是项目价值实现的关键环节。

1.2 核心目标

  • 按空间维度组织文件上传和进度管理
  • 实现四个执行阶段的串行推进
  • 提供实时进度跟踪和状态可视化
  • 支持组长审核和质量把控
  • 确保交付物符合质量标准

1.3 涉及角色

  • 设计师:负责建模、软装、后期等设计工作
  • 渲染师:负责渲染阶段的大图输出
  • 组长:审核各阶段交付物、把控质量
  • 技术:验收最终交付物、确认质量

1.4 四大执行子阶段

graph LR
    A[方案确认] --> B[建模]
    B --> C[软装]
    C --> D[渲染]
    D --> E[后期]
    E --> F[尾款结算]

    style B fill:#e3f2fd
    style C fill:#fff3e0
    style D fill:#fce4ec
    style E fill:#f3e5f5

2. 空间管理系统

2.1 空间数据结构

2.1.1 DeliveryProcess 接口

interface DeliveryProcess {
  id: string;                           // 流程ID: 'modeling' | 'softDecor' | 'rendering' | 'postProcess'
  name: string;                         // 流程名称:建模/软装/渲染/后期
  type: 'modeling' | 'softDecor' | 'rendering' | 'postProcess';
  isExpanded: boolean;                  // 是否展开
  spaces: DeliverySpace[];              // 空间列表
  content: {
    [spaceId: string]: SpaceContent;    // 按空间ID索引的内容
  };
}

interface DeliverySpace {
  id: string;                           // 空间ID
  name: string;                         // 空间名称:卧室/客厅/厨房等
  isExpanded: boolean;                  // 是否展开
  order: number;                        // 排序顺序
}

interface SpaceContent {
  images: Array<{
    id: string;
    name: string;
    url: string;
    size?: string;
    reviewStatus?: 'pending' | 'approved' | 'rejected';
    synced?: boolean;                   // 是否已同步到客户端
  }>;
  progress: number;                     // 进度 0-100
  status: 'pending' | 'in_progress' | 'completed' | 'approved';
  notes: string;                        // 备注信息
  lastUpdated: Date;                    // 最后更新时间
}

2.1.2 初始空间配置

// project-detail.ts lines 458-523
deliveryProcesses: DeliveryProcess[] = [
  {
    id: 'modeling',
    name: '建模',
    type: 'modeling',
    isExpanded: true,                   // 默认展开第一个
    spaces: [
      { id: 'bedroom', name: '卧室', isExpanded: false, order: 1 },
      { id: 'living', name: '客厅', isExpanded: false, order: 2 },
      { id: 'kitchen', name: '厨房', isExpanded: false, order: 3 }
    ],
    content: {
      'bedroom': { images: [], progress: 0, status: 'pending', notes: '', lastUpdated: new Date() },
      'living': { images: [], progress: 0, status: 'pending', notes: '', lastUpdated: new Date() },
      'kitchen': { images: [], progress: 0, status: 'pending', notes: '', lastUpdated: new Date() }
    }
  },
  // 软装、渲染、后期流程结构相同
];

2.2 空间管理功能

2.2.1 添加新空间

// project-detail.ts lines 5150-5184
addSpace(processId: string): void {
  const spaceName = this.newSpaceName[processId]?.trim();
  if (!spaceName) return;

  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process) return;

  // 生成新的空间ID
  const spaceId = `space_${Date.now()}`;

  // 添加到spaces数组
  const newSpace: DeliverySpace = {
    id: spaceId,
    name: spaceName,
    isExpanded: false,
    order: process.spaces.length + 1
  };

  process.spaces.push(newSpace);

  // 初始化content数据
  process.content[spaceId] = {
    images: [],
    progress: 0,
    status: 'pending',
    notes: '',
    lastUpdated: new Date()
  };

  // 清空输入框并隐藏
  this.newSpaceName[processId] = '';
  this.showAddSpaceInput[processId] = false;

  console.log(`已添加空间: ${spaceName} 到流程 ${process.name}`);
}

UI交互

<!-- 添加空间输入框 -->
@if (showAddSpaceInput[process.id]) {
  <div class="add-space-input">
    <input
      type="text"
      [(ngModel)]="newSpaceName[process.id]"
      placeholder="输入空间名称(如:次卧、书房)"
      (keydown.enter)="addSpace(process.id)"
      (keydown.escape)="cancelAddSpace(process.id)">
    <button class="btn-primary" (click)="addSpace(process.id)">确定</button>
    <button class="btn-secondary" (click)="cancelAddSpace(process.id)">取消</button>
  </div>
} @else {
  <button class="btn-add-space" (click)="showAddSpaceInput[process.id] = true">
    + 添加空间
  </button>
}

2.2.2 删除空间

// project-detail.ts lines 5219-5242
removeSpace(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process) return;

  // 从spaces数组中移除
  const spaceIndex = process.spaces.findIndex(s => s.id === spaceId);
  if (spaceIndex > -1) {
    const spaceName = process.spaces[spaceIndex].name;
    process.spaces.splice(spaceIndex, 1);

    // 清理content数据
    if (process.content[spaceId]) {
      // 释放图片URL资源
      process.content[spaceId].images.forEach(img => {
        if (img.url && img.url.startsWith('blob:')) {
          URL.revokeObjectURL(img.url);
        }
      });
      delete process.content[spaceId];
    }

    console.log(`已删除空间: ${spaceName} 从流程 ${process.name}`);
  }
}

2.2.3 空间展开/收起

// project-detail.ts lines 5200-5208
toggleSpace(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process) return;

  const space = process.spaces.find(s => s.id === spaceId);
  if (space) {
    space.isExpanded = !space.isExpanded;
  }
}

2.3 进度管理

2.3.1 进度计算逻辑

// project-detail.ts lines 5377-5397
private updateSpaceProgress(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const content = process.content[spaceId];
  const imageCount = content.images.length;

  // 根据图片数量和状态计算进度
  if (imageCount === 0) {
    content.progress = 0;
    content.status = 'pending';
  } else if (imageCount < 3) {
    content.progress = Math.min(imageCount * 30, 90);
    content.status = 'in_progress';
  } else {
    content.progress = 100;
    content.status = 'completed';
  }

  content.lastUpdated = new Date();
}

进度规则

  • 0张图片:0%进度,状态为待开始
  • 1-2张图片:30%-60%进度,状态为进行中
  • 3张及以上:100%进度,状态为已完成

2.3.2 获取空间进度

// project-detail.ts lines 5211-5216
getSpaceProgress(processId: string, spaceId: string): number {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return 0;

  return process.content[spaceId].progress || 0;
}

2.3.3 进度可视化

<div class="space-progress-bar">
  <div class="progress-fill"
       [style.width.%]="getSpaceProgress(process.id, space.id)"
       [class.pending]="getSpaceProgress(process.id, space.id) === 0"
       [class.in-progress]="getSpaceProgress(process.id, space.id) > 0 && getSpaceProgress(process.id, space.id) < 100"
       [class.completed]="getSpaceProgress(process.id, space.id) === 100">
  </div>
  <span class="progress-text">{{ getSpaceProgress(process.id, space.id) }}%</span>
</div>

3. 建模阶段

3.1 功能特点

  • 白模图片上传
  • 模型检查项验证
  • 户型匹配度检查
  • 尺寸精度验证

3.2 白模上传

3.2.1 文件上传处理

// project-detail.ts lines 1838-1850
onWhiteModelSelected(event: Event): void {
  const input = event.target as HTMLInputElement;
  if (!input.files || input.files.length === 0) return;
  const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));
  const items = files.map(f => this.makeImageItem(f));
  this.whiteModelImages.unshift(...items);
  input.value = '';
}

removeWhiteModelImage(id: string): void {
  const target = this.whiteModelImages.find(i => i.id === id);
  if (target) this.revokeUrl(target.url);
  this.whiteModelImages = this.whiteModelImages.filter(i => i.id !== id);
}

3.2.2 图片对象生成

// project-detail.ts lines 1826-1830
private makeImageItem(file: File): { id: string; name: string; url: string; size: string } {
  const id = `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
  const url = URL.createObjectURL(file);
  return { id, name: file.name, url, size: this.formatFileSize(file.size) };
}

3.2.3 文件大小格式化

// project-detail.ts lines 1815-1823
private formatFileSize(bytes: number): string {
  if (bytes < 1024) return `${bytes}B`;
  const kb = bytes / 1024;
  if (kb < 1024) return `${kb.toFixed(1)}KB`;
  const mb = kb / 1024;
  if (mb < 1024) return `${mb.toFixed(1)}MB`;
  const gb = mb / 1024;
  return `${gb.toFixed(2)}GB`;
}

3.3 模型检查项

3.3.1 检查项数据结构

interface ModelCheckItem {
  id: string;
  name: string;
  isPassed: boolean;
  notes: string;
}

// project-detail.ts lines 449-455
modelCheckItems: ModelCheckItem[] = [
  { id: 'check-1', name: '户型匹配度检查', isPassed: false, notes: '' },
  { id: 'check-2', name: '尺寸精度验证', isPassed: false, notes: '' },
  { id: 'check-3', name: '材质贴图检查', isPassed: false, notes: '' },
  { id: 'check-4', name: '光影效果验证', isPassed: false, notes: '' },
  { id: 'check-5', name: '细节完整性检查', isPassed: false, notes: '' }
];

3.3.2 检查项UI

<div class="model-check-list">
  <h4>模型检查项</h4>
  @for (item of modelCheckItems; track item.id) {
    <div class="check-item">
      <label>
        <input
          type="checkbox"
          [(ngModel)]="item.isPassed"
          [disabled]="isReadOnly()">
        <span class="check-name">{{ item.name }}</span>
      </label>
      <input
        type="text"
        [(ngModel)]="item.notes"
        placeholder="备注说明"
        [disabled]="isReadOnly()"
        class="check-notes">
    </div>
  }
</div>

3.4 建模阶段完成

3.4.1 确认上传方法

// project-detail.ts lines 1853-1866
confirmWhiteModelUpload(): void {
  // 检查建模阶段的图片数据
  const modelingProcess = this.deliveryProcesses.find(p => p.id === 'modeling');
  if (!modelingProcess) return;

  // 检查是否有任何空间上传了图片
  const hasImages = modelingProcess.spaces.some(space => {
    const content = modelingProcess.content[space.id];
    return content && content.images && content.images.length > 0;
  });

  if (!hasImages) return;
  this.advanceToNextStage('建模');
}

3.4.2 阶段推进逻辑

// project-detail.ts lines 1391-1423
advanceToNextStage(afterStage: ProjectStage): void {
  const idx = this.stageOrder.indexOf(afterStage);
  if (idx >= 0 && idx < this.stageOrder.length - 1) {
    const next = this.stageOrder[idx + 1];

    // 更新项目阶段
    this.updateProjectStage(next);

    // 更新展开状态,折叠当前、展开下一阶段
    this.expandedStages[afterStage] = false;
    this.expandedStages[next] = true;

    // 更新板块展开状态
    const nextSection = this.getSectionKeyForStage(next);
    this.expandedSection = nextSection;

    // 触发变更检测以更新导航栏颜色
    this.cdr.detectChanges();
  }
}

4. 软装阶段

4.1 功能特点

  • 小图上传(建议≤1MB,不强制)
  • 支持拖拽上传
  • 实时预览功能
  • 按空间组织

4.2 小图上传

4.2.1 文件选择处理

// project-detail.ts lines 1869-1881
onSoftDecorSmallPicsSelected(event: Event): void {
  const input = event.target as HTMLInputElement;
  if (!input.files || input.files.length === 0) return;
  const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));
  const warnOversize = files.filter(f => f.size > 1024 * 1024);
  if (warnOversize.length > 0) {
    // 仅提示,不阻断
    console.warn('软装小图建议≤1MB,以下文件较大:', warnOversize.map(f => f.name));
  }
  const items = files.map(f => this.makeImageItem(f));
  this.softDecorImages.unshift(...items);
  input.value = '';
}

文件大小校验

  • 建议≤1MB,超过仅警告不阻断
  • 支持 JPG、JPEG、PNG 格式
  • 自动过滤非图片文件

4.2.2 拖拽上传支持

// project-detail.ts lines 1956-1998
onDragOver(event: DragEvent): void {
  event.preventDefault();
  event.stopPropagation();
  this.isDragOver = true;
}

onDragLeave(event: DragEvent): void {
  event.preventDefault();
  event.stopPropagation();
  this.isDragOver = false;
}

onFileDrop(event: DragEvent, type: 'whiteModel' | 'softDecor' | 'render' | 'postProcess'): void {
  event.preventDefault();
  event.stopPropagation();
  this.isDragOver = false;

  const files = event.dataTransfer?.files;
  if (!files || files.length === 0) return;

  // 创建模拟的input事件
  const mockEvent = {
    target: {
      files: files
    }
  } as any;

  // 根据类型调用相应的处理方法
  switch (type) {
    case 'softDecor':
      this.onSoftDecorSmallPicsSelected(mockEvent);
      break;
    // ... 其他类型
  }
}

拖拽区域样式

<div class="upload-zone"
     [class.drag-over]="isDragOver"
     (dragover)="onDragOver($event)"
     (dragleave)="onDragLeave($event)"
     (drop)="onFileDrop($event, 'softDecor')">
  <div class="upload-prompt">
    <i class="icon-upload"></i>
    <p>拖拽图片到此处上传</p>
    <p class="hint">或点击选择文件(建议≤1MB)</p>
  </div>
</div>

4.3 图片预览

4.3.1 预览功能

// project-detail.ts lines 1890-1903
previewImage(img: any): void {
  const isRenderLarge = !!this.renderLargeImages.find(i => i.id === img?.id);
  if (isRenderLarge && img?.locked) {
    alert('该渲染大图已加锁,需完成尾款结算并上传/识别支付凭证后方可预览。');
    return;
  }
  this.previewImageData = img;
  this.showImagePreview = true;
}

closeImagePreview(): void {
  this.showImagePreview = false;
  this.previewImageData = null;
}

4.3.2 预览弹窗

@if (showImagePreview && previewImageData) {
  <div class="image-preview-modal">
    <div class="modal-overlay" (click)="closeImagePreview()"></div>
    <div class="modal-content">
      <div class="modal-header">
        <h3>{{ previewImageData.name }}</h3>
        <button class="close-btn" (click)="closeImagePreview()">×</button>
      </div>
      <div class="modal-body">
        <img [src]="previewImageData.url" [alt]="previewImageData.name">
      </div>
      <div class="modal-footer">
        <button class="btn-secondary" (click)="downloadImage(previewImageData)">
          下载图片
        </button>
        <button class="btn-danger" (click)="removeImageFromPreview()">
          删除图片
        </button>
      </div>
    </div>
  </div>
}

4.4 软装阶段完成

// project-detail.ts lines 2098-2111
confirmSoftDecorUpload(): void {
  // 检查软装阶段的图片数据
  const softDecorProcess = this.deliveryProcesses.find(p => p.id === 'soft-decoration');
  if (!softDecorProcess) return;

  // 检查是否有任何空间上传了图片
  const hasImages = softDecorProcess.spaces.some(space => {
    const content = softDecorProcess.content[space.id];
    return content && content.images && content.images.length > 0;
  });

  if (!hasImages) return;
  this.advanceToNextStage('软装');
}

5. 渲染阶段

5.1 功能特点

  • 4K图片强制校验(最大边≥4000像素)
  • 渲染大图自动加锁
  • 渲染进度监控
  • 异常反馈系统

5.2 4K图片校验

5.2.1 图片尺寸验证

// 4K校验方法
private async validateImage4K(file: File): Promise<boolean> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const url = URL.createObjectURL(file);

    img.onload = () => {
      URL.revokeObjectURL(url);
      const maxDimension = Math.max(img.width, img.height);

      // 4K标准:最大边需≥4000像素
      if (maxDimension >= 4000) {
        resolve(true);
      } else {
        resolve(false);
      }
    };

    img.onerror = () => {
      URL.revokeObjectURL(url);
      reject(new Error('图片加载失败'));
    };

    img.src = url;
  });
}

5.2.2 渲染大图上传

// project-detail.ts lines 2142-2164
async onRenderLargePicsSelected(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement;
  if (!input.files || input.files.length === 0) return;
  const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));

  for (const f of files) {
    const ok = await this.validateImage4K(f).catch(() => false);
    if (!ok) {
      alert(`图片不符合4K标准(最大边需≥4000像素):${f.name}`);
      continue;
    }
    const item = this.makeImageItem(f);
    // 直接添加到正式列表,渲染大图默认加锁
    this.renderLargeImages.unshift({
      id: item.id,
      name: item.name,
      url: item.url,
      size: this.formatFileSize(f.size),
      locked: true  // 渲染大图默认加锁
    });
  }
  input.value = '';
}

校验规则

  • 支持 JPG、JPEG、PNG 格式
  • 最大边(宽或高)必须≥4000像素
  • 不符合标准的图片拒绝上传并提示

5.3 渲染大图加锁机制

5.3.1 加锁逻辑

// 渲染大图默认加锁
renderLargeImages: Array<{
  id: string;
  name: string;
  url: string;
  size?: string;
  locked?: boolean;  // 加锁标记
  reviewStatus?: 'pending' | 'approved' | 'rejected';
  synced?: boolean;
}> = [];

加锁规则

  • 所有渲染大图上传后自动加锁
  • 加锁状态下不可预览和下载
  • 需完成尾款结算后自动解锁

5.3.2 解锁逻辑

// 尾款到账后自动解锁
onPaymentReceived(paymentInfo?: any): void {
  // 更新结算状态
  this.settlementRecord.status = 'completed';
  this.settlementRecord.paidAmount = paymentInfo?.amount || this.settlementRecord.remainingAmount;
  this.settlementRecord.paidAt = new Date();

  // 解锁渲染大图
  this.autoUnlockAndSendImages();

  // 发送支付确认通知
  this.sendPaymentConfirmationNotifications();
}

private autoUnlockAndSendImages(): void {
  // 解锁所有渲染大图
  this.renderLargeImages.forEach(img => {
    img.locked = false;
  });

  console.log('✅ 渲染大图已自动解锁');
  alert('尾款已到账,渲染大图已解锁!客服可发送给客户。');
}

5.4 渲染异常反馈

5.4.1 异常类型

type ExceptionType = 'failed' | 'stuck' | 'quality' | 'other';

interface ExceptionHistory {
  id: string;
  type: ExceptionType;
  description: string;
  submitTime: Date;
  status: '待处理' | '处理中' | '已解决';
  screenshotUrl?: string;
  resolver?: string;
  resolvedAt?: Date;
}

5.4.2 提交异常反馈

// project-detail.ts lines 1715-1749
submitExceptionFeedback(): void {
  if (!this.exceptionDescription.trim() || this.isSubmittingFeedback) {
    alert('请填写异常类型和描述');
    return;
  }

  this.isSubmittingFeedback = true;

  // 模拟提交反馈到服务器
  setTimeout(() => {
    const newException: ExceptionHistory = {
      id: `exception-${Date.now()}`,
      type: this.exceptionType,
      description: this.exceptionDescription,
      submitTime: new Date(),
      status: '待处理'
    };

    // 添加到历史记录中
    this.exceptionHistories.unshift(newException);

    // 通知客服和技术支持
    this.notifyTechnicalSupport(newException);

    // 清空表单
    this.exceptionDescription = '';
    this.clearExceptionScreenshot();
    this.showExceptionForm = false;

    // 显示成功消息
    alert('异常反馈已提交,技术支持将尽快处理');

    this.isSubmittingFeedback = false;
  }, 1000);
}

5.4.3 异常反馈UI

<div class="exception-feedback-section">
  <h4>渲染异常反馈</h4>

  <button class="btn-report-exception" (click)="showExceptionForm = true">
    报告渲染异常
  </button>

  @if (showExceptionForm) {
    <div class="exception-form">
      <div class="form-group">
        <label>异常类型</label>
        <select [(ngModel)]="exceptionType">
          <option value="failed">渲染失败</option>
          <option value="stuck">渲染卡顿</option>
          <option value="quality">渲染质量问题</option>
          <option value="other">其他问题</option>
        </select>
      </div>

      <div class="form-group">
        <label>问题描述</label>
        <textarea
          [(ngModel)]="exceptionDescription"
          placeholder="请详细描述遇到的问题..."
          rows="4">
        </textarea>
      </div>

      <div class="form-group">
        <label>上传截图(可选)</label>
        <input
          type="file"
          id="screenshot-upload"
          accept="image/*"
          (change)="uploadExceptionScreenshot($event)">
        @if (exceptionScreenshotUrl) {
          <img [src]="exceptionScreenshotUrl" class="screenshot-preview">
          <button class="btn-remove" (click)="clearExceptionScreenshot()">移除</button>
        }
      </div>

      <div class="form-actions">
        <button class="btn-primary"
                (click)="submitExceptionFeedback()"
                [disabled]="isSubmittingFeedback">
          {{ isSubmittingFeedback ? '提交中...' : '提交反馈' }}
        </button>
        <button class="btn-secondary" (click)="showExceptionForm = false">
          取消
        </button>
      </div>
    </div>
  }

  <!-- 异常历史记录 -->
  <div class="exception-history">
    <h5>异常记录</h5>
    @for (exception of exceptionHistories; track exception.id) {
      <div class="exception-item" [class.resolved]="exception.status === '已解决'">
        <div class="exception-header">
          <span class="type-badge">{{ getExceptionTypeText(exception.type) }}</span>
          <span class="status-badge">{{ exception.status }}</span>
        </div>
        <div class="exception-content">
          <p>{{ exception.description }}</p>
          <span class="time">{{ formatDateTime(exception.submitTime) }}</span>
        </div>
      </div>
    }
  </div>
</div>

5.5 渲染阶段完成

// project-detail.ts lines 2114-2127
confirmRenderUpload(): void {
  // 检查渲染阶段的图片数据
  const renderProcess = this.deliveryProcesses.find(p => p.id === 'rendering');
  if (!renderProcess) return;

  // 检查是否有任何空间上传了图片
  const hasImages = renderProcess.spaces.some(space => {
    const content = renderProcess.content[space.id];
    return content && content.images && content.images.length > 0;
  });

  if (!hasImages) return;
  this.advanceToNextStage('渲染');
}

6. 后期阶段

6.1 功能特点

  • 最终图片处理
  • 色彩校正确认
  • 细节优化验证
  • 交付物整理

6.2 后期图片上传

// project-detail.ts lines 2030-2051
async onPostProcessPicsSelected(event: Event): Promise<void> {
  const input = event.target as HTMLInputElement;
  if (!input.files || input.files.length === 0) return;
  const files = Array.from(input.files).filter(f => /\.(jpg|jpeg|png)$/i.test(f.name));

  for (const f of files) {
    const item = this.makeImageItem(f);
    this.postProcessImages.unshift({
      id: item.id,
      name: item.name,
      url: item.url,
      size: this.formatFileSize(f.size)
    });
  }
  input.value = '';
}

removePostProcessImage(id: string): void {
  const target = this.postProcessImages.find(i => i.id === id);
  if (target) this.revokeUrl(target.url);
  this.postProcessImages = this.postProcessImages.filter(i => i.id !== id);
}

6.3 后期处理项

常见后期处理任务

  • 色彩校正和调整
  • 亮度/对比度优化
  • 细节锐化
  • 瑕疵修复
  • 水印添加(可选)
  • 文件格式转换

6.4 后期阶段完成

// project-detail.ts lines 2054-2067
confirmPostProcessUpload(): void {
  // 检查后期阶段的图片数据
  const postProcessProcess = this.deliveryProcesses.find(p => p.id === 'post-processing');
  if (!postProcessProcess) return;

  // 检查是否有任何空间上传了图片
  const hasImages = postProcessProcess.spaces.some(space => {
    const content = postProcessProcess.content[space.id];
    return content && content.images && content.images.length > 0;
  });

  if (!hasImages) return;
  this.advanceToNextStage('后期');
}

7. 统一空间文件处理

7.1 空间文件上传

7.1.1 触发文件选择

// project-detail.ts lines 5244-5251
triggerSpaceFileInput(processId: string, spaceId: string): void {
  const inputId = `space-file-input-${processId}-${spaceId}`;
  const input = document.getElementById(inputId) as HTMLInputElement;
  if (input) {
    input.click();
  }
}

7.1.2 处理空间文件

// project-detail.ts lines 5265-5284
private handleSpaceFiles(files: File[], processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  files.forEach(file => {
    if (/\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name)) {
      const imageItem = this.makeImageItem(file);
      process.content[spaceId].images.push({
        id: imageItem.id,
        name: imageItem.name,
        url: imageItem.url,
        size: this.formatFileSize(file.size),
        reviewStatus: 'pending'
      });

      // 更新进度
      this.updateSpaceProgress(processId, spaceId);
    }
  });
}

7.1.3 空间文件拖拽

// project-detail.ts lines 5254-5262
onSpaceFileDrop(event: DragEvent, processId: string, spaceId: string): void {
  event.preventDefault();
  event.stopPropagation();

  const files = event.dataTransfer?.files;
  if (!files || files.length === 0) return;

  this.handleSpaceFiles(Array.from(files), processId, spaceId);
}

7.2 获取空间图片列表

// project-detail.ts lines 5287-5292
getSpaceImages(processId: string, spaceId: string): Array<{
  id: string;
  name: string;
  url: string;
  size?: string;
  reviewStatus?: 'pending' | 'approved' | 'rejected'
}> {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return [];

  return process.content[spaceId].images || [];
}

7.3 空间图片删除

// 从空间中删除图片
removeSpaceImage(processId: string, spaceId: string, imageId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const images = process.content[spaceId].images;
  const imageIndex = images.findIndex(img => img.id === imageId);

  if (imageIndex > -1) {
    // 释放URL资源
    const image = images[imageIndex];
    if (image.url && image.url.startsWith('blob:')) {
      URL.revokeObjectURL(image.url);
    }

    // 从数组中移除
    images.splice(imageIndex, 1);

    // 更新进度
    this.updateSpaceProgress(processId, spaceId);
  }
}

8. 审核流程

8.1 审核状态管理

8.1.1 审核状态枚举

type ReviewStatus = 'pending' | 'approved' | 'rejected';

interface ImageWithReview {
  id: string;
  name: string;
  url: string;
  size?: string;
  reviewStatus?: ReviewStatus;
  reviewNotes?: string;
  reviewedBy?: string;
  reviewedAt?: Date;
  synced?: boolean;  // 是否已同步到客户端
}

8.1.2 审核操作

// 组长审核图片
reviewSpaceImage(
  processId: string,
  spaceId: string,
  imageId: string,
  status: ReviewStatus,
  notes?: string
): void {
  if (!this.isTeamLeaderView()) {
    alert('仅组长可以审核图片');
    return;
  }

  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const image = process.content[spaceId].images.find(img => img.id === imageId);
  if (!image) return;

  // 更新审核状态
  image.reviewStatus = status;
  image.reviewNotes = notes;
  image.reviewedBy = this.getCurrentUserName();
  image.reviewedAt = new Date();

  // 如果审核通过,标记为已同步
  if (status === 'approved') {
    image.synced = true;
  }

  console.log(`图片审核完成: ${image.name} - ${status}`);
}

8.2 批量审核

// 批量审核空间内所有图片
batchReviewSpaceImages(
  processId: string,
  spaceId: string,
  status: ReviewStatus
): void {
  if (!this.isTeamLeaderView()) {
    alert('仅组长可以批量审核图片');
    return;
  }

  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const images = process.content[spaceId].images;
  const pendingImages = images.filter(img => img.reviewStatus === 'pending');

  if (pendingImages.length === 0) {
    alert('没有待审核的图片');
    return;
  }

  const confirmed = confirm(
    `确定要批量${status === 'approved' ? '通过' : '驳回'}${pendingImages.length}张图片吗?`
  );

  if (!confirmed) return;

  pendingImages.forEach(image => {
    image.reviewStatus = status;
    image.reviewedBy = this.getCurrentUserName();
    image.reviewedAt = new Date();
    if (status === 'approved') {
      image.synced = true;
    }
  });

  alert(`已批量审核${pendingImages.length}张图片`);
}

8.3 审核统计

// 获取空间审核统计
getSpaceReviewStats(processId: string, spaceId: string): {
  total: number;
  pending: number;
  approved: number;
  rejected: number;
} {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) {
    return { total: 0, pending: 0, approved: 0, rejected: 0 };
  }

  const images = process.content[spaceId].images;

  return {
    total: images.length,
    pending: images.filter(img => img.reviewStatus === 'pending').length,
    approved: images.filter(img => img.reviewStatus === 'approved').length,
    rejected: images.filter(img => img.reviewStatus === 'rejected').length
  };
}

9. 权限控制

9.1 角色权限矩阵

操作 设计师 渲染师 组长 技术
查看交付执行板块
上传建模图片
上传软装图片
上传渲染图片
上传后期图片
添加/删除空间
审核图片
确认阶段完成
报告渲染异常
最终验收

9.2 权限检查方法

// project-detail.ts lines 911-936
isDesignerView(): boolean {
  return this.roleContext === 'designer';
}

isTeamLeaderView(): boolean {
  return this.roleContext === 'team-leader';
}

isTechnicalView(): boolean {
  return this.roleContext === 'technical';
}

canEditSection(sectionKey: SectionKey): boolean {
  if (this.isCustomerServiceView()) {
    return sectionKey === 'order' ||
           sectionKey === 'requirements' ||
           sectionKey === 'aftercare';
  }
  return true; // 设计师和组长可以编辑所有板块
}

canEditStage(stage: ProjectStage): boolean {
  if (this.isCustomerServiceView()) {
    const editableStages: ProjectStage[] = [
      '订单分配', '需求沟通', '方案确认',
      '尾款结算', '客户评价', '投诉处理'
    ];
    return editableStages.includes(stage);
  }
  return true;
}

9.3 UI权限控制

<!-- 上传按钮权限 -->
@if (!isReadOnly() && canEditStage('建模')) {
  <button class="btn-upload" (click)="triggerSpaceFileInput(process.id, space.id)">
    上传图片
  </button>
}

<!-- 审核按钮权限 -->
@if (isTeamLeaderView()) {
  <button class="btn-review" (click)="reviewSpaceImage(process.id, space.id, image.id, 'approved')">
    通过
  </button>
  <button class="btn-reject" (click)="reviewSpaceImage(process.id, space.id, image.id, 'rejected')">
    驳回
  </button>
}

<!-- 删除按钮权限 -->
@if (!isReadOnly() && (isDesignerView() || isTeamLeaderView())) {
  <button class="btn-delete" (click)="removeSpaceImage(process.id, space.id, image.id)">
    删除
  </button>
}

10. 数据流转

10.1 阶段推进流程

sequenceDiagram
    participant Designer as 设计师
    participant System as 系统
    participant Leader as 组长
    participant Next as 下一阶段

    Designer->>System: 上传图片到空间
    System->>System: 更新空间进度
    System->>System: 计算阶段完成度
    Designer->>System: 确认阶段上传
    System->>System: 验证图片数量
    alt 有图片
        System->>Leader: 通知审核
        Leader->>System: 审核图片
        System->>Next: 推进到下一阶段
    else 无图片
        System->>Designer: 提示先上传图片
    end

10.2 进度同步机制

// 更新空间进度后同步到项目
private syncProgressToProject(processId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process) return;

  // 计算整体进度
  const spaces = process.spaces;
  const totalProgress = spaces.reduce((sum, space) => {
    return sum + (process.content[space.id]?.progress || 0);
  }, 0);

  const averageProgress = spaces.length > 0
    ? Math.round(totalProgress / spaces.length)
    : 0;

  // 更新项目进度
  if (this.project) {
    this.project.progress = averageProgress;
  }

  // 触发变更检测
  this.cdr.detectChanges();
}

10.3 客户端数据同步

// 审核通过后同步到客户端
private syncApprovedImagesToClient(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const approvedImages = process.content[spaceId].images
    .filter(img => img.reviewStatus === 'approved' && !img.synced);

  if (approvedImages.length === 0) return;

  // 调用API同步到客户端
  this.projectService.syncImagesToClient(
    this.projectId,
    processId,
    spaceId,
    approvedImages.map(img => img.id)
  ).subscribe({
    next: (result) => {
      if (result.success) {
        // 标记为已同步
        approvedImages.forEach(img => {
          img.synced = true;
        });
        console.log(`已同步${approvedImages.length}张图片到客户端`);
      }
    },
    error: (error) => {
      console.error('同步图片失败:', error);
    }
  });
}

11. 异常处理

11.1 文件上传失败

// 文件上传错误处理
private handleFileUploadError(error: any, fileName: string): void {
  let errorMessage = '文件上传失败';

  if (error.status === 413) {
    errorMessage = `文件过大:${fileName}(最大10MB)`;
  } else if (error.status === 415) {
    errorMessage = `不支持的文件格式:${fileName}`;
  } else if (error.status === 500) {
    errorMessage = '服务器错误,请稍后重试';
  }

  alert(errorMessage);
  console.error('文件上传失败:', error);
}

11.2 4K校验失败

// 4K校验失败处理
private handle4KValidationFailure(file: File, dimensions: {width: number; height: number}): void {
  const maxDimension = Math.max(dimensions.width, dimensions.height);

  const message = `
    图片不符合4K标准

    文件名: ${file.name}
    当前尺寸: ${dimensions.width} × ${dimensions.height}
    最大边: ${maxDimension}px
    要求: 最大边 ≥ 4000px

    请使用符合4K标准的图片重新上传。
  `;

  alert(message);
  console.warn('4K校验失败:', file.name, dimensions);
}

11.3 渲染异常处理

// 渲染超时预警
checkRenderTimeout(): void {
  if (!this.renderProgress || !this.project) return;

  const deliveryTime = new Date(this.project.deadline);
  const currentTime = new Date();
  const timeDifference = deliveryTime.getTime() - currentTime.getTime();
  const hoursRemaining = Math.floor(timeDifference / (1000 * 60 * 60));

  if (hoursRemaining <= 3 && hoursRemaining > 0) {
    alert('渲染进度预警:交付前3小时,请关注渲染进度');
  }

  if (hoursRemaining <= 1 && hoursRemaining > 0) {
    alert('渲染进度严重预警:交付前1小时,渲染可能无法按时完成!');
    this.notifyTeamLeader('render-failed');
  }
}

11.4 空间操作失败

// 删除空间时的安全检查
removeSpace(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process) return;

  const space = process.spaces.find(s => s.id === spaceId);
  if (!space) return;

  // 检查空间是否有图片
  const hasImages = process.content[spaceId]?.images?.length > 0;

  if (hasImages) {
    const confirmed = confirm(
      `空间"${space.name}"中有${process.content[spaceId].images.length}张图片,确定要删除吗?\n删除后图片将无法恢复。`
    );
    if (!confirmed) return;
  }

  // 执行删除
  const spaceIndex = process.spaces.findIndex(s => s.id === spaceId);
  if (spaceIndex > -1) {
    process.spaces.splice(spaceIndex, 1);

    // 清理资源
    if (process.content[spaceId]) {
      process.content[spaceId].images.forEach(img => {
        if (img.url && img.url.startsWith('blob:')) {
          URL.revokeObjectURL(img.url);
        }
      });
      delete process.content[spaceId];
    }

    console.log(`已删除空间: ${space.name}`);
  }
}

12. 性能优化

12.1 Blob URL管理

// 组件销毁时清理所有Blob URL
ngOnDestroy(): void {
  // 释放所有 blob 预览 URL
  const revokeList: string[] = [];

  // 收集所有Blob URL
  this.deliveryProcesses.forEach(process => {
    Object.values(process.content).forEach(content => {
      content.images.forEach(img => {
        if (img.url && img.url.startsWith('blob:')) {
          revokeList.push(img.url);
        }
      });
    });
  });

  // 批量释放
  revokeList.forEach(url => URL.revokeObjectURL(url));

  console.log(`已释放${revokeList.length}个Blob URL`);
}

12.2 图片懒加载

// 使用Intersection Observer实现懒加载
private setupImageLazyLoading(): void {
  if (!('IntersectionObserver' in window)) return;

  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const img = entry.target as HTMLImageElement;
        const src = img.dataset['src'];
        if (src) {
          img.src = src;
          observer.unobserve(img);
        }
      }
    });
  }, {
    rootMargin: '50px'  // 提前50px开始加载
  });

  // 观察所有懒加载图片
  document.querySelectorAll('img[data-src]').forEach(img => {
    observer.observe(img);
  });
}

12.3 进度计算优化

// 使用防抖避免频繁计算
private progressUpdateDebounce: any;

private updateSpaceProgress(processId: string, spaceId: string): void {
  // 清除之前的定时器
  if (this.progressUpdateDebounce) {
    clearTimeout(this.progressUpdateDebounce);
  }

  // 延迟300ms执行
  this.progressUpdateDebounce = setTimeout(() => {
    this.doUpdateSpaceProgress(processId, spaceId);
  }, 300);
}

private doUpdateSpaceProgress(processId: string, spaceId: string): void {
  const process = this.deliveryProcesses.find(p => p.id === processId);
  if (!process || !process.content[spaceId]) return;

  const content = process.content[spaceId];
  const imageCount = content.images.length;

  // 计算进度
  if (imageCount === 0) {
    content.progress = 0;
    content.status = 'pending';
  } else if (imageCount < 3) {
    content.progress = Math.min(imageCount * 30, 90);
    content.status = 'in_progress';
  } else {
    content.progress = 100;
    content.status = 'completed';
  }

  content.lastUpdated = new Date();

  // 同步到项目
  this.syncProgressToProject(processId);
}

13. 测试用例

13.1 空间管理测试

describe('Space Management', () => {
  it('should add new space to process', () => {
    component.newSpaceName['modeling'] = '书房';
    component.addSpace('modeling');

    const modelingProcess = component.deliveryProcesses.find(p => p.id === 'modeling');
    expect(modelingProcess?.spaces.length).toBe(4);
    expect(modelingProcess?.spaces[3].name).toBe('书房');
    expect(modelingProcess?.content['space_*']).toBeDefined();
  });

  it('should remove space and clean up resources', () => {
    const process = component.deliveryProcesses[0];
    const spaceId = process.spaces[0].id;

    // 添加一些图片
    process.content[spaceId].images = [
      { id: '1', name: 'test.jpg', url: 'blob:test', size: '1MB' }
    ];

    component.removeSpace(process.id, spaceId);

    expect(process.spaces.length).toBe(2);
    expect(process.content[spaceId]).toBeUndefined();
  });

  it('should toggle space expansion', () => {
    const process = component.deliveryProcesses[0];
    const space = process.spaces[0];
    const initialState = space.isExpanded;

    component.toggleSpace(process.id, space.id);

    expect(space.isExpanded).toBe(!initialState);
  });
});

13.2 文件上传测试

describe('File Upload', () => {
  it('should validate 4K images correctly', async () => {
    const file = new File([''], 'test-4k.jpg', { type: 'image/jpeg' });

    // Mock image dimensions
    spyOn<any>(component, 'validateImage4K').and.returnValue(Promise.resolve(true));

    await component.onRenderLargePicsSelected({
      target: { files: [file] }
    } as any);

    expect(component.renderLargeImages.length).toBeGreaterThan(0);
    expect(component.renderLargeImages[0].locked).toBe(true);
  });

  it('should reject non-4K images', async () => {
    const file = new File([''], 'small.jpg', { type: 'image/jpeg' });

    spyOn<any>(component, 'validateImage4K').and.returnValue(Promise.resolve(false));
    spyOn(window, 'alert');

    await component.onRenderLargePicsSelected({
      target: { files: [file] }
    } as any);

    expect(window.alert).toHaveBeenCalledWith(jasmine.stringContaining('不符合4K标准'));
  });

  it('should handle soft decor upload with size warning', () => {
    const largeFile = new File(['x'.repeat(2 * 1024 * 1024)], 'large.jpg', { type: 'image/jpeg' });

    spyOn(console, 'warn');

    component.onSoftDecorSmallPicsSelected({
      target: { files: [largeFile] }
    } as any);

    expect(console.warn).toHaveBeenCalled();
    expect(component.softDecorImages.length).toBeGreaterThan(0);
  });
});

13.3 进度更新测试

describe('Progress Tracking', () => {
  it('should update space progress based on image count', () => {
    const process = component.deliveryProcesses[0];
    const spaceId = process.spaces[0].id;

    // 添加2张图片
    process.content[spaceId].images = [
      { id: '1', name: 'img1.jpg', url: 'blob:1' },
      { id: '2', name: 'img2.jpg', url: 'blob:2' }
    ];

    component['updateSpaceProgress'](process.id, spaceId);

    expect(process.content[spaceId].progress).toBe(60);
    expect(process.content[spaceId].status).toBe('in_progress');
  });

  it('should mark as completed with 3+ images', () => {
    const process = component.deliveryProcesses[0];
    const spaceId = process.spaces[0].id;

    process.content[spaceId].images = [
      { id: '1', name: 'img1.jpg', url: 'blob:1' },
      { id: '2', name: 'img2.jpg', url: 'blob:2' },
      { id: '3', name: 'img3.jpg', url: 'blob:3' }
    ];

    component['updateSpaceProgress'](process.id, spaceId);

    expect(process.content[spaceId].progress).toBe(100);
    expect(process.content[spaceId].status).toBe('completed');
  });
});

13.4 阶段推进测试

describe('Stage Progression', () => {
  it('should advance to next stage after confirmation', () => {
    // 设置建模阶段有图片
    const modelingProcess = component.deliveryProcesses.find(p => p.id === 'modeling');
    if (modelingProcess) {
      modelingProcess.content['bedroom'].images = [
        { id: '1', name: 'test.jpg', url: 'blob:test' }
      ];
    }

    component.currentStage = '建模';
    component.confirmWhiteModelUpload();

    expect(component.currentStage).toBe('软装');
    expect(component.expandedStages['软装']).toBe(true);
    expect(component.expandedStages['建模']).toBe(false);
  });

  it('should not advance without images', () => {
    component.currentStage = '建模';
    const initialStage = component.currentStage;

    component.confirmWhiteModelUpload();

    expect(component.currentStage).toBe(initialStage);
  });
});

文档版本:v1.0.0 创建日期:2025-10-16 最后更新:2025-10-16 维护人:产品团队