交付执行阶段是项目管理流程的核心执行环节,包含建模、软装、渲染、后期四个连续子阶段。该阶段负责将设计方案转化为可交付的视觉成果,是项目价值实现的关键环节。
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
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; // 最后更新时间
}
// 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() }
}
},
// 软装、渲染、后期流程结构相同
];
// 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>
}
// 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}`);
}
}
// 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;
}
}
// 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();
}
进度规则:
// 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;
}
<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>
// 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);
}
// 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) };
}
// 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`;
}
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: '' }
];
<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>
// 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('建模');
}
// 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();
}
}
// 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 = '';
}
文件大小校验:
// 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>
// 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;
}
@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>
}
// 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('软装');
}
// 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;
});
}
// 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 = '';
}
校验规则:
// 渲染大图默认加锁
renderLargeImages: Array<{
id: string;
name: string;
url: string;
size?: string;
locked?: boolean; // 加锁标记
reviewStatus?: 'pending' | 'approved' | 'rejected';
synced?: boolean;
}> = [];
加锁规则:
// 尾款到账后自动解锁
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('尾款已到账,渲染大图已解锁!客服可发送给客户。');
}
type ExceptionType = 'failed' | 'stuck' | 'quality' | 'other';
interface ExceptionHistory {
id: string;
type: ExceptionType;
description: string;
submitTime: Date;
status: '待处理' | '处理中' | '已解决';
screenshotUrl?: string;
resolver?: string;
resolvedAt?: Date;
}
// 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);
}
<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>
// 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('渲染');
}
// 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);
}
常见后期处理任务:
// 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('后期');
}
// 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();
}
}
// 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);
}
});
}
// 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);
}
// 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 || [];
}
// 从空间中删除图片
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);
}
}
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; // 是否已同步到客户端
}
// 组长审核图片
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}`);
}
// 批量审核空间内所有图片
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}张图片`);
}
// 获取空间审核统计
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
};
}
操作 | 设计师 | 渲染师 | 组长 | 技术 |
---|---|---|---|---|
查看交付执行板块 | ✅ | ✅ | ✅ | ✅ |
上传建模图片 | ✅ | ❌ | ✅ | ❌ |
上传软装图片 | ✅ | ❌ | ✅ | ❌ |
上传渲染图片 | ❌ | ✅ | ✅ | ❌ |
上传后期图片 | ✅ | ❌ | ✅ | ❌ |
添加/删除空间 | ✅ | ✅ | ✅ | ❌ |
审核图片 | ❌ | ❌ | ✅ | ❌ |
确认阶段完成 | ✅ | ✅ | ✅ | ❌ |
报告渲染异常 | ❌ | ✅ | ✅ | ❌ |
最终验收 | ❌ | ❌ | ❌ | ✅ |
// 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;
}
<!-- 上传按钮权限 -->
@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>
}
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
// 更新空间进度后同步到项目
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();
}
// 审核通过后同步到客户端
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);
}
});
}
// 文件上传错误处理
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);
}
// 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);
}
// 渲染超时预警
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');
}
}
// 删除空间时的安全检查
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}`);
}
}
// 组件销毁时清理所有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`);
}
// 使用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);
});
}
// 使用防抖避免频繁计算
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);
}
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);
});
});
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);
});
});
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');
});
});
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 维护人:产品团队