|
@@ -1,4 +1,4 @@
|
|
|
-# 项目管理 - 交付执行阶段 PRD
|
|
|
|
|
|
|
+# 项目管理 - 交付执行阶段 PRD (Product表版本)
|
|
|
|
|
|
|
|
## 1. 功能概述
|
|
## 1. 功能概述
|
|
|
|
|
|
|
@@ -6,11 +6,13 @@
|
|
|
交付执行阶段是项目管理流程的核心执行环节,包含建模、软装、渲染、后期四个连续子阶段。该阶段负责将设计方案转化为可交付的视觉成果,是项目价值实现的关键环节。
|
|
交付执行阶段是项目管理流程的核心执行环节,包含建模、软装、渲染、后期四个连续子阶段。该阶段负责将设计方案转化为可交付的视觉成果,是项目价值实现的关键环节。
|
|
|
|
|
|
|
|
### 1.2 核心目标
|
|
### 1.2 核心目标
|
|
|
-- 按空间维度组织文件上传和进度管理
|
|
|
|
|
-- 实现四个执行阶段的串行推进
|
|
|
|
|
-- 提供实时进度跟踪和状态可视化
|
|
|
|
|
-- 支持组长审核和质量把控
|
|
|
|
|
-- 确保交付物符合质量标准
|
|
|
|
|
|
|
+- **多产品设计协同管理**:支持单产品设计到多产品设计项目的灵活管理
|
|
|
|
|
+- **按产品设计维度组织文件上传和进度管理**
|
|
|
|
|
+- **实现四个执行阶段的串行推进**
|
|
|
|
|
+- **跨产品设计协调与依赖管理**:处理产品设计间的风格一致性、色彩流线、材质匹配
|
|
|
|
|
+- **提供实时进度跟踪和状态可视化**
|
|
|
|
|
+- **支持组长审核和质量把控**
|
|
|
|
|
+- **确保交付物符合质量标准**
|
|
|
|
|
|
|
|
### 1.3 涉及角色
|
|
### 1.3 涉及角色
|
|
|
- **设计师**:负责建模、软装、后期等设计工作
|
|
- **设计师**:负责建模、软装、后期等设计工作
|
|
@@ -34,1642 +36,1041 @@ graph LR
|
|
|
style E fill:#f3e5f5
|
|
style E fill:#f3e5f5
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-## 2. 空间管理系统
|
|
|
|
|
|
|
+## 2. 基于Product表的交付管理系统
|
|
|
|
|
|
|
|
-### 2.1 空间数据结构
|
|
|
|
|
|
|
+### 2.1 产品交付管理架构
|
|
|
|
|
|
|
|
-#### 2.1.1 DeliveryProcess 接口
|
|
|
|
|
|
|
+#### 2.1.1 增强的DeliveryProcess接口
|
|
|
```typescript
|
|
```typescript
|
|
|
-interface DeliveryProcess {
|
|
|
|
|
|
|
+interface ProductDeliveryProcess {
|
|
|
id: string; // 流程ID: 'modeling' | 'softDecor' | 'rendering' | 'postProcess'
|
|
id: string; // 流程ID: 'modeling' | 'softDecor' | 'rendering' | 'postProcess'
|
|
|
name: string; // 流程名称:建模/软装/渲染/后期
|
|
name: string; // 流程名称:建模/软装/渲染/后期
|
|
|
type: 'modeling' | 'softDecor' | 'rendering' | 'postProcess';
|
|
type: 'modeling' | 'softDecor' | 'rendering' | 'postProcess';
|
|
|
isExpanded: boolean; // 是否展开
|
|
isExpanded: boolean; // 是否展开
|
|
|
- spaces: DeliverySpace[]; // 空间列表
|
|
|
|
|
- content: {
|
|
|
|
|
- [spaceId: string]: SpaceContent; // 按空间ID索引的内容
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 产品管理(基于Product表)
|
|
|
|
|
+ products: ProductDelivery[];
|
|
|
|
|
+ content: Record<string, ProductContent>;
|
|
|
|
|
+
|
|
|
|
|
+ // 跨产品协调(基于Product表)
|
|
|
|
|
+ crossProductCoordination: {
|
|
|
|
|
+ dependencies: ProductDependency[]; // 产品依赖关系
|
|
|
|
|
+ batchOperations: BatchOperation[]; // 批量操作
|
|
|
|
|
+ qualityStandards: QualityStandard[]; // 质量标准
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 整体进度管理
|
|
|
|
|
+ overallProgress: {
|
|
|
|
|
+ total: number; // 总体进度
|
|
|
|
|
+ byProduct: Record<string, number>; // 各产品进度
|
|
|
|
|
+ byStage: Record<string, number>; // 各阶段进度
|
|
|
|
|
+ estimatedCompletion: Date;
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-interface DeliverySpace {
|
|
|
|
|
- id: string; // 空间ID
|
|
|
|
|
- name: string; // 空间名称:卧室/客厅/厨房等
|
|
|
|
|
|
|
+interface ProductDelivery {
|
|
|
|
|
+ productId: string; // 产品ID(与Product.objectId关联)
|
|
|
|
|
+ productName: string; // 产品名称:李总主卧设计
|
|
|
|
|
+ productType: string; // 产品类型:bedroom
|
|
|
isExpanded: boolean; // 是否展开
|
|
isExpanded: boolean; // 是否展开
|
|
|
- order: number; // 排序顺序
|
|
|
|
|
|
|
+ order: number; // 排序顺序
|
|
|
|
|
+ priority: number; // 优先级
|
|
|
|
|
+ status: ProductStatus; // 产品状态
|
|
|
|
|
+ assigneeId: string; // 负责人ID(Product.profile)
|
|
|
|
|
+ estimatedHours: number; // 预估工时
|
|
|
|
|
+ actualHours: number; // 实际工时
|
|
|
|
|
+
|
|
|
|
|
+ // 产品报价信息
|
|
|
|
|
+ quotation: {
|
|
|
|
|
+ price: number;
|
|
|
|
|
+ breakdown: {
|
|
|
|
|
+ design: number;
|
|
|
|
|
+ modeling: number;
|
|
|
|
|
+ rendering: number;
|
|
|
|
|
+ softDecor: number;
|
|
|
|
|
+ };
|
|
|
|
|
+ status: string;
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-interface SpaceContent {
|
|
|
|
|
- images: Array<{
|
|
|
|
|
|
|
+interface ProductContent {
|
|
|
|
|
+ // 产品文件(基于ProjectFile分类)
|
|
|
|
|
+ files: Array<{
|
|
|
id: string;
|
|
id: string;
|
|
|
name: string;
|
|
name: string;
|
|
|
url: string;
|
|
url: string;
|
|
|
size?: string;
|
|
size?: string;
|
|
|
|
|
+ fileCategory: string; // 'delivery' | 'reference' | 'other'
|
|
|
reviewStatus?: 'pending' | 'approved' | 'rejected';
|
|
reviewStatus?: 'pending' | 'approved' | 'rejected';
|
|
|
- synced?: boolean; // 是否已同步到客户端
|
|
|
|
|
|
|
+ synced?: boolean; // 是否已同步到客户端
|
|
|
|
|
+ uploadTime: Date; // 上传时间
|
|
|
|
|
+ uploadedBy: string; // 上传人
|
|
|
}>;
|
|
}>;
|
|
|
- progress: number; // 进度 0-100
|
|
|
|
|
- status: 'pending' | 'in_progress' | 'completed' | 'approved';
|
|
|
|
|
- notes: string; // 备注信息
|
|
|
|
|
- lastUpdated: Date; // 最后更新时间
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
|
|
|
-#### 2.1.2 初始空间配置
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 添加新空间
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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()
|
|
|
|
|
|
|
+ progress: number; // 进度 0-100
|
|
|
|
|
+ status: 'pending' | 'in_progress' | 'completed' | 'approved';
|
|
|
|
|
+ notes: string; // 备注信息
|
|
|
|
|
+ lastUpdated: Date; // 最后更新时间
|
|
|
|
|
+
|
|
|
|
|
+ // 产品特定字段
|
|
|
|
|
+ productSpecific: {
|
|
|
|
|
+ // 建模阶段特有
|
|
|
|
|
+ modelingComplexity?: 'simple' | 'medium' | 'complex';
|
|
|
|
|
+ structuralConstraints?: string[];
|
|
|
|
|
+
|
|
|
|
|
+ // 软装阶段特有
|
|
|
|
|
+ furnitureList?: string[];
|
|
|
|
|
+ materialSelection?: string[];
|
|
|
|
|
+
|
|
|
|
|
+ // 渲染阶段特有
|
|
|
|
|
+ renderingQuality?: 'standard' | 'high' | 'ultra';
|
|
|
|
|
+ outputResolution?: string;
|
|
|
|
|
+
|
|
|
|
|
+ // 后期阶段特有
|
|
|
|
|
+ postProcessingTypes?: string[];
|
|
|
|
|
+ finalTouches?: string[];
|
|
|
};
|
|
};
|
|
|
-
|
|
|
|
|
- // 清空输入框并隐藏
|
|
|
|
|
- this.newSpaceName[processId] = '';
|
|
|
|
|
- this.showAddSpaceInput[processId] = false;
|
|
|
|
|
-
|
|
|
|
|
- console.log(`已添加空间: ${spaceName} 到流程 ${process.name}`);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**UI交互**:
|
|
|
|
|
-```html
|
|
|
|
|
-<!-- 添加空间输入框 -->
|
|
|
|
|
-@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 删除空间
|
|
|
|
|
|
|
+#### 2.1.2 基于Product表的交付管理服务
|
|
|
```typescript
|
|
```typescript
|
|
|
-// 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);
|
|
|
|
|
|
|
+class ProductDeliveryService {
|
|
|
|
|
+ // 获取项目的交付管理数据
|
|
|
|
|
+ async getProjectDeliveryData(projectId: string): Promise<ProductDeliveryProcess[]> {
|
|
|
|
|
+ // 1. 获取项目的所有产品
|
|
|
|
|
+ const productQuery = new Parse.Query("Product");
|
|
|
|
|
+ productQuery.equalTo("project", { __type: "Pointer", className: "Project", objectId: projectId });
|
|
|
|
|
+ productQuery.include("profile");
|
|
|
|
|
+ productQuery.ascending("order");
|
|
|
|
|
+ const products = await productQuery.find();
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 构建交付管理数据结构
|
|
|
|
|
+ const deliveryProcesses: ProductDeliveryProcess[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const stage of ['modeling', 'softDecor', 'rendering', 'postProcess']) {
|
|
|
|
|
+ const process: ProductDeliveryProcess = {
|
|
|
|
|
+ id: stage,
|
|
|
|
|
+ name: this.getStageName(stage),
|
|
|
|
|
+ type: stage as any,
|
|
|
|
|
+ isExpanded: false,
|
|
|
|
|
+ products: [],
|
|
|
|
|
+ content: {},
|
|
|
|
|
+ crossProductCoordination: {
|
|
|
|
|
+ dependencies: [],
|
|
|
|
|
+ batchOperations: [],
|
|
|
|
|
+ qualityStandards: []
|
|
|
|
|
+ },
|
|
|
|
|
+ overallProgress: {
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ byProduct: {},
|
|
|
|
|
+ byStage: {},
|
|
|
|
|
+ estimatedCompletion: new Date()
|
|
|
}
|
|
}
|
|
|
- });
|
|
|
|
|
- delete process.content[spaceId];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- console.log(`已删除空间: ${spaceName} 从流程 ${process.name}`);
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-#### 2.2.3 空间展开/收起
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 进度计算逻辑
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 获取空间进度
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 进度可视化
|
|
|
|
|
-```html
|
|
|
|
|
-<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 文件上传处理
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 图片对象生成
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 文件大小格式化
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 检查项数据结构
|
|
|
|
|
-```typescript
|
|
|
|
|
-interface ModelCheckItem {
|
|
|
|
|
- id: string;
|
|
|
|
|
- name: string;
|
|
|
|
|
- isPassed: boolean;
|
|
|
|
|
- notes: string;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 为每个产品构建交付数据
|
|
|
|
|
+ for (const product of products) {
|
|
|
|
|
+ const productDelivery: ProductDelivery = {
|
|
|
|
|
+ productId: product.id,
|
|
|
|
|
+ productName: product.get("productName"),
|
|
|
|
|
+ productType: product.get("productType"),
|
|
|
|
|
+ isExpanded: false,
|
|
|
|
|
+ order: product.get("order") || 0,
|
|
|
|
|
+ priority: product.get("space")?.priority || 5,
|
|
|
|
|
+ status: product.get("status") || "not_started",
|
|
|
|
|
+ assigneeId: product.get("profile")?.id,
|
|
|
|
|
+ estimatedHours: product.get("estimatedDuration") * 8, // 天数转小时
|
|
|
|
|
+ actualHours: 0,
|
|
|
|
|
+ quotation: product.get("quotation") || {}
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 4. 获取产品的交付文件
|
|
|
|
|
+ const productFiles = await this.getProductDeliveryFiles(product.id, stage);
|
|
|
|
|
+
|
|
|
|
|
+ // 5. 构建产品内容
|
|
|
|
|
+ const productContent: ProductContent = {
|
|
|
|
|
+ files: productFiles.map(file => ({
|
|
|
|
|
+ id: file.id,
|
|
|
|
|
+ name: file.get("fileName"),
|
|
|
|
|
+ url: file.get("fileUrl"),
|
|
|
|
|
+ size: this.formatFileSize(file.get("fileSize")),
|
|
|
|
|
+ fileCategory: file.get("fileCategory"),
|
|
|
|
|
+ reviewStatus: file.get("data")?.reviewStatus || "pending",
|
|
|
|
|
+ uploadTime: file.get("createdAt"),
|
|
|
|
|
+ uploadedBy: file.get("uploadedBy")?.get("name")
|
|
|
|
|
+ })),
|
|
|
|
|
+ progress: this.calculateProductProgress(product, stage),
|
|
|
|
|
+ status: this.getProductStatus(product, stage),
|
|
|
|
|
+ notes: product.get("data")?.deliveryNotes || "",
|
|
|
|
|
+ lastUpdated: product.get("updatedAt"),
|
|
|
|
|
+ productSpecific: this.getProductSpecificFields(product, stage)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ process.products.push(productDelivery);
|
|
|
|
|
+ process.content[product.id] = productContent;
|
|
|
|
|
+ process.overallProgress.byProduct[product.id] = productContent.progress;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-// 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: '' }
|
|
|
|
|
-];
|
|
|
|
|
-```
|
|
|
|
|
|
|
+ deliveryProcesses.push(process);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-#### 3.3.2 检查项UI
|
|
|
|
|
-```html
|
|
|
|
|
-<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>
|
|
|
|
|
|
|
+ return deliveryProcesses;
|
|
|
}
|
|
}
|
|
|
-</div>
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 3.4 建模阶段完成
|
|
|
|
|
-
|
|
|
|
|
-#### 3.4.1 确认上传方法
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 阶段推进逻辑
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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;
|
|
|
|
|
|
|
+ // 获取产品的交付文件
|
|
|
|
|
+ async getProductDeliveryFiles(productId: string, stage: string): Promise<Parse.Object[]> {
|
|
|
|
|
+ const fileQuery = new Parse.Query("ProjectFile");
|
|
|
|
|
+ fileQuery.equalTo("product", { __type: "Pointer", className: "Product", objectId: productId });
|
|
|
|
|
+ fileQuery.equalTo("fileCategory", "delivery");
|
|
|
|
|
+ fileQuery.equalTo("stage", stage);
|
|
|
|
|
+ fileQuery.notEqualTo("isDeleted", true);
|
|
|
|
|
+ fileQuery.descending("createdAt");
|
|
|
|
|
|
|
|
- // 触发变更检测以更新导航栏颜色
|
|
|
|
|
- this.cdr.detectChanges();
|
|
|
|
|
|
|
+ return await fileQuery.find();
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
|
|
|
-## 4. 软装阶段
|
|
|
|
|
|
|
+ // 计算产品进度
|
|
|
|
|
+ calculateProductProgress(product: Parse.Object, stage: string): number {
|
|
|
|
|
+ const productFiles = product.get("deliveryFiles") || [];
|
|
|
|
|
+ const stageFiles = productFiles.filter((file: any) => file.stage === stage);
|
|
|
|
|
|
|
|
-### 4.1 功能特点
|
|
|
|
|
-- 小图上传(建议≤1MB,不强制)
|
|
|
|
|
-- 支持拖拽上传
|
|
|
|
|
-- 实时预览功能
|
|
|
|
|
-- 按空间组织
|
|
|
|
|
|
|
+ if (stageFiles.length === 0) return 0;
|
|
|
|
|
|
|
|
-### 4.2 小图上传
|
|
|
|
|
|
|
+ const completedFiles = stageFiles.filter((file: any) =>
|
|
|
|
|
+ file.reviewStatus === "approved"
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
-#### 4.2.1 文件选择处理
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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));
|
|
|
|
|
|
|
+ return Math.round((completedFiles.length / stageFiles.length) * 100);
|
|
|
}
|
|
}
|
|
|
- const items = files.map(f => this.makeImageItem(f));
|
|
|
|
|
- this.softDecorImages.unshift(...items);
|
|
|
|
|
- input.value = '';
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**文件大小校验**:
|
|
|
|
|
-- 建议≤1MB,超过仅警告不阻断
|
|
|
|
|
-- 支持 JPG、JPEG、PNG 格式
|
|
|
|
|
-- 自动过滤非图片文件
|
|
|
|
|
|
|
|
|
|
-#### 4.2.2 拖拽上传支持
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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;
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ // 获取产品状态
|
|
|
|
|
+ getProductStatus(product: Parse.Object, stage: string): string {
|
|
|
|
|
+ const currentStage = product.get("stage");
|
|
|
|
|
|
|
|
-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
|
|
|
|
|
|
|
+ if (currentStage === stage) {
|
|
|
|
|
+ return product.get("status") || "not_started";
|
|
|
|
|
+ } else if (this.isStageCompleted(stage, currentStage)) {
|
|
|
|
|
+ return "completed";
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return "pending";
|
|
|
}
|
|
}
|
|
|
- } as any;
|
|
|
|
|
-
|
|
|
|
|
- // 根据类型调用相应的处理方法
|
|
|
|
|
- switch (type) {
|
|
|
|
|
- case 'softDecor':
|
|
|
|
|
- this.onSoftDecorSmallPicsSelected(mockEvent);
|
|
|
|
|
- break;
|
|
|
|
|
- // ... 其他类型
|
|
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**拖拽区域样式**:
|
|
|
|
|
-```html
|
|
|
|
|
-<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 预览功能
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 预览弹窗
|
|
|
|
|
-```html
|
|
|
|
|
-@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 软装阶段完成
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 图片尺寸验证
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 获取产品特定字段
|
|
|
|
|
+ getProductSpecificFields(product: Parse.Object, stage: string): any {
|
|
|
|
|
+ const space = product.get("space") || {};
|
|
|
|
|
+ const baseFields = {
|
|
|
|
|
+ structuralConstraints: space.constraints || []
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- img.onerror = () => {
|
|
|
|
|
- URL.revokeObjectURL(url);
|
|
|
|
|
- reject(new Error('图片加载失败'));
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- img.src = url;
|
|
|
|
|
- });
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-#### 5.2.2 渲染大图上传
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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;
|
|
|
|
|
|
|
+ switch (stage) {
|
|
|
|
|
+ case "modeling":
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...baseFields,
|
|
|
|
|
+ modelingComplexity: space.complexity || "medium"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ case "softDecor":
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...baseFields,
|
|
|
|
|
+ furnitureList: product.get("data")?.furnitureList || [],
|
|
|
|
|
+ materialSelection: product.get("data")?.materialSelection || []
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ case "rendering":
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...baseFields,
|
|
|
|
|
+ renderingQuality: product.get("data")?.renderingQuality || "standard",
|
|
|
|
|
+ outputResolution: product.get("data")?.outputResolution || "1920x1080"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ case "postProcess":
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...baseFields,
|
|
|
|
|
+ postProcessingTypes: product.get("data")?.postProcessingTypes || [],
|
|
|
|
|
+ finalTouches: product.get("data")?.finalTouches || []
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ default:
|
|
|
|
|
+ return baseFields;
|
|
|
}
|
|
}
|
|
|
- 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 加锁逻辑
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 渲染大图默认加锁
|
|
|
|
|
-renderLargeImages: Array<{
|
|
|
|
|
- id: string;
|
|
|
|
|
- name: string;
|
|
|
|
|
- url: string;
|
|
|
|
|
- size?: string;
|
|
|
|
|
- locked?: boolean; // 加锁标记
|
|
|
|
|
- reviewStatus?: 'pending' | 'approved' | 'rejected';
|
|
|
|
|
- synced?: boolean;
|
|
|
|
|
-}> = [];
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-**加锁规则**:
|
|
|
|
|
-- 所有渲染大图上传后自动加锁
|
|
|
|
|
-- 加锁状态下不可预览和下载
|
|
|
|
|
-- 需完成尾款结算后自动解锁
|
|
|
|
|
-
|
|
|
|
|
-#### 5.3.2 解锁逻辑
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 尾款到账后自动解锁
|
|
|
|
|
-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 异常类型
|
|
|
|
|
-```typescript
|
|
|
|
|
-type ExceptionType = 'failed' | 'stuck' | 'quality' | 'other';
|
|
|
|
|
-
|
|
|
|
|
-interface ExceptionHistory {
|
|
|
|
|
- id: string;
|
|
|
|
|
- type: ExceptionType;
|
|
|
|
|
- description: string;
|
|
|
|
|
- submitTime: Date;
|
|
|
|
|
- status: '待处理' | '处理中' | '已解决';
|
|
|
|
|
- screenshotUrl?: string;
|
|
|
|
|
- resolver?: string;
|
|
|
|
|
- resolvedAt?: Date;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
+### 2.2 产品交付进度管理
|
|
|
|
|
|
|
|
-#### 5.4.2 提交异常反馈
|
|
|
|
|
|
|
+#### 2.2.1 进度跟踪服务
|
|
|
```typescript
|
|
```typescript
|
|
|
-// project-detail.ts lines 1715-1749
|
|
|
|
|
-submitExceptionFeedback(): void {
|
|
|
|
|
- if (!this.exceptionDescription.trim() || this.isSubmittingFeedback) {
|
|
|
|
|
- alert('请填写异常类型和描述');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+class ProductProgressService {
|
|
|
|
|
+ // 更新产品进度
|
|
|
|
|
+ async updateProductProgress(
|
|
|
|
|
+ productId: string,
|
|
|
|
|
+ stage: string,
|
|
|
|
|
+ progressData: ProgressUpdateData
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ const productQuery = new Parse.Query("Product");
|
|
|
|
|
+ const product = await productQuery.get(productId);
|
|
|
|
|
+
|
|
|
|
|
+ // 更新产品状态
|
|
|
|
|
+ if (progressData.status) {
|
|
|
|
|
+ product.set("status", progressData.status);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- this.isSubmittingFeedback = true;
|
|
|
|
|
|
|
+ if (progressData.stage) {
|
|
|
|
|
+ product.set("stage", progressData.stage);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // 模拟提交反馈到服务器
|
|
|
|
|
- setTimeout(() => {
|
|
|
|
|
- const newException: ExceptionHistory = {
|
|
|
|
|
- id: `exception-${Date.now()}`,
|
|
|
|
|
- type: this.exceptionType,
|
|
|
|
|
- description: this.exceptionDescription,
|
|
|
|
|
- submitTime: new Date(),
|
|
|
|
|
- status: '待处理'
|
|
|
|
|
|
|
+ // 更新产品数据
|
|
|
|
|
+ const currentData = product.get("data") || {};
|
|
|
|
|
+ const updatedData = {
|
|
|
|
|
+ ...currentData,
|
|
|
|
|
+ [`${stage}Progress`]: progressData.progress,
|
|
|
|
|
+ [`${stage}Notes`]: progressData.notes,
|
|
|
|
|
+ [`${stage}LastUpdated`]: new Date()
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
- // 添加到历史记录中
|
|
|
|
|
- this.exceptionHistories.unshift(newException);
|
|
|
|
|
-
|
|
|
|
|
- // 通知客服和技术支持
|
|
|
|
|
- this.notifyTechnicalSupport(newException);
|
|
|
|
|
|
|
+ product.set("data", updatedData);
|
|
|
|
|
+ await product.save();
|
|
|
|
|
|
|
|
- // 清空表单
|
|
|
|
|
- this.exceptionDescription = '';
|
|
|
|
|
- this.clearExceptionScreenshot();
|
|
|
|
|
- this.showExceptionForm = false;
|
|
|
|
|
-
|
|
|
|
|
- // 显示成功消息
|
|
|
|
|
- alert('异常反馈已提交,技术支持将尽快处理');
|
|
|
|
|
-
|
|
|
|
|
- this.isSubmittingFeedback = false;
|
|
|
|
|
- }, 1000);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-#### 5.4.3 异常反馈UI
|
|
|
|
|
-```html
|
|
|
|
|
-<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>
|
|
|
|
|
|
|
+ // 触发进度更新事件
|
|
|
|
|
+ this.emitProgressUpdate(productId, stage, progressData);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- <!-- 异常历史记录 -->
|
|
|
|
|
- <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>
|
|
|
|
|
|
|
+ // 批量更新产品进度
|
|
|
|
|
+ async batchUpdateProgress(
|
|
|
|
|
+ productIds: string[],
|
|
|
|
|
+ stage: string,
|
|
|
|
|
+ progressData: ProgressUpdateData
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ const productQuery = new Parse.Query("Product");
|
|
|
|
|
+ productQuery.containedIn("objectId", productIds);
|
|
|
|
|
+ const products = await productQuery.find();
|
|
|
|
|
+
|
|
|
|
|
+ for (const product of products) {
|
|
|
|
|
+ await this.updateProductProgress(product.id, stage, progressData);
|
|
|
}
|
|
}
|
|
|
- </div>
|
|
|
|
|
-</div>
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 5.5 渲染阶段完成
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 后期图片上传
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 后期阶段完成
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 触发文件选择
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 处理空间文件
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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'
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ // 计算整体项目进度
|
|
|
|
|
+ calculateOverallProjectProgress(
|
|
|
|
|
+ deliveryProcesses: ProductDeliveryProcess[]
|
|
|
|
|
+ ): ProjectProgress {
|
|
|
|
|
+ const totalProducts = deliveryProcesses.reduce((sum, process) => sum + process.products.length, 0);
|
|
|
|
|
|
|
|
- // 更新进度
|
|
|
|
|
- this.updateSpaceProgress(processId, spaceId);
|
|
|
|
|
|
|
+ if (totalProducts === 0) {
|
|
|
|
|
+ return { total: 0, byStage: {}, byProduct: {} };
|
|
|
}
|
|
}
|
|
|
- });
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-#### 7.1.3 空间文件拖拽
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 获取空间图片列表
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 空间图片删除
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 从空间中删除图片
|
|
|
|
|
-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 审核状态枚举
|
|
|
|
|
-```typescript
|
|
|
|
|
-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; // 是否已同步到客户端
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
+ const progress: ProjectProgress = {
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ byStage: {},
|
|
|
|
|
+ byProduct: {}
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
-#### 8.1.2 审核操作
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 组长审核图片
|
|
|
|
|
-reviewSpaceImage(
|
|
|
|
|
- processId: string,
|
|
|
|
|
- spaceId: string,
|
|
|
|
|
- imageId: string,
|
|
|
|
|
- status: ReviewStatus,
|
|
|
|
|
- notes?: string
|
|
|
|
|
-): void {
|
|
|
|
|
- if (!this.isTeamLeaderView()) {
|
|
|
|
|
- alert('仅组长可以审核图片');
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // 计算各阶段进度
|
|
|
|
|
+ deliveryProcesses.forEach(process => {
|
|
|
|
|
+ const stageProgress = process.products.reduce((sum, product) => {
|
|
|
|
|
+ return sum + (process.content[product.productId]?.progress || 0);
|
|
|
|
|
+ }, 0);
|
|
|
|
|
|
|
|
- const process = this.deliveryProcesses.find(p => p.id === processId);
|
|
|
|
|
- if (!process || !process.content[spaceId]) return;
|
|
|
|
|
|
|
+ progress.byStage[process.id] = Math.round(stageProgress / process.products.length);
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- const image = process.content[spaceId].images.find(img => img.id === imageId);
|
|
|
|
|
- if (!image) return;
|
|
|
|
|
|
|
+ // 计算各产品进度
|
|
|
|
|
+ deliveryProcesses.forEach(process => {
|
|
|
|
|
+ process.products.forEach(product => {
|
|
|
|
|
+ const productProgress = process.content[product.productId]?.progress || 0;
|
|
|
|
|
+ progress.byProduct[product.productId] = productProgress;
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- // 更新审核状态
|
|
|
|
|
- image.reviewStatus = status;
|
|
|
|
|
- image.reviewNotes = notes;
|
|
|
|
|
- image.reviewedBy = this.getCurrentUserName();
|
|
|
|
|
- image.reviewedAt = new Date();
|
|
|
|
|
|
|
+ // 计算总体进度
|
|
|
|
|
+ const stageSum = Object.values(progress.byStage).reduce((sum, val) => sum + val, 0);
|
|
|
|
|
+ progress.total = Math.round(stageSum / Object.keys(progress.byStage).length);
|
|
|
|
|
|
|
|
- // 如果审核通过,标记为已同步
|
|
|
|
|
- if (status === 'approved') {
|
|
|
|
|
- image.synced = true;
|
|
|
|
|
|
|
+ return progress;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- console.log(`图片审核完成: ${image.name} - ${status}`);
|
|
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-### 8.2 批量审核
|
|
|
|
|
|
|
+### 2.3 跨产品协调管理
|
|
|
|
|
|
|
|
|
|
+#### 2.3.1 产品依赖管理
|
|
|
```typescript
|
|
```typescript
|
|
|
-// 批量审核空间内所有图片
|
|
|
|
|
-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');
|
|
|
|
|
|
|
+class ProductDependencyManager {
|
|
|
|
|
+ // 分析产品间依赖关系
|
|
|
|
|
+ analyzeProductDependencies(products: Product[]): ProductDependency[] {
|
|
|
|
|
+ const dependencies: ProductDependency[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ // 基于产品类型分析依赖
|
|
|
|
|
+ for (let i = 0; i < products.length; i++) {
|
|
|
|
|
+ for (let j = i + 1; j < products.length; j++) {
|
|
|
|
|
+ const fromProduct = products[i];
|
|
|
|
|
+ const toProduct = products[j];
|
|
|
|
|
+
|
|
|
|
|
+ const dependency = this.analyzeDependency(fromProduct, toProduct);
|
|
|
|
|
+ if (dependency) {
|
|
|
|
|
+ dependencies.push(dependency);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- if (pendingImages.length === 0) {
|
|
|
|
|
- alert('没有待审核的图片');
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ return dependencies;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const confirmed = confirm(
|
|
|
|
|
- `确定要批量${status === 'approved' ? '通过' : '驳回'}${pendingImages.length}张图片吗?`
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- if (!confirmed) return;
|
|
|
|
|
|
|
+ // 分析两个产品间的依赖关系
|
|
|
|
|
+ private analyzeDependency(
|
|
|
|
|
+ fromProduct: Product,
|
|
|
|
|
+ toProduct: Product
|
|
|
|
|
+ ): ProductDependency | null {
|
|
|
|
|
+ const fromType = fromProduct.productType;
|
|
|
|
|
+ const toType = toProduct.productType;
|
|
|
|
|
+
|
|
|
|
|
+ // 定义产品类型间的依赖关系
|
|
|
|
|
+ const dependencyRules: Record<string, { dependsOn: string[]; reason: string }> = {
|
|
|
|
|
+ 'living_room': {
|
|
|
|
|
+ dependsOn: [],
|
|
|
|
|
+ reason: '客厅通常是风格参考的起点'
|
|
|
|
|
+ },
|
|
|
|
|
+ 'dining_room': {
|
|
|
|
|
+ dependsOn: ['living_room'],
|
|
|
|
|
+ reason: '餐厅通常需要与客厅风格保持一致'
|
|
|
|
|
+ },
|
|
|
|
|
+ 'kitchen': {
|
|
|
|
|
+ dependsOn: ['dining_room'],
|
|
|
|
|
+ reason: '厨房与餐厅在空间和功能上紧密相关'
|
|
|
|
|
+ },
|
|
|
|
|
+ 'bedroom': {
|
|
|
|
|
+ dependsOn: ['living_room', 'corridor'],
|
|
|
|
|
+ reason: '卧室通常需要参考客厅的整体风格'
|
|
|
|
|
+ },
|
|
|
|
|
+ 'bathroom': {
|
|
|
|
|
+ dependsOn: ['bedroom'],
|
|
|
|
|
+ reason: '卫生间与卧室在功能上紧密相关'
|
|
|
|
|
+ },
|
|
|
|
|
+ 'balcony': {
|
|
|
|
|
+ dependsOn: ['living_room', 'bedroom'],
|
|
|
|
|
+ reason: '阳台通常连接客厅或卧室'
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
- pendingImages.forEach(image => {
|
|
|
|
|
- image.reviewStatus = status;
|
|
|
|
|
- image.reviewedBy = this.getCurrentUserName();
|
|
|
|
|
- image.reviewedAt = new Date();
|
|
|
|
|
- if (status === 'approved') {
|
|
|
|
|
- image.synced = true;
|
|
|
|
|
|
|
+ const rule = dependencyRules[toType];
|
|
|
|
|
+ if (rule && rule.dependsOn.includes(fromType)) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: `${fromProduct.productId}-${toProduct.productId}`,
|
|
|
|
|
+ fromProductId: fromProduct.productId,
|
|
|
|
|
+ toProductId: toProduct.productId,
|
|
|
|
|
+ fromProductName: fromProduct.productName,
|
|
|
|
|
+ toProductName: toProduct.productName,
|
|
|
|
|
+ type: 'style_reference',
|
|
|
|
|
+ description: rule.reason,
|
|
|
|
|
+ priority: this.calculateDependencyPriority(fromType, toType),
|
|
|
|
|
+ confidence: 0.8
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- alert(`已批量审核${pendingImages.length}张图片`);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
|
|
|
-### 8.3 审核统计
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 获取空间审核统计
|
|
|
|
|
-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 };
|
|
|
|
|
|
|
+ return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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 权限检查方法
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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; // 设计师和组长可以编辑所有板块
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ // 生成协调建议
|
|
|
|
|
+ generateCoordinationSuggestions(
|
|
|
|
|
+ dependencies: ProductDependency[]
|
|
|
|
|
+ ): CoordinationSuggestion[] {
|
|
|
|
|
+ const suggestions: CoordinationSuggestion[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ dependencies.forEach(dep => {
|
|
|
|
|
+ suggestions.push({
|
|
|
|
|
+ id: `coord-${dep.id}`,
|
|
|
|
|
+ dependencyId: dep.id,
|
|
|
|
|
+ type: 'style_consistency',
|
|
|
|
|
+ title: `风格一致性建议:${dep.fromProductName} → ${dep.toProductName}`,
|
|
|
|
|
+ description: `建议${dep.toProductName}在设计时参考${dep.fromProductName}的整体风格,确保空间的协调统一`,
|
|
|
|
|
+ actions: [
|
|
|
|
|
+ `参考${dep.fromProductName}的色彩方案`,
|
|
|
|
|
+ `保持材质选择的一致性`,
|
|
|
|
|
+ `考虑空间功能的连续性`
|
|
|
|
|
+ ],
|
|
|
|
|
+ priority: dep.priority
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
-canEditStage(stage: ProjectStage): boolean {
|
|
|
|
|
- if (this.isCustomerServiceView()) {
|
|
|
|
|
- const editableStages: ProjectStage[] = [
|
|
|
|
|
- '订单分配', '需求沟通', '方案确认',
|
|
|
|
|
- '尾款结算', '客户评价', '投诉处理'
|
|
|
|
|
- ];
|
|
|
|
|
- return editableStages.includes(stage);
|
|
|
|
|
|
|
+ return suggestions;
|
|
|
}
|
|
}
|
|
|
- return true;
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 9.3 UI权限控制
|
|
|
|
|
-
|
|
|
|
|
-```html
|
|
|
|
|
-<!-- 上传按钮权限 -->
|
|
|
|
|
-@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 阶段推进流程
|
|
|
|
|
-
|
|
|
|
|
-```mermaid
|
|
|
|
|
-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 进度同步机制
|
|
|
|
|
|
|
+### 2.4 批量操作管理
|
|
|
|
|
|
|
|
|
|
+#### 2.4.1 批量操作服务
|
|
|
```typescript
|
|
```typescript
|
|
|
-// 更新空间进度后同步到项目
|
|
|
|
|
-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();
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
+class BatchOperationService {
|
|
|
|
|
+ // 批量上传文件
|
|
|
|
|
+ async batchUploadFiles(
|
|
|
|
|
+ productIds: string[],
|
|
|
|
|
+ files: File[],
|
|
|
|
|
+ fileCategory: string,
|
|
|
|
|
+ stage: string,
|
|
|
|
|
+ uploaderId: string
|
|
|
|
|
+ ): Promise<BatchUploadResult> {
|
|
|
|
|
+ const results: BatchUploadResult = {
|
|
|
|
|
+ successful: [],
|
|
|
|
|
+ failed: [],
|
|
|
|
|
+ summary: {
|
|
|
|
|
+ total: files.length,
|
|
|
|
|
+ uploaded: 0,
|
|
|
|
|
+ failed: 0
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
-### 10.3 客户端数据同步
|
|
|
|
|
|
|
+ for (const file of files) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 为每个产品上传文件
|
|
|
|
|
+ for (const productId of productIds) {
|
|
|
|
|
+ const projectFile = new Parse.Object("ProjectFile");
|
|
|
|
|
+ projectFile.set("product", { __type: "Pointer", className: "Product", objectId: productId });
|
|
|
|
|
+ projectFile.set("fileCategory", fileCategory);
|
|
|
|
|
+ projectFile.set("stage", stage);
|
|
|
|
|
+ projectFile.set("uploadedBy", { __type: "Pointer", className: "Profile", objectId: uploaderId });
|
|
|
|
|
+
|
|
|
|
|
+ // 设置文件基本信息
|
|
|
|
|
+ projectFile.set("fileName", file.name);
|
|
|
|
|
+ projectFile.set("fileSize", file.size);
|
|
|
|
|
+
|
|
|
|
|
+ // 上传文件到存储
|
|
|
|
|
+ const fileData = await this.uploadFile(file);
|
|
|
|
|
+ projectFile.set("fileUrl", fileData.url);
|
|
|
|
|
+ projectFile.set("attach", fileData.attachment);
|
|
|
|
|
+
|
|
|
|
|
+ await projectFile.save();
|
|
|
|
|
+ results.successful.push({
|
|
|
|
|
+ productId,
|
|
|
|
|
+ fileName: file.name,
|
|
|
|
|
+ fileId: projectFile.id
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 审核通过后同步到客户端
|
|
|
|
|
-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;
|
|
|
|
|
|
|
+ results.summary.uploaded++;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ results.failed.push({
|
|
|
|
|
+ fileName: file.name,
|
|
|
|
|
+ error: error.message
|
|
|
});
|
|
});
|
|
|
- console.log(`已同步${approvedImages.length}张图片到客户端`);
|
|
|
|
|
|
|
+ results.summary.failed++;
|
|
|
}
|
|
}
|
|
|
- },
|
|
|
|
|
- error: (error) => {
|
|
|
|
|
- console.error('同步图片失败:', error);
|
|
|
|
|
}
|
|
}
|
|
|
- });
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
|
|
|
-## 11. 异常处理
|
|
|
|
|
-
|
|
|
|
|
-### 11.1 文件上传失败
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 文件上传错误处理
|
|
|
|
|
-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 = '服务器错误,请稍后重试';
|
|
|
|
|
|
|
+ return results;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- alert(errorMessage);
|
|
|
|
|
- console.error('文件上传失败:', error);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
-
|
|
|
|
|
-### 11.2 4K校验失败
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 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 渲染异常处理
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 渲染超时预警
|
|
|
|
|
-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 空间操作失败
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 删除空间时的安全检查
|
|
|
|
|
-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;
|
|
|
|
|
|
|
+ // 批量更新产品状态
|
|
|
|
|
+ async batchUpdateProductStatus(
|
|
|
|
|
+ productIds: string[],
|
|
|
|
|
+ status: string,
|
|
|
|
|
+ stage: string
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ const productQuery = new Parse.Query("Product");
|
|
|
|
|
+ productQuery.containedIn("objectId", productIds);
|
|
|
|
|
+ const products = await productQuery.find();
|
|
|
|
|
+
|
|
|
|
|
+ for (const product of products) {
|
|
|
|
|
+ product.set("status", status);
|
|
|
|
|
+ product.set("stage", stage);
|
|
|
|
|
+ await product.save();
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 执行删除
|
|
|
|
|
- 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];
|
|
|
|
|
|
|
+ // 批量发送审核通知
|
|
|
|
|
+ async batchSendReviewNotifications(
|
|
|
|
|
+ productIds: string[],
|
|
|
|
|
+ stage: string,
|
|
|
|
|
+ reviewerIds: string[]
|
|
|
|
|
+ ): Promise<void> {
|
|
|
|
|
+ // 获取需要审核的产品
|
|
|
|
|
+ const productQuery = new Parse.Query("Product");
|
|
|
|
|
+ productQuery.containedIn("objectId", productIds);
|
|
|
|
|
+ productQuery.include("profile");
|
|
|
|
|
+ const products = await productQuery.find();
|
|
|
|
|
+
|
|
|
|
|
+ // 发送通知给审核人员
|
|
|
|
|
+ for (const reviewerId of reviewerIds) {
|
|
|
|
|
+ for (const product of products) {
|
|
|
|
|
+ await this.sendReviewNotification({
|
|
|
|
|
+ reviewerId,
|
|
|
|
|
+ productId: product.id,
|
|
|
|
|
+ productName: product.get("productName"),
|
|
|
|
|
+ stage,
|
|
|
|
|
+ designerId: product.get("profile")?.id
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- console.log(`已删除空间: ${space.name}`);
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-## 12. 性能优化
|
|
|
|
|
-
|
|
|
|
|
-### 12.1 Blob URL管理
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 组件销毁时清理所有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 图片懒加载
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 使用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);
|
|
|
|
|
- });
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
+## 3. 交付执行界面设计
|
|
|
|
|
|
|
|
-### 12.3 进度计算优化
|
|
|
|
|
|
|
+### 3.1 产品交付管理主界面
|
|
|
|
|
+```html
|
|
|
|
|
+<!-- 产品交付管理主界面 -->
|
|
|
|
|
+<div class="product-delivery-container">
|
|
|
|
|
+ <!-- 阶段导航 -->
|
|
|
|
|
+ <div class="stage-navigation">
|
|
|
|
|
+ <div class="stage-tabs">
|
|
|
|
|
+ <div v-for="stage in deliveryStages"
|
|
|
|
|
+ :key="stage.id"
|
|
|
|
|
+ class="stage-tab"
|
|
|
|
|
+ :class="{ active: activeStage === stage.id }"
|
|
|
|
|
+ @click="switchStage(stage.id)">
|
|
|
|
|
+ <div class="stage-icon">
|
|
|
|
|
+ <i :class="getStageIcon(stage.id)"></i>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stage-info">
|
|
|
|
|
+ <h4>{{ stage.name }}</h4>
|
|
|
|
|
+ <div class="progress-bar">
|
|
|
|
|
+ <div class="progress-fill"
|
|
|
|
|
+ :style="{ width: stage.progress + '%' }"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <span class="progress-text">{{ stage.progress }}%</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-```typescript
|
|
|
|
|
-// 使用防抖避免频繁计算
|
|
|
|
|
-private progressUpdateDebounce: any;
|
|
|
|
|
|
|
+ <!-- 批量操作工具栏 -->
|
|
|
|
|
+ <div class="batch-operations">
|
|
|
|
|
+ <div class="operation-group">
|
|
|
|
|
+ <button class="btn btn-primary"
|
|
|
|
|
+ @click="showBatchUpload = true"
|
|
|
|
|
+ :disabled="selectedProducts.length === 0">
|
|
|
|
|
+ <i class="fas fa-upload"></i>
|
|
|
|
|
+ 批量上传文件
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <button class="btn btn-secondary"
|
|
|
|
|
+ @click="showBatchStatusUpdate = true"
|
|
|
|
|
+ :disabled="selectedProducts.length === 0">
|
|
|
|
|
+ <i class="fas fa-edit"></i>
|
|
|
|
|
+ 批量更新状态
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ <button class="btn btn-info"
|
|
|
|
|
+ @click="generateCoordinationReport">
|
|
|
|
|
+ <i class="fas fa-project-diagram"></i>
|
|
|
|
|
+ 协调报告
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-private updateSpaceProgress(processId: string, spaceId: string): void {
|
|
|
|
|
- // 清除之前的定时器
|
|
|
|
|
- if (this.progressUpdateDebounce) {
|
|
|
|
|
- clearTimeout(this.progressUpdateDebounce);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ <div class="selection-info">
|
|
|
|
|
+ <span v-if="selectedProducts.length > 0">
|
|
|
|
|
+ 已选择 {{ selectedProducts.length }} 个产品
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- // 延迟300ms执行
|
|
|
|
|
- this.progressUpdateDebounce = setTimeout(() => {
|
|
|
|
|
- this.doUpdateSpaceProgress(processId, spaceId);
|
|
|
|
|
- }, 300);
|
|
|
|
|
-}
|
|
|
|
|
|
|
+ <!-- 产品列表 -->
|
|
|
|
|
+ <div class="product-delivery-section">
|
|
|
|
|
+ <h3>产品交付管理 - {{ getStageName(activeStage) }}</h3>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="product-delivery-grid">
|
|
|
|
|
+ <div v-for="product in filteredProducts"
|
|
|
|
|
+ :key="product.productId"
|
|
|
|
|
+ class="product-delivery-card"
|
|
|
|
|
+ :class="{
|
|
|
|
|
+ active: selectedProducts.includes(product.productId),
|
|
|
|
|
+ expanded: product.isExpanded,
|
|
|
|
|
+ 'status-' + product.status
|
|
|
|
|
+ }"
|
|
|
|
|
+ @click="toggleProductExpansion(product.productId)">
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 产品基本信息 -->
|
|
|
|
|
+ <div class="product-header">
|
|
|
|
|
+ <div class="product-info">
|
|
|
|
|
+ <h4>{{ product.productName }}</h4>
|
|
|
|
|
+ <span class="product-type">{{ getProductTypeLabel(product.productType) }}</span>
|
|
|
|
|
+ <div class="designer-info">
|
|
|
|
|
+ <img :src="product.designerAvatar" class="designer-avatar" />
|
|
|
|
|
+ <span>{{ product.designerName }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="product-status">
|
|
|
|
|
+ <span class="status-badge" :class="product.status">
|
|
|
|
|
+ {{ getStatusLabel(product.status) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="product-actions">
|
|
|
|
|
+ <label class="checkbox-wrapper">
|
|
|
|
|
+ <input type="checkbox"
|
|
|
|
|
+ :value="product.productId"
|
|
|
|
|
+ v-model="selectedProducts"
|
|
|
|
|
+ @click.stop>
|
|
|
|
|
+ <span class="checkmark"></span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-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';
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ <!-- 产品进度信息 -->
|
|
|
|
|
+ <div class="product-progress">
|
|
|
|
|
+ <div class="progress-stats">
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <label>进度</label>
|
|
|
|
|
+ <span class="progress-value">{{ product.content.progress }}%</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <label>文件</label>
|
|
|
|
|
+ <span class="file-count">{{ product.content.files.length }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <label>工时</label>
|
|
|
|
|
+ <span class="hours">{{ product.actualHours }}/{{ product.estimatedHours }}h</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="progress-bar">
|
|
|
|
|
+ <div class="progress-fill"
|
|
|
|
|
+ :style="{ width: product.content.progress + '%' }"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- content.lastUpdated = new Date();
|
|
|
|
|
|
|
+ <!-- 产品报价信息 -->
|
|
|
|
|
+ <div class="product-quotation" v-if="product.quotation">
|
|
|
|
|
+ <div class="quotation-header">
|
|
|
|
|
+ <span class="quotation-price">¥{{ product.quotation.price.toLocaleString() }}</span>
|
|
|
|
|
+ <span class="quotation-status" :class="product.quotation.status">
|
|
|
|
|
+ {{ getQuotationStatusLabel(product.quotation.status) }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="quotation-breakdown">
|
|
|
|
|
+ <div v-for="(item, key) in product.quotation.breakdown"
|
|
|
|
|
+ :key="key"
|
|
|
|
|
+ class="breakdown-item">
|
|
|
|
|
+ <span class="breakdown-type">{{ getBreakdownTypeLabel(key) }}:</span>
|
|
|
|
|
+ <span class="breakdown-amount">¥{{ item.toLocaleString() }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- // 同步到项目
|
|
|
|
|
- this.syncProgressToProject(processId);
|
|
|
|
|
-}
|
|
|
|
|
-```
|
|
|
|
|
|
|
+ <!-- 展开的详细内容 -->
|
|
|
|
|
+ <div v-if="product.isExpanded" class="product-details">
|
|
|
|
|
+ <!-- 文件管理 -->
|
|
|
|
|
+ <div class="file-management">
|
|
|
|
|
+ <h5>交付文件</h5>
|
|
|
|
|
+ <div class="file-list">
|
|
|
|
|
+ <div v-for="file in product.content.files"
|
|
|
|
|
+ :key="file.id"
|
|
|
|
|
+ class="file-item"
|
|
|
|
|
+ :class="'status-' + file.reviewStatus">
|
|
|
|
|
+ <div class="file-info">
|
|
|
|
|
+ <i :class="getFileIcon(file.name)"></i>
|
|
|
|
|
+ <div class="file-details">
|
|
|
|
|
+ <span class="file-name">{{ file.name }}</span>
|
|
|
|
|
+ <span class="file-size">{{ file.size }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="file-actions">
|
|
|
|
|
+ <button class="btn-sm"
|
|
|
|
|
+ @click="previewFile(file)"
|
|
|
|
|
+ title="预览">
|
|
|
|
|
+ <i class="fas fa-eye"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn-sm"
|
|
|
|
|
+ @click="downloadFile(file)"
|
|
|
|
|
+ title="下载">
|
|
|
|
|
+ <i class="fas fa-download"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn-sm btn-success"
|
|
|
|
|
+ v-if="file.reviewStatus === 'pending'"
|
|
|
|
|
+ @click="approveFile(file)"
|
|
|
|
|
+ title="审核通过">
|
|
|
|
|
+ <i class="fas fa-check"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 文件上传按钮 -->
|
|
|
|
|
+ <div class="file-upload">
|
|
|
|
|
+ <button class="btn btn-sm btn-outline"
|
|
|
|
|
+ @click="showFileUpload = true">
|
|
|
|
|
+ <i class="fas fa-plus"></i>
|
|
|
|
|
+ 上传文件
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 产品特定字段 -->
|
|
|
|
|
+ <div class="product-specific-fields">
|
|
|
|
|
+ <h5>产品特定信息</h5>
|
|
|
|
|
+ <div class="specific-fields-grid">
|
|
|
|
|
+ <div v-for="(value, key) in product.content.productSpecific"
|
|
|
|
|
+ :key="key"
|
|
|
|
|
+ class="field-item">
|
|
|
|
|
+ <label>{{ getFieldLabel(key) }}:</label>
|
|
|
|
|
+ <span>{{ formatFieldValue(key, value) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 备注和日志 -->
|
|
|
|
|
+ <div class="notes-section">
|
|
|
|
|
+ <h5>备注信息</h5>
|
|
|
|
|
+ <textarea class="notes-textarea"
|
|
|
|
|
+ v-model="product.content.notes"
|
|
|
|
|
+ @blur="updateProductNotes(product.productId)"
|
|
|
|
|
+ placeholder="添加备注信息..."></textarea>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="update-log">
|
|
|
|
|
+ <small>最后更新: {{ formatDateTime(product.content.lastUpdated) }}</small>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-## 13. 测试用例
|
|
|
|
|
|
|
+ <!-- 协调报告 -->
|
|
|
|
|
+ <div class="coordination-report" v-if="coordinationReport">
|
|
|
|
|
+ <h3>产品协调报告</h3>
|
|
|
|
|
+ <div class="report-summary">
|
|
|
|
|
+ <div class="summary-item">
|
|
|
|
|
+ <label>依赖关系:</label>
|
|
|
|
|
+ <span>{{ coordinationReport.dependencies.length }}个</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="summary-item">
|
|
|
|
|
+ <label>协调建议:</label>
|
|
|
|
|
+ <span>{{ coordinationReport.suggestions.length }}个</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-### 13.1 空间管理测试
|
|
|
|
|
|
|
+ <div class="report-details">
|
|
|
|
|
+ <!-- 依赖关系 -->
|
|
|
|
|
+ <div class="dependencies-section">
|
|
|
|
|
+ <h4>产品依赖关系</h4>
|
|
|
|
|
+ <div class="dependency-list">
|
|
|
|
|
+ <div v-for="dep in coordinationReport.dependencies"
|
|
|
|
|
+ :key="dep.id"
|
|
|
|
|
+ class="dependency-item">
|
|
|
|
|
+ <div class="dependency-arrow">
|
|
|
|
|
+ <i class="fas fa-arrow-right"></i>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="dependency-content">
|
|
|
|
|
+ <span class="from-product">{{ dep.fromProductName }}</span>
|
|
|
|
|
+ <span class="dependency-type">→</span>
|
|
|
|
|
+ <span class="to-product">{{ dep.toProductName }}</span>
|
|
|
|
|
+ <div class="dependency-description">{{ dep.description }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-```typescript
|
|
|
|
|
-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);
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
|
|
+ <!-- 协调建议 -->
|
|
|
|
|
+ <div class="suggestions-section">
|
|
|
|
|
+ <h4>协调建议</h4>
|
|
|
|
|
+ <div class="suggestion-list">
|
|
|
|
|
+ <div v-for="suggestion in coordinationReport.suggestions"
|
|
|
|
|
+ :key="suggestion.id"
|
|
|
|
|
+ class="suggestion-item"
|
|
|
|
|
+ :class="'priority-' + suggestion.priority">
|
|
|
|
|
+ <div class="suggestion-header">
|
|
|
|
|
+ <i class="fas fa-lightbulb"></i>
|
|
|
|
|
+ <span class="suggestion-title">{{ suggestion.title }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="suggestion-description">{{ suggestion.description }}</div>
|
|
|
|
|
+ <div class="suggestion-actions">
|
|
|
|
|
+ <span v-for="action in suggestion.actions" :key="action" class="action-item">
|
|
|
|
|
+ • {{ action }}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-### 13.2 文件上传测试
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-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));
|
|
|
|
|
|
|
+### 3.2 批量操作界面
|
|
|
|
|
+```html
|
|
|
|
|
+<!-- 批量上传文件弹窗 -->
|
|
|
|
|
+<div v-if="showBatchUpload" class="modal-overlay" @click="showBatchUpload = false">
|
|
|
|
|
+ <div class="modal-content" @click.stop>
|
|
|
|
|
+ <div class="modal-header">
|
|
|
|
|
+ <h3>批量上传文件</h3>
|
|
|
|
|
+ <button class="close-button" @click="showBatchUpload = false">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- await component.onRenderLargePicsSelected({
|
|
|
|
|
- target: { files: [file] }
|
|
|
|
|
- } as any);
|
|
|
|
|
|
|
+ <div class="modal-body">
|
|
|
|
|
+ <div class="upload-area"
|
|
|
|
|
+ @dragover.prevent="onDragOver"
|
|
|
|
|
+ @drop.prevent="onDrop"
|
|
|
|
|
+ :class="{ 'drag-over': isDragOver }">
|
|
|
|
|
+ <i class="fas fa-cloud-upload-alt upload-icon"></i>
|
|
|
|
|
+ <p>拖拽文件到此处或点击选择文件</p>
|
|
|
|
|
+ <input type="file"
|
|
|
|
|
+ multiple
|
|
|
|
|
+ ref="fileInput"
|
|
|
|
|
+ @change="onFileSelect"
|
|
|
|
|
+ style="display: none;">
|
|
|
|
|
+ <button class="btn btn-primary"
|
|
|
|
|
+ @click="$refs.fileInput.click()">
|
|
|
|
|
+ 选择文件
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- expect(component.renderLargeImages.length).toBeGreaterThan(0);
|
|
|
|
|
- expect(component.renderLargeImages[0].locked).toBe(true);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ <div class="file-list" v-if="selectedFiles.length > 0">
|
|
|
|
|
+ <h4>选择的文件 ({{ selectedFiles.length }})</h4>
|
|
|
|
|
+ <div class="file-items">
|
|
|
|
|
+ <div v-for="(file, index) in selectedFiles"
|
|
|
|
|
+ :key="index"
|
|
|
|
|
+ class="file-item">
|
|
|
|
|
+ <div class="file-info">
|
|
|
|
|
+ <i :class="getFileIcon(file.name)"></i>
|
|
|
|
|
+ <span class="file-name">{{ file.name }}</span>
|
|
|
|
|
+ <span class="file-size">{{ formatFileSize(file.size) }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button class="btn-sm btn-danger"
|
|
|
|
|
+ @click="removeFile(index)">
|
|
|
|
|
+ <i class="fas fa-times"></i>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- it('should reject non-4K images', async () => {
|
|
|
|
|
- const file = new File([''], 'small.jpg', { type: 'image/jpeg' });
|
|
|
|
|
|
|
+ <div class="upload-options">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>文件分类:</label>
|
|
|
|
|
+ <select v-model="batchUploadOptions.fileCategory">
|
|
|
|
|
+ <option value="delivery">交付物文件</option>
|
|
|
|
|
+ <option value="reference">参考文件</option>
|
|
|
|
|
+ <option value="document">文档资料</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- spyOn<any>(component, 'validateImage4K').and.returnValue(Promise.resolve(false));
|
|
|
|
|
- spyOn(window, 'alert');
|
|
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>目标阶段:</label>
|
|
|
|
|
+ <select v-model="batchUploadOptions.stage">
|
|
|
|
|
+ <option value="modeling">建模</option>
|
|
|
|
|
+ <option value="softDecor">软装</option>
|
|
|
|
|
+ <option value="rendering">渲染</option>
|
|
|
|
|
+ <option value="postProcess">后期</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- await component.onRenderLargePicsSelected({
|
|
|
|
|
- target: { files: [file] }
|
|
|
|
|
- } as any);
|
|
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>目标产品:</label>
|
|
|
|
|
+ <select v-model="batchUploadOptions.targetProducts" multiple>
|
|
|
|
|
+ <option v-for="product in products"
|
|
|
|
|
+ :key="product.productId"
|
|
|
|
|
+ :value="product.productId">
|
|
|
|
|
+ {{ product.productName }}
|
|
|
|
|
+ </option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- expect(window.alert).toHaveBeenCalledWith(jasmine.stringContaining('不符合4K标准'));
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ <div class="modal-footer">
|
|
|
|
|
+ <button class="btn btn-secondary"
|
|
|
|
|
+ @click="showBatchUpload = false">
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn btn-primary"
|
|
|
|
|
+ :disabled="selectedFiles.length === 0 || batchUploadOptions.targetProducts.length === 0"
|
|
|
|
|
+ @click="executeBatchUpload">
|
|
|
|
|
+ 开始上传
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
|
|
|
|
|
- it('should handle soft decor upload with size warning', () => {
|
|
|
|
|
- const largeFile = new File(['x'.repeat(2 * 1024 * 1024)], 'large.jpg', { type: 'image/jpeg' });
|
|
|
|
|
|
|
+<!-- 批量状态更新弹窗 -->
|
|
|
|
|
+<div v-if="showBatchStatusUpdate" class="modal-overlay" @click="showBatchStatusUpdate = false">
|
|
|
|
|
+ <div class="modal-content" @click.stop>
|
|
|
|
|
+ <div class="modal-header">
|
|
|
|
|
+ <h3>批量更新状态</h3>
|
|
|
|
|
+ <button class="close-button" @click="showBatchStatusUpdate = false">×</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- spyOn(console, 'warn');
|
|
|
|
|
|
|
+ <div class="modal-body">
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>更新状态:</label>
|
|
|
|
|
+ <select v-model="batchStatusOptions.status">
|
|
|
|
|
+ <option value="not_started">未开始</option>
|
|
|
|
|
+ <option value="in_progress">进行中</option>
|
|
|
|
|
+ <option value="awaiting_review">待审核</option>
|
|
|
|
|
+ <option value="completed">已完成</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- component.onSoftDecorSmallPicsSelected({
|
|
|
|
|
- target: { files: [largeFile] }
|
|
|
|
|
- } as any);
|
|
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>更新阶段:</label>
|
|
|
|
|
+ <select v-model="batchStatusOptions.stage">
|
|
|
|
|
+ <option value="modeling">建模</option>
|
|
|
|
|
+ <option value="softDecor">软装</option>
|
|
|
|
|
+ <option value="rendering">渲染</option>
|
|
|
|
|
+ <option value="postProcess">后期</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
- expect(console.warn).toHaveBeenCalled();
|
|
|
|
|
- expect(component.softDecorImages.length).toBeGreaterThan(0);
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
|
|
+ <div class="form-group">
|
|
|
|
|
+ <label>备注:</label>
|
|
|
|
|
+ <textarea v-model="batchStatusOptions.notes"
|
|
|
|
|
+ placeholder="批量更新备注..."
|
|
|
|
|
+ rows="3"></textarea>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-### 13.3 进度更新测试
|
|
|
|
|
|
|
+ <div class="affected-products">
|
|
|
|
|
+ <h4>将更新的产品 ({{ selectedProducts.length }})</h4>
|
|
|
|
|
+ <div class="product-list">
|
|
|
|
|
+ <div v-for="product in selectedProducts"
|
|
|
|
|
+ :key="product.productId"
|
|
|
|
|
+ class="affected-product">
|
|
|
|
|
+ <span class="product-name">{{ product.productName }}</span>
|
|
|
|
|
+ <span class="product-type">{{ product.productType }}</span>
|
|
|
|
|
+ <span class="current-status">{{ product.status }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
|
|
|
-```typescript
|
|
|
|
|
-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');
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
|
|
+ <div class="modal-footer">
|
|
|
|
|
+ <button class="btn btn-secondary"
|
|
|
|
|
+ @click="showBatchStatusUpdate = false">
|
|
|
|
|
+ 取消
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <button class="btn btn-primary"
|
|
|
|
|
+ @click="executeBatchStatusUpdate">
|
|
|
|
|
+ 确认更新
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</div>
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-### 13.4 阶段推进测试
|
|
|
|
|
-
|
|
|
|
|
-```typescript
|
|
|
|
|
-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' }
|
|
|
|
|
- ];
|
|
|
|
|
- }
|
|
|
|
|
|
|
+## 4. 技术实现要点
|
|
|
|
|
|
|
|
- component.currentStage = '建模';
|
|
|
|
|
- component.confirmWhiteModelUpload();
|
|
|
|
|
|
|
+### 4.1 性能优化
|
|
|
|
|
+- **懒加载**:按需加载产品详情和文件列表
|
|
|
|
|
+- **虚拟滚动**:处理大量产品时的性能问题
|
|
|
|
|
+- **缓存机制**:缓存产品状态和进度数据
|
|
|
|
|
|
|
|
- expect(component.currentStage).toBe('软装');
|
|
|
|
|
- expect(component.expandedStages['软装']).toBe(true);
|
|
|
|
|
- expect(component.expandedStages['建模']).toBe(false);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+### 4.2 用户体验优化
|
|
|
|
|
+- **拖拽上传**:支持文件拖拽批量上传
|
|
|
|
|
+- **实时同步**:进度更新实时推送到界面
|
|
|
|
|
+- **离线支持**:基本的离线操作支持
|
|
|
|
|
|
|
|
- it('should not advance without images', () => {
|
|
|
|
|
- component.currentStage = '建模';
|
|
|
|
|
- const initialStage = component.currentStage;
|
|
|
|
|
-
|
|
|
|
|
- component.confirmWhiteModelUpload();
|
|
|
|
|
-
|
|
|
|
|
- expect(component.currentStage).toBe(initialStage);
|
|
|
|
|
- });
|
|
|
|
|
-});
|
|
|
|
|
-```
|
|
|
|
|
|
|
+### 4.3 数据一致性
|
|
|
|
|
+- **事务处理**:确保批量操作的数据一致性
|
|
|
|
|
+- **冲突检测**:检测并发修改冲突
|
|
|
|
|
+- **版本控制**:文件版本管理和回滚
|
|
|
|
|
|
|
|
---
|
|
---
|
|
|
|
|
|
|
|
-**文档版本**:v1.0.0
|
|
|
|
|
-**创建日期**:2025-10-16
|
|
|
|
|
-**最后更新**:2025-10-16
|
|
|
|
|
-**维护人**:产品团队
|
|
|
|
|
|
|
+**文档版本**: v3.0 (Product表统一空间管理)
|
|
|
|
|
+**最后更新**: 2025-10-20
|
|
|
|
|
+**维护者**: YSS Development Team
|