瀏覽代碼

feat: requirements

ryanemax 1 天之前
父節點
當前提交
1ebe0a594d

+ 1 - 0
docs/task/2025102104-aftercare.md

@@ -0,0 +1 @@
+请您参考docs/prd/项目-售后归档.md,帮我设计完善,再开发完整的src/modules/project/pages/project-detail/stages/stage-aftercare.component.ts scss html相关功能,其中存储使用src/modules/project/services/project-file.service.ts. 项目ProjectPayment支付是整体的,但是评价不同Product场景空间是分开的. 您可以自主设计并开发,确保最终功能完整,体验交互好,界面美观适合移动端

+ 8 - 0
docs/task/2025102104-delivery.md

@@ -0,0 +1,8 @@
+请您参考docs/prd/项目-交付执行.md,帮我设计完善,再开发完整的src/modules/project/pages/project-detail/stages/stage-aftercare.component.ts scss html相关功能
+
+其中存储使用src/modules/project/services/project-file.service.ts
+数据持久化参考./rules/schemas.md,使用FmodeParse
+参考相关ProjectFile等表
+多个场景Product,对应交付的白模、软装、渲染、后期是分开的
+
+您可以自主设计并开发,确保最终功能完整,体验交互好,界面美观适合移动端

+ 1 - 0
package.json

@@ -70,6 +70,7 @@
     "eventemitter3": "^5.0.1",
     "fmode-ng": "^0.0.222",
     "highlight.js": "^11.11.1",
+    "ionicons": "^8.0.13",
     "jquery": "^3.7.1",
     "markdown-it": "^14.1.0",
     "markdown-it-abbr": "^1.0.4",

+ 1 - 1
src/modules/project/components/color-get/color-get-dialog.component.scss

@@ -36,7 +36,7 @@
   grid-template-columns: 1fr 1fr;
   gap: 16px;
 
-  @media (max-width: 768px) {
+  @media (max-width: 1400px) {
     grid-template-columns: 1fr;
   }
 }

+ 1 - 1
src/modules/project/components/project-files-modal/project-files-modal.component.ts

@@ -93,7 +93,7 @@ export class ProjectFilesModalComponent implements OnInit {
         type: attach.get('mime') || '',
         mime: attach.get('mime') || '',
         size: attach.get('size') || 0,
-        uploadedBy: file.get('uploadedBy')?.toJSON(),
+        uploadedBy: file.get('uploadedBy')?.toJSON?.() || '',
         uploadedAt: file.get('uploadedAt') || file.createdAt,
         source: attach.get('source') || 'unknown',
         md5: attach.get('md5'),

+ 29 - 160
src/modules/project/pages/project-detail/stages/stage-requirements.component.html

@@ -44,12 +44,6 @@
             (click)="selectRequirementsSegment('spaces')">
             产品需求
           </button>
-          <button
-            class="segment-btn"
-            [class.active]="requirementsSegment == 'cross-space'"
-            (click)="selectRequirementsSegment('cross-space')">
-            跨产品协调
-          </button>
         }
       </div>
     </div>
@@ -64,7 +58,7 @@
               <span class="icon">📷</span>
               参考图片
             </h3>
-            <p class="card-subtitle">上传风格、空间或材质参考图</p>
+            <p class="card-subtitle">上传风格、空间或材质参考图,点击图片可查看色彩分析</p>
             <div class="ai-analysis-actions">
               @if (referenceImages.length > 0 && canEdit) {
                 <button
@@ -153,27 +147,37 @@
             <div class="images-grid">
               @for (image of getFilteredReferenceImages(); track image.id) {
                 <div class="image-item">
-                  <img [src]="image.url" [alt]="image.name" />
+                  <img [src]="image.url" [alt]="image.name" (click)="viewImageColorAnalysis(image.id)" />
                   <div class="image-overlay">
-                    <span class="badge" [class]="getImageTypeBadgeClass(image.type)">
-                      {{ getImageTypeLabel(image.type) }}
-                    </span>
-                    @if (image.spaceId) {
-                      <span class="badge badge-outline">{{ getProductDisplayNameById(image.spaceId || '') }}</span>
-                    }
-                    @if (hasImageAnalysis(image.id)) {
-                      <span class="badge badge-success">
-                        <ion-icon name="sparkles"></ion-icon>
-                        已分析
+                    <div class="overlay-top">
+                      <span class="badge" [class]="getImageTypeBadgeClass(image.type)">
+                        {{ getImageTypeLabel(image.type) }}
                       </span>
-                    }
-                    @if (canEdit) {
+                      @if (image.spaceId) {
+                        <span class="badge badge-outline">{{ getProductDisplayNameById(image.spaceId || '') }}</span>
+                      }
+                      @if (hasImageAnalysis(image.id)) {
+                        <span class="badge badge-success">
+                          <ion-icon name="sparkles"></ion-icon>
+                          已分析
+                        </span>
+                      }
+                    </div>
+                    <div class="overlay-actions">
                       <button
-                        class="btn-icon btn-danger"
-                        (click)="deleteReferenceImage(image.id)">
-                        <ion-icon name="trash"></ion-icon>
+                        class="btn-icon btn-primary"
+                        (click)="viewImageColorAnalysis(image.id); $event.stopPropagation()"
+                        title="查看色彩分析">
+                        <ion-icon name="color-palette"></ion-icon>
                       </button>
-                    }
+                      @if (canEdit) {
+                        <button
+                          class="btn-icon btn-danger"
+                          (click)="deleteReferenceImage(image.id); $event.stopPropagation()">
+                          <ion-icon name="trash"></ion-icon>
+                        </button>
+                      }
+                    </div>
                   </div>
                 </div>
               }
@@ -430,32 +434,6 @@
               </div>
             </div>
 
-            <!-- 预算范围 -->
-            <div class="budget-range">
-              <h4>预算范围(万元)</h4>
-              <div class="budget-inputs">
-                <div class="form-group">
-                  <label class="form-label">最低</label>
-                  <input
-                    type="number"
-                    class="form-input"
-                    [(ngModel)]="globalRequirements.overallBudget.min"
-                    [disabled]="!canEdit"
-                    placeholder="0" />
-                </div>
-                <span class="separator">-</span>
-                <div class="form-group">
-                  <label class="form-label">最高</label>
-                  <input
-                    type="number"
-                    class="form-input"
-                    [(ngModel)]="globalRequirements.overallBudget.max"
-                    [disabled]="!canEdit"
-                    placeholder="0" />
-                </div>
-              </div>
-            </div>
-
             <!-- 质量等级 -->
             <div class="quality-level">
               <label class="form-label">质量等级</label>
@@ -573,57 +551,7 @@
       </div>
     }
 
-    <!-- 跨空间协调需求 -->
-    @if (requirementsSegment == 'cross-space' && isMultiProductProject) {
-      <div class="cross-space-requirements">
-        <div class="card">
-          <div class="card-header">
-            <h3 class="card-title">
-              <ion-icon name="links"></ion-icon>
-              跨空间协调需求
-            </h3>
-            <button class="btn btn-outline btn-sm" (click)="createCrossProductRequirement()">
-              <ion-icon name="add"></ion-icon>
-              添加协调需求
-            </button>
-          </div>
-          <div class="card-content">
-            @if (crossSpaceRequirements.length == 0) {
-              <div class="empty-state">
-                <ion-icon name="links-outline" class="icon-large"></ion-icon>
-                <p>暂无跨空间协调需求</p>
-              </div>
-            } @else {
-              <div class="cross-space-list">
-                @for (requirement of crossSpaceRequirements; track requirement.id) {
-                  <div class="cross-space-item">
-                    <div class="item-header">
-                      <span class="badge badge-primary">{{ getCrossSpaceRequirementTypeName(requirement.type) }}</span>
-                      <button
-                        class="btn-icon btn-danger"
-                        (click)="deleteCrossProductRequirement(requirement.id)">
-                        <ion-icon name="trash"></ion-icon>
-                      </button>
-                    </div>
-                    <p class="description">{{ requirement.description }}</p>
-                    <div class="related-spaces">
-                      <span class="label">涉及空间:</span>
-                      <div class="space-tags">
-                        @for (spaceId of getRelatedSpaceIds(requirement); track spaceId) {
-                          <span class="badge badge-outline">
-                            {{ getProductDisplayNameById(spaceId) }}
-                          </span>
-                        }
-                      </div>
-                    </div>
-                  </div>
-                }
-              </div>
-            }
-          </div>
-        </div>
-      </div>
-    }
+
 
     <!-- 综合AI分析 -->
     @if (aiAnalysisResults.comprehensiveAnalysis) {
@@ -785,17 +713,6 @@
                   </div>
                   <p class="style-desc">{{ space.styleDescription }}</p>
 
-                  <div class="solution-details">
-                    <div class="detail-item">
-                      <span class="label">预估造价:</span>
-                      <span class="value">¥{{ space.estimatedCost }}</span>
-                    </div>
-                    <div class="detail-item">
-                      <span class="label">工期:</span>
-                      <span class="value">{{ space.timeline }}</span>
-                    </div>
-                  </div>
-
                   <div class="color-palette">
                     <span class="label">色彩搭配:</span>
                     <div class="colors">
@@ -826,54 +743,6 @@
               }
             </div>
 
-            <!-- 跨空间协调方案 -->
-            @if (aiSolution.crossSpaceCoordination && isMultiProductProject) {
-              <div class="cross-space-coordination">
-                <h4>跨空间协调方案</h4>
-                <div class="coordination-items">
-                  <div class="coordination-item">
-                    <span class="icon">🎨</span>
-                    <div>
-                      <h5>风格统一</h5>
-                      <p>{{ aiSolution.crossSpaceCoordination.styleConsistency.description }}</p>
-                    </div>
-                  </div>
-                  <div class="coordination-item">
-                    <ion-icon name="git-network"></ion-icon>
-                    <div>
-                      <h5>功能流线</h5>
-                      <p>{{ aiSolution.crossSpaceCoordination.functionalFlow.description }}</p>
-                    </div>
-                  </div>
-                  <div class="coordination-item">
-                    <ion-icon name="time"></ion-icon>
-                    <div>
-                      <h5>时间协调</h5>
-                      <p>{{ aiSolution.crossSpaceCoordination.timelineCoordination.strategy }}</p>
-                    </div>
-                  </div>
-                </div>
-              </div>
-            }
-
-            <!-- 预算与时间线 -->
-            <div class="summary">
-              <div class="summary-item">
-                <ion-icon name="cash"></ion-icon>
-                <div>
-                  <p class="label">预估总造价</p>
-                  <h3>¥{{ aiSolution.estimatedCost }}</h3>
-                </div>
-              </div>
-
-              <div class="summary-item">
-                <ion-icon name="time"></ion-icon>
-                <div>
-                  <p class="label">项目周期</p>
-                  <p>{{ aiSolution.timeline }}</p>
-                </div>
-              </div>
-            </div>
           </div>
         }
       </div>

+ 63 - 17
src/modules/project/pages/project-detail/stages/stage-requirements.component.scss

@@ -845,11 +845,22 @@
         overflow: hidden;
         cursor: pointer;
         box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+        transition: transform 0.2s, box-shadow 0.2s;
+
+        &:hover {
+          transform: translateY(-2px);
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+        }
 
         img {
           width: 100%;
           height: 100%;
           object-fit: cover;
+          transition: transform 0.3s;
+        }
+
+        &:hover img {
+          transform: scale(1.05);
         }
 
         .image-overlay {
@@ -858,7 +869,7 @@
           left: 0;
           right: 0;
           bottom: 0;
-          background: linear-gradient(to bottom, rgba(0, 0, 0, 0.3), transparent);
+          background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.7));
           display: flex;
           flex-direction: column;
           justify-content: space-between;
@@ -866,16 +877,41 @@
           opacity: 0;
           transition: opacity 0.3s;
 
-          .badge {
-            height: fit-content;
-            font-size: 10px;
-            padding: 4px 8px;
+          .overlay-top {
+            display: flex;
+            flex-wrap: wrap;
+            gap: 4px;
+            align-items: flex-start;
+
+            .badge {
+              height: fit-content;
+              font-size: 10px;
+              padding: 4px 8px;
+            }
           }
 
-          .btn-icon {
-            width: 32px;
-            height: 32px;
-            padding: 4px;
+          .overlay-actions {
+            display: flex;
+            gap: 6px;
+            justify-content: flex-end;
+            align-items: center;
+
+            .btn-icon {
+              width: 32px;
+              height: 32px;
+              padding: 4px;
+              backdrop-filter: blur(4px);
+
+              &.btn-primary {
+                background: rgba(var(--primary-rgb), 0.9);
+                color: white;
+
+                &:hover:not(:disabled) {
+                  background: var(--primary-color);
+                  transform: scale(1.1);
+                }
+              }
+            }
           }
         }
 
@@ -1680,20 +1716,30 @@
         gap: 8px;
 
         .image-item {
+          // 在移动端始终显示overlay,便于点击操作
           .image-overlay {
+            opacity: 1;
+            background: linear-gradient(to bottom, rgba(0, 0, 0, 0.4), transparent, rgba(0, 0, 0, 0.6));
             padding: 6px;
 
-            .badge {
-              font-size: 9px;
-              padding: 3px 6px;
+            .overlay-top {
+              .badge {
+                font-size: 9px;
+                padding: 3px 6px;
+              }
             }
 
-            .btn-icon {
-              width: 28px;
-              height: 28px;
+            .overlay-actions {
+              gap: 4px;
 
-              .icon, .space-icon {
-                font-size: 20px;
+              .btn-icon {
+                width: 28px;
+                height: 28px;
+                padding: 3px;
+
+                .icon, .space-icon {
+                  font-size: 18px;
+                }
               }
             }
           }

+ 152 - 25
src/modules/project/pages/project-detail/stages/stage-requirements.component.ts

@@ -3,9 +3,17 @@ import { CommonModule } from '@angular/common';
 import { FormsModule, ReactiveFormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
 import { IonIcon } from '@ionic/angular/standalone';
+import { MatDialog } from '@angular/material/dialog';
 import { ProductSpaceService, Project } from '../../../services/product-space.service';
+import { ProjectFileService } from '../../../services/project-file.service';
+import { ColorGetDialogComponent } from '../../../components/color-get/color-get-dialog.component';
 import { completionJSON } from 'fmode-ng/core';
+import { addIcons } from 'ionicons';
+import { add, colorPalette, sparkles, trash } from 'ionicons/icons';
 
+addIcons({
+  add,sparkles,colorPalette,trash
+})
 /**
  * 确认需求阶段组件 - Product表统一空间管理
  */
@@ -13,6 +21,7 @@ import { completionJSON } from 'fmode-ng/core';
   selector: 'app-stage-requirements',
   standalone: true,
   imports: [CommonModule, FormsModule, ReactiveFormsModule, IonIcon],
+  providers: [],
   templateUrl: './stage-requirements.component.html',
   styleUrls: ['./stage-requirements.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush
@@ -76,6 +85,7 @@ export class StageRequirementsComponent implements OnInit {
     description?: string;
     spaceId?: string;
     tags: string[];
+    projectFile?: any; // ProjectFile对象引用
   }> = [];
 
   // CAD文件
@@ -86,6 +96,7 @@ export class StageRequirementsComponent implements OnInit {
     uploadTime: Date;
     size: number;
     spaceId?: string;
+    projectFile?: any; // ProjectFile对象引用
   }> = [];
 
   // AI生成的方案
@@ -182,7 +193,9 @@ export class StageRequirementsComponent implements OnInit {
   constructor(
     private route: ActivatedRoute,
     private cdr: ChangeDetectorRef,
-    private productSpaceService: ProductSpaceService
+    private productSpaceService: ProductSpaceService,
+    private projectFileService: ProjectFileService,
+    private dialog: MatDialog
   ) {}
 
   async ngOnInit() {
@@ -294,7 +307,7 @@ export class StageRequirementsComponent implements OnInit {
   }
 
   /**
-   * 上传参考图片
+   * 上传参考图片 - 使用ProjectFileService实际存储
    */
   async uploadReferenceImage(event: any, productId?: string): Promise<void> {
     const files = event.target.files;
@@ -302,42 +315,69 @@ export class StageRequirementsComponent implements OnInit {
 
     try {
       this.uploading = true;
+      const targetProductId = productId || this.activeProductId;
+      const targetProjectId = this.projectId || this.project?.id;
+
+      if (!targetProjectId) {
+        console.error('未找到项目ID,无法上传文件');
+        return;
+      }
 
       for (let i = 0; i < files.length; i++) {
         const file = files[i];
 
-        // 简单的文件类型验证
+        // 文件类型验证
         if (!file.type.startsWith('image/')) {
           console.warn(`文件 ${file.name} 不是图片格式,跳过`);
           continue;
         }
 
-        // 验证文件大小 (10MB)
+        // 文件大小验证 (10MB)
         if (file.size > 10 * 1024 * 1024) {
           console.warn(`文件 ${file.name} 超过10MB限制,跳过`);
           continue;
         }
 
-        // 模拟文件上传
+        // 使用ProjectFileService上传到服务器
+        const projectFile = await this.projectFileService.uploadProjectFileWithRecord(
+          file,
+          targetProjectId,
+          'reference_image',
+          targetProductId,
+          'requirements', // stage参数
+          {
+            imageType: 'style',
+            uploadedFor: 'requirements_analysis'
+          },
+          (progress) => {
+            console.log(`上传进度: ${progress}%`);
+          }
+        );
+
+        // 创建参考图片记录
         const uploadedFile = {
-          id: `img_${Date.now()}_${i}`,
-          url: URL.createObjectURL(file),
-          name: file.name,
+          id: projectFile.id || '',
+          url: projectFile.get('fileUrl') || '',
+          name: projectFile.get('fileName') || file.name,
           type: 'style',
-          uploadTime: new Date(),
-          spaceId: productId || this.activeProductId, // 保持兼容性,后续可改为productId
-          tags: []
+          uploadTime: projectFile.createdAt || new Date(),
+          spaceId: targetProductId,
+          tags: [],
+          projectFile: projectFile // 保存ProjectFile对象引用
         };
 
         // 添加到参考图片列表
-        this.analysisImageMap[uploadedFile?.id] = uploadedFile
-        this.referenceImages.push(uploadedFile);
+        if (uploadedFile.id) {
+          this.analysisImageMap[uploadedFile.id] = uploadedFile;
+          this.referenceImages.push(uploadedFile);
+        }
       }
 
       this.cdr.markForCheck();
 
     } catch (error) {
       console.error('上传失败:', error);
+      alert('文件上传失败,请重试');
     } finally {
       this.uploading = false;
     }
@@ -345,21 +385,69 @@ export class StageRequirementsComponent implements OnInit {
 
   analysisImageMap:any = {}
   /**
-   * 删除参考图片
+   * 删除参考图片 - 同时删除服务器文件
    */
   async deleteReferenceImage(imageId: string): Promise<void> {
     try {
+      // 查找图片记录
+      const image = this.referenceImages.find(img => img.id === imageId);
+
+      if (image && image.projectFile) {
+        // 使用ProjectFileService删除服务器上的文件
+        await this.projectFileService.deleteProjectFile(imageId);
+      }
+
       // 从列表中移除
       this.referenceImages = this.referenceImages.filter(img => img.id !== imageId);
+      delete this.analysisImageMap[imageId];
+
       this.cdr.markForCheck();
 
     } catch (error) {
       console.error('删除参考图片失败:', error);
+      alert('删除文件失败,请重试');
     }
   }
 
   /**
-   * 上传CAD文件
+   * 查看图片色彩分析 - 打开ColorGetDialog
+   */
+  async viewImageColorAnalysis(imageId: string): Promise<void> {
+    const image = this.referenceImages.find(img => img.id === imageId);
+    if (!image) {
+      console.error('未找到图片记录');
+      return;
+    }
+
+    try {
+      // 打开色彩分析对话框
+      const dialogRef = this.dialog.open(ColorGetDialogComponent, {
+        width: '90vw',
+        maxWidth: '800px',
+        height: 'auto',
+        maxHeight: '90vh',
+        data: {
+          fileId: image.id,
+          fileObject: image.projectFile,
+          url: image.url,
+          name: image.name
+        },
+        panelClass: 'color-analysis-dialog'
+      });
+
+      // 对话框关闭后,刷新分析结果
+      dialogRef.afterClosed().subscribe(result => {
+        console.log('色彩分析对话框已关闭', result);
+        this.cdr.markForCheck();
+      });
+
+    } catch (error) {
+      console.error('打开色彩分析对话框失败:', error);
+    }
+  }
+
+  /**
+   * 上传CAD文件 - 使用ProjectFileService实际存储
    */
   async uploadCAD(event: any, productId?: string): Promise<void> {
     const files = event.target.files;
@@ -367,6 +455,13 @@ export class StageRequirementsComponent implements OnInit {
 
     try {
       this.uploading = true;
+      const targetProductId = productId || this.activeProductId;
+      const targetProjectId = this.projectId || this.project?.id;
+
+      if (!targetProjectId) {
+        console.error('未找到项目ID,无法上传文件');
+        return;
+      }
 
       for (let i = 0; i < files.length; i++) {
         const file = files[i];
@@ -385,40 +480,72 @@ export class StageRequirementsComponent implements OnInit {
           continue;
         }
 
-        // 模拟文件上传
+        // 使用ProjectFileService上传到服务器
+        const projectFile = await this.projectFileService.uploadProjectFileWithRecord(
+          file,
+          targetProjectId,
+          'cad_drawing',
+          targetProductId,
+          'requirements', // stage参数
+          {
+            cadFormat: fileExtension.replace('.', ''),
+            uploadedFor: 'requirements_analysis'
+          },
+          (progress) => {
+            console.log(`上传进度: ${progress}%`);
+          }
+        );
+
+        // 创建CAD文件记录
         const uploadedFile = {
-          id: `cad_${Date.now()}_${i}`,
-          url: URL.createObjectURL(file),
-          name: file.name,
-          uploadTime: new Date(),
-          size: file.size,
-          spaceId: productId || this.activeProductId // 保持兼容性,后续可改为productId
+          id: projectFile.id || '',
+          url: projectFile.get('fileUrl') || '',
+          name: projectFile.get('fileName') || file.name,
+          uploadTime: projectFile.createdAt || new Date(),
+          size: projectFile.get('fileSize') || file.size,
+          spaceId: targetProductId,
+          projectFile: projectFile // 保存ProjectFile对象引用
         };
 
         // 添加到CAD文件列表
-        this.cadFiles.push(uploadedFile);
+        if (uploadedFile.id) {
+          this.analysisFileMap[uploadedFile.id] = uploadedFile;
+          this.cadFiles.push(uploadedFile);
+        }
       }
 
       this.cdr.markForCheck();
 
     } catch (error) {
       console.error('上传失败:', error);
+      alert('文件上传失败,请重试');
     } finally {
       this.uploading = false;
     }
   }
 
   /**
-   * 删除CAD文件
+   * 删除CAD文件 - 同时删除服务器文件
    */
   async deleteCAD(fileId: string): Promise<void> {
     try {
+      // 查找文件记录
+      const file = this.cadFiles.find(f => f.id === fileId);
+
+      if (file && file.projectFile) {
+        // 使用ProjectFileService删除服务器上的文件
+        await this.projectFileService.deleteProjectFile(fileId);
+      }
+
       // 从列表中移除
-      this.cadFiles = this.cadFiles.filter(file => file.id !== fileId);
+      this.cadFiles = this.cadFiles.filter(f => f.id !== fileId);
+      delete this.analysisFileMap[fileId];
+
       this.cdr.markForCheck();
 
     } catch (error) {
       console.error('删除CAD文件失败:', error);
+      alert('删除文件失败,请重试');
     }
   }
 

+ 3 - 4
src/modules/project/services/project-file.service.ts

@@ -101,11 +101,10 @@ export class ProjectFileService {
     // 设置关联关系
     const cid = localStorage.getItem('company');
     if (cid) {
-      const companyQuery = new Parse.Query('Company');
-      companyQuery.equalTo('corpId', cid);
-      const company = await companyQuery.first();
+      let company = new Parse.Object('Company');
+      company.id = cid
       if (company) {
-        attachment.set('company', company);
+        attachment.set('company', company.toPointer());
       }
     }