Browse Source

feat: stage-order & quotation editor

ryanemax 7 hours ago
parent
commit
a6a0795213

+ 21 - 0
rules/schemas.md

@@ -754,6 +754,7 @@ await project.save();
 | profile | Pointer | 是 | 团队成员 | → Profile |
 | role | String | 是 | 项目角色 | "主设计师" / "建模师" / "渲染师" |
 | workload | Number | 否 | 工作量占比 | 0.6 |
+| data | Object | 否 | 扩展数据 | { spaces: [...], performance: {...} } |
 | isDeleted | Boolean | 否 | 软删除标记 | false |
 | createdAt | Date | 自动 | 加入时间 | 2024-01-01T00:00:00.000Z |
 
@@ -764,6 +765,26 @@ await project.save();
 - `软装师`: 负责软装设计
 - `助理`: 辅助工作
 
+**data字段扩展示例**:
+```json
+{
+  "spaces": ["客厅", "主卧"],
+  "assignedAt": "2024-10-17T10:00:00.000Z",
+  "assignedBy": "prof001",
+  "performance": {
+    "quality": 4.5,
+    "efficiency": 4.0,
+    "communication": 5.0,
+    "notes": "设计方案客户满意度高,沟通及时"
+  },
+  "review": {
+    "reviewedAt": "2024-11-15T10:00:00.000Z",
+    "reviewer": "prof003",
+    "comments": "表现优秀,按时完成任务"
+  }
+}
+```
+
 **使用场景**:
 ```typescript
 // 添加团队成员

+ 2 - 2
src/app/pages/admin/groupchats/groupchats.html

@@ -42,7 +42,7 @@
         <tr *ngFor="let group of filtered" [class.disabled]="group.isDisabled">
           <td>{{ group.name }}</td>
           <td><code>{{ group.chat_id }}</code></td>
-          <td>{{ group.project || '未关联' }}</td>
+          <td>{{ group.project?.get("title") || '未关联' }}</td>
           <td>{{ group.memberCount }}</td>
           <td>
             <span [class]="'status ' + (group.isDisabled ? 'disabled' : 'active')">
@@ -78,7 +78,7 @@
           <div class="section-title">基础信息</div>
           <div class="detail-item"><label>群名称</label><div>{{ currentGroupChat.name }}</div></div>
           <div class="detail-item"><label>企微群ID</label><div><code>{{ currentGroupChat.chat_id }}</code></div></div>
-          <div class="detail-item"><label>关联项目</label><div>{{ currentGroupChat.project || '未关联' }}</div></div>
+          <div class="detail-item"><label>关联项目</label><div>{{ currentGroupChat.project?.get("title") || '未关联' }}</div></div>
           <div class="detail-item"><label>成员数</label><div>{{ currentGroupChat.memberCount }}</div></div>
           <div class="detail-item"><label>状态</label><div>{{ currentGroupChat.isDisabled ? '已禁用' : '正常' }}</div></div>
 

+ 4 - 5
src/app/pages/admin/groupchats/groupchats.ts

@@ -10,7 +10,7 @@ interface GroupChat {
   id: string;
   chat_id: string;
   name: string;
-  project?: string;
+  project?: FmodeObject;
   projectId?: string;
   memberCount: number;
   isDisabled?: boolean;
@@ -69,14 +69,14 @@ export class GroupChats implements OnInit {
     this.loading.set(true);
     try {
       const groups = await this.groupChatService.findGroupChats();
-
+      console.log("groups",groups)
       const groupList: GroupChat[] = groups.map(g => {
         const json = this.groupChatService.toJSON(g);
         return {
           id: json.objectId,
           chat_id: json.chat_id || '',
           name: json.name || '未命名群组',
-          project: json.projectTitle,
+          project: g.get("project"),
           projectId: json.projectId,
           memberCount: json.member_list?.length || 0,
           isDisabled: json.isDisabled || false,
@@ -218,14 +218,13 @@ export class GroupChats implements OnInit {
     const rows = this.filtered.map(g => [
       g.name,
       g.chat_id,
-      g.project || '未关联',
+      g.project?.get("title") || '未关联',
       String(g.memberCount),
       g.isDisabled ? '已禁用' : '正常',
       g.createdAt instanceof Date
         ? g.createdAt.toISOString().slice(0, 10)
         : String(g.createdAt || '')
     ]);
-
     this.downloadCSV('群组列表.csv', [header, ...rows]);
   }
 

+ 208 - 0
src/modules/project/components/quotation-editor.component.html

@@ -0,0 +1,208 @@
+<!-- 报价编辑器组件 -->
+<div class="quotation-editor">
+  @if (quotation.spaces.length === 0) {
+    <!-- 空状态 -->
+    <div class="empty-state">
+      <svg class="icon empty-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+        <rect x="112" y="128" width="288" height="320" rx="48" ry="48" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32"/>
+        <path fill="currentColor" d="M320 304h-32v-24a8 8 0 00-8-8h-48a8 8 0 00-8 8v24h-32a8 8 0 00-8 8v16a8 8 0 008 8h32v24a8 8 0 008 8h48a8 8 0 008-8v-24h32a8 8 0 008-8v-16a8 8 0 00-8-8z"/>
+      </svg>
+      <p class="empty-message">尚未生成报价</p>
+      <p class="empty-hint">请先选择场景并生成报价表</p>
+    </div>
+  } @else {
+    <!-- 报价工具栏 -->
+    <div class="quotation-toolbar">
+      <div class="toolbar-left">
+        <h4 class="toolbar-title">报价明细 ({{ quotation.spaces.length }}个空间)</h4>
+      </div>
+      <div class="toolbar-right">
+        <button class="btn-icon" (click)="expandAll()" title="展开全部">
+          <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M112 184l144 144 144-144M256 328V88"/>
+          </svg>
+        </button>
+        <button class="btn-icon" (click)="collapseAll()" title="折叠全部">
+          <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M112 328l144-144 144 144M256 184v240"/>
+          </svg>
+        </button>
+      </div>
+    </div>
+
+    <!-- 卡片视图 -->
+    @if (viewMode === 'card') {
+      <div class="quotation-spaces">
+        @for (space of quotation.spaces; track space.name) {
+          <div class="space-card" [class.expanded]="isSpaceExpanded(space.name)">
+            <!-- 空间头部 -->
+            <div class="space-header" (click)="toggleSpaceExpand(space.name)">
+              <div class="space-info">
+                <h3 class="space-name">{{ space.name }}</h3>
+                <p class="space-subtotal">小计: ¥{{ calculateSpaceSubtotal(space).toFixed(2) }}</p>
+              </div>
+              <div class="space-toggle">
+                <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                  <path fill="currentColor" d="M256 294.1L383 167c9.4-9.4 24.6-9.4 33.9 0s9.3 24.6 0 34L273 345c-9.1 9.1-23.7 9.3-33.1.7L95 201.1c-4.7-4.7-7-10.9-7-17s2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l127.1 127z"/>
+                </svg>
+              </div>
+            </div>
+
+            <!-- 空间详情 -->
+            @if (isSpaceExpanded(space.name)) {
+              <div class="space-content">
+                <!-- 工序网格 -->
+                <div class="process-grid">
+                  @for (processType of processTypes; track processType.key) {
+                    <div
+                      class="process-item"
+                      [class.enabled]="isProcessEnabled(space, processType.key)">
+                      <div class="process-header" (click)="canEdit && toggleProcess(space, processType.key)">
+                        <label class="checkbox-wrapper">
+                          <input
+                            type="checkbox"
+                            class="checkbox-input"
+                            [checked]="isProcessEnabled(space, processType.key)"
+                            [disabled]="!canEdit" />
+                          <span class="checkbox-custom"></span>
+                        </label>
+                        <span class="badge" [attr.data-color]="processType.color">
+                          {{ processType.name }}
+                        </span>
+                      </div>
+
+                      @if (isProcessEnabled(space, processType.key)) {
+                        <div class="process-inputs">
+                          <div class="input-group">
+                            <label class="input-label">单价</label>
+                            <div class="input-with-note">
+                              <input
+                                class="input-field"
+                                type="number"
+                                [ngModel]="getProcessPrice(space, processType.key)"
+                                (ngModelChange)="setProcessPrice(space, processType.key, $event); onProcessChange()"
+                                [disabled]="!canEdit"
+                                placeholder="0" />
+                              <span class="input-note">元/{{ getProcessUnit(space, processType.key) }}</span>
+                            </div>
+                          </div>
+
+                          <div class="input-group">
+                            <label class="input-label">数量</label>
+                            <div class="input-with-note">
+                              <input
+                                class="input-field"
+                                type="number"
+                                [ngModel]="getProcessQuantity(space, processType.key)"
+                                (ngModelChange)="setProcessQuantity(space, processType.key, $event); onProcessChange()"
+                                [disabled]="!canEdit"
+                                placeholder="0" />
+                              <span class="input-note">{{ getProcessUnit(space, processType.key) }}</span>
+                            </div>
+                          </div>
+
+                          <div class="process-subtotal">
+                            小计: ¥{{ calculateProcessSubtotal(space, processType.key).toFixed(2) }}
+                          </div>
+                        </div>
+                      }
+                    </div>
+                  }
+                </div>
+              </div>
+            }
+          </div>
+        }
+      </div>
+    }
+
+    <!-- 表格视图 -->
+    @if (viewMode === 'table') {
+      <div class="quotation-table">
+        @for (space of quotation.spaces; track space.name) {
+          <div class="table-section">
+            <div class="table-header">
+              <h3 class="table-space-name">{{ space.name }}</h3>
+              <span class="table-space-subtotal">小计: ¥{{ calculateSpaceSubtotal(space).toFixed(2) }}</span>
+            </div>
+            <table class="process-table">
+              <thead>
+                <tr>
+                  <th class="col-checkbox"></th>
+                  <th class="col-process">工序</th>
+                  <th class="col-price">单价(元)</th>
+                  <th class="col-quantity">数量</th>
+                  <th class="col-unit">单位</th>
+                  <th class="col-subtotal">小计(元)</th>
+                </tr>
+              </thead>
+              <tbody>
+                @for (processType of processTypes; track processType.key) {
+                  <tr [class.enabled]="isProcessEnabled(space, processType.key)">
+                    <td class="col-checkbox">
+                      <label class="checkbox-wrapper">
+                        <input
+                          type="checkbox"
+                          [checked]="isProcessEnabled(space, processType.key)"
+                          (change)="canEdit && toggleProcess(space, processType.key)"
+                          [disabled]="!canEdit" />
+                        <span class="checkbox-custom"></span>
+                      </label>
+                    </td>
+                    <td class="col-process">
+                      <span class="badge" [attr.data-color]="processType.color">
+                        {{ processType.name }}
+                      </span>
+                    </td>
+                    <td class="col-price">
+                      @if (isProcessEnabled(space, processType.key)) {
+                        <input
+                          class="table-input"
+                          type="number"
+                          [ngModel]="getProcessPrice(space, processType.key)"
+                          (ngModelChange)="setProcessPrice(space, processType.key, $event); onProcessChange()"
+                          [disabled]="!canEdit"
+                          placeholder="0" />
+                      } @else {
+                        <span class="disabled-value">-</span>
+                      }
+                    </td>
+                    <td class="col-quantity">
+                      @if (isProcessEnabled(space, processType.key)) {
+                        <input
+                          class="table-input"
+                          type="number"
+                          [ngModel]="getProcessQuantity(space, processType.key)"
+                          (ngModelChange)="setProcessQuantity(space, processType.key, $event); onProcessChange()"
+                          [disabled]="!canEdit"
+                          placeholder="0" />
+                      } @else {
+                        <span class="disabled-value">-</span>
+                      }
+                    </td>
+                    <td class="col-unit">
+                      {{ getProcessUnit(space, processType.key) || '-' }}
+                    </td>
+                    <td class="col-subtotal">
+                      @if (isProcessEnabled(space, processType.key)) {
+                        <strong>¥{{ calculateProcessSubtotal(space, processType.key).toFixed(2) }}</strong>
+                      } @else {
+                        <span class="disabled-value">-</span>
+                      }
+                    </td>
+                  </tr>
+                }
+              </tbody>
+            </table>
+          </div>
+        }
+      </div>
+    }
+
+    <!-- 总价 -->
+    <div class="quotation-total">
+      <div class="total-label">报价总额</div>
+      <div class="total-amount">¥{{ quotation.total.toFixed(2) }}</div>
+    </div>
+  }
+</div>

+ 504 - 0
src/modules/project/components/quotation-editor.component.scss

@@ -0,0 +1,504 @@
+.quotation-editor {
+  width: 100%;
+
+  // 空状态
+  .empty-state {
+    text-align: center;
+    padding: 48px 24px;
+    color: var(--ion-color-medium);
+
+    .empty-icon {
+      width: 80px;
+      height: 80px;
+      margin: 0 auto 16px;
+      opacity: 0.3;
+    }
+
+    .empty-message {
+      font-size: 16px;
+      font-weight: 500;
+      margin: 0 0 8px;
+      color: var(--ion-color-dark);
+    }
+
+    .empty-hint {
+      font-size: 14px;
+      margin: 0;
+    }
+  }
+
+  // 工具栏
+  .quotation-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 16px;
+    background: var(--ion-color-light);
+    border-radius: 8px;
+    margin-bottom: 16px;
+
+    .toolbar-title {
+      font-size: 16px;
+      font-weight: 600;
+      margin: 0;
+      color: var(--ion-color-dark);
+    }
+
+    .toolbar-right {
+      display: flex;
+      gap: 8px;
+    }
+
+    .btn-icon {
+      padding: 6px;
+      background: transparent;
+      border: 1px solid var(--ion-color-medium);
+      border-radius: 4px;
+      cursor: pointer;
+      transition: all 0.2s;
+
+      &:hover {
+        background: var(--ion-color-light-shade);
+        border-color: var(--ion-color-primary);
+      }
+
+      .icon {
+        width: 18px;
+        height: 18px;
+        color: var(--ion-color-medium-shade);
+      }
+    }
+  }
+
+  // 卡片视图
+  .quotation-spaces {
+    display: flex;
+    flex-direction: column;
+    gap: 16px;
+  }
+
+  .space-card {
+    border: 1px solid var(--ion-color-light-shade);
+    border-radius: 8px;
+    background: white;
+    overflow: hidden;
+    transition: all 0.3s;
+
+    &.expanded {
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+      .space-header {
+        background: var(--ion-color-light);
+        border-bottom: 1px solid var(--ion-color-light-shade);
+      }
+
+      .space-toggle .icon {
+        transform: rotate(180deg);
+      }
+    }
+  }
+
+  .space-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 16px;
+    cursor: pointer;
+    transition: background 0.2s;
+
+    &:hover {
+      background: var(--ion-color-light-tint);
+    }
+
+    .space-info {
+      flex: 1;
+    }
+
+    .space-name {
+      font-size: 16px;
+      font-weight: 600;
+      margin: 0 0 4px;
+      color: var(--ion-color-dark);
+    }
+
+    .space-subtotal {
+      font-size: 14px;
+      margin: 0;
+      color: var(--ion-color-primary);
+      font-weight: 500;
+    }
+
+    .space-toggle {
+      .icon {
+        width: 20px;
+        height: 20px;
+        color: var(--ion-color-medium);
+        transition: transform 0.3s;
+      }
+    }
+  }
+
+  .space-content {
+    padding: 16px;
+    animation: slideDown 0.3s ease-out;
+  }
+
+  @keyframes slideDown {
+    from {
+      opacity: 0;
+      transform: translateY(-10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0);
+    }
+  }
+
+  // 工序网格
+  .process-grid {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+    gap: 12px;
+  }
+
+  .process-item {
+    border: 1px solid var(--ion-color-light-shade);
+    border-radius: 6px;
+    padding: 12px;
+    background: var(--ion-color-light-tint);
+    transition: all 0.2s;
+
+    &.enabled {
+      border-color: var(--ion-color-primary);
+      background: white;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+    }
+
+    .process-header {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      cursor: pointer;
+      margin-bottom: 8px;
+
+      .checkbox-wrapper {
+        display: flex;
+        align-items: center;
+
+        .checkbox-input {
+          display: none;
+
+          &:checked + .checkbox-custom {
+            background: var(--ion-color-primary);
+            border-color: var(--ion-color-primary);
+
+            &::after {
+              opacity: 1;
+            }
+          }
+        }
+
+        .checkbox-custom {
+          width: 18px;
+          height: 18px;
+          border: 2px solid var(--ion-color-medium);
+          border-radius: 4px;
+          position: relative;
+          transition: all 0.2s;
+
+          &::after {
+            content: '';
+            position: absolute;
+            left: 4px;
+            top: 1px;
+            width: 5px;
+            height: 9px;
+            border: solid white;
+            border-width: 0 2px 2px 0;
+            transform: rotate(45deg);
+            opacity: 0;
+            transition: opacity 0.2s;
+          }
+        }
+      }
+
+      .badge {
+        padding: 4px 12px;
+        border-radius: 12px;
+        font-size: 16px;
+        font-weight: 700;
+        background: white;
+
+        &[data-color="primary"] {
+          color: var(--ion-color-primary-tint);
+                    border: solid 1px var(--ion-color-primary-tint);
+        }
+
+        &[data-color="secondary"] {
+          color: var(--ion-color-secondary-tint);
+                    border: solid 1px var(--ion-color-secondary-tint);
+        }
+
+        &[data-color="tertiary"] {
+          color: var(--ion-color-tertiary-tint);
+                    border: solid 1px var(--ion-color-tertiary-tint);
+        }
+
+        &[data-color="success"] {
+          color: var(--ion-color-success-tint);
+                    border: solid 1px var(--ion-color-success-tint);
+        }
+      }
+    }
+
+    .process-inputs {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+
+      .input-group {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+
+        .input-label {
+          font-size: 12px;
+          color: var(--ion-color-medium-shade);
+          font-weight: 500;
+        }
+
+        .input-with-note {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+
+          .input-field {
+            flex: 1;
+            padding: 6px 8px;
+            border: 1px solid var(--ion-color-light-shade);
+            border-radius: 4px;
+            font-size: 14px;
+
+            &:focus {
+              outline: none;
+              border-color: var(--ion-color-primary);
+            }
+
+            &:disabled {
+              background: var(--ion-color-light);
+              color: var(--ion-color-medium);
+            }
+          }
+
+          .input-note {
+            font-size: 12px;
+            color: var(--ion-color-medium);
+            white-space: nowrap;
+          }
+        }
+      }
+
+      .process-subtotal {
+        padding: 6px 8px;
+        background: var(--ion-color-light);
+        border-radius: 4px;
+        font-size: 13px;
+        font-weight: 600;
+        color: var(--ion-color-primary);
+        text-align: right;
+      }
+    }
+  }
+
+  // 表格视图
+  .quotation-table {
+    display: flex;
+    flex-direction: column;
+    gap: 24px;
+  }
+
+  .table-section {
+    border: 1px solid var(--ion-color-light-shade);
+    border-radius: 8px;
+    overflow: hidden;
+    background: white;
+
+    .table-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 12px 16px;
+      background: var(--ion-color-light);
+      border-bottom: 1px solid var(--ion-color-light-shade);
+
+      .table-space-name {
+        font-size: 16px;
+        font-weight: 600;
+        margin: 0;
+        color: var(--ion-color-dark);
+      }
+
+      .table-space-subtotal {
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--ion-color-primary);
+      }
+    }
+  }
+
+  .process-table {
+    width: 100%;
+    border-collapse: collapse;
+
+    thead {
+      background: var(--ion-color-light-tint);
+
+      th {
+        padding: 10px 12px;
+        text-align: left;
+        font-size: 13px;
+        font-weight: 600;
+        color: var(--ion-color-medium-shade);
+        border-bottom: 2px solid var(--ion-color-light-shade);
+
+        &.col-checkbox {
+          width: 40px;
+        }
+
+        &.col-process {
+          width: auto;
+        }
+
+        &.col-price,
+        &.col-quantity {
+          width: 120px;
+        }
+
+        &.col-unit {
+          width: 80px;
+        }
+
+        &.col-subtotal {
+          width: 120px;
+          text-align: right;
+        }
+      }
+    }
+
+    tbody {
+      tr {
+        border-bottom: 1px solid var(--ion-color-light-shade);
+        transition: background 0.2s;
+
+        &:hover {
+          background: var(--ion-color-light-tint);
+        }
+
+        &.enabled {
+          background: rgba(var(--ion-color-primary-rgb), 0.02);
+
+          &:hover {
+            background: rgba(var(--ion-color-primary-rgb), 0.05);
+          }
+        }
+
+        td {
+          padding: 12px;
+          font-size: 14px;
+
+          &.col-subtotal {
+            text-align: right;
+          }
+
+          .checkbox-wrapper {
+            display: flex;
+            align-items: center;
+            justify-content: center;
+
+            .checkbox-input {
+              display: none;
+
+              &:checked + .checkbox-custom {
+                background: var(--ion-color-primary);
+                border-color: var(--ion-color-primary);
+
+                &::after {
+                  opacity: 1;
+                }
+              }
+            }
+
+            .checkbox-custom {
+              width: 18px;
+              height: 18px;
+              border: 2px solid var(--ion-color-medium);
+              border-radius: 4px;
+              position: relative;
+              transition: all 0.2s;
+              cursor: pointer;
+
+              &::after {
+                content: '';
+                position: absolute;
+                left: 4px;
+                top: 1px;
+                width: 5px;
+                height: 9px;
+                border: solid white;
+                border-width: 0 2px 2px 0;
+                transform: rotate(45deg);
+                opacity: 0;
+                transition: opacity 0.2s;
+              }
+            }
+          }
+
+          .table-input {
+            width: 100%;
+            padding: 6px 8px;
+            border: 1px solid var(--ion-color-light-shade);
+            border-radius: 4px;
+            font-size: 14px;
+
+            &:focus {
+              outline: none;
+              border-color: var(--ion-color-primary);
+            }
+
+            &:disabled {
+              background: var(--ion-color-light);
+              color: var(--ion-color-medium);
+            }
+          }
+
+          .disabled-value {
+            color: var(--ion-color-medium);
+          }
+        }
+      }
+    }
+  }
+
+  // 总价
+  .quotation-total {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 16px 20px;
+    background: linear-gradient(135deg, var(--ion-color-primary) 0%, var(--ion-color-primary-shade) 100%);
+    border-radius: 8px;
+    margin-top: 16px;
+
+    .total-label {
+      font-size: 16px;
+      font-weight: 500;
+      color: white;
+    }
+
+    .total-amount {
+      font-size: 24px;
+      font-weight: 700;
+      color: white;
+    }
+  }
+}

+ 194 - 0
src/modules/project/components/quotation-editor.component.ts

@@ -0,0 +1,194 @@
+import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+
+/**
+ * 报价编辑器组件
+ *
+ * 功能:
+ * 1. 展示报价明细,支持折叠/展开
+ * 2. 编辑工序价格和数量
+ * 3. 自动计算小计和总价
+ * 4. 支持表格和卡片两种展示模式
+ */
+@Component({
+  selector: 'app-quotation-editor',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './quotation-editor.component.html',
+  styleUrls: ['./quotation-editor.component.scss']
+})
+export class QuotationEditorComponent implements OnChanges {
+  @Input() quotation: any = { spaces: [], total: 0 };
+  @Input() canEdit: boolean = false;
+  @Input() viewMode: 'table' | 'card' = 'card'; // 展示模式
+
+  @Output() quotationChange = new EventEmitter<any>();
+  @Output() totalChange = new EventEmitter<number>();
+
+  // 工序类型定义
+  processTypes = [
+    { key: 'modeling', name: '建模', color: 'primary', icon: 'cube-outline' },
+    { key: 'softDecor', name: '软装', color: 'secondary', icon: 'color-palette-outline' },
+    { key: 'rendering', name: '渲染', color: 'tertiary', icon: 'image-outline' },
+    { key: 'postProcess', name: '后期', color: 'success', icon: 'sparkles-outline' }
+  ];
+
+  // 折叠状态
+  expandedSpaces: Set<string> = new Set();
+
+  ngOnChanges(changes: SimpleChanges) {
+    if (changes['quotation'] && this.quotation?.spaces?.length > 0) {
+      // 默认展开第一个空间
+      if (this.expandedSpaces.size === 0) {
+        this.expandedSpaces.add(this.quotation.spaces[0].name);
+      }
+    }
+  }
+
+  /**
+   * 切换空间展开/折叠状态
+   */
+  toggleSpaceExpand(spaceName: string) {
+    if (this.expandedSpaces.has(spaceName)) {
+      this.expandedSpaces.delete(spaceName);
+    } else {
+      this.expandedSpaces.add(spaceName);
+    }
+  }
+
+  /**
+   * 检查空间是否展开
+   */
+  isSpaceExpanded(spaceName: string): boolean {
+    return this.expandedSpaces.has(spaceName);
+  }
+
+  /**
+   * 展开所有空间
+   */
+  expandAll() {
+    this.quotation.spaces.forEach((space: any) => {
+      this.expandedSpaces.add(space.name);
+    });
+  }
+
+  /**
+   * 折叠所有空间
+   */
+  collapseAll() {
+    this.expandedSpaces.clear();
+  }
+
+  /**
+   * 切换工序启用状态
+   */
+  toggleProcess(space: any, processKey: string) {
+    const process = space.processes[processKey];
+    process.enabled = !process.enabled;
+    if (!process.enabled) {
+      process.price = 0;
+      process.quantity = 0;
+    }
+    this.calculateTotal();
+  }
+
+  /**
+   * 工序价格或数量变化
+   */
+  onProcessChange() {
+    this.calculateTotal();
+  }
+
+  /**
+   * 计算报价总额
+   */
+  calculateTotal() {
+    let total = 0;
+    for (const space of this.quotation.spaces) {
+      for (const processKey of Object.keys(space.processes)) {
+        const process = space.processes[processKey];
+        if (process.enabled) {
+          total += process.price * process.quantity;
+        }
+      }
+    }
+    this.quotation.total = total;
+    this.quotationChange.emit(this.quotation);
+    this.totalChange.emit(total);
+  }
+
+  /**
+   * 计算空间小计
+   */
+  calculateSpaceSubtotal(space: any): number {
+    let subtotal = 0;
+    for (const processKey of Object.keys(space.processes)) {
+      const process = space.processes[processKey];
+      if (process.enabled) {
+        subtotal += process.price * process.quantity;
+      }
+    }
+    return subtotal;
+  }
+
+  /**
+   * 辅助方法:检查工序是否启用
+   */
+  isProcessEnabled(space: any, processKey: string): boolean {
+    const process = space.processes?.[processKey];
+    return process?.enabled || false;
+  }
+
+  /**
+   * 辅助方法:设置工序价格
+   */
+  setProcessPrice(space: any, processKey: string, value: any): void {
+    const process = space.processes?.[processKey];
+    if (process) {
+      process.price = value;
+    }
+  }
+
+  /**
+   * 辅助方法:设置工序数量
+   */
+  setProcessQuantity(space: any, processKey: string, value: any): void {
+    const process = space.processes?.[processKey];
+    if (process) {
+      process.quantity = value;
+    }
+  }
+
+  /**
+   * 辅助方法:获取工序价格
+   */
+  getProcessPrice(space: any, processKey: string): number {
+    const process = space.processes?.[processKey];
+    return process?.price || 0;
+  }
+
+  /**
+   * 辅助方法:获取工序数量
+   */
+  getProcessQuantity(space: any, processKey: string): number {
+    const process = space.processes?.[processKey];
+    return process?.quantity || 0;
+  }
+
+  /**
+   * 辅助方法:获取工序单位
+   */
+  getProcessUnit(space: any, processKey: string): string {
+    const process = space.processes?.[processKey];
+    return process?.unit || '';
+  }
+
+  /**
+   * 辅助方法:计算工序小计
+   */
+  calculateProcessSubtotal(space: any, processKey: string): number {
+    const process = space.processes?.[processKey];
+    return (process?.price || 0) * (process?.quantity || 0);
+  }
+}

+ 12 - 2
src/modules/project/pages/project-detail/project-detail.component.html

@@ -87,15 +87,25 @@
             </div>
             <div class="info-text">
               <h3>{{ customer?.get('name') || '待设置' }}</h3>
-              @if (canViewCustomerPhone) {
+              @if (customer && canViewCustomerPhone) {
                 <p>{{ customer?.get('mobile') }}</p>
+                <p class="wechat-id">微信: {{ customer?.get('data')?.wechat || customer?.get('external_userid') }}</p>
+              } @else if (customer) {
+                <p class="info-limited">仅客服可查看联系方式</p>
               }
               <div class="tags">
-                <span class="badge badge-primary">{{ customer?.get('source') }}</span>
+                @if (customer?.get('source')) {
+                  <span class="badge badge-primary">{{ customer?.get('source') }}</span>
+                }
                 <span class="badge" [class.badge-success]="project.get('status') === '进行中'" [class.badge-warning]="project.get('status') !== '进行中'">
                   {{ project.get('status') }}
                 </span>
               </div>
+              @if (!customer && canEdit && role === '客服') {
+                <button class="btn btn-sm btn-primary" (click)="selectCustomer()">
+                  选择客户
+                </button>
+              }
             </div>
           </div>
         </div>

+ 78 - 0
src/modules/project/pages/project-detail/project-detail.component.ts

@@ -330,4 +330,82 @@ export class ProjectDetailComponent implements OnInit {
       console.error('发送消息失败:', err);
     }
   }
+
+  /**
+   * 选择客户(从群聊成员中选择外部联系人)
+   */
+  async selectCustomer() {
+    if (!this.canEdit || !this.groupChat) return;
+
+    try {
+      const memberList = this.groupChat.get('member_list') || [];
+      const externalMembers = memberList.filter((m: any) => m.type === 2);
+
+      if (externalMembers.length === 0) {
+        alert('当前群聊中没有外部联系人');
+        return;
+      }
+
+      // 简单实现:选择第一个外部联系人
+      // TODO: 实现选择器UI
+      const selectedMember = externalMembers[0];
+
+      await this.setCustomerFromMember(selectedMember);
+    } catch (err) {
+      console.error('选择客户失败:', err);
+      alert('选择客户失败');
+    }
+  }
+
+  /**
+   * 从群成员设置客户
+   */
+  async setCustomerFromMember(member: any) {
+    if (!this.wecorp) return;
+
+    try {
+      const companyId = this.currentUser?.get('company')?.id;
+      if (!companyId) throw new Error('无法获取企业信息');
+
+      // 1. 查询是否已存在 ContactInfo
+      const query = new Parse.Query('ContactInfo');
+      query.equalTo('external_userid', member.userid);
+      query.equalTo('company', companyId);
+      let contactInfo = await query.first();
+
+      // 2. 如果不存在,通过企微API获取并创建
+      if (!contactInfo) {
+        const externalContactData = await this.wecorp.externalContact.get(member.userid);
+
+        const ContactInfo = Parse.Object.extend('ContactInfo');
+        contactInfo = new ContactInfo();
+        contactInfo.set('name', externalContactData.name);
+        contactInfo.set('external_userid', member.userid);
+
+        const company = new Parse.Object('Company');
+        company.id = companyId;
+        const companyPointer = company.toPointer();
+        contactInfo.set('company', companyPointer);
+
+        contactInfo.set('data', {
+          avatar: externalContactData.avatar,
+          type: externalContactData.type,
+          gender: externalContactData.gender,
+          follow_user: externalContactData.follow_user
+        });
+        await contactInfo.save();
+      }
+
+      // 3. 设置为项目客户
+      if (this.project) {
+        this.project.set('customer', contactInfo.toPointer());
+        await this.project.save();
+        this.customer = contactInfo;
+        alert('客户设置成功');
+      }
+    } catch (err) {
+      console.error('设置客户失败:', err);
+      throw err;
+    }
+  }
 }

+ 104 - 75
src/modules/project/pages/project-detail/stages/stage-order.component.html

@@ -251,9 +251,13 @@
               </div>
 
               <button
-                class="btn btn-primary"
+                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>
@@ -362,9 +366,13 @@
               </div>
 
               <button
-                class="btn btn-primary"
+                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>
@@ -373,7 +381,7 @@
       </div>
     }
 
-    <!-- 4. 报价明细 -->
+    <!-- 4. 报价明细(使用独立组件) -->
     @if (quotation.spaces.length > 0) {
       <div class="card quotation-card">
         <div class="card-header">
@@ -384,80 +392,16 @@
             </svg>
             报价明细
           </h3>
-          <p class="card-subtitle">可手动调整工序和价格</p>
+          <p class="card-subtitle">支持折叠展开,可手动调整工序和价格</p>
         </div>
         <div class="card-content">
-          @for (space of quotation.spaces; track space.name) {
-            <div class="space-section">
-              <div class="space-header">
-                <h3>{{ space.name }}</h3>
-              </div>
-
-              <div class="process-grid">
-                @for (processType of processTypes; track processType.key) {
-                  <div
-                    class="process-item"
-                    [class.enabled]="isProcessEnabled(space, processType.key)">
-                    <div class="process-header" (click)="canEdit && toggleProcess(space, processType.key)">
-                      <label class="checkbox-wrapper">
-                        <input
-                          type="checkbox"
-                          class="checkbox-input"
-                          [checked]="isProcessEnabled(space, processType.key)"
-                          [disabled]="!canEdit" />
-                        <span class="checkbox-custom"></span>
-                      </label>
-                      <span class="badge" [attr.data-color]="processType.color">
-                        {{ processType.name }}
-                      </span>
-                    </div>
-
-                    @if (isProcessEnabled(space, processType.key)) {
-                      <div class="process-inputs">
-                        <div class="input-group">
-                          <label class="input-label">单价</label>
-                          <div class="input-with-note">
-                            <input
-                              class="input-field"
-                              type="number"
-                              [ngModel]="getProcessPrice(space, processType.key)"
-                              (ngModelChange)="setProcessPrice(space, processType.key, $event); onProcessChange()"
-                              [disabled]="!canEdit"
-                              placeholder="0" />
-                            <span class="input-note">元/{{ getProcessUnit(space, processType.key) }}</span>
-                          </div>
-                        </div>
-
-                        <div class="input-group">
-                          <label class="input-label">数量</label>
-                          <div class="input-with-note">
-                            <input
-                              class="input-field"
-                              type="number"
-                              [ngModel]="getProcessQuantity(space, processType.key)"
-                              (ngModelChange)="setProcessQuantity(space, processType.key, $event); onProcessChange()"
-                              [disabled]="!canEdit"
-                              placeholder="0" />
-                            <span class="input-note">{{ getProcessUnit(space, processType.key) }}</span>
-                          </div>
-                        </div>
-
-                        <div class="process-subtotal">
-                          小计: ¥{{ calculateProcessSubtotal(space, processType.key).toFixed(2) }}
-                        </div>
-                      </div>
-                    }
-                  </div>
-                }
-              </div>
-            </div>
-          }
-
-          <!-- 总价 -->
-          <div class="total-section">
-            <div class="total-label">报价总额</div>
-            <div class="total-amount">¥{{ quotation.total.toFixed(2) }}</div>
-          </div>
+          <app-quotation-editor
+            [quotation]="quotation"
+            [canEdit]="canEdit"
+            [viewMode]="'card'"
+            (quotationChange)="onQuotationChange($event)"
+            (totalChange)="onTotalChange($event)">
+          </app-quotation-editor>
         </div>
       </div>
     }
@@ -475,6 +419,33 @@
         <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">
+                  <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>
+                </div>
+              }
+            </div>
+          </div>
+        }
         <!-- 项目组选择 -->
         <div class="department-section">
           <h4 class="section-title">选择项目组</h4>
@@ -574,4 +545,62 @@
       </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">分配设计师</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">
+            确认分配
+          </button>
+        </div>
+      </div>
+    </div>
+  }
 }

+ 68 - 0
src/modules/project/pages/project-detail/stages/stage-order.component.scss

@@ -699,6 +699,40 @@
         margin-bottom: 20px;
       }
 
+      .generate-quotation-btn {
+        width: 100%;
+        margin-top: 24px;
+        padding: 14px 24px;
+        font-size: 16px;
+        font-weight: 600;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 8px;
+        box-shadow: 0 4px 12px rgba(56, 128, 255, 0.25);
+        transition: all 0.3s ease;
+
+        .icon {
+          width: 20px;
+          height: 20px;
+        }
+
+        &:hover:not(:disabled) {
+          transform: translateY(-2px);
+          box-shadow: 0 6px 16px rgba(56, 128, 255, 0.35);
+        }
+
+        &:active:not(:disabled) {
+          transform: translateY(0);
+        }
+
+        &:disabled {
+          opacity: 0.5;
+          cursor: not-allowed;
+          box-shadow: none;
+        }
+      }
+
       .room-item {
         border: 2px solid var(--light-shade);
         border-radius: 8px;
@@ -776,6 +810,40 @@
         margin-bottom: 20px;
       }
 
+      .generate-quotation-btn {
+        width: 100%;
+        margin-top: 24px;
+        padding: 14px 24px;
+        font-size: 16px;
+        font-weight: 600;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        gap: 8px;
+        box-shadow: 0 4px 12px rgba(56, 128, 255, 0.25);
+        transition: all 0.3s ease;
+
+        .icon {
+          width: 20px;
+          height: 20px;
+        }
+
+        &:hover:not(:disabled) {
+          transform: translateY(-2px);
+          box-shadow: 0 6px 16px rgba(56, 128, 255, 0.35);
+        }
+
+        &:active:not(:disabled) {
+          transform: translateY(0);
+        }
+
+        &:disabled {
+          opacity: 0.5;
+          cursor: not-allowed;
+          box-shadow: none;
+        }
+      }
+
       .space-item {
         border: 2px solid var(--light-shade);
         border-radius: 8px;

+ 171 - 93
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -14,6 +14,7 @@ import {
   calculateFinalPrice,
   getDefaultProcesses
 } from '../../../config/quotation-rules';
+import { QuotationEditorComponent } from '../../../components/quotation-editor.component';
 
 const Parse = FmodeParse.with('nova');
 
@@ -29,7 +30,7 @@ const Parse = FmodeParse.with('nova');
 @Component({
   selector: 'app-stage-order',
   standalone: true,
-  imports: [CommonModule, FormsModule],
+  imports: [CommonModule, FormsModule, QuotationEditorComponent],
   templateUrl: './stage-order.component.html',
   styleUrls: ['./stage-order.component.scss']
 })
@@ -123,10 +124,19 @@ export class StageOrderComponent implements OnInit {
   departmentMembers: FmodeObject[] = [];
   selectedDesigner: FmodeObject | null = null;
 
+  // 已分配的项目团队成员
+  projectTeams: FmodeObject[] = [];
+
+  // 设计师分配对话框
+  showAssignDialog: boolean = false;
+  assigningDesigner: FmodeObject | null = null;
+  selectedSpaces: string[] = [];
+
   // 加载状态
   loading: boolean = true;
   saving: boolean = false;
   loadingMembers: boolean = false;
+  loadingTeams: boolean = false;
 
   // 路由参数
   cid: string = '';
@@ -223,6 +233,9 @@ export class StageOrderComponent implements OnInit {
       deptQuery.ascending('name');
       this.departments = await deptQuery.find();
 
+      // 加载已分配的项目团队
+      await this.loadProjectTeams();
+
     } catch (err) {
       console.error('加载失败:', err);
     } finally {
@@ -417,13 +430,14 @@ export class StageOrderComponent implements OnInit {
    * 选择项目组(Department)
    */
   async selectDepartment(department: FmodeObject) {
-    if (!this.canEdit && this.project?.get('assignee')?.id) return;
-
-    this.selectedDepartment = department;
-    this.selectedDesigner = null;
-    this.departmentMembers = [];
-
-    await this.loadDepartmentMembers(department);
+    if (this.canEdit || this.project?.get('assignee')?.id){
+      
+      this.selectedDepartment = department;
+      this.selectedDesigner = null;
+      this.departmentMembers = [];
+      
+      await this.loadDepartmentMembers(department);
+    }
   }
 
   /**
@@ -454,10 +468,151 @@ export class StageOrderComponent implements OnInit {
   }
 
   /**
-   * 选择设计师
+   * 加载已分配的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) {
-    this.selectedDesigner = designer;
+    // 检查是否已分配
+    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;
+  }
+
+  /**
+   * 切换空间选择
+   */
+  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;
+
+      // 创建 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'));
+
+      // 重新加载团队列表
+      await this.loadProjectTeams();
+
+      this.showAssignDialog = false;
+      this.assigningDesigner = null;
+      this.selectedSpaces = [];
+
+      alert('分配成功');
+    } catch (err) {
+      console.error('分配设计师失败:', err);
+      alert('分配失败');
+    } finally {
+      this.saving = false;
+    }
+  }
+
+  /**
+   * 取消分配对话框
+   */
+  cancelAssignDialog() {
+    this.showAssignDialog = false;
+    this.assigningDesigner = null;
+    this.selectedSpaces = [];
+  }
+
+  /**
+   * 添加成员到群聊(静默执行)
+   */
+  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('、') || '未分配';
   }
 
   /**
@@ -469,23 +624,17 @@ export class StageOrderComponent implements OnInit {
   }
 
   /**
-   * 切换工序启用状态
+   * 报价数据变化回调
    */
-  toggleProcess(space: any, processKey: string) {
-    const process = space.processes[processKey];
-    process.enabled = !process.enabled;
-    if (!process.enabled) {
-      process.price = 0;
-      process.quantity = 0;
-    }
-    this.calculateTotal();
+  onQuotationChange(updatedQuotation: any) {
+    this.quotation = updatedQuotation;
   }
 
   /**
-   * 工序价格或数量变化
+   * 报价总额变化回调
    */
-  onProcessChange() {
-    this.calculateTotal();
+  onTotalChange(total: number) {
+    this.quotation.total = total;
   }
 
   /**
@@ -611,75 +760,4 @@ export class StageOrderComponent implements OnInit {
     }
   }
 
-  /**
-   * 辅助方法:检查工序是否启用
-   */
-  isProcessEnabled(space: any, processKey: string): boolean {
-    const process = (space.processes as any)[processKey];
-    return process?.enabled || false;
-  }
-
-  /**
-   * 辅助方法:设置工序价格
-   */
-  setProcessPrice(space: any, processKey: string, value: any): void {
-    const process = (space.processes as any)[processKey];
-    if (process) {
-      process.price = value;
-    }
-  }
-
-  /**
-   * 辅助方法:设置工序数量
-   */
-  setProcessQuantity(space: any, processKey: string, value: any): void {
-    const process = (space.processes as any)[processKey];
-    if (process) {
-      process.quantity = value;
-    }
-  }
-
-  /**
-   * 辅助方法:获取工序价格
-   */
-  getProcessPrice(space: any, processKey: string): number {
-    const process = (space.processes as any)[processKey];
-    return process?.price || 0;
-  }
-
-  /**
-   * 辅助方法:获取工序数量
-   */
-  getProcessQuantity(space: any, processKey: string): number {
-    const process = (space.processes as any)[processKey];
-    return process?.quantity || 0;
-  }
-
-  /**
-   * 辅助方法:获取工序单位
-   */
-  getProcessUnit(space: any, processKey: string): string {
-    const process = (space.processes as any)[processKey];
-    return process?.unit || '';
-  }
-
-  /**
-   * 辅助方法:计算工序小计
-   */
-  calculateProcessSubtotal(space: any, processKey: string): number {
-    const process = (space.processes as any)[processKey];
-    return (process?.price || 0) * (process?.quantity || 0);
-  }
-
-  /**
-   * 辅助方法:获取项目类型图标
-   */
-  getProjectTypeIcon(type: string): string {
-    const iconMap: any = {
-      '家装': 'home-outline',
-      '工装': 'business-outline',
-      '软装设计': 'color-palette-outline'
-    };
-    return iconMap[type] || 'document-outline';
-  }
 }