소스 검색

Merge branch 'master' of http://git.fmode.cn:3000/nkkj/yss-project

ryanemax 1 일 전
부모
커밋
d9e095af63

+ 53 - 0
docs/task/20251020-fix-product.md

@@ -0,0 +1,53 @@
+# 2025-10-20 修复与改版方案:多空间 Product 模型下的 StageOrder 与报价组件
+
+## 背景与问题定位
+- 当前页面卡在“加载订单信息...”,核心原因:StageOrder 使用 `ChangeDetectionStrategy.OnPush`,异步加载完成后未触发变更检查,`loading` 置为 `false` 未被视图采纳。
+- `loadDepartmentMembers` 的调用在 `loadData` 中传入了 `department.id`,而方法签名要求 `FmodeObject`,导致潜在运行时异常与列表加载失败。
+- 架构变更:`ProjectSpace` 迁移为以 `Product` 统一管理空间,报价、需求、交付整合到 `Project.data`,StageOrder 中保留了部分旧的空间与报价组织逻辑,需统一由 `quotation-editor` 组件基于 `Project.id` 驱动。
+
+## 目标
+- 页面稳定可用:修复加载状态与成员加载问题,使订单分配页可正常显示。
+- 报价逻辑收敛:将多空间报价、产品管理逻辑封装在 `quotation-editor`,StageOrder 仅透传 `project.id`、权限与用户信息。
+- 与 `schemas.md` 一致:遵循 `Product` 表为统一空间管理的范式,报价数据写回 `Project.data.quotation`。
+
+## 修复项与改动摘要
+1. OnPush 变更侦测修复
+   - 在 `StageOrderComponent` 构造函数中注入 `ChangeDetectorRef`。
+   - 在 `loadData` 与 `loadProjectSpaces` 的 `finally` 中调用 `cdr.markForCheck()`,确保 `loading` 与 `loadingSpaces` 更新后视图刷新。
+2. 调用参数纠正
+   - 在 `loadData` 内将 `await this.loadDepartmentMembers(department.id)` 改为传 `department` 对象:`await this.loadDepartmentMembers(department)`。
+3. 报价编辑器集成核验
+   - `app-quotation-editor` 已支持 `@Input() projectId: string`,并在 `ngOnInit/ngOnChanges` 自动加载 `Project`、`Product` 并生成或同步报价;`loadingChange` 事件会同步加载状态。
+   - StageOrder 中保留 `onQuotationChange/onTotalChange/onProductsChange` 用于接收报价结果与产品列表,但不再在页面内重复生成报价。
+
+## 设计与实现要点(多空间报价)
+- `quotation-editor` 负责:
+  - 通过 `projectId` 加载 `Project` 与其 `Product` 列表;当无产品时依据项目类型生成默认产品房间。
+  - 生成可编辑的工序(建模/软装/渲染/后期)的报价明细,计算各产品小计与总价。
+  - 将生成后的报价写回 `Project.data.quotation`,并通过事件输出到父组件。
+- StageOrder 负责:
+  - 展示客户信息、项目基本信息、部门与成员分配、项目文件处理。
+  - 通过 `app-quotation-editor [projectId]` 使用报价逻辑,无需维护 `quotation.spaces` 的内部冗余生成流程。
+
+## 风险与回退
+- 若 `Product` 表数据为空且项目类型未选择,默认产品生成会跳过;需在 UI 上引导选择项目类型后再生成报价。
+- 若企业微信环境拖拽不可用,文件上传路径已具备浏览器选择备选方案。
+
+## 验证计划
+- 启动开发服务器 `ng serve --port 4300`,打开项目详情 → 订单分配页。
+- 验证加载提示消失,客户/项目信息、报价编辑器渲染正常。
+- 切换项目类型生成默认产品,检查报价总价与产品占比明细是否更新并写回。
+- 选择项目组与成员,确认成员列表能正确加载并可分配。
+
+## 后续优化建议
+- 在 `StageOrder.onQuotationLoadingChange` 中联动顶层 `loading`(或独立 `quotationLoading`)以更细粒度展示报价加载状态。
+- 将旧的空间管理展示与 `Product` 映射进一步简化,减少维护重复结构。
+- 补充单元测试:
+  - `quotation-editor` 的报价生成与保存。
+  - `StageOrder` 的加载状态与成员加载行为。
+
+---
+
+## 变更日志
+- 修复 `StageOrder` OnPush 视图刷新与成员加载参数类型。
+- 保持 `quotation-editor` 以 `projectId` 驱动多空间报价生成与管理。

+ 45 - 3
src/modules/project/components/quotation-editor.component.ts

@@ -27,6 +27,7 @@ const Parse = FmodeParse.with('nova');
 export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
   // 输入属性
   @Input() projectId: string = '';
+  @Input() project: any = null;
   @Input() canEdit: boolean = false;
   @Input() viewMode: 'table' | 'card' = 'card';
   @Input() currentUser: any = null;
@@ -39,7 +40,6 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
 
   // 数据状态
   loading: boolean = false;
-  project: any = null;
   products: any[] = [];
   projectInfo: any = {
     title: '',
@@ -85,13 +85,17 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
 
   ngOnInit() {
     this.loadQuotationConfig();
-    if (this.projectId) {
+    if (this.project) {
+      this.loadProjectDataFromProject();
+    } else if (this.projectId) {
       this.loadProjectData();
     }
   }
 
   ngOnChanges(changes: SimpleChanges) {
-    if (changes['projectId'] && changes['projectId'].currentValue) {
+    if (changes['project'] && changes['project'].currentValue) {
+      this.loadProjectDataFromProject();
+    } else if (changes['projectId'] && changes['projectId'].currentValue) {
       this.loadProjectData();
     }
 
@@ -873,4 +877,42 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
     const breakdown = this.quotation.spaceBreakdown.find((b: any) => b.spaceId === spaceId);
     return breakdown?.percentage || 0;
   }
+
+  /**
+   * 从传入的项目对象初始化数据
+   */
+  private async loadProjectDataFromProject(): Promise<void> {
+    if (!this.project) return;
+
+    try {
+      this.loading = true;
+      this.loadingChange.emit(true);
+
+      // 项目信息
+      this.projectInfo.title = this.project.get('title') || '';
+      this.projectInfo.projectType = this.project.get('projectType') || '';
+      this.projectInfo.renderType = this.project.get('renderType') || '';
+      this.projectInfo.deadline = this.project.get('deadline') || '';
+      this.projectInfo.description = this.project.get('description') || '';
+
+      const data = this.project.get('data') || {};
+      if (data.priceLevel) {
+        this.projectInfo.priceLevel = data.priceLevel;
+      }
+
+      // 加载产品列表
+      await this.loadProjectProducts();
+
+      // 加载现有报价
+      if (data.quotation) {
+        this.quotation = data.quotation;
+        this.updateProductsFromQuotation();
+      }
+    } catch (error) {
+      console.error('从项目对象加载数据失败:', error);
+    } finally {
+      this.loading = false;
+      this.loadingChange.emit(false);
+    }
+  }
 }

+ 181 - 0
src/modules/project/components/team-assign/team-assign.component.html

@@ -0,0 +1,181 @@
+<!-- 设计师分配组件 -->
+<div class="card designer-card">
+  <div class="card-header">
+    <h3 class="card-title">
+      <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+        <path fill="currentColor" d="M402 168c-2.93 40.67-33.1 72-66 72s-63.12-31.32-66-72c-3-42.31 26.37-72 66-72s69 30.46 66 72z"/>
+        <path fill="currentColor" d="M336 304c-65.17 0-127.84 32.37-143.54 95.41-2.08 8.34 3.15 16.59 11.72 16.59h263.65c8.57 0 13.77-8.25 11.72-16.59C463.85 335.36 401.18 304 336 304z"/>
+      </svg>
+      设计师分配
+    </h3>
+    <p class="card-subtitle">先选择项目组,再选择组员</p>
+  </div>
+
+  <div class="card-content">
+    <!-- 已分配组员展示 -->
+    @if (projectTeams.length > 0) {
+      <div class="assigned-teams-section">
+        <h4 class="section-title">已分配组员</h4>
+        <div class="team-list">
+          @for (team of projectTeams; track team.id) {
+            <div
+              class="team-item"
+              [class.clickable]="canEdit"
+              (click)="canEdit ? editAssignedDesigner(team) : null">
+              <div class="team-member">
+                <div class="member-avatar">
+                  @if (team.get('profile')?.get('data')?.avatar) {
+                    <img [src]="team.get('profile').get('data').avatar" alt="组员头像" />
+                  } @else {
+                    <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                      <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
+                    </svg>
+                  }
+                </div>
+                <div class="member-info">
+                  <h5>{{ team.get('profile')?.get('name') }}</h5>
+                  <p class="member-spaces">负责空间: {{ getMemberSpaces(team) }}</p>
+                </div>
+              </div>
+              @if (canEdit) {
+                <svg class="icon edit-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                  <path fill="currentColor" d="M384 224v184a40 40 0 01-40 40H104a40 40 0 01-40-40V168a40 40 0 0140-40h167.48M459.94 53.25a16.06 16.06 0 00-23.22-.56L424.35 65a8 8 0 000 11.31l11.34 11.32a8 8 0 0011.34 0l12.06-12c6.1-6.09 6.67-16.01.85-22.38zM399.34 90L218.82 270.2a9 9 0 00-2.31 3.93L208.16 299a3.91 3.91 0 004.86 4.86l24.85-8.35a9 9 0 003.93-2.31L422 112.66a9 9 0 000-12.66l-9.95-10a9 9 0 00-12.71 0z"/>
+                </svg>
+              }
+            </div>
+          }
+        </div>
+      </div>
+    }
+
+    <!-- 项目组选择 -->
+    <div class="department-section">
+      <h4 class="section-title">选择项目组</h4>
+      @if (departments.length === 0) {
+        <div class="empty-state">
+          <p>暂无可用项目组</p>
+        </div>
+      } @else {
+        <div class="department-grid">
+          @for (dept of departments; track dept.id) {
+            <div
+              class="department-item"
+              [class.selected]="selectedDepartment?.id === dept.id"
+              (click)="selectDepartment(dept)">
+              <h5>{{ dept.get('name') }}</h5>
+              <p>组长: {{ dept.get('leader')?.get('name') || '未指定' }}</p>
+              @if (selectedDepartment?.id === dept.id) {
+                <svg class="icon selected-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                  <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-38 312.38L137.4 280.8a24 24 0 0133.94-33.94l50.2 50.2 95.74-95.74a24 24 0 0133.94 33.94z"/>
+                </svg>
+              }
+            </div>
+          }
+        </div>
+      }
+    </div>
+
+    <!-- 组员选择 -->
+    @if (selectedDepartment) {
+      <div class="designer-section">
+        <h4 class="section-title">选择组员</h4>
+        @if (loadingMembers) {
+          <div class="loading-spinner">
+            <div class="spinner-sm"></div>
+            <p>加载组员中...</p>
+          </div>
+        } @else if (departmentMembers.length === 0) {
+          <div class="empty-state">
+            <p>该项目组暂无可用组员</p>
+          </div>
+        } @else {
+          <div class="designer-grid">
+            @for (designer of departmentMembers; track designer.id) {
+              <div
+                class="designer-item"
+                [class.selected]="selectedDesigner?.id === designer.id"
+                (click)="selectDesigner(designer)">
+                <div class="designer-avatar">
+                  @if (designer.get('data')?.avatar) {
+                    <img [src]="designer.get('data').avatar" alt="设计师头像" />
+                  } @else {
+                    <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                      <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
+                    </svg>
+                  }
+                </div>
+                <div class="designer-info">
+                  <h4>{{ designer.get('name') }}</h4>
+                  <p>{{ getDesignerWorkload(designer) }}</p>
+                </div>
+                @if (selectedDesigner?.id === designer.id) {
+                  <svg class="icon selected-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                    <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-38 312.38L137.4 280.8a24 24 0 0133.94-33.94l50.2 50.2 95.74-95.74a24 24 0 0133.94 33.94z"/>
+                  </svg>
+                }
+              </div>
+            }
+          </div>
+        }
+      </div>
+    }
+  </div>
+</div>
+
+<!-- 设计师分配对话框 -->
+@if (showAssignDialog && assigningDesigner) {
+  <div class="modal-overlay" (click)="cancelAssignDialog()">
+    <div class="modal-dialog" (click)="$event.stopPropagation()">
+      <div class="modal-header">
+        <h3 class="modal-title">{{ editingTeam ? '编辑分配' : '分配设计师' }}</h3>
+        <button class="modal-close" (click)="cancelAssignDialog()">
+          <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M289.94 256l95-95A24 24 0 00351 127l-95 95-95-95a24 24 0 00-34 34l95 95-95 95a24 24 0 1034 34l95-95 95 95a24 24 0 0034-34z"/>
+          </svg>
+        </button>
+      </div>
+      <div class="modal-content">
+        <div class="designer-preview">
+          <div class="designer-avatar">
+            @if (assigningDesigner.get('data')?.avatar) {
+              <img [src]="assigningDesigner.get('data').avatar" alt="设计师头像" />
+            } @else {
+              <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
+              </svg>
+            }
+          </div>
+          <div class="designer-name">{{ assigningDesigner.get('name') }}</div>
+        </div>
+
+        <div class="space-selection-section">
+          <h4 class="form-label">指派空间场景 <span class="required">*</span></h4>
+          <p class="form-help">请选择该设计师负责的空间</p>
+          <div class="space-checkbox-list">
+            @for (space of projectSpaces; track space.id) {
+              <label class="space-checkbox-item">
+                <input
+                  type="checkbox"
+                  [checked]="selectedSpaces.includes(space.name)"
+                  (change)="toggleSpaceSelection(space.name)" />
+                <span class="checkbox-custom"></span>
+                <span class="space-name">{{ space.name }}</span>
+              </label>
+            }
+          </div>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <button class="btn btn-outline" (click)="cancelAssignDialog()">
+          取消
+        </button>
+        <button
+          class="btn btn-primary"
+          (click)="confirmAssignDesigner()"
+          [disabled]="saving || selectedSpaces.length === 0">
+          {{ editingTeam ? '确认更新' : '确认分配' }}
+        </button>
+      </div>
+    </div>
+  </div>
+}

+ 525 - 0
src/modules/project/components/team-assign/team-assign.component.scss

@@ -0,0 +1,525 @@
+/* 设计师分配组件样式(复用 StageOrder 风格) */
+
+.designer-card {
+  .section-title {
+    font-size: 15px;
+    font-weight: 600;
+    color: var(--dark-color);
+    margin: 0 0 12px;
+
+    &:not(:first-child) {
+      margin-top: 24px;
+    }
+  }
+
+  .loading-spinner {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 12px;
+    padding: 40px 20px;
+
+    .spinner-sm {
+      width: 24px;
+      height: 24px;
+      border: 3px solid var(--light-shade);
+      border-top-color: var(--primary-color);
+      border-radius: 50%;
+      animation: spin 0.8s linear infinite;
+    }
+
+    p {
+      margin: 0;
+      font-size: 14px;
+      color: var(--medium-color);
+    }
+  }
+
+  /* 已分配组员列表(基础样式) */
+  .assigned-teams-section {
+    margin-bottom: 24px;
+
+    .team-list {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+      gap: 12px;
+    }
+
+    .team-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 12px;
+      border: 2px solid var(--light-shade);
+      border-radius: 8px;
+      background: var(--white);
+      transition: all 0.2s;
+
+      &.clickable {
+        cursor: pointer;
+
+        &:hover {
+          border-color: rgba(var(--primary-rgb), 0.3);
+          background: rgba(var(--primary-rgb), 0.03);
+        }
+      }
+
+      .team-member {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+
+        .member-avatar {
+          width: 32px;
+          height: 32px;
+          border-radius: 50%;
+          overflow: hidden;
+          background: var(--light-shade);
+          display: flex;
+          align-items: center;
+          justify-content: center;
+
+          img {
+            width: 100%;
+            height: 100%;
+            object-fit: cover;
+          }
+
+          .avatar-icon {
+            width: 32px;
+            height: 32px;
+            color: var(--primary-color);
+          }
+        }
+
+        .member-info {
+          flex: 1;
+          min-width: 0;
+
+          h5 {
+            margin: 0 0 6px;
+            font-size: 16px;
+            font-weight: 700;
+            color: var(--dark-color);
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+          }
+
+          .member-spaces {
+            margin: 0;
+            font-size: 13px;
+            color: var(--primary-color);
+            font-weight: 500;
+            display: flex;
+            align-items: center;
+            gap: 4px;
+
+            &::before {
+              content: '📦';
+              font-size: 14px;
+            }
+          }
+        }
+      }
+
+      .edit-icon {
+        width: 20px;
+        height: 20px;
+        color: var(--medium-color);
+      }
+    }
+  }
+
+  /* 项目组选择 */
+  .department-section {
+    margin-bottom: 24px;
+
+    .department-grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+      gap: 12px;
+    }
+
+    .department-item {
+      position: relative;
+      padding: 14px;
+      border: 2px solid var(--light-shade);
+      border-radius: 8px;
+      background: var(--white);
+      cursor: pointer;
+      transition: all 0.3s;
+
+      &:hover {
+        border-color: rgba(var(--primary-rgb), 0.3);
+        background: rgba(var(--primary-rgb), 0.03);
+      }
+
+      &.selected {
+        border-color: var(--primary-color);
+        background: rgba(var(--primary-rgb), 0.08);
+        box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.2);
+
+        .selected-icon {
+          color: var(--primary-color);
+        }
+      }
+
+      h5 {
+        margin: 0 0 6px;
+        font-size: 16px;
+        font-weight: 700;
+        color: var(--dark-color);
+      }
+
+      p {
+        margin: 0;
+        font-size: 12px;
+        color: var(--medium-color);
+      }
+
+      .selected-icon {
+        position: absolute;
+        top: 8px;
+        right: 8px;
+        width: 20px;
+        height: 20px;
+        color: var(--primary-color);
+      }
+    }
+  }
+
+  /* 组员选择 */
+  .designer-section {
+    .designer-grid {
+      display: grid;
+      grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+      gap: 12px;
+    }
+
+    .designer-item {
+      position: relative;
+      display: flex;
+      align-items: center;
+      gap: 12px;
+      padding: 12px;
+      border: 2px solid var(--light-shade);
+      border-radius: 8px;
+      background: var(--white);
+      cursor: pointer;
+      transition: all 0.3s;
+
+      &:hover {
+        background-color: var(--light-shade);
+        transform: translateX(2px);
+      }
+
+      &.selected {
+        border-color: var(--primary-color);
+        background: rgba(var(--primary-rgb), 0.08);
+        box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.2);
+
+        .designer-info h4 {
+          color: var(--primary-color);
+        }
+      }
+
+      .designer-avatar {
+        width: 48px;
+        height: 48px;
+        border-radius: 50%;
+        overflow: hidden;
+        background: var(--light-shade);
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        img {
+          width: 100%;
+          height: 100%;
+          object-fit: cover;
+        }
+
+        .avatar-icon {
+          width: 48px;
+          height: 48px;
+          color: var(--medium-color);
+        }
+      }
+
+      .designer-info {
+        flex: 1;
+        min-width: 0;
+
+        h4 {
+          margin: 0 0 4px;
+          font-size: 15px;
+          font-weight: 600;
+          color: var(--dark-color);
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          transition: color 0.3s;
+        }
+
+        p {
+          margin: 0;
+          font-size: 12px;
+          color: var(--medium-color);
+        }
+      }
+
+      .selected-icon {
+        position: absolute;
+        top: 8px;
+        right: 8px;
+        width: 20px;
+        height: 20px;
+        color: var(--primary-color);
+      }
+    }
+  }
+}
+
+/* 模态框样式 */
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 10000;
+  animation: fadeIn 0.2s ease-out;
+}
+
+@keyframes fadeIn {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+
+.modal-dialog {
+  background: var(--white);
+  border-radius: 12px;
+  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
+  width: 90%;
+  max-width: 500px;
+  max-height: 90vh;
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  animation: slideUp 0.3s ease-out;
+}
+
+@keyframes slideUp {
+  from { opacity: 0; transform: translateY(20px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.modal-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20px 24px;
+
+  .modal-title {
+    margin: 0;
+    font-size: 20px;
+    font-weight: 600;
+    color: var(--dark-color);
+  }
+
+  .modal-close {
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 4px;
+    width: 32px;
+    height: 32px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 4px;
+    transition: all 0.2s;
+
+    .icon {
+      width: 20px;
+      height: 20px;
+      color: var(--medium-color);
+    }
+
+    &:hover {
+      background: var(--light-color);
+
+      .icon { color: var(--dark-color); }
+    }
+  }
+}
+
+.modal-content {
+  flex: 1;
+  overflow-y: auto;
+  padding: 24px;
+
+  .designer-preview {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 12px;
+    padding: 20px;
+    background: linear-gradient(135deg, rgba(var(--primary-rgb), 0.05) 0%, rgba(var(--primary-rgb), 0.02) 100%);
+    border-radius: 10px;
+    margin-bottom: 24px;
+
+    .designer-avatar {
+      width: 64px;
+      height: 64px;
+      border-radius: 50%;
+      overflow: hidden;
+      background: var(--white);
+      border: 3px solid var(--primary-color);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.2);
+
+      img { width: 100%; height: 100%; object-fit: cover; }
+      .avatar-icon { width: 36px; height: 36px; }
+    }
+
+    .designer-name {
+      font-size: 18px;
+      font-weight: 600;
+      color: var(--dark-color);
+    }
+  }
+
+  .space-selection-section {
+    .form-label {
+      display: block;
+      font-weight: 500;
+      color: var(--dark-color);
+      margin-bottom: 8px;
+      font-size: 14px;
+      .required { color: var(--danger-color); margin-left: 4px; }
+    }
+
+    .form-help {
+      margin: 0 0 12px;
+      font-size: 13px;
+      color: var(--medium-color);
+    }
+
+    .space-checkbox-list {
+      display: flex;
+      flex-direction: column;
+      gap: 10px;
+
+      .space-checkbox-item {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        padding: 12px 16px;
+        background: var(--light-color);
+        border: 2px solid transparent;
+        border-radius: 8px;
+        cursor: pointer;
+        transition: all 0.3s;
+        user-select: none;
+
+        &:hover {
+          background: var(--light-shade);
+          border-color: rgba(var(--primary-rgb), 0.3);
+        }
+
+        input[type="checkbox"] {
+          position: absolute;
+          opacity: 0;
+          cursor: pointer;
+          width: 0;
+          height: 0;
+
+          &:checked + .checkbox-custom {
+            background-color: var(--primary-color);
+            border-color: var(--primary-color);
+
+            &::after { display: block; }
+          }
+
+          &:checked ~ .space-name { font-weight: 600; }
+        }
+
+        .checkbox-custom {
+          position: relative;
+          height: 20px;
+          width: 20px;
+          background-color: var(--white);
+          border: 2px solid var(--medium-color);
+          border-radius: 4px;
+          transition: all 0.3s;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          flex-shrink: 0;
+
+          &::after {
+            content: '';
+            display: none;
+            width: 5px;
+            height: 10px;
+            border: solid white;
+            border-width: 0 2px 2px 0;
+            transform: rotate(45deg);
+          }
+        }
+
+        .space-name {
+          flex: 1;
+          font-size: 14px;
+          color: var(--dark-color);
+          transition: all 0.3s;
+        }
+      }
+    }
+  }
+}
+
+.modal-footer {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 12px;
+  padding: 16px 24px;
+  border-top: 1px solid var(--light-shade);
+
+  .btn {
+    padding: 10px 20px;
+    border-radius: 8px;
+    font-size: 14px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.3s;
+    border: none;
+    outline: none;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+
+    &.btn-outline {
+      background: white;
+      color: var(--dark-color);
+      border: 2px solid var(--light-shade);
+    }
+
+    &.btn-primary {
+      background: var(--primary-color);
+      color: white;
+    }
+
+    &:disabled { opacity: 0.5; cursor: not-allowed; }
+  }
+}

+ 299 - 0
src/modules/project/components/team-assign/team-assign.component.ts

@@ -0,0 +1,299 @@
+import { Component, Input, OnInit, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { FmodeObject, FmodeParse } from 'fmode-ng/parse';
+import { ProductSpaceService, Project } from '../../services/product-space.service';
+
+const Parse = FmodeParse.with('nova');
+
+@Component({
+  selector: 'app-team-assign',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './team-assign.component.html',
+  styleUrls: ['./team-assign.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class TeamAssignComponent implements OnInit {
+  @Input() project: FmodeObject | null = null;
+  @Input() canEdit: boolean = true; // 可选:未传入时默认允许编辑
+  @Input() currentUser: FmodeObject | null = null; // 可选:未传入时为 null
+
+  // 项目组(Department)列表
+  departments: FmodeObject[] = [];
+  selectedDepartment: FmodeObject | null = null;
+
+  // 项目组成员(Profile)列表
+  departmentMembers: FmodeObject[] = [];
+  selectedDesigner: FmodeObject | null = null;
+
+  // 已分配的项目团队成员
+  projectTeams: FmodeObject[] = [];
+
+  // 设计师分配对话框
+  showAssignDialog: boolean = false;
+  assigningDesigner: FmodeObject | null = null;
+  selectedSpaces: string[] = [];
+  editingTeam: FmodeObject | null = null; // 当前正在编辑的团队对象
+
+  // 加载状态
+  loadingMembers: boolean = false;
+  loadingTeams: boolean = false;
+  loadingSpaces: boolean = false;
+  saving: boolean = false;
+
+  // 空间数据
+  projectSpaces: Project[] = [];
+
+  constructor(
+    private productSpaceService: ProductSpaceService,
+    private cdr: ChangeDetectorRef
+  ) {}
+
+  async ngOnInit() {
+    await this.loadData();
+  }
+
+  async loadData() {
+    if (!this.project) return;
+
+    try {
+      // 初始化已选择的项目组与设计师(若项目已有)
+      const department = this.project.get('department');
+      if (department) {
+        this.selectedDepartment = department;
+        await this.loadDepartmentMembers(department);
+      }
+
+      const assignee = this.project.get('assignee');
+      if (assignee) {
+        this.selectedDesigner = assignee;
+      }
+
+      // 加载项目组列表
+      const deptQuery = new Parse.Query('Department');
+      deptQuery.include('leader');
+      deptQuery.equalTo('type', 'project');
+      deptQuery.equalTo('company', localStorage.getItem('company'));
+      deptQuery.notEqualTo('isDeleted', true);
+      deptQuery.ascending('name');
+      this.departments = await deptQuery.find();
+
+      // 加载项目团队
+      await this.loadProjectTeams();
+
+      // 加载项目空间
+      await this.loadProjectSpaces();
+    } catch (err) {
+      console.error('加载团队分配数据失败:', err);
+    } finally {
+      this.cdr.markForCheck();
+    }
+  }
+
+  async loadProjectSpaces(): Promise<void> {
+    if (!this.project) return;
+
+    try {
+      this.loadingSpaces = true;
+      const projectId = this.project.id || '';
+      this.projectSpaces = await this.productSpaceService.getProjectProductSpaces(projectId);
+    } catch (err) {
+      console.error('加载项目空间失败:', err);
+    } finally {
+      this.loadingSpaces = false;
+      this.cdr.markForCheck();
+    }
+  }
+
+  async loadProjectTeams() {
+    if (!this.project) return;
+
+    try {
+      this.loadingTeams = true;
+
+      const query = new Parse.Query('ProjectTeam');
+      query.equalTo('project', this.project.toPointer());
+      query.include('profile');
+      query.notEqualTo('isDeleted', true);
+
+      this.projectTeams = await query.find();
+    } catch (err) {
+      console.error('加载项目团队失败:', err);
+    } finally {
+      this.loadingTeams = false;
+    }
+  }
+
+  async selectDepartment(department: FmodeObject) {
+    this.selectedDepartment = department;
+    this.selectedDesigner = null;
+    this.departmentMembers = [];
+
+    await this.loadDepartmentMembers(department);
+  }
+
+  async loadDepartmentMembers(department: FmodeObject) {
+    const departmentId = department?.id;
+    if (!departmentId) return [];
+
+    try {
+      this.loadingMembers = true;
+
+      const query = new Parse.Query('Profile');
+      query.equalTo('department', departmentId);
+      query.equalTo('roleName', '组员');
+      query.notEqualTo('isDeleted', true);
+      query.ascending('name');
+
+      this.departmentMembers = await query.find();
+      // 将组长置顶展示
+      const leader = department?.get('leader');
+      if (leader) {
+        this.departmentMembers.unshift(leader);
+      }
+      return this.departmentMembers;
+    } catch (err) {
+      console.error('加载项目组成员失败:', err);
+    } finally {
+      this.loadingMembers = false;
+    }
+    return [];
+  }
+
+  selectDesigner(designer: FmodeObject) {
+    // 检查是否已分配
+    const isAssigned = this.projectTeams.some(team => team.get('profile')?.id === designer.id);
+    if (isAssigned) {
+      alert('该设计师已分配到此项目');
+      return;
+    }
+
+    this.assigningDesigner = designer;
+    this.selectedSpaces = [];
+
+    // 如果只有一个空间,默认选中
+    if (this.projectSpaces.length === 1) {
+      const only = this.projectSpaces[0];
+      this.selectedSpaces = [only.name];
+    }
+
+    this.showAssignDialog = true;
+  }
+
+  editAssignedDesigner(team: FmodeObject) {
+    const designer = team.get('profile');
+    if (!designer) return;
+
+    this.assigningDesigner = designer;
+    this.editingTeam = team;
+
+    const currentSpaces = team.get('data')?.spaces || [];
+    this.selectedSpaces = [...currentSpaces];
+
+    this.showAssignDialog = true;
+  }
+
+  toggleSpaceSelection(spaceName: string) {
+    const index = this.selectedSpaces.indexOf(spaceName);
+    if (index > -1) {
+      this.selectedSpaces.splice(index, 1);
+    } else {
+      this.selectedSpaces.push(spaceName);
+    }
+  }
+
+  async confirmAssignDesigner() {
+    if (!this.assigningDesigner || !this.project) return;
+
+    if (this.selectedSpaces.length === 0) {
+      alert('请至少选择一个空间场景');
+      return;
+    }
+
+    try {
+      this.saving = true;
+
+      if (this.editingTeam) {
+        // 更新现有团队成员的空间分配
+        const data = this.editingTeam.get('data') || {};
+        data.spaces = this.selectedSpaces;
+        data.updatedAt = new Date();
+        data.updatedBy = this.currentUser?.id;
+        this.editingTeam.set('data', data);
+
+        await this.editingTeam.save();
+
+        alert('更新成功');
+      } else {
+        // 创建新的 ProjectTeam
+        const ProjectTeam = Parse.Object.extend('ProjectTeam');
+        const team = new ProjectTeam();
+        team.set('project', this.project.toPointer());
+        team.set('profile', this.assigningDesigner.toPointer());
+        team.set('role', '组员');
+        team.set('data', {
+          spaces: this.selectedSpaces,
+          assignedAt: new Date(),
+          assignedBy: this.currentUser?.id
+        });
+
+        await team.save();
+
+        // 加入群聊(静默执行)
+        await this.addMemberToGroupChat(this.assigningDesigner.get('userId'));
+
+        alert('分配成功');
+      }
+
+      await this.loadProjectTeams();
+
+      this.showAssignDialog = false;
+      this.assigningDesigner = null;
+      this.selectedSpaces = [];
+      this.editingTeam = null;
+    } catch (err) {
+      console.error(this.editingTeam ? '更新失败:' : '分配设计师失败:', err);
+      alert(this.editingTeam ? '更新失败' : '分配失败');
+    } finally {
+      this.saving = false;
+    }
+  }
+
+  cancelAssignDialog() {
+    this.showAssignDialog = false;
+    this.assigningDesigner = null;
+    this.selectedSpaces = [];
+    this.editingTeam = null;
+  }
+
+  async addMemberToGroupChat(userId: string) {
+    if (!userId) return;
+
+    try {
+      const groupChat = (this as any).groupChat;
+      if (!groupChat) return;
+
+      const chatId = groupChat.get('chat_id');
+      if (!chatId) return;
+
+      if (typeof (window as any).ww !== 'undefined') {
+        await (window as any).ww.updateEnterpriseChat({
+          chatId: chatId,
+          userIdsToAdd: [userId]
+        });
+      }
+    } catch (err) {
+      console.warn('添加群成员失败:', err);
+    }
+  }
+
+  getMemberSpaces(team: FmodeObject): string {
+    const spaces = team.get('data')?.spaces || [];
+    return spaces.join('、') || '未分配';
+  }
+
+  getDesignerWorkload(designer: FmodeObject): string {
+    return '3个项目';
+  }
+}

+ 6 - 424
src/modules/project/pages/project-detail/stages/stage-order.component.html

@@ -89,7 +89,6 @@
               <select
                 class="form-select"
                 [(ngModel)]="projectInfo.renderType"
-                (change)="onHomeSceneChange()"
                 [disabled]="!canEdit">
                 <option value="">请选择渲染类型</option>
                 <option value="静态单张">静态单张</option>
@@ -135,252 +134,6 @@
       </div>
     </div>
 
-    <!-- 3. 场景选择(家装) -->
-    @if (projectInfo.projectType === '家装' && projectInfo.renderType) {
-      <div class="card scene-card">
-        <div class="card-header">
-          <h3 class="card-title">
-            <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-              <path fill="currentColor" d="M261.56 101.28a8 8 0 00-11.06 0L66.4 277.15a8 8 0 00-2.47 5.79L63.9 448a32 32 0 0032 32H192a16 16 0 0016-16V328a8 8 0 018-8h80a8 8 0 018 8v136a16 16 0 0016 16h96.06a32 32 0 0032-32V282.94a8 8 0 00-2.47-5.79z"/>
-              <path fill="currentColor" d="M490.91 244.15l-74.8-71.56V64a16 16 0 00-16-16h-48a16 16 0 00-16 16v48l-57.92-55.38C272.77 51.14 264.71 48 256 48c-8.68 0-16.72 3.14-22.14 8.63l-212.7 203.5c-6.22 6-7 15.87-1.34 22.37A16 16 0 0043 284.12l213.3-204.2a8 8 0 0111.47 0l213.34 204.16a16 16 0 0023.22-.75c5.57-6.5 4.93-16.36-1.42-22.18z"/>
-            </svg>
-            场景快速选择
-          </h3>
-          <p class="card-subtitle">选择空间类型和风格,快速生成报价</p>
-        </div>
-        <div class="card-content">
-          <div class="form-list">
-            <!-- 空间类型 -->
-            <div class="form-group">
-              <label class="form-label">空间类型 <span class="required">*</span></label>
-              <div class="radio-group">
-                @for (spaceType of ['平层', '跃层', '挑空']; track spaceType) {
-                  <label class="radio-label">
-                    <input
-                      type="radio"
-                      name="spaceType"
-                      [value]="spaceType"
-                      [(ngModel)]="homeScenes.spaceType"
-                      (change)="onHomeSceneChange()"
-                      [disabled]="!canEdit" />
-                    <span class="radio-text">{{ spaceType }}</span>
-                  </label>
-                }
-              </div>
-            </div>
-
-            <!-- 风格等级 -->
-            <div class="form-group">
-              <label class="form-label">风格等级 <span class="required">*</span></label>
-              <div class="radio-group">
-                @for (styleLevel of getLevels(styleLevels); track styleLevel) {
-                  <label class="radio-label">
-                    <input
-                      type="radio"
-                      name="styleLevel"
-                      [value]="styleLevel"
-                      [(ngModel)]="homeScenes.styleLevel"
-                      (change)="onHomeSceneChange()"
-                      [disabled]="!canEdit" />
-                    <span class="radio-text">{{ styleLevel }}</span>
-                  </label>
-                }
-              </div>
-            </div>
-          </div>
-
-          <!-- 房间选择 -->
-          @if (homeScenes.rooms.length > 0) {
-            <div class="room-selection">
-              <h4 class="section-title">选择房间</h4>
-              <div class="room-grid">
-                @for (room of homeScenes.rooms; track room.name) {
-                  <div class="room-item" [class.selected]="room.selected">
-                    <label class="room-checkbox">
-                      <input
-                        type="checkbox"
-                        [(ngModel)]="room.selected"
-                        [disabled]="!canEdit" />
-                      <span class="checkbox-custom"></span>
-                      <div class="room-info">
-                        <h5>{{ room.name }}</h5>
-                        <p class="base-price">基础价: ¥{{ room.basePrice }}</p>
-                      </div>
-                    </label>
-
-                    <!-- 加价选项 -->
-                    @if (room.selected && canEdit) {
-                      <div class="room-adjustments">
-                        <div class="adjustment-item">
-                          <label class="adjustment-label">功能区加价</label>
-                          <input
-                            type="number"
-                            class="adjustment-input"
-                            [(ngModel)]="room.adjustments.extraFunction"
-                            min="0"
-                            step="100"
-                            placeholder="0" />
-                          <span class="adjustment-unit">元</span>
-                        </div>
-
-                        <div class="adjustment-item">
-                          <label class="adjustment-label">造型复杂度</label>
-                          <input
-                            type="number"
-                            class="adjustment-input"
-                            [(ngModel)]="room.adjustments.complexity"
-                            min="0"
-                            max="200"
-                            step="50"
-                            placeholder="0" />
-                          <span class="adjustment-unit">元</span>
-                        </div>
-
-                        <div class="adjustment-item">
-                          <label class="adjustment-checkbox">
-                            <input
-                              type="checkbox"
-                              [(ngModel)]="room.adjustments.design" />
-                            <span class="checkbox-text">需要设计服务(×2)</span>
-                          </label>
-                        </div>
-                      </div>
-                    }
-                  </div>
-                }
-              </div>
-
-              <button
-                class="btn btn-primary generate-quotation-btn"
-                (click)="generateQuotation()"
-                [disabled]="hasRoomsDisabled()">
-                <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                  <path fill="currentColor" d="M416 221.25V416a48 48 0 01-48 48H144a48 48 0 01-48-48V96a48 48 0 0148-48h98.75a32 32 0 0122.62 9.37l141.26 141.26a32 32 0 019.37 22.62z"/>
-                  <path fill="currentColor" d="M256 56v120a32 32 0 0032 32h120"/>
-                </svg>
-                生成报价表
-              </button>
-            </div>
-          }
-        </div>
-      </div>
-    }
-
-    <!-- 3. 场景选择(工装) -->
-    @if (projectInfo.projectType === '工装') {
-      <div class="card scene-card">
-        <div class="card-header">
-          <h3 class="card-title">
-            <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-              <path fill="currentColor" d="M408 64H104a56.16 56.16 0 00-56 56v192a56.16 56.16 0 0056 56h40v72h32v-72h160v72h32v-72h40a56.16 56.16 0 0056-56V120a56.16 56.16 0 00-56-56zm24 248a24 24 0 01-24 24H104a24 24 0 01-24-24V120a24 24 0 0124-24h304a24 24 0 0124 24z"/>
-            </svg>
-            场景快速选择
-          </h3>
-          <p class="card-subtitle">选择业态类型,快速生成报价</p>
-        </div>
-        <div class="card-content">
-          <!-- 业态类型 -->
-          <div class="form-group">
-            <label class="form-label">业态类型 <span class="required">*</span></label>
-            <select
-              class="form-select"
-              [(ngModel)]="commercialScenes.businessType"
-              (change)="onCommercialTypeChange()"
-              [disabled]="!canEdit">
-              <option value="">请选择业态类型</option>
-              @for (type of businessTypes; track type) {
-                <option [value]="type">{{ type }}</option>
-              }
-            </select>
-          </div>
-
-          <!-- 空间选择 -->
-          @if (commercialScenes.spaces.length > 0) {
-            <div class="space-selection">
-              <h4 class="section-title">选择空间</h4>
-              <div class="space-grid">
-                @for (space of commercialScenes.spaces; track space.name; let idx = $index) {
-                  <div class="space-item" [class.selected]="space.selected">
-                    <label class="space-checkbox">
-                      <input
-                        type="checkbox"
-                        [(ngModel)]="space.selected"
-                        [disabled]="!canEdit" />
-                      <span class="checkbox-custom"></span>
-                      <div class="space-info">
-                        <h5>{{ space.name }}</h5>
-                        <p class="space-type">{{ space.type }}</p>
-                        <p class="base-price">基础价: ¥{{ space.basePrice }}</p>
-                      </div>
-                    </label>
-
-                    <!-- 加价选项 -->
-                    @if (space.selected && canEdit) {
-                      <div class="space-adjustments">
-                        <div class="adjustment-item">
-                          <label class="adjustment-label">功能区加价</label>
-                          <input
-                            type="number"
-                            class="adjustment-input"
-                            [(ngModel)]="space.adjustments.extraFunction"
-                            min="0"
-                            step="400"
-                            placeholder="0" />
-                          <span class="adjustment-unit">元</span>
-                        </div>
-
-                        <div class="adjustment-item">
-                          <label class="adjustment-label">造型复杂度</label>
-                          <input
-                            type="number"
-                            class="adjustment-input"
-                            [(ngModel)]="space.adjustments.complexity"
-                            min="0"
-                            max="200"
-                            step="50"
-                            placeholder="0" />
-                          <span class="adjustment-unit">元</span>
-                        </div>
-
-                        <div class="adjustment-item">
-                          <label class="adjustment-checkbox">
-                            <input
-                              type="checkbox"
-                              [(ngModel)]="space.adjustments.design" />
-                            <span class="checkbox-text">需要设计服务(×2)</span>
-                          </label>
-                        </div>
-
-                        <div class="adjustment-item">
-                          <label class="adjustment-checkbox">
-                            <input
-                              type="checkbox"
-                              [(ngModel)]="space.adjustments.panoramic" />
-                            <span class="checkbox-text">全景渲染(×2)</span>
-                          </label>
-                        </div>
-                      </div>
-                    }
-                  </div>
-                }
-              </div>
-
-              <button
-                class="btn btn-primary generate-quotation-btn"
-                (click)="generateQuotation()"
-                [disabled]="hasSpacesDisabled()">
-                <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                  <path fill="currentColor" d="M416 221.25V416a48 48 0 01-48 48H144a48 48 0 01-48-48V96a48 48 0 0148-48h98.75a32 32 0 0122.62 9.37l141.26 141.26a32 32 0 019.37 22.62z"/>
-                  <path fill="currentColor" d="M256 56v120a32 32 0 0032 32h120"/>
-                </svg>
-                生成报价表
-              </button>
-            </div>
-          }
-        </div>
-      </div>
-    }
-
     <!-- 4. 基于Product表的报价管理 -->
     <div class="card quotation-card">
       <div class="card-header">
@@ -396,6 +149,7 @@
       <div class="card-content">
         <app-quotation-editor
           [projectId]="projectId"
+          [project]="project"
           [canEdit]="canEdit"
           [viewMode]="'card'"
           [currentUser]="currentUser"
@@ -408,126 +162,11 @@
     </div>
 
     <!-- 5. 设计师分配 -->
-    <div class="card designer-card">
-      <div class="card-header">
-        <h3 class="card-title">
-          <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-            <path fill="currentColor" d="M402 168c-2.93 40.67-33.1 72-66 72s-63.12-31.32-66-72c-3-42.31 26.37-72 66-72s69 30.46 66 72z"/>
-            <path fill="currentColor" d="M336 304c-65.17 0-127.84 32.37-143.54 95.41-2.08 8.34 3.15 16.59 11.72 16.59h263.65c8.57 0 13.77-8.25 11.72-16.59C463.85 335.36 401.18 304 336 304z"/>
-          </svg>
-          设计师分配
-        </h3>
-        <p class="card-subtitle">先选择项目组,再选择组员</p>
-      </div>
-      <div class="card-content">
-        <!-- 已分配组员展示 -->
-        @if (projectTeams.length > 0) {
-          <div class="assigned-teams-section">
-            <h4 class="section-title">已分配组员</h4>
-            <div class="team-list">
-              @for (team of projectTeams; track team.id) {
-                <div
-                  class="team-item"
-                  [class.clickable]="canEdit"
-                  (click)="canEdit ? editAssignedDesigner(team) : null">
-                  <div class="team-member">
-                    <div class="member-avatar">
-                      @if (team.get('profile')?.get('data')?.avatar) {
-                        <img [src]="team.get('profile').get('data').avatar" alt="组员头像" />
-                      } @else {
-                        <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                          <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
-                        </svg>
-                      }
-                    </div>
-                    <div class="member-info">
-                      <h5>{{ team.get('profile')?.get('name') }}</h5>
-                      <p class="member-spaces">负责空间: {{ getMemberSpaces(team) }}</p>
-                    </div>
-                  </div>
-                  @if (canEdit) {
-                    <svg class="icon edit-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                      <path fill="currentColor" d="M384 224v184a40 40 0 01-40 40H104a40 40 0 01-40-40V168a40 40 0 0140-40h167.48M459.94 53.25a16.06 16.06 0 00-23.22-.56L424.35 65a8 8 0 000 11.31l11.34 11.32a8 8 0 0011.34 0l12.06-12c6.1-6.09 6.67-16.01.85-22.38zM399.34 90L218.82 270.2a9 9 0 00-2.31 3.93L208.16 299a3.91 3.91 0 004.86 4.86l24.85-8.35a9 9 0 003.93-2.31L422 112.66a9 9 0 000-12.66l-9.95-10a9 9 0 00-12.71 0z"/>
-                    </svg>
-                  }
-                </div>
-              }
-            </div>
-          </div>
-        }
-        <!-- 项目组选择 -->
-        <div class="department-section">
-          <h4 class="section-title">选择项目组</h4>
-          @if (departments.length === 0) {
-            <div class="empty-state">
-              <p>暂无可用项目组</p>
-            </div>
-          } @else {
-            <div class="department-grid">
-              @for (dept of departments; track dept.id) {
-                <div
-                  class="department-item"
-                  [class.selected]="selectedDepartment?.id === dept.id"
-                  (click)="selectDepartment(dept)">
-                  <h5>{{ dept.get('name') }}</h5>
-                  <p>组长: {{ dept.get('leader')?.get('name') || '未指定' }}</p>
-                  @if (selectedDepartment?.id === dept.id) {
-                    <svg class="icon selected-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                      <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-38 312.38L137.4 280.8a24 24 0 0133.94-33.94l50.2 50.2 95.74-95.74a24 24 0 0133.94 33.94z"/>
-                    </svg>
-                  }
-                </div>
-              }
-            </div>
-          }
-        </div>
-
-        <!-- 组员选择 -->
-        @if (selectedDepartment) {
-          <div class="designer-section">
-            <h4 class="section-title">选择组员</h4>
-            @if (loadingMembers) {
-              <div class="loading-spinner">
-                <div class="spinner-sm"></div>
-                <p>加载组员中...</p>
-              </div>
-            } @else if (departmentMembers.length === 0) {
-              <div class="empty-state">
-                <p>该项目组暂无可用组员</p>
-              </div>
-            } @else {
-              <div class="designer-grid">
-                @for (designer of departmentMembers; track designer.id) {
-                  <div
-                    class="designer-item"
-                    [class.selected]="selectedDesigner?.id === designer.id"
-                    (click)="selectDesigner(designer)">
-                    <div class="designer-avatar">
-                      @if (designer.get('data')?.avatar) {
-                        <img [src]="designer.get('data').avatar" alt="设计师头像" />
-                      } @else {
-                        <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                          <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
-                        </svg>
-                      }
-                    </div>
-                    <div class="designer-info">
-                      <h4>{{ designer.get('name') }}</h4>
-                      <p>{{ getDesignerWorkload(designer) }}</p>
-                    </div>
-                    @if (selectedDesigner?.id === designer.id) {
-                      <svg class="icon selected-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                        <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-38 312.38L137.4 280.8a24 24 0 0133.94-33.94l50.2 50.2 95.74-95.74a24 24 0 0133.94 33.94z"/>
-                      </svg>
-                    }
-                  </div>
-                }
-              </div>
-            }
-          </div>
-        }
-      </div>
-    </div>
+    <app-team-assign
+      [project]="project"
+      [canEdit]="canEdit"
+      [currentUser]="currentUser">
+    </app-team-assign>
 
     <!-- 6. 项目文件管理 -->
     <div class="card files-card">
@@ -708,61 +347,4 @@
     }
   </div>
 
-  <!-- 设计师分配对话框 -->
-  @if (showAssignDialog && assigningDesigner) {
-    <div class="modal-overlay" (click)="cancelAssignDialog()">
-      <div class="modal-dialog" (click)="$event.stopPropagation()">
-        <div class="modal-header">
-          <h3 class="modal-title">{{ editingTeam ? '编辑分配' : '分配设计师' }}</h3>
-          <button class="modal-close" (click)="cancelAssignDialog()">
-            <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-              <path fill="currentColor" d="M289.94 256l95-95A24 24 0 00351 127l-95 95-95-95a24 24 0 00-34 34l95 95-95 95a24 24 0 1034 34l95-95 95 95a24 24 0 0034-34z"/>
-            </svg>
-          </button>
-        </div>
-        <div class="modal-content">
-          <div class="designer-preview">
-            <div class="designer-avatar">
-              @if (assigningDesigner.get('data')?.avatar) {
-                <img [src]="assigningDesigner.get('data').avatar" alt="设计师头像" />
-              } @else {
-                <svg class="icon avatar-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
-                  <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
-                </svg>
-              }
-            </div>
-            <div class="designer-name">{{ assigningDesigner.get('name') }}</div>
-          </div>
-
-          <div class="space-selection-section">
-            <h4 class="form-label">指派空间场景 <span class="required">*</span></h4>
-            <p class="form-help">请选择该设计师负责的空间</p>
-            <div class="space-checkbox-list">
-              @for (space of quotation.spaces; track space.name) {
-                <label class="space-checkbox-item">
-                  <input
-                    type="checkbox"
-                    [checked]="selectedSpaces.includes(space.name)"
-                    (change)="toggleSpaceSelection(space.name)" />
-                  <span class="checkbox-custom"></span>
-                  <span class="space-name">{{ space.name }}</span>
-                </label>
-              }
-            </div>
-          </div>
-        </div>
-        <div class="modal-footer">
-          <button class="btn btn-outline" (click)="cancelAssignDialog()">
-            取消
-          </button>
-          <button
-            class="btn btn-primary"
-            (click)="confirmAssignDesigner()"
-            [disabled]="saving || selectedSpaces.length === 0">
-            {{ editingTeam ? '确认更新' : '确认分配' }}
-          </button>
-        </div>
-      </div>
-    </div>
-  }
 }

+ 36 - 317
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit, Input, ViewChild, ElementRef, ChangeDetectionStrategy } from '@angular/core';
+import { Component, OnInit, Input, ViewChild, ElementRef, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
@@ -17,6 +17,7 @@ import {
   getDefaultProcesses
 } from '../../../config/quotation-rules';
 import { QuotationEditorComponent } from '../../../components/quotation-editor.component';
+import { TeamAssignComponent } from '../../../components/team-assign/team-assign.component';
 
 const Parse = FmodeParse.with('nova');
 
@@ -34,7 +35,7 @@ const Parse = FmodeParse.with('nova');
 @Component({
   selector: 'app-stage-order',
   standalone: true,
-  imports: [CommonModule, FormsModule, QuotationEditorComponent],
+  imports: [CommonModule, FormsModule, QuotationEditorComponent, TeamAssignComponent],
   templateUrl: './stage-order.component.html',
   styleUrls: ['./stage-order.component.scss'],
   changeDetection: ChangeDetectionStrategy.OnPush
@@ -143,28 +144,9 @@ export class StageOrderComponent implements OnInit {
     { key: 'postProcess', name: '后期', color: 'success' }
   ];
 
-  // 项目组(Department)列表
-  departments: FmodeObject[] = [];
-  selectedDepartment: FmodeObject | null = null;
-
-  // 项目组成员(Profile)列表
-  departmentMembers: FmodeObject[] = [];
-  selectedDesigner: FmodeObject | null = null;
-
-  // 已分配的项目团队成员
-  projectTeams: FmodeObject[] = [];
-
-  // 设计师分配对话框
-  showAssignDialog: boolean = false;
-  assigningDesigner: FmodeObject | null = null;
-  selectedSpaces: string[] = [];
-  editingTeam: FmodeObject | null = null; // 当前正在编辑的团队对象
-
   // 加载状态
   loading: boolean = true;
   saving: boolean = false;
-  loadingMembers: boolean = false;
-  loadingTeams: boolean = false;
   loadingSpaces: boolean = false;
 
   // 路由参数
@@ -204,7 +186,8 @@ export class StageOrderComponent implements OnInit {
   constructor(
     private route: ActivatedRoute,
     private projectFileService: ProjectFileService,
-    private productSpaceService: ProductSpaceService
+    private productSpaceService: ProductSpaceService,
+    private cdr: ChangeDetectorRef
   ) {
     this.checkWxWorkSupport();
   }
@@ -299,7 +282,6 @@ export class StageOrderComponent implements OnInit {
         query.include('customer', 'assignee', 'department');
         this.project = await query.get(this.projectId);
         this.customer = this.project.get('customer');
-        this.selectedDesigner = this.project.get('assignee');
       }
 
       if (!this.currentUser && this.cid) {
@@ -346,31 +328,9 @@ export class StageOrderComponent implements OnInit {
           this.commercialScenes = data.commercialScenes;
         }
 
-        // 加载已分配的项目组和设计师
-        const department = this.project.get('department');
-        if (department) {
-          this.selectedDepartment = department;
-          await this.loadDepartmentMembers(department.id);
-        }
-
-        const assignee = this.project.get('assignee');
-        if (assignee) {
-          this.selectedDesigner = assignee;
-        }
+        // 团队分配由 TeamAssign 组件管理
       }
 
-      // 使用FmodeParse加载项目组列表(Department表)
-      const deptQuery = new Parse.Query('Department');
-      deptQuery.include("leader");
-      deptQuery.equalTo('type', 'project');
-      deptQuery.equalTo('company', localStorage.getItem('company'));
-      deptQuery.notEqualTo('isDeleted', true);
-      deptQuery.ascending('name');
-      this.departments = await deptQuery.find();
-
-      // 加载已分配的项目团队
-      await this.loadProjectTeams();
-
       // 加载项目文件
       await this.loadProjectFiles();
 
@@ -378,6 +338,7 @@ export class StageOrderComponent implements OnInit {
       console.error('加载失败:', err);
     } finally {
       this.loading = false;
+      this.cdr.markForCheck();
     }
   }
 
@@ -410,6 +371,7 @@ export class StageOrderComponent implements OnInit {
       console.error('加载项目空间失败:', error);
     } finally {
       this.loadingSpaces = false;
+      this.cdr.markForCheck();
     }
   }
 
@@ -682,20 +644,6 @@ export class StageOrderComponent implements OnInit {
     this.quotation.total = total;
   }
 
-  /**
-   * 空方法保持兼容性
-   */
-  onHomeSceneChange() {
-    // 空实现,功能已移至quotation-editor组件
-  }
-
-  onCommercialTypeChange() {
-    // 空实现,功能已移至quotation-editor组件
-  }
-
-  generateQuotation() {
-    // 空实现,功能已移至quotation-editor组件
-  }
 
   /**
    * 获取空间进度
@@ -746,234 +694,7 @@ export class StageOrderComponent implements OnInit {
   /**
    * 选择项目组(Department)
    */
-  async selectDepartment(department: FmodeObject) {
-    if (this.canEdit || this.project?.get('assignee')?.id) {
-      this.selectedDepartment = department;
-      this.selectedDesigner = null;
-      this.departmentMembers = [];
-
-      await this.loadDepartmentMembers(department);
-    }
-  }
-
-  /**
-   * 加载项目组成员
-   */
-  async loadDepartmentMembers(department: FmodeObject) {
-    let departmentId = department.id
-    if (!departmentId) return []
-    try {
-      this.loadingMembers = true;
-
-      // 使用FmodeParse查询项目组的组员(Profile表,roleName为"组员")
-      const query = new Parse.Query('Profile');
-      query.equalTo('department', departmentId);
-      query.equalTo('roleName', '组员');
-      query.notEqualTo('isDeleted', true);
-      query.ascending('name');
-
-      this.departmentMembers = await query.find();
-      this.departmentMembers.unshift(department?.get("leader"))
-      return this.departmentMembers
-    } catch (err) {
-      console.error('加载项目组成员失败:', err);
-    } finally {
-      this.loadingMembers = false;
-    }
-    return []
-  }
-
-  /**
-   * 加载已分配的ProjectTeam
-   */
-  async loadProjectTeams() {
-    if (!this.project) return;
-
-    try {
-      this.loadingTeams = true;
-
-      const query = new Parse.Query('ProjectTeam');
-      query.equalTo('project', this.project.toPointer());
-      query.include('profile');
-      query.notEqualTo('isDeleted', true);
-
-      this.projectTeams = await query.find();
-    } catch (err) {
-      console.error('加载项目团队失败:', err);
-    } finally {
-      this.loadingTeams = false;
-    }
-  }
-
-  /**
-   * 选择设计师 - 弹出分配对话框
-   */
-  selectDesigner(designer: FmodeObject) {
-    // 检查是否已分配
-    const isAssigned = this.projectTeams.some(team => team.get('profile')?.id === designer.id);
-    if (isAssigned) {
-      alert('该设计师已分配到此项目');
-      return;
-    }
-
-    this.assigningDesigner = designer;
-    this.selectedSpaces = [];
-
-    // 如果只有一个空间,默认选中
-    if (this.quotation.spaces.length === 1) {
-      this.selectedSpaces = [this.quotation.spaces[0].name];
-    }
-
-    this.showAssignDialog = true;
-  }
-
-  /**
-   * 编辑已分配的设计师 - 重用分配对话框
-   */
-  editAssignedDesigner(team: FmodeObject) {
-    if (!this.canEdit) return;
-
-    const designer = team.get('profile');
-    if (!designer) return;
-
-    // 设置当前编辑的设计师和团队对象
-    this.assigningDesigner = designer;
-    this.editingTeam = team;
-
-    // 预选当前已分配的空间
-    const currentSpaces = team.get('data')?.spaces || [];
-    this.selectedSpaces = [...currentSpaces];
-
-    this.showAssignDialog = true;
-  }
-
-  /**
-   * 切换空间选择
-   */
-  toggleSpaceSelection(spaceName: string) {
-    const index = this.selectedSpaces.indexOf(spaceName);
-    if (index > -1) {
-      this.selectedSpaces.splice(index, 1);
-    } else {
-      this.selectedSpaces.push(spaceName);
-    }
-  }
-
-  /**
-   * 确认分配设计师(支持创建和更新)
-   */
-  async confirmAssignDesigner() {
-    if (!this.assigningDesigner || !this.project) return;
-
-    if (this.selectedSpaces.length === 0) {
-      alert('请至少选择一个空间场景');
-      return;
-    }
-
-    try {
-      this.saving = true;
-
-      if (this.editingTeam) {
-        // 更新现有团队成员的空间分配
-        const data = this.editingTeam.get('data') || {};
-        data.spaces = this.selectedSpaces;
-        data.updatedAt = new Date();
-        data.updatedBy = this.currentUser?.id;
-        this.editingTeam.set('data', data);
-
-        await this.editingTeam.save();
-
-        alert('更新成功');
-      } else {
-        // 创建新的 ProjectTeam
-        const ProjectTeam = Parse.Object.extend('ProjectTeam');
-        const team = new ProjectTeam();
-        team.set('project', this.project.toPointer());
-        team.set('profile', this.assigningDesigner.toPointer());
-        team.set('role', '组员');
-        team.set('data', {
-          spaces: this.selectedSpaces,
-          assignedAt: new Date(),
-          assignedBy: this.currentUser?.id
-        });
-
-        await team.save();
-
-        // 加入群聊(静默执行)
-        await this.addMemberToGroupChat(this.assigningDesigner.get('userId'));
-
-        alert('分配成功');
-      }
-
-      // 重新加载团队列表
-      await this.loadProjectTeams();
-
-      this.showAssignDialog = false;
-      this.assigningDesigner = null;
-      this.selectedSpaces = [];
-      this.editingTeam = null;
-
-    } catch (err) {
-      console.error(this.editingTeam ? '更新失败:' : '分配设计师失败:', err);
-      alert(this.editingTeam ? '更新失败' : '分配失败');
-    } finally {
-      this.saving = false;
-    }
-  }
-
-  /**
-   * 取消分配对话框
-   */
-  cancelAssignDialog() {
-    this.showAssignDialog = false;
-    this.assigningDesigner = null;
-    this.selectedSpaces = [];
-    this.editingTeam = null;
-  }
-
-  /**
-   * 添加成员到群聊(静默执行)
-   */
-  async addMemberToGroupChat(userId: string) {
-    if (!userId) return;
-
-    try {
-      // 从父组件获取groupChat和chatId
-      const groupChat = (this as any).groupChat;
-      if (!groupChat) return;
-
-      const chatId = groupChat.get('chat_id');
-      if (!chatId) return;
-
-      // 调用企微SDK添加成员(需要在前端通过ww对象调用)
-      // 这部分需要在浏览器环境中通过企微JSSDK执行
-      if (typeof (window as any).ww !== 'undefined') {
-        await (window as any).ww.updateEnterpriseChat({
-          chatId: chatId,
-          userIdsToAdd: [userId]
-        });
-      }
-    } catch (err) {
-      // 静默失败,不影响主流程
-      console.warn('添加群成员失败:', err);
-    }
-  }
-
-  /**
-   * 获取团队成员负责的空间
-   */
-  getMemberSpaces(team: FmodeObject): string {
-    const spaces = team.get('data')?.spaces || [];
-    return spaces.join('、') || '未分配';
-  }
-
-  /**
-   * 获取设计师工作量
-   */
-  getDesignerWorkload(designer: FmodeObject): string {
-    // TODO: 查询该设计师当前进行中的项目数量
-    return '3个项目';
-  }
+  // 团队分配相关逻辑已迁移至 TeamAssignComponent
 
   /**
    * 报价数据变化回调
@@ -1029,13 +750,7 @@ export class StageOrderComponent implements OnInit {
       data.commercialScenes = this.commercialScenes;
       this.project.set('data', data);
 
-      if (this.selectedDepartment) {
-        this.project.set('department', this.selectedDepartment.toPointer());
-      }
-
-      if (this.selectedDesigner) {
-        this.project.set('assignee', this.selectedDesigner.toPointer());
-      }
+      // 团队分配由 TeamAssign 组件负责,不在此保存 department/assignee
 
       await this.project.save();
 
@@ -1054,47 +769,58 @@ export class StageOrderComponent implements OnInit {
   async submitForOrder() {
     if (!this.project || !this.canEdit) return;
 
-    // 验证
+    // 基础验证
     if (!this.projectInfo.title.trim()) {
       alert('请填写项目名称');
       return;
     }
-
     if (!this.projectInfo.projectType) {
       alert('请选择项目类型');
       return;
     }
-
     if (!this.projectInfo.deadline) {
       alert('请选择交付期限');
       return;
     }
-
     if (this.quotation.total === 0) {
       alert('请配置报价明细');
       return;
     }
 
-    if (!this.selectedDepartment) {
-      alert('请选择项目组');
-      return;
-    }
-
-    if (!this.selectedDesigner) {
-      alert('请选择设计师');
-      return;
-    }
-
     try {
       this.saving = true;
 
+      // 校验是否已在 TeamAssign 中分配至少一位组员
+      const query = new Parse.Query('ProjectTeam');
+      query.equalTo('project', this.project.toPointer());
+      query.include('profile');
+      query.notEqualTo('isDeleted', true);
+      const assignedTeams = await query.find();
+      if (assignedTeams.length === 0) {
+        alert('请在“设计师分配”中分配至少一位组员');
+        this.saving = false;
+        return;
+      }
+
       await this.saveDraft();
 
+      // 推进阶段
       this.project.set('currentStage', '确认需求');
 
+      // 记录审批历史(包含团队快照)
       const data = this.project.get('data') || {};
       const approvalHistory = data.approvalHistory || [];
 
+      const teamSnapshot = assignedTeams.map(team => {
+        const profile = team.get('profile');
+        const spaces = team.get('data')?.spaces || [];
+        return {
+          id: profile?.id,
+          name: profile?.get('name'),
+          spaces
+        };
+      });
+
       approvalHistory.push({
         stage: '订单分配',
         submitter: {
@@ -1105,14 +831,7 @@ export class StageOrderComponent implements OnInit {
         submitTime: new Date(),
         status: 'confirm',
         quotationTotal: this.quotation.total,
-        department: {
-          id: this.selectedDepartment.id,
-          name: this.selectedDepartment.get('name')
-        },
-        assignee: {
-          id: this.selectedDesigner.id,
-          name: this.selectedDesigner.get('name')
-        }
+        teams: teamSnapshot
       });
 
       data.approvalHistory = approvalHistory;