日期: 2025-10-25
状态: ✅ 已完成
在设计师分配弹窗中集成真实的空间数据,从 Parse Server 的 Product 表自动加载项目的空间场景数据,实现真实的空间分配功能。
rules/schemas.md)根据 rules/schemas.md,Product 表是空间管理的核心:
Product {
  objectId: 'prod001',
  project: Pointer<Project>,      // 所属项目
  profile: Pointer<Profile>,       // 负责设计师
  productName: '主卧设计',         // 空间名称
  productType: 'bedroom',          // 空间类型
  status: 'in_progress',           // 产品状态
  space: {                         // 空间信息
    spaceName: '主卧',
    area: 18.5,
    dimensions: { length: 4.5, width: 4.1, height: 2.8 },
    features: ['朝南', '飘窗', '独立卫浴']
  },
  quotation: { /* 报价信息 */ },
  requirements: { /* 设计需求 */ },
  order: 1,                        // 排序
  isDeleted: false
}
ProjectTeam {
  objectId: 'team001',
  project: Pointer<Project>,       // 所属项目
  profile: Pointer<Profile>,       // 设计师
  role: '设计师',                  // 角色
  workload: 70,                    // 工作负载
  data: {
    assignedSpaces: ['prod001', 'prod002'],  // 负责的空间Product ID列表
    assignedDate: '2025-10-25'
  },
  isDeleted: false
}
import { ProductSpaceService, Project as ProductSpace } from 
  '../../../../../../modules/project/services/product-space.service';
constructor(
  private cdr: ChangeDetectorRef,
  private productSpaceService: ProductSpaceService
) {}
@Input() loadRealSpaces: boolean = true; // 是否自动加载真实空间数据
private parseProducts: ProductSpace[] = []; // 项目的产品空间列表
loadingSpaces = false;                      // 空间加载状态
spaceLoadError = '';                        // 空间加载错误
loadRealProjectSpaces() 方法async loadRealProjectSpaces() {
  if (!this.projectId) {
    console.warn('未提供projectId,无法加载空间数据');
    return;
  }
  try {
    this.loadingSpaces = true;
    this.spaceLoadError = '';
    // 使用ProductSpaceService查询项目的所有空间产品
    this.parseProducts = await this.productSpaceService
      .getProjectProductSpaces(this.projectId);
    if (this.parseProducts.length === 0) {
      console.warn('未找到项目空间数据');
      this.spaceLoadError = '未找到项目空间数据';
      return;
    }
    // 转换为SpaceScene格式
    this.spaceScenes = this.parseProducts.map(product => ({
      id: product.id,              // Product.objectId
      name: product.name,          // Product.productName
      area: product.area,          // Product.space.area
      description: this.getProductDescription(product)
    }));
    console.log('成功加载项目空间数据:', this.spaceScenes);
  } catch (err) {
    console.error('加载项目空间数据失败:', err);
    this.spaceLoadError = '加载项目空间数据失败';
  } finally {
    this.loadingSpaces = false;
    this.cdr.markForCheck();
  }
}
getProductDescription() 方法生成空间的描述信息:
private getProductDescription(product: ProductSpace): string {
  const parts: string[] = [];
  
  // 产品类型映射
  if (product.type) {
    const typeMap: Record<string, string> = {
      'living_room': '客厅',
      'bedroom': '卧室',
      'kitchen': '厨房',
      'bathroom': '卫生间',
      'study': '书房',
      'dining_room': '餐厅',
      'balcony': '阳台',
      'entrance': '玄关',
      'other': '其他'
    };
    parts.push(typeMap[product.type] || product.type);
  }
  
  // 面积
  if (product.area) {
    parts.push(`${product.area}㎡`);
  }
  
  // 状态映射
  if (product.status) {
    const statusMap: Record<string, string> = {
      'pending': '待开始',
      'in_progress': '进行中',
      'completed': '已完成',
      'on_hold': '暂停中'
    };
    parts.push(statusMap[product.status] || product.status);
  }
  
  // 自定义描述
  if (product.metadata?.description) {
    parts.push(product.metadata.description);
  }
  
  return parts.join(' · ') || '暂无描述';
}
@if (loadingSpaces) {
  <div class="space-loading">
    <div class="spinner"></div>
    <span>正在加载空间数据...</span>
  </div>
}
@if (spaceLoadError && !loadingSpaces) {
  <div class="space-error">
    <svg class="icon-warning" viewBox="0 0 24 24">
      <path fill="currentColor" d="M12 2L1 21h22L12 2z..."/>
    </svg>
    <span>{{ spaceLoadError }}</span>
  </div>
}
@if (spaceScenes.length === 0) {
  <div class="space-empty">
    <svg class="icon-empty" viewBox="0 0 24 24">
      <path fill="currentColor" d="M12 2C6.48 2..."/>
    </svg>
    <p>该项目暂无空间数据</p>
    <small>请先在项目中创建空间产品(Product)</small>
  </div>
}
@for (space of spaceScenes; track space.id) {
  <label class="space-checkbox-item">
    <input 
      type="checkbox"
      [checked]="isSpaceSelected(selectedDesignerForSpaceAssignment.id, space.id)"
      (change)="toggleSpaceSelection(selectedDesignerForSpaceAssignment.id, space.id)"
    >
    <span class="checkbox-custom"></span>
    <div class="space-info">
      <span class="space-name">{{ space.name }}</span>
      @if (space.area) {
        <span class="space-area">{{ space.area }}㎡</span>
      }
      @if (space.description) {
        <span class="space-desc">{{ space.description }}</span>
      }
    </div>
  </label>
}
新增了以下样式类:
.space-loading - 加载状态样式.space-error - 错误提示样式.space-empty - 空状态样式@keyframes spin - 加载动画1. 用户打开设计师分配弹窗
   ↓
2. 弹窗初始化 (ngOnInit)
   ├─ loadRealProjectTeams() - 加载项目组和成员
   └─ loadRealProjectSpaces() - 加载项目空间
   ↓
3. ProductSpaceService.getProjectProductSpaces(projectId)
   ├─ 查询 Product 表
   │  WHERE project = projectId
   │  ORDER BY createdAt ASC
   └─ 返回 Product[] 列表
   ↓
4. 转换为 SpaceScene[] 格式
   {
     id: product.id,
     name: product.productName,
     area: product.space?.area,
     description: 'bedroom · 18.5㎡ · 进行中'
   }
   ↓
5. 用户选择设计师并分配空间
   ├─ 点击设计师卡片的 "🏠" 按钮
   ├─ 勾选该设计师负责的空间
   └─ 点击"确认"
   ↓
6. 生成 DesignerAssignmentResult
   {
     selectedDesigners: [Designer[]],
     spaceAssignments: [
       {
         designerId: 'profile001',
         designerName: '张设计师',
         spaceIds: ['prod001', 'prod002']  // Product IDs
       }
     ]
   }
   ↓
7. 父组件保存到 ProjectTeam
   ProjectTeam.data.assignedSpaces = ['prod001', 'prod002']
// ProductSpaceService 中的查询
const query = new Parse.Query('Product');
query.equalTo('project', {
  __type: 'Pointer',
  className: 'Project',
  objectId: projectId
});
query.include('profile');
query.ascending('createdAt');
const results = await query.find();
// Product -> SpaceScene
const spaceScene: SpaceScene = {
  id: 'prod001',                  // Product.objectId
  name: '主卧设计',                // Product.productName
  area: 18.5,                     // Product.space.area
  description: 'bedroom · 18.5㎡ · 进行中'
};
<app-designer-team-assignment-modal
  [visible]="showDesignerModal"
  [projectId]="project?.id || ''"
  [loadRealData]="true"
  [loadRealSpaces]="true"          <!-- 启用真实空间加载 -->
  [enableSpaceAssignment]="true"
  [calendarViewMode]="'month'"
  [selectedTeamId]="modalSelectedTeamId"
  (close)="closeDesignerModal()"
  (confirm)="handleDesignerAssignment($event)"
></app-designer-team-assignment-modal>
async handleDesignerAssignment(result: DesignerAssignmentResult) {
  for (const designer of result.selectedDesigners) {
    // 查找该设计师负责的空间
    const spaceAssignment = result.spaceAssignments.find(
      sa => sa.designerId === designer.id
    );
    
    // 保存到ProjectTeam,空间ID列表保存在data.assignedSpaces
    await this.saveDesignerToTeam(
      designer, 
      spaceAssignment?.spaceIds || []
    );
  }
}
async saveDesignerToTeam(designer: Designer, spaceIds: string[]) {
  const ProjectTeam = Parse.Object.extend('ProjectTeam');
  const teamMember = new ProjectTeam();
  
  teamMember.set('project', this.project.toPointer());
  teamMember.set('profile', Parse.Object.extend('Profile')
    .createWithoutData(designer.id));
  teamMember.set('role', '设计师');
  teamMember.set('data', {
    assignedSpaces: spaceIds,        // Product IDs
    assignedDate: new Date().toISOString()
  });
  
  await teamMember.save();
}
前提: 项目已创建多个 Product
操作: 打开设计师分配弹窗
预期: 
  - 显示"正在加载空间数据..."
  - 成功加载后显示所有空间列表
  - 每个空间显示名称、面积、描述
前提: 项目未创建任何 Product
操作: 打开设计师分配弹窗
预期:
  - 显示空状态图标和提示
  - 提示"该项目暂无空间数据"
  - 引导用户创建空间产品
前提: 网络异常或服务器错误
操作: 打开设计师分配弹窗
预期:
  - 显示错误图标和错误信息
  - 错误信息清晰明了
  - 可以关闭弹窗重试
前提: 项目有空间数据
操作:
  1. 选择设计师
  2. 点击 "🏠" 按钮
  3. 勾选空间
  4. 确认分配
预期:
  - 空间列表正确显示
  - 勾选状态正确更新
  - 分配结果正确保存到 ProjectTeam.data.assignedSpaces
{
  objectId: String,
  project: Pointer<Project>,
  profile: Pointer<Profile>,      // 负责设计师
  productName: String,             // 空间名称
  productType: String,             // 空间类型
  space: {                         // 空间信息
    spaceName: String,
    area: Number,
    dimensions: Object,
    features: Array
  },
  quotation: Object,
  order: Number,
  isDeleted: Boolean
}
{
  objectId: String,
  project: Pointer<Project>,
  profile: Pointer<Profile>,
  role: String,
  workload: Number,
  data: {
    assignedSpaces: Array<String>,  // Product objectId 数组
    assignedDate: String
  },
  isDeleted: Boolean
}
查询项目空间
query.equalTo('project', projectPointer)
query.notEqualTo('isDeleted', true)
query.ascending('order')
查询设计师负责的空间
query.equalTo('profile', designerPointer)
query.notEqualTo('isDeleted', true)
查询项目团队的空间分配
const team = await teamQuery.find();
const assignedSpaces = team.get('data')?.assignedSpaces || [];
loadRealSpaces=true 时加载projectId 时加载loadingSpaces 避免重复加载ChangeDetectorRef 优化检测src/app/pages/designer/project-detail/components/designer-team-assignment-modal/
designer-team-assignment-modal.component.ts - 组件逻辑designer-team-assignment-modal.component.html - 模板designer-team-assignment-modal.component.scss - 样式src/modules/project/services/product-space.service.ts - 空间数据服务src/modules/project/components/team-assign/ - 团队分配组件集成rules/schemas.md - Parse Server 数据范式<app-designer-team-assignment-modal
  [visible]="showModal"
  [projectId]="project.id"
  [loadRealSpaces]="true"
  [enableSpaceAssignment]="true"
  (confirm)="handleAssignment($event)"
></app-designer-team-assignment-modal>
<app-designer-team-assignment-modal
  [visible]="showModal"
  [loadRealSpaces]="false"
  [spaceScenes]="customSpaces"
  [enableSpaceAssignment]="true"
  (confirm)="handleAssignment($event)"
></app-designer-team-assignment-modal>