Selaa lähdekoodia

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

徐福静0235668 3 viikkoa sitten
vanhempi
commit
2a22297bb1
58 muutettua tiedostoa jossa 8486 lisäystä ja 767 poistoa
  1. 71 0
      CHANGELOG.md
  2. 60 2
      docs/data/quotation.md
  3. 591 0
      docs/prd/功能-报价自动分配.md
  4. 649 0
      docs/prd/组件-项目问卷.md
  5. 1 1
      docs/service-integration-complete.md
  6. 457 0
      docs/task/2025102220-fix-unassigned-projects-complete.md
  7. 117 0
      docs/task/2025102220-fix-unassigned-projects.md
  8. 296 0
      docs/task/2025102221-console-migration-script.md
  9. 424 0
      docs/task/2025102221-fix-project-assignee-and-spaces.md
  10. 432 0
      docs/task/2025102221-implementation-summary.md
  11. 296 0
      docs/task/2025102221-migration-guide.md
  12. 239 0
      docs/task/2025102221-quick-guide.md
  13. 343 0
      docs/task/2025102221-simple-solution.md
  14. 6 4
      npminstall-debug.log
  15. 1 1
      package.json
  16. 19 1
      rules/schemas.md
  17. 15 8
      src/app/app.routes.ts
  18. 14 11
      src/app/pages/admin/customers/customers.html
  19. 10 174
      src/app/pages/admin/customers/customers.scss
  20. 31 62
      src/app/pages/admin/customers/customers.ts
  21. 50 18
      src/app/pages/admin/employees/employees.html
  22. 16 44
      src/app/pages/admin/employees/employees.scss
  23. 33 7
      src/app/pages/admin/employees/employees.ts
  24. 247 0
      src/app/pages/admin/services/project-migration.service.ts
  25. 43 4
      src/app/pages/admin/services/project.service.ts
  26. 12 5
      src/app/pages/team-leader/dashboard/dashboard.html
  27. 36 0
      src/app/pages/team-leader/dashboard/dashboard.scss
  28. 314 53
      src/app/pages/team-leader/dashboard/dashboard.ts
  29. 36 19
      src/app/pages/team-leader/services/designer.service.ts
  30. 6 0
      src/app/wxwork-auth-guard.ts
  31. 71 0
      src/modules/project/components/contact-selector/contact-selector.component.html
  32. 30 0
      src/modules/project/components/contact-selector/contact-selector.component.scss
  33. 195 0
      src/modules/project/components/contact-selector/contact-selector.component.ts
  34. 3 2
      src/modules/project/components/project-bottom-card/project-bottom-card.component.scss
  35. 43 38
      src/modules/project/components/project-files-modal/project-files-modal.component.html
  36. 74 99
      src/modules/project/components/project-files-modal/project-files-modal.component.scss
  37. 52 0
      src/modules/project/components/project-issues-modal/project-issues-modal.component.scss
  38. 5 2
      src/modules/project/components/project-members-modal/project-members-modal.component.html
  39. 56 87
      src/modules/project/components/project-members-modal/project-members-modal.component.scss
  40. 32 0
      src/modules/project/components/project-members-modal/project-members-modal.component.ts
  41. 74 0
      src/modules/project/components/quotation-editor.component.html
  42. 244 0
      src/modules/project/components/quotation-editor.component.scss
  43. 35 0
      src/modules/project/components/quotation-editor.component.ts
  44. 5 0
      src/modules/project/components/team-assign/team-assign.component.html
  45. 5 0
      src/modules/project/components/team-assign/team-assign.component.scss
  46. 79 0
      src/modules/project/components/team-assign/team-assign.component.ts
  47. 103 0
      src/modules/project/config/quotation-rules.ts
  48. 4 3
      src/modules/project/pages/contact/contact.component.html
  49. 65 54
      src/modules/project/pages/contact/contact.component.scss
  50. 72 29
      src/modules/project/pages/contact/contact.component.ts
  51. 104 33
      src/modules/project/pages/project-detail/project-detail.component.html
  52. 303 0
      src/modules/project/pages/project-detail/project-detail.component.scss
  53. 148 3
      src/modules/project/pages/project-detail/project-detail.component.ts
  54. 1 1
      src/modules/project/pages/project-detail/stages/stage-order.component.ts
  55. 2 2
      src/modules/project/pages/project-loader/project-loader.component.ts
  56. 385 0
      src/modules/project/pages/project-survey/project-survey.component.html
  57. 887 0
      src/modules/project/pages/project-survey/project-survey.component.scss
  58. 544 0
      src/modules/project/pages/project-survey/project-survey.component.ts

+ 71 - 0
CHANGELOG.md

@@ -0,0 +1,71 @@
+# TODOLIST 代办事项
+## 第三方接口的代办工作
+- [ ] 渲染监听:渲酷农场渲染进度的数据对接及交付阶段,渲染任务的选择设置
+- [ ] 会话归档:群聊消息监听的权限审核与数据采集分析
+
+## 2025-10-25
+- [ ] 智能交付:交付页面,拖拽文件,并细化大模型识别文件类型与空间的功能
+- [ ] 报价规则:价格报价器的细节规则优化
+    - 报价细项的优化与合并
+        - 内部执行报价分为:建模阶段、软装渲染、公司分配
+            - 建模阶段:占比10%
+            - 软装渲染:占比40%
+            - 公司分配:占比50%
+        - 报价自动分配比例是指在原有三级报价总价基础上,自动填写三类分配金额
+    - 按默认比例分配 + 人工填写报价
+- [ ] 改图工单:改图是独立于初期报价,后期延续的灵活工单,需要独立设计
+- [ ] 员工问卷:专业特长及偏好问卷功能
+- [ ] 方案深化:参考图区分软装、硬装、氛围等用途标签,对应不同分析结果
+- [x] 组员派单:根据新的空间报价,修改派单逻辑,增加已派单成员移除的功能
+
+# CHANGELOG 更新日志
+
+## 2025-10-24
+### 项目启动访谈问卷
+- 新增项目问卷:面向客户的首次需求调研,3-5分钟选择式题目,覆盖基础需求、核心侧重、协作节奏、特殊提醒四大模块。
+- 快速入口:项目详情的客户卡片展示问卷状态;未填写显示“发送问卷”,已填写显示“查看问卷”。
+- 群聊发送:支持在企业微信群聊一键发送问卷链接,客户点击即可填写。
+- 多联系人支持:同一项目的多个联系人可分别填写,答卷独立保存。
+- 结果查看:客服/组员/组长可随时查看已填写的问卷结果。
+- 自动保存与完成标记:答题过程逐题自动保存;提交后标记完成并记录时间。
+- 联系人信息补全:问卷中填写的姓名/手机号可同步至联系人资料;手机号对非本人脱敏显示。
+- 权限与隐私:客户本人与内部成员可查看完整结果,其他外部联系人无权查看。
+
+### 页面手机端适配
+- 项目详情-文件,标题与筛选操作栏,紧凑布局
+- 项目详情-成员,标题与筛选操作栏,紧凑布局
+- 项目详情-问题,标题与筛选操作栏,紧凑布局并全屏弹窗
+
+### 组员分配(项目详情页)
+- 增加删除组员功能,将组员移出项目组
+- 企业微信SDK:添加成员后,自动邀请进群(企业微信PC/手机端)
+
+## 2025-10-23
+
+### 员工管理(后台)
+- 员工列表显示头像与职位,缺失头像自动使用统一占位图,列表更整齐。
+- 员工详情弹窗更丰富:手机号、邮箱、企微ID、身份、部门、入职时间、技能与工作量等一目了然。
+- 弹窗样式与布局优化,对齐与间距更合理,信息更易读。
+
+### 表格与界面一致性
+- 多页面的列宽与对齐优化,整体信息密度与可读性提升,浏览体验更统一。
+
+### 稳定性与兼容性
+- 刷新后列表自动同步最新数据,减少信息不一致的情况。
+- 无跟进记录时自动显示历史跟进信息,保证页面内容完整性。
+## 2025-10-22
+
+### 客户选择与详情体验
+- 项目页支持便捷选择或创建客户,已建档/未建档清晰分区,搜索更高效。
+- 新增“一键刷新客户信息”,可同步企业微信的最新资料,名单与详情保持一致。
+- 客户详情以侧栏弹窗方式展示,点击返回或遮罩即可关闭,不会跳转到错误页面。
+- 跟进记录默认显示当前项目的记录,支持切换查看该客户的全部跟进历史。
+
+### 客户信息显示优化
+- 统一头像占位图为 `/assets/images/default-avatar.svg`,列表与详情一致,缺失头像时显示更友好。
+- “所在群聊”改为纵向列表,信息展示更完整,阅读更舒适。
+
+### 客户管理(后台)
+- 客户列表增加头像、类型、名称等信息显示,点击即可查看详情。
+- 在详情中可直接刷新客户数据,确保资料实时准确。
+

+ 60 - 2
docs/data/quotation.md

@@ -430,6 +430,64 @@
 
 ---
 
-**文档版本**:2024最新版
-**更新日期**:2024年
+---
+
+## 七、内部执行分配规则 (2025新增)
+
+### 7.1 分配体系说明
+
+基于**三级报价总价**,系统自动按固定比例拆分为内部执行三个阶段:
+
+| 分配类型 | 占比 | 说明 | 用途 |
+|---------|------|------|------|
+| **建模阶段** | 10% | 3D模型构建 | 支付给建模设计师的费用 |
+| **软装渲染** | 40% | 软装搭配+效果图渲染 | 支付给软装和渲染设计师的费用 |
+| **公司分配** | 50% | 公司运营与利润 | 公司管理成本、平台服务费、利润等 |
+
+**计算公式:**
+```
+建模阶段金额 = 报价总价 × 10%
+软装渲染金额 = 报价总价 × 40%
+公司分配金额 = 报价总价 × 50%
+```
+
+### 7.2 自动分配触发
+
+内部执行分配在以下时机自动计算:
+1. **生成报价时**: 点击"生成报价"按钮后自动计算
+2. **修改价格时**: 工序价格或数量变化导致总价变化时实时重新计算
+3. **保存报价时**: 保存报价前确保分配数据最新
+
+### 7.3 分配示例
+
+#### 示例1: 家装项目
+- **报价总价**: ¥60,000
+- **建模阶段**: ¥60,000 × 10% = ¥6,000
+- **软装渲染**: ¥60,000 × 40% = ¥24,000
+- **公司分配**: ¥60,000 × 50% = ¥30,000
+
+#### 示例2: 工装项目
+- **报价总价**: ¥85,000
+- **建模阶段**: ¥85,000 × 10% = ¥8,500
+- **软装渲染**: ¥85,000 × 40% = ¥34,000
+- **公司分配**: ¥85,000 × 50% = ¥42,500
+
+### 7.4 重要说明
+
+1. **自动计算**: 内部分配比例为系统固定规则,不支持手动调整
+2. **实时更新**: 报价总价变化时,分配金额自动同步更新
+3. **四舍五入**: 分配金额使用Math.round()四舍五入到整数
+4. **客户不可见**: 内部分配仅在系统内部显示,客户报价中不包含此信息
+
+### 7.5 技术实现
+
+内部分配功能已集成到报价编辑器组件,技术文档详见:
+- **配置文件**: `src/modules/project/config/quotation-rules.ts`
+- **PRD文档**: `docs/prd/功能-报价自动分配.md`
+- **组件实现**: `src/modules/project/components/quotation-editor.component.ts`
+
+---
+
+**文档版本**:v2.0 (新增内部执行分配规则)
+**更新日期**:2025-10-25
 **适用范围**:映三色视觉设计表现公司所有项目报价

+ 591 - 0
docs/prd/功能-报价自动分配.md

@@ -0,0 +1,591 @@
+# 报价自动分配功能需求文档 (PRD)
+
+## 一、功能概述
+
+### 1.1 功能定位
+报价自动分配功能是映三色视觉设计表现公司报价系统的核心优化模块,实现从客户报价到内部执行的自动化价格拆分与分配。
+
+### 1.2 业务价值
+- **提升效率**: 自动化价格分配,减少人工计算时间
+- **标准化流程**: 统一内部执行报价标准,避免人为偏差
+- **透明管理**: 清晰展示价格构成,便于财务核算和绩效考核
+- **利润保障**: 确保公司分配占比,保障企业利润空间
+
+---
+
+## 二、核心业务规则
+
+### 2.1 内部执行报价三级分配体系
+
+基于**三级报价总价**(一级/二级/三级客户报价),自动按以下比例拆分:
+
+| 分配类型 | 占比 | 说明 | 用途 |
+|---------|------|------|------|
+| **建模阶段** | 10% | 3D模型构建阶段 | 支付给建模设计师的费用 |
+| **软装渲染** | 40% | 软装搭配+效果图渲染 | 支付给软装和渲染设计师的费用 |
+| **公司分配** | 50% | 公司运营与利润 | 公司管理成本、平台服务费、利润等 |
+
+**计算公式:**
+```
+建模阶段金额 = 三级报价总价 × 10%
+软装渲染金额 = 三级报价总价 × 40%
+公司分配金额 = 三级报价总价 × 50%
+```
+
+### 2.2 分配示例
+
+#### 示例1: 家装平层项目
+- **项目类型**: 家装平层(现代风格)
+- **报价等级**: 三级
+- **三级报价总价**: ¥60,000
+
+**自动分配结果:**
+```
+建模阶段: ¥60,000 × 10% = ¥6,000
+软装渲染: ¥60,000 × 40% = ¥24,000
+公司分配: ¥60,000 × 50% = ¥30,000
+```
+
+#### 示例2: 工装办公空间
+- **项目类型**: 工装办公空间
+- **报价等级**: 二级
+- **三级报价总价**: ¥85,000
+
+**自动分配结果:**
+```
+建模阶段: ¥85,000 × 10% = ¥8,500
+软装渲染: ¥85,000 × 40% = ¥34,000
+公司分配: ¥85,000 × 50% = ¥42,500
+```
+
+---
+
+## 三、数据结构设计
+
+### 3.1 报价数据结构扩展
+
+在现有 `quotation` 对象基础上,新增 `allocation` 字段:
+
+```typescript
+interface Quotation {
+  spaces: Space[];              // 产品设计产品列表
+  total: number;                // 报价总额
+  spaceBreakdown: SpaceBreakdown[];  // 产品占比明细
+
+  // 新增: 内部执行分配
+  allocation?: {
+    modeling: {
+      amount: number;           // 建模阶段金额 (10%)
+      percentage: 10;           // 固定占比
+      description: string;      // '3D模型构建'
+    };
+    decoration: {
+      amount: number;           // 软装渲染金额 (40%)
+      percentage: 40;           // 固定占比
+      description: string;      // '软装搭配+效果图渲染'
+    };
+    company: {
+      amount: number;           // 公司分配金额 (50%)
+      percentage: 50;           // 固定占比
+      description: string;      // '公司运营与利润'
+    };
+    updatedAt: Date;            // 分配计算时间
+  };
+
+  generatedAt: Date;
+  validUntil: Date;
+}
+```
+
+### 3.2 Product 报价数据扩展
+
+每个产品的 `quotation` 对象也需要支持分配信息:
+
+```typescript
+interface ProductQuotation {
+  price: number;                // 产品报价
+  currency: string;             // 货币类型
+  breakdown: ProcessBreakdown;  // 工序明细
+
+  // 新增: 产品级别的内部分配
+  allocation?: {
+    modeling: number;           // 该产品的建模阶段金额
+    decoration: number;         // 该产品的软装渲染金额
+    company: number;            // 该产品的公司分配金额
+  };
+
+  status: string;
+  validUntil: Date;
+}
+```
+
+---
+
+## 四、功能实现方案
+
+### 4.1 自动分配触发时机
+
+自动分配在以下时机触发:
+
+1. **生成报价时**: 点击"生成报价"按钮后,自动计算分配
+2. **修改价格时**: 工序价格或数量变化导致总价变化时,实时重新计算
+3. **保存报价时**: 保存报价前,确保分配数据最新
+
+### 4.2 分配计算逻辑
+
+#### 步骤1: 计算项目总价
+```typescript
+function calculateTotal(): number {
+  let total = 0;
+  for (const space of quotation.spaces) {
+    for (const processKey of Object.keys(space.processes)) {
+      const process = space.processes[processKey];
+      if (process.enabled) {
+        total += process.price * process.quantity;
+      }
+    }
+  }
+  return total;
+}
+```
+
+#### 步骤2: 自动分配内部执行金额
+```typescript
+function calculateAllocation(total: number) {
+  return {
+    modeling: {
+      amount: Math.round(total * 0.10),  // 10%
+      percentage: 10,
+      description: '3D模型构建'
+    },
+    decoration: {
+      amount: Math.round(total * 0.40),  // 40%
+      percentage: 40,
+      description: '软装搭配+效果图渲染'
+    },
+    company: {
+      amount: Math.round(total * 0.50),  // 50%
+      percentage: 50,
+      description: '公司运营与利润'
+    },
+    updatedAt: new Date()
+  };
+}
+```
+
+#### 步骤3: 产品级别分配
+```typescript
+function calculateProductAllocation(productPrice: number) {
+  return {
+    modeling: Math.round(productPrice * 0.10),
+    decoration: Math.round(productPrice * 0.40),
+    company: Math.round(productPrice * 0.50)
+  };
+}
+```
+
+### 4.3 UI展示设计
+
+#### 位置1: 报价汇总区域
+在现有的"报价汇总"模块中,新增"内部执行分配"折叠面板:
+
+```html
+<!-- 报价汇总 -->
+<div class="quotation-summary">
+  <!-- 现有的产品占比明细 -->
+  ...
+
+  <!-- 新增: 内部执行分配 -->
+  <div class="allocation-section">
+    <div class="allocation-header">
+      <h4>内部执行分配</h4>
+      <button (click)="toggleAllocation()">
+        {{ showAllocation ? '隐藏' : '显示' }}
+      </button>
+    </div>
+
+    @if (showAllocation && quotation.allocation) {
+      <div class="allocation-list">
+        <div class="allocation-item modeling">
+          <div class="allocation-info">
+            <span class="allocation-name">建模阶段</span>
+            <span class="allocation-desc">3D模型构建</span>
+          </div>
+          <div class="allocation-values">
+            <span class="allocation-percentage">10%</span>
+            <span class="allocation-amount">{{ formatPrice(quotation.allocation.modeling.amount) }}</span>
+          </div>
+        </div>
+
+        <div class="allocation-item decoration">
+          <div class="allocation-info">
+            <span class="allocation-name">软装渲染</span>
+            <span class="allocation-desc">软装搭配+效果图渲染</span>
+          </div>
+          <div class="allocation-values">
+            <span class="allocation-percentage">40%</span>
+            <span class="allocation-amount">{{ formatPrice(quotation.allocation.decoration.amount) }}</span>
+          </div>
+        </div>
+
+        <div class="allocation-item company">
+          <div class="allocation-info">
+            <span class="allocation-name">公司分配</span>
+            <span class="allocation-desc">公司运营与利润</span>
+          </div>
+          <div class="allocation-values">
+            <span class="allocation-percentage">50%</span>
+            <span class="allocation-amount">{{ formatPrice(quotation.allocation.company.amount) }}</span>
+          </div>
+        </div>
+      </div>
+    }
+  </div>
+
+  <!-- 报价总额 -->
+  <div class="total-section">
+    ...
+  </div>
+</div>
+```
+
+#### 位置2: 产品详情(可选)
+在每个产品卡片的工序明细下方,可展示该产品的分配明细:
+
+```html
+<div class="product-allocation-hint">
+  <svg class="icon" viewBox="0 0 512 512">...</svg>
+  <span>该产品内部分配: 建模 ¥{{ product.allocation.modeling }} / 软装渲染 ¥{{ product.allocation.decoration }} / 公司 ¥{{ product.allocation.company }}</span>
+</div>
+```
+
+---
+
+## 五、样式设计
+
+### 5.1 分配列表样式
+
+```scss
+.allocation-section {
+  margin-top: 20px;
+  padding-top: 20px;
+  border-top: 1px solid var(--ion-color-light-shade);
+
+  .allocation-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 16px;
+
+    h4 {
+      margin: 0;
+      font-size: 16px;
+      font-weight: 600;
+      color: var(--ion-color-dark);
+    }
+  }
+
+  .allocation-list {
+    display: flex;
+    flex-direction: column;
+    gap: 12px;
+
+    .allocation-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 12px 16px;
+      border-radius: 8px;
+      border-left: 4px solid;
+      background: var(--ion-color-light-tint);
+
+      &.modeling {
+        border-left-color: #8B5CF6; // 紫色 - 建模
+        background: rgba(139, 92, 246, 0.05);
+      }
+
+      &.decoration {
+        border-left-color: #F59E0B; // 橙色 - 软装渲染
+        background: rgba(245, 158, 11, 0.05);
+      }
+
+      &.company {
+        border-left-color: #10B981; // 绿色 - 公司分配
+        background: rgba(16, 185, 129, 0.05);
+      }
+
+      .allocation-info {
+        display: flex;
+        flex-direction: column;
+        gap: 4px;
+
+        .allocation-name {
+          font-size: 15px;
+          font-weight: 600;
+          color: var(--ion-color-dark);
+        }
+
+        .allocation-desc {
+          font-size: 12px;
+          color: var(--ion-color-medium);
+        }
+      }
+
+      .allocation-values {
+        display: flex;
+        align-items: baseline;
+        gap: 12px;
+
+        .allocation-percentage {
+          font-size: 14px;
+          font-weight: 500;
+          color: var(--ion-color-medium);
+          min-width: 40px;
+          text-align: right;
+        }
+
+        .allocation-amount {
+          font-size: 18px;
+          font-weight: 700;
+          color: var(--ion-color-dark);
+        }
+      }
+    }
+  }
+}
+```
+
+### 5.2 移动端适配
+
+```scss
+@media (max-width: 768px) {
+  .allocation-item {
+    flex-direction: column;
+    align-items: flex-start !important;
+    gap: 8px;
+
+    .allocation-values {
+      width: 100%;
+      justify-content: space-between;
+    }
+  }
+}
+```
+
+---
+
+## 六、权限控制
+
+### 6.1 可见性权限
+- **全部角色可见**: 内部执行分配信息对内部所有角色可见
+- **客户不可见**: 外部客户看到的报价中,不包含内部分配信息
+
+### 6.2 编辑权限
+- **系统自动计算**: 内部分配比例(10%-40%-50%)为系统固定,不支持手动修改
+- **管理员可调整**: 仅管理员可在系统配置中调整分配比例(未来扩展功能)
+
+---
+
+## 七、技术实现要点
+
+### 7.1 配置文件更新
+
+在 `quotation-rules.ts` 中新增分配规则配置:
+
+```typescript
+/**
+ * 内部执行分配规则
+ */
+export const ALLOCATION_RULES = {
+  modeling: {
+    percentage: 0.10,
+    label: '建模阶段',
+    description: '3D模型构建',
+    color: '#8B5CF6'
+  },
+  decoration: {
+    percentage: 0.40,
+    label: '软装渲染',
+    description: '软装搭配+效果图渲染',
+    color: '#F59E0B'
+  },
+  company: {
+    percentage: 0.50,
+    label: '公司分配',
+    description: '公司运营与利润',
+    color: '#10B981'
+  }
+};
+
+/**
+ * 计算内部执行分配
+ */
+export function calculateAllocation(totalPrice: number) {
+  return {
+    modeling: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.modeling.percentage),
+      percentage: ALLOCATION_RULES.modeling.percentage * 100,
+      description: ALLOCATION_RULES.modeling.description,
+      color: ALLOCATION_RULES.modeling.color
+    },
+    decoration: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.decoration.percentage),
+      percentage: ALLOCATION_RULES.decoration.percentage * 100,
+      description: ALLOCATION_RULES.decoration.description,
+      color: ALLOCATION_RULES.decoration.color
+    },
+    company: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.company.percentage),
+      percentage: ALLOCATION_RULES.company.percentage * 100,
+      description: ALLOCATION_RULES.company.description,
+      color: ALLOCATION_RULES.company.color
+    },
+    updatedAt: new Date()
+  };
+}
+```
+
+### 7.2 组件方法扩展
+
+在 `QuotationEditorComponent` 中新增方法:
+
+```typescript
+// UI 状态
+showAllocation: boolean = false;
+
+// 计算总价时同步更新分配
+calculateTotal() {
+  // ... 现有逻辑 ...
+
+  // 自动计算内部执行分配
+  this.quotation.allocation = calculateAllocation(this.quotation.total);
+  this.updateProductBreakdown();
+}
+
+// 切换分配显示
+toggleAllocation() {
+  this.showAllocation = !this.showAllocation;
+}
+
+// 更新产品级别的分配
+private updateProductAllocation(product: any, productPrice: number) {
+  const quotation = product.get('quotation') || {};
+  quotation.allocation = {
+    modeling: Math.round(productPrice * 0.10),
+    decoration: Math.round(productPrice * 0.40),
+    company: Math.round(productPrice * 0.50)
+  };
+  product.set('quotation', quotation);
+}
+```
+
+---
+
+## 八、测试用例
+
+### 8.1 单元测试
+
+#### 测试用例1: 分配计算准确性
+```typescript
+test('should calculate allocation correctly', () => {
+  const total = 60000;
+  const allocation = calculateAllocation(total);
+
+  expect(allocation.modeling.amount).toBe(6000);   // 10%
+  expect(allocation.decoration.amount).toBe(24000); // 40%
+  expect(allocation.company.amount).toBe(30000);    // 50%
+});
+```
+
+#### 测试用例2: 四舍五入处理
+```typescript
+test('should round allocation amounts', () => {
+  const total = 12345;
+  const allocation = calculateAllocation(total);
+
+  expect(allocation.modeling.amount).toBe(1235);   // 10% rounded
+  expect(allocation.decoration.amount).toBe(4938); // 40% rounded
+  expect(allocation.company.amount).toBe(6173);    // 50% rounded
+});
+```
+
+### 8.2 集成测试
+
+#### 测试用例3: 生成报价后自动计算分配
+```typescript
+test('should auto-calculate allocation after generating quotation', async () => {
+  const component = new QuotationEditorComponent(...);
+  await component.generateQuotationFromProducts();
+
+  expect(component.quotation.allocation).toBeDefined();
+  expect(component.quotation.allocation.modeling.amount).toBeGreaterThan(0);
+});
+```
+
+---
+
+## 九、用户使用流程
+
+### 9.1 报价生成流程
+
+1. 客服在项目详情页打开报价编辑器
+2. 点击"生成报价"按钮,系统自动:
+   - 根据产品列表生成工序明细
+   - 计算报价总额
+   - **自动计算内部执行分配 (10%-40%-50%)**
+   - 保存到项目数据
+3. 在"报价汇总"区域查看:
+   - 报价总额
+   - 产品占比明细
+   - **内部执行分配明细(新增)**
+
+### 9.2 分配信息查看流程
+
+1. 展开"报价汇总"模块
+2. 点击"内部执行分配"区域的"显示"按钮
+3. 查看三类分配及其金额:
+   - 建模阶段: 10% + 具体金额
+   - 软装渲染: 40% + 具体金额
+   - 公司分配: 50% + 具体金额
+
+---
+
+## 十、未来扩展
+
+### 10.1 动态比例配置(Phase 2)
+- 支持管理员在系统设置中调整分配比例
+- 支持不同项目类型设置不同分配规则
+- 支持历史分配规则版本管理
+
+### 10.2 设计师绩效对接(Phase 3)
+- 建模设计师绩效 = 所有项目的建模阶段金额总和
+- 软装渲染设计师绩效 = 所有项目的软装渲染金额总和
+- 自动生成设计师收入报表
+
+### 10.3 财务报表集成(Phase 4)
+- 自动生成内部分配财务报表
+- 支持按部门、按时间段统计分配金额
+- 支持导出Excel财务报表
+
+---
+
+## 十一、FAQ
+
+### Q1: 分配比例是否可以手动调整?
+**A:** 当前版本不支持。内部分配比例(10%-40%-50%)为系统固定规则,确保所有项目的一致性和公平性。
+
+### Q2: 如果总价有小数,如何处理分配金额?
+**A:** 使用 `Math.round()` 四舍五入到整数。例如: ¥12345 × 10% = ¥1234.5 → ¥1235
+
+### Q3: 客户是否能看到内部分配信息?
+**A:** 不能。内部分配仅在系统内部显示,客户看到的报价只包含产品和工序明细,不包含内部分配。
+
+### Q4: 分配信息何时更新?
+**A:** 每次报价总额发生变化时(生成报价、修改工序价格、保存报价),系统自动重新计算分配。
+
+---
+
+**文档版本**: v1.0
+**创建日期**: 2025-10-25
+**作者**: Claude Code AI
+**审核状态**: 待审核

+ 649 - 0
docs/prd/组件-项目问卷.md

@@ -0,0 +1,649 @@
+# 项目问卷组件产品需求文档
+
+## 一、概述
+
+### 1.1 功能定位
+项目问卷是家装效果图服务的**初次合作需求调研工具**,通过精简的选择式问卷快速了解客户需求、服务偏好和协作习惯,帮助团队更精准地提供服务。
+
+### 1.2 业务价值
+- **客户视角**: 5分钟快速完成,明确表达需求偏好,减少后期沟通成本
+- **服务视角**: 提前了解客户侧重点,制定针对性服务方案,提升满意度
+- **数据视角**: 积累客户需求数据,优化服务流程和质量管控点
+
+### 1.3 应用场景
+1. **项目启动前**: 客服在项目订单分配阶段,发送问卷给客户填写
+2. **群聊分享**: 通过企微群聊直接发送问卷链接,客户点击即可填写
+3. **多客户项目**: 支持一个项目多个客户联系人分别填写(如公司项目的多个负责人)
+4. **结果查看**: 客服/组员/组长可随时查看客户已填写的问卷结果
+
+---
+
+## 二、数据范式
+
+### 2.1 SurveyLog 问卷结果表
+
+| 字段名 | 类型 | 必填 | 说明 | 示例值 |
+|--------|------|------|------|--------|
+| objectId | String | 是 | 主键ID | "survey001" |
+| **contact** | **Pointer** | **是** | **提交联系人** | **→ ContactInfo** |
+| **project** | **Pointer** | **是** | **关联项目** | **→ Project** |
+| profile | Pointer | 否 | 提交员工(内部员工填写时使用) | → Profile |
+| **company** | **Pointer** | **是** | **所属帐套** | **→ Company** |
+| **type** | **String** | **是** | **问卷类型** | **"survey-project"** |
+| **data** | **Object** | **是** | **问卷结果** | **{q1: "答案1", ...}** |
+| isCompleted | Boolean | 否 | 是否完整填写 | true |
+| completedAt | Date | 否 | 完成时间 | 2024-12-01T10:00:00.000Z |
+| isDeleted | Boolean | 否 | 软删除标记 | false |
+| createdAt | Date | 自动 | 创建时间 | 2024-12-01T09:00:00.000Z |
+| updatedAt | Date | 自动 | 更新时间 | 2024-12-01T10:00:00.000Z |
+
+**type 枚举值**:
+- `survey-project`: 项目问卷
+- `survey-contact`: 联系人问卷(暂未实现)
+- `survey-profile`: 员工问卷(暂未实现)
+
+**data 字段结构示例**:
+```json
+{
+  "q1_service_type": "效果图+技术配合",
+  "q2_space_count": "3",
+  "q2_space_types": "客厅/主卧/儿童房",
+  "q3_value_focus": ["细节写实度", "视觉吸引力"],
+  "q4_tech_support": "需要",
+  "q4_tech_focus": ["材质搭配", "灯光布局"],
+  "q5_cooperation_mode": "前期多沟通",
+  "q6_attention_points": ["软装色调易偏差"],
+  "q7_special_requirements": "业主喜欢暖色调,注意避免冷色",
+  "q8_has_reference": "有",
+  "contact_name": "李总",
+  "contact_phone": "13800138000"
+}
+```
+
+---
+
+## 三、核心组件设计
+
+### 3.1 ProjectSurveyComponent 项目问卷组件
+
+#### 3.1.1 路由配置
+```typescript
+// 路由: /wxwork/:cid/survey/project/:projectId
+{
+  path: 'wxwork/:cid',
+  children: [
+    {
+      path: 'survey/project/:projectId',
+      loadComponent: () => import('../modules/project/pages/project-survey/project-survey.component'),
+      title: '项目需求调查'
+    }
+  ]
+}
+```
+
+#### 3.1.2 组件状态机
+组件包含三种状态,通过 `currentState` 控制:
+
+```typescript
+type SurveyState = 'welcome' | 'questionnaire' | 'result';
+
+currentState: SurveyState = 'welcome';
+```
+
+**状态转换流程**:
+```
+[欢迎页] --点击开始--> [答题页] --提交完成--> [结果页]
+    ↑                                              ↓
+    └──────────────── 查看结果 ──────────────────┘
+```
+
+---
+
+### 3.2 欢迎页 (welcome)
+
+#### 3.2.1 页面布局
+```
+┌─────────────────────────────────┐
+│         问卷欢迎页                │
+├─────────────────────────────────┤
+│  [用户头像]                       │
+│  您好,李总                        │
+│                                  │
+│  《家装效果图服务初次合作需求调查表》 │
+│                                  │
+│  尊敬的伙伴:                      │
+│  为让本次效果图服务更贴合您的工作节  │
+│  奏与核心需求,我们准备了简短选择式  │
+│  问卷,您的偏好将直接帮我们校准服务  │
+│  方向,感谢支持!                   │
+│                                  │
+│  • 预计用时: 3-5分钟               │
+│  • 题目数量: 8题                  │
+│  • 题型: 选择题为主                │
+│                                  │
+│         [开始填写]                 │
+└─────────────────────────────────┘
+```
+
+#### 3.2.2 功能实现
+1. **用户识别**:
+   - 通过 `WxworkAuth.currentContact()` 获取当前外部联系人
+   - 显示联系人头像和名称
+   - 记录 `contact.id` 用于后续保存
+
+2. **数据检查**:
+   - 组件初始化时查询 SurveyLog 表
+   - 条件: `project == projectId AND contact == contactId`
+   - 如果已存在且 `isCompleted == true`,直接跳转到结果页
+
+3. **开始按钮**:
+   - 点击后执行 `startSurvey()`
+   - 切换状态: `currentState = 'questionnaire'`
+   - 初始化题目索引: `currentQuestionIndex = 0`
+
+---
+
+### 3.3 答题页 (questionnaire)
+
+#### 3.3.1 页面布局
+```
+┌─────────────────────────────────┐
+│  进度: 1/8 ●●○○○○○○            │
+├─────────────────────────────────┤
+│  一、基础需求                     │
+│                                  │
+│  1. 本次您需要的核心服务是?        │
+│                                  │
+│  ○ 纯效果图渲染                   │
+│  ● 效果图+技术配合                │
+│  ○ 其他补充: [____________]      │
+│                                  │
+│                                  │
+│         [← 上一题]  [下一题 →]   │
+└─────────────────────────────────┘
+```
+
+#### 3.3.2 题目数据结构
+```typescript
+interface Question {
+  id: string;              // 题目ID,如 "q1", "q2"
+  section: string;         // 章节,如 "基础需求", "核心侧重"
+  title: string;           // 题目文本
+  type: 'single' | 'multiple' | 'text' | 'number'; // 题型
+  options?: string[];      // 选项列表
+  hasOther?: boolean;      // 是否有"其他"选项
+  required?: boolean;      // 是否必填
+  skipCondition?: (contact: any) => boolean; // 跳过条件
+}
+```
+
+#### 3.3.3 题目列表
+```typescript
+const questions: Question[] = [
+  // 一、基础需求
+  {
+    id: 'q1',
+    section: '基础需求',
+    title: '本次您需要的核心服务是?',
+    type: 'single',
+    options: ['纯效果图渲染', '效果图+技术配合'],
+    hasOther: true,
+    required: true
+  },
+  {
+    id: 'q2',
+    section: '基础需求',
+    title: '需覆盖的关键空间数量及类型?',
+    type: 'text',
+    placeholder: '例: 3个,客厅/主卧/儿童房',
+    required: true
+  },
+
+  // 二、核心侧重
+  {
+    id: 'q3',
+    section: '核心侧重',
+    title: '您更希望本次效果图突出哪些价值?(可多选2-3项)',
+    type: 'multiple',
+    options: ['细节写实度', '视觉吸引力', '风格适配性'],
+    hasOther: true,
+    required: true
+  },
+  {
+    id: 'q4',
+    section: '核心侧重',
+    title: '关于方案建议,是否需要我们技术团队配合?',
+    type: 'single',
+    options: ['需要', '暂不需要'],
+    required: true
+  },
+
+  // 三、协作节奏
+  {
+    id: 'q5',
+    section: '协作节奏',
+    title: '您偏好的服务协作方式是?',
+    type: 'single',
+    options: ['前期多沟通', '先出初版再修改', '灵活协调'],
+    required: true
+  },
+
+  // 四、特殊提醒
+  {
+    id: 'q6',
+    section: '特殊提醒',
+    title: '过往合作中,是否有需要特别注意的点?(可多选)',
+    type: 'multiple',
+    options: ['软装色调易偏差', '建模细节需盯控'],
+    hasOther: true
+  },
+  {
+    id: 'q7',
+    section: '特殊提醒',
+    title: '本次项目是否有特殊要求?(如业主禁忌、重点展示点)',
+    type: 'text',
+    placeholder: '请输入特殊要求...'
+  },
+  {
+    id: 'q8',
+    section: '特殊提醒',
+    title: '是否有参考素材?(如风格图、实景图)',
+    type: 'single',
+    options: ['有(后续群内发送)', '无(需求已清晰)']
+  },
+
+  // 联系信息(自动跳过)
+  {
+    id: 'contact_name',
+    section: '联系信息',
+    title: '对接人姓名',
+    type: 'text',
+    required: true,
+    skipCondition: (contact) => !!contact?.get('realname')
+  },
+  {
+    id: 'contact_phone',
+    section: '联系信息',
+    title: '对接人电话',
+    type: 'text',
+    required: true,
+    skipCondition: (contact) => !!contact?.get('mobile')
+  }
+];
+```
+
+#### 3.3.4 答题交互逻辑
+
+1. **单选题**:
+   - 点击选项后自动保存答案到 `answers[questionId]`
+   - 如果不是"其他"选项,自动跳转下一题
+   - 如果是"其他"选项,显示输入框,输入完成后需手动点击"下一题"
+
+2. **多选题**:
+   - 可选择多个选项
+   - 点击"下一题"后保存并跳转
+
+3. **文本题/数字题**:
+   - 输入完成后点击"下一题"
+
+4. **题目跳过**:
+   - 如果 `skipCondition` 返回 `true`,自动跳过该题
+   - 例如: ContactInfo 已有手机号,跳过手机号填写
+
+5. **进度指示**:
+   - 顶部显示进度条: `currentQuestionIndex / totalQuestions`
+   - 显示当前章节名称
+
+6. **导航按钮**:
+   - "上一题": 返回上一题,可修改答案
+   - "下一题": 保存当前答案并跳转(最后一题显示"提交")
+
+#### 3.3.5 数据保存策略
+
+**自动保存**:
+- 每答完一题后自动保存到 Parse (防止中途退出丢失数据)
+- 保存方式:
+  ```typescript
+  surveyLog.set('data', {
+    ...surveyLog.get('data'),
+    [questionId]: answer
+  });
+  await surveyLog.save();
+  ```
+
+**完成标记**:
+- 最后一题提交后设置 `isCompleted = true`
+- 设置 `completedAt = new Date()`
+
+---
+
+### 3.4 结果页 (result)
+
+#### 3.4.1 页面布局
+```
+┌─────────────────────────────────┐
+│  ✓ 问卷提交成功                   │
+├─────────────────────────────────┤
+│  感谢您的反馈!                    │
+│  我们将根据您的选择制定服务方案    │
+│                                  │
+│  【您的答卷】                     │
+│  ━━━━━━━━━━━━━━━━━━━━━━━       │
+│  核心服务: 效果图+技术配合         │
+│  空间数量: 3个(客厅/主卧/儿童房)   │
+│  价值侧重: 细节写实度、视觉吸引力  │
+│  技术配合: 需要(材质搭配、灯光布局) │
+│  协作方式: 前期多沟通              │
+│  注意事项: 软装色调易偏差          │
+│  特殊要求: 业主喜欢暖色调          │
+│  参考素材: 有(后续群内发送)        │
+│  ━━━━━━━━━━━━━━━━━━━━━━━       │
+│  对接人: 李总                     │
+│  电话: 138****8000               │
+│                                  │
+│         [返回项目]                 │
+└─────────────────────────────────┘
+```
+
+#### 3.4.2 功能实现
+1. **结果展示**:
+   - 从 SurveyLog.data 读取答案
+   - 格式化显示(选择题显示选项文本,文本题直接显示)
+   - 手机号脱敏显示(中间4位显示为 ****)
+
+2. **权限控制**:
+   - 客户本人: 可查看完整结果(包括完整手机号)
+   - 客服/组员/组长: 可查看完整结果
+   - 其他外部联系人: 无权查看
+
+3. **返回按钮**:
+   - 返回项目详情页
+
+---
+
+## 四、项目详情页集成
+
+### 4.1 客户卡片问卷状态显示
+
+在 `project-detail.component.html` 的客户联系人卡片区域添加问卷入口:
+
+```html
+<!-- 客户信息卡片 -->
+<div class="contact-card">
+  <div class="contact-info" (click)="openContactPanel()">
+    <img [src]="contact?.get('data')?.avatar || 'assets/default-avatar.png'" />
+    <div>
+      <h3>{{ contact?.get('realname') || contact?.get('name') }}</h3>
+      <p>{{ canViewCustomerPhone ? contact?.get('mobile') : '***' }}</p>
+    </div>
+  </div>
+
+  <!-- 问卷状态 -->
+  <div class="survey-status" (click)="handleSurveyClick($event)">
+    <ion-icon [name]="surveyStatus.icon"></ion-icon>
+    <span>{{ surveyStatus.text }}</span>
+  </div>
+</div>
+```
+
+### 4.2 问卷状态查询
+
+在 `project-detail.component.ts` 中添加:
+
+```typescript
+// 问卷状态
+surveyStatus: {
+  filled: boolean;
+  text: string;
+  icon: string;
+  surveyLog?: FmodeObject;
+} = {
+  filled: false,
+  text: '发送问卷',
+  icon: 'document-text-outline'
+};
+
+async loadSurveyStatus() {
+  if (!this.project?.id || !this.contact?.id) return;
+
+  try {
+    const query = new Parse.Query('SurveyLog');
+    query.equalTo('project', this.project.toPointer());
+    query.equalTo('contact', this.contact.toPointer());
+    query.equalTo('type', 'survey-project');
+    query.equalTo('isCompleted', true);
+    const surveyLog = await query.first();
+
+    if (surveyLog) {
+      this.surveyStatus = {
+        filled: true,
+        text: '查看问卷',
+        icon: 'checkmark-circle',
+        surveyLog
+      };
+    }
+  } catch (err) {
+    console.error('查询问卷状态失败:', err);
+  }
+}
+```
+
+### 4.3 问卷发送功能
+
+```typescript
+async sendSurvey() {
+  if (!this.groupChat || !this.wxwork) return;
+
+  try {
+    const chatId = this.groupChat.get('chat_id');
+    const surveyUrl = `${window.location.origin}/wxwork/${this.cid}/survey/project/${this.project?.id}`;
+
+    await this.wxwork.ww.openExistedChatWithMsg({
+      chatId: chatId,
+      msg: {
+        msgtype: 'link',
+        link: {
+          title: '《家装效果图服务初次合作需求调查表》',
+          desc: '为让本次服务更贴合您的需求,请花3-5分钟填写简短问卷,感谢支持!',
+          url: surveyUrl,
+          imgUrl: `${window.location.origin}/assets/logo.jpg`
+        }
+      }
+    });
+
+    alert('问卷已发送到群聊!');
+  } catch (err) {
+    console.error('发送问卷失败:', err);
+    alert('发送失败,请重试');
+  }
+}
+```
+
+### 4.4 问卷查看功能
+
+```typescript
+// 新增模态框状态
+showSurveyModal: boolean = false;
+selectedSurveyLog: FmodeObject | null = null;
+
+async viewSurvey() {
+  if (!this.surveyStatus.surveyLog) return;
+
+  this.selectedSurveyLog = this.surveyStatus.surveyLog;
+  this.showSurveyModal = true;
+}
+
+async handleSurveyClick(event: Event) {
+  event.stopPropagation();
+
+  if (this.surveyStatus.filled) {
+    // 已填写,查看结果
+    await this.viewSurvey();
+  } else {
+    // 未填写,发送问卷
+    await this.sendSurvey();
+  }
+}
+```
+
+---
+
+## 五、技术实现要点
+
+### 5.1 企微授权集成
+
+```typescript
+import { WxworkAuth } from 'fmode-ng/core';
+
+async ngOnInit() {
+  // 1. 初始化企微授权
+  const cid = this.route.snapshot.paramMap.get('cid') || '';
+  this.wxAuth = new WxworkAuth({ cid, appId: 'crm' });
+
+  // 2. 获取当前外部联系人
+  try {
+    this.currentContact = await this.wxAuth.currentContact();
+    console.log('当前联系人:', this.currentContact);
+  } catch (error) {
+    console.error('获取联系人失败:', error);
+    alert('无法识别您的身份,请通过企微群聊进入');
+    return;
+  }
+
+  // 3. 检查是否已填写问卷
+  await this.checkExistingSurvey();
+}
+```
+
+### 5.2 数据查询与保存
+
+```typescript
+// 查询现有问卷
+async checkExistingSurvey() {
+  const query = new Parse.Query('SurveyLog');
+  query.equalTo('project', this.projectId);
+  query.equalTo('contact', this.currentContact.toPointer());
+  query.equalTo('type', 'survey-project');
+
+  this.surveyLog = await query.first();
+
+  if (this.surveyLog?.get('isCompleted')) {
+    // 已完成,直接显示结果
+    this.currentState = 'result';
+  } else if (this.surveyLog) {
+    // 未完成,恢复进度
+    this.answers = this.surveyLog.get('data') || {};
+    this.currentState = 'questionnaire';
+  }
+}
+
+// 保存答案
+async saveAnswer(questionId: string, answer: any) {
+  if (!this.surveyLog) {
+    // 首次保存,创建记录
+    const SurveyLog = Parse.Object.extend('SurveyLog');
+    this.surveyLog = new SurveyLog();
+
+    const company = new Parse.Object('Company');
+    company.id = localStorage.getItem('company') || '';
+
+    const project = new Parse.Object('Project');
+    project.id = this.projectId;
+
+    this.surveyLog.set('company', company.toPointer());
+    this.surveyLog.set('project', project.toPointer());
+    this.surveyLog.set('contact', this.currentContact.toPointer());
+    this.surveyLog.set('type', 'survey-project');
+  }
+
+  // 更新答案
+  const data = this.surveyLog.get('data') || {};
+  data[questionId] = answer;
+  this.surveyLog.set('data', data);
+
+  await this.surveyLog.save();
+}
+
+// 完成问卷
+async completeSurvey() {
+  if (!this.surveyLog) return;
+
+  this.surveyLog.set('isCompleted', true);
+  this.surveyLog.set('completedAt', new Date());
+  await this.surveyLog.save();
+
+  // 切换到结果页
+  this.currentState = 'result';
+}
+```
+
+### 5.3 联系人信息补全
+
+如果问卷中填写了姓名/手机号,需要同步更新 ContactInfo 表:
+
+```typescript
+async updateContactInfo() {
+  const data = this.surveyLog.get('data');
+
+  if (data.contact_name || data.contact_phone) {
+    if (data.contact_name && !this.currentContact.get('realname')) {
+      this.currentContact.set('realname', data.contact_name);
+    }
+
+    if (data.contact_phone && !this.currentContact.get('mobile')) {
+      this.currentContact.set('mobile', data.contact_phone);
+    }
+
+    await this.currentContact.save();
+  }
+}
+```
+
+---
+
+## 六、《家装效果图服务初次合作需求调查表》
+### 尊敬的伙伴:
+为让本次效果图服务更贴合您的工作节奏与核心需求,我们准备了简短选择式问卷,您的偏好将直接帮我们校准服务方向,感谢支持!
+
+
+### 一、基础需求:快速明确服务范围
+1. 本次您需要的核心服务是?
+   □ 纯效果图渲染(仅输出可视化图像)
+   □ 效果图+技术配合(含方案相关建议)
+   □ 其他补充:______
+
+2. 需覆盖的关键空间数量及类型?
+   数量:______个(例:3个,空间类型:客厅/主卧/儿童房)
+
+
+### 二、核心侧重:帮我们锁定服务重点
+3. 您更希望本次效果图突出哪些价值?(可多选,选2-3项)
+   □ 细节写实度(如空间尺寸匹配、材质还原,贴合落地需求)
+   □ 视觉吸引力(如氛围营造、风格亮点,方便对接业主)
+   □ 风格适配性(精准匹配预设调性,减少后期调整)
+   □ 其他重点:______
+
+4. 关于方案建议,是否需要我们技术团队配合?
+   □ 需要(侧重方向:□ 材质搭配 □ 灯光布局 □ 空间优化)
+   □ 暂不需要(已有明确方案,仅需渲染)
+
+
+### 三、协作节奏:匹配您的沟通习惯
+5. 您偏好的服务协作方式是?
+   □ 前期多沟通(确认方向、细节后再推进,减少返工)
+   □ 先出初版再修改(快速看到成果,针对性调整)
+   □ 灵活协调(根据进度随时沟通)
+
+
+### 四、特殊提醒:提前规避潜在偏差
+6. 过往合作中,是否有需要特别注意的点?(可多选)
+   □ 软装色调易偏差 □ 建模细节需盯控 □ 其他:______
+
+7. 本次项目是否有特殊要求?(如业主禁忌、重点展示点)
+   ______
+
+8. 是否有参考素材(如风格图、实景图)需同步?
+   □ 有(后续群内发送) □ 无(需求已清晰)
+
+
+感谢您的反馈!我们将根据您的选择制定服务方案,对接人:______(姓名),电话:______,有问题可随时联系~

+ 1 - 1
docs/service-integration-complete.md

@@ -184,7 +184,7 @@ const subscription = this.aiService.streamCompletion(
 
 ### 3. WxworkSDKService (企微SDK服务)
 
-**文件**: `/src/modules/project/services/wxwork-sdk.service.ts`
+**文件**: `import {WxworkSDK} from 'fmode-ng/core'`
 
 **核心功能**:
 - ✅ JSAPI注册与签名

+ 457 - 0
docs/task/2025102220-fix-unassigned-projects-complete.md

@@ -0,0 +1,457 @@
+# 修复"未分配"问题 - 完整显示所有组员
+
+**日期**: 2025-10-24  
+**问题**: 组长端工作量负载概览图未显示全部组员,且项目负责人显示"未分配"
+
+---
+
+## 🔴 问题根源
+
+### 问题1: 项目显示"未分配"
+
+**原因**: 数据库中 `Project` 表的 `assignee` 字段为空或null
+
+**数据流**:
+```
+Parse数据库
+  ↓
+Project表.assignee字段 = null
+  ↓
+transformProject() 转换
+  ↓
+designerName = assignee?.get('name') || '未分配'
+  ↓
+前端显示"未分配"
+```
+
+### 问题2: 甘特图不显示所有组员
+
+**原因**: 只统计了有项目的设计师,没有项目的组员不显示
+
+**旧逻辑** (错误):
+```typescript
+// ❌ 只从项目中提取设计师
+const assigned = this.filteredProjects.filter(p => !!p.designerName);
+const designers = Array.from(new Set(assigned.map(p => p.designerName)));
+
+// 结果:只显示有项目的设计师,且包含"未分配"
+// 例如:['未分配', '张三', '李四']
+```
+
+**问题**:
+- ❌ 没有项目的设计师不显示(如"王五")
+- ❌ "未分配"也被当作设计师显示
+- ❌ 无法看到团队全貌
+
+---
+
+## ✅ 解决方案
+
+### 修改1: 甘特图使用真实设计师列表
+
+**文件**: `src/app/pages/team-leader/dashboard/dashboard.ts`
+
+**位置**: 第1869-1900行(updateWorkloadGantt方法内)
+
+**修改内容**:
+
+```typescript
+// ✅ 获取所有真实设计师(优先使用realDesigners)
+let designers: string[] = [];
+
+if (this.realDesigners && this.realDesigners.length > 0) {
+  // 使用真实的设计师列表
+  designers = this.realDesigners.map(d => d.name);
+  console.log('✅ 使用真实设计师列表:', designers.length, '人');
+} else {
+  // 降级:从已分配的项目中提取设计师(过滤掉"未分配")
+  const assigned = this.filteredProjects.filter(p => p.designerName && p.designerName !== '未分配');
+  designers = Array.from(new Set(assigned.map(p => p.designerName)));
+  console.warn('⚠️ 使用项目中提取的设计师列表:', designers.length, '人');
+}
+
+if (designers.length === 0) {
+  // 没有设计师数据,显示空状态
+  const emptyOption = {
+    title: {
+      text: '暂无组员数据',
+      subtext: '请先在系统中添加设计师(组员角色)',
+      left: 'center',
+      top: 'center',
+      textStyle: { fontSize: 16, color: '#9ca3af' },
+      subtextStyle: { fontSize: 13, color: '#d1d5db' }
+    }
+  };
+  this.workloadGanttChart.setOption(emptyOption, true);
+  return;
+}
+
+// 获取所有已分配的项目(过滤掉"未分配")
+const assigned = this.filteredProjects.filter(p => p.designerName && p.designerName !== '未分配');
+```
+
+**改进点**:
+
+1. **优先使用真实设计师列表** (`this.realDesigners`)
+   - 从Parse数据库查询的所有组员
+   - 不依赖项目数据
+   - 包含没有项目的设计师
+
+2. **过滤"未分配"**
+   - `p.designerName !== '未分配'`
+   - 不将"未分配"当作设计师显示
+
+3. **友好的空状态提示**
+   - 明确提示"暂无组员数据"
+   - 给出操作建议
+
+---
+
+## 📊 效果对比
+
+### 修改前
+
+**甘特图显示**:
+```
+未分配 (5个项目) ❌ 不应该显示
+张三 (3个项目)
+李四 (2个项目)
+```
+
+**问题**:
+- ❌ "未分配"占据第一行
+- ❌ "王五"(无项目)不显示
+- ❌ 无法看到团队完整负载
+
+### 修改后
+
+**甘特图显示**:
+```
+张三 (3个项目)  ✅ 有项目,显示
+李四 (2个项目)  ✅ 有项目,显示
+王五 (0个项目)  ✅ 无项目,也显示
+```
+
+**优点**:
+- ✅ 显示所有真实组员
+- ✅ 不显示"未分配"
+- ✅ 清晰看到谁有空闲
+- ✅ 便于分配新项目
+
+---
+
+## 🔧 如何分配项目给组员
+
+### 方式1: 使用DesignerService的assignProject方法
+
+**代码示例**:
+
+```typescript
+// 在组长端调用
+async assignProjectToDesigner(projectId: string, designerId: string) {
+  try {
+    const success = await this.designerService.assignProject(projectId, designerId);
+    
+    if (success) {
+      console.log('✅ 项目分配成功');
+      // 刷新项目列表
+      await this.loadProjects();
+      // 更新甘特图
+      this.updateWorkloadGantt();
+    } else {
+      console.error('❌ 项目分配失败');
+    }
+  } catch (error) {
+    console.error('❌ 分配出错:', error);
+  }
+}
+```
+
+**DesignerService中的实现** (已有):
+
+```typescript
+// src/app/pages/team-leader/services/designer.service.ts
+async assignProject(projectId: string, designerId: string): Promise<boolean> {
+  const Parse = await this.ensureParse();
+  if (!Parse) return false;
+  
+  try {
+    const project = Parse.Object.extend('Project').createWithoutData(projectId);
+    const designer = Parse.Object.extend('Profile').createWithoutData(designerId);
+    
+    project.set('assignee', designer);
+    project.set('status', '进行中');
+    
+    await project.save();
+    console.log('✅ 项目分配成功');
+    return true;
+  } catch (error) {
+    console.error('❌ 项目分配失败:', error);
+    return false;
+  }
+}
+```
+
+### 方式2: 在Parse Dashboard手动分配
+
+**步骤**:
+
+1. **打开Parse Dashboard**
+   - 访问Parse管理后台
+   - 进入数据库
+
+2. **找到Project表**
+   - 点击 `Project` 表
+   - 找到需要分配的项目
+
+3. **设置assignee字段**
+   ```
+   字段: assignee
+   类型: Pointer<Profile>
+   值: 选择设计师的Profile记录
+   ```
+
+4. **设置status**
+   ```
+   字段: status
+   类型: String
+   值: '进行中'
+   ```
+
+5. **保存**
+   - 点击保存按钮
+   - 刷新组长端页面查看
+
+### 方式3: 使用智能推荐(快速分配)
+
+**组长端操作**:
+
+1. 在项目卡片上点击"🤖 智能推荐"按钮
+2. 系统自动推荐最合适的设计师
+3. 确认后自动分配
+
+**智能推荐逻辑**:
+- 风格匹配度
+- 当前负载
+- 历史表现
+- 紧急适配度
+
+---
+
+## 🧪 测试验证
+
+### 测试步骤
+
+1. **准备测试数据**
+   
+   **设计师数据** (Profile表):
+   ```javascript
+   // 应该有至少3个组员
+   设计师1: { name: '张三', roleName: '组员' }
+   设计师2: { name: '李四', roleName: '组员' }
+   设计师3: { name: '王五', roleName: '组员' }
+   ```
+
+   **项目数据** (Project表):
+   ```javascript
+   项目1: { title: '项目A', assignee: 指向张三 }
+   项目2: { title: '项目B', assignee: 指向张三 }
+   项目3: { title: '项目C', assignee: 指向李四 }
+   项目4: { title: '项目D', assignee: null }  // 未分配
+   ```
+
+2. **访问组长端Dashboard**
+   ```
+   http://localhost:4200/team-leader/dashboard
+   ```
+
+3. **查看工作量负载概览图**
+
+   **预期显示**:
+   ```
+   张三 (2个项目) 🔥  ← 有项目,显示项目数
+   李四 (1个项目) ✓   ← 有项目,显示项目数
+   王五 (0个项目) ○   ← 无项目,也显示
+   ```
+
+   **不应显示**:
+   ```
+   ❌ 未分配 (1个项目)  ← 不应该出现
+   ```
+
+4. **验证控制台日志**
+   ```javascript
+   ✅ 使用真实设计师列表: 3 人
+   ✅ 加载设计师数据成功: 3 人
+   ✅ 加载真实项目数据成功: 4 个项目
+   ```
+
+5. **测试项目分配**
+   
+   **在浏览器控制台执行**:
+   ```javascript
+   // 获取组件实例
+   const dashboard = angular.element(document.querySelector('app-dashboard')).scope();
+   
+   // 分配项目D给王五
+   await dashboard.designerService.assignProject('项目D的ID', '王五的ID');
+   
+   // 刷新页面
+   location.reload();
+   ```
+
+   **预期结果**:
+   - 项目D的负责人从"未分配"变为"王五"
+   - 甘特图中王五显示 (1个项目)
+
+---
+
+## 📋 数据库检查清单
+
+### 检查Profile表
+
+```sql
+-- 查询所有组员
+SELECT objectId, name, roleName, company 
+FROM Profile 
+WHERE roleName = '组员' 
+AND isDeleted != true;
+
+-- 预期:应该看到多个组员记录
+```
+
+### 检查Project表
+
+```sql
+-- 查询未分配的项目
+SELECT objectId, title, assignee, status 
+FROM Project 
+WHERE (assignee IS NULL OR assignee = '')
+AND isDeleted != true;
+
+-- 预期:未分配的项目列表
+```
+
+### 修复未分配的项目
+
+```sql
+-- 方式1: 分配给指定设计师
+UPDATE Project 
+SET assignee = {Profile的Pointer},
+    status = '进行中'
+WHERE objectId = '项目ID';
+
+-- 方式2: 批量分配(循环分配给不同设计师)
+-- 需要在应用层或脚本中处理
+```
+
+---
+
+## 🎯 核心改进
+
+### 1. 数据源优化
+
+```typescript
+// ❌ 旧方式:从项目中提取
+const designers = projects.map(p => p.designerName);
+
+// ✅ 新方式:使用真实设计师数据
+const designers = this.realDesigners.map(d => d.name);
+```
+
+### 2. 过滤逻辑
+
+```typescript
+// ✅ 过滤"未分配"
+const assigned = this.filteredProjects.filter(
+  p => p.designerName && p.designerName !== '未分配'
+);
+```
+
+### 3. 降级策略
+
+```typescript
+if (this.realDesigners && this.realDesigners.length > 0) {
+  // 优先使用真实数据
+  designers = this.realDesigners.map(d => d.name);
+} else {
+  // 降级到从项目提取(开发模式)
+  designers = projects.map(p => p.designerName);
+}
+```
+
+---
+
+## 📖 相关文件
+
+### 修改的文件
+
+1. ✅ `src/app/pages/team-leader/dashboard/dashboard.ts`
+   - 第1869-1900行:修改 `updateWorkloadGantt()` 方法
+   - 使用真实设计师列表
+   - 过滤"未分配"
+
+### 相关服务
+
+1. `src/app/pages/team-leader/services/designer.service.ts`
+   - `getDesigners()`: 获取所有组员
+   - `getProjects()`: 获取所有项目
+   - `assignProject()`: 分配项目
+   - `transformProject()`: 转换项目数据(设置designerName)
+
+### 数据表
+
+1. **Profile表** (设计师/组员)
+   - `name`: 姓名
+   - `roleName`: '组员'
+   - `company`: 公司ID
+   - `isDeleted`: 软删除标记
+
+2. **Project表** (项目)
+   - `title`: 项目名称
+   - `assignee`: Pointer<Profile> (负责人)
+   - `status`: 项目状态
+   - `company`: 公司ID
+   - `isDeleted`: 软删除标记
+
+---
+
+## 🎉 总结
+
+### 修改前的问题
+
+- ❌ 甘特图只显示有项目的设计师
+- ❌ "未分配"占据甘特图第一行
+- ❌ 无法看到空闲的设计师
+- ❌ 项目负责人显示"未分配"
+
+### 修改后的效果
+
+- ✅ 甘特图显示所有真实组员
+- ✅ 不显示"未分配"伪设计师
+- ✅ 清晰显示谁有空闲(0个项目)
+- ✅ 便于识别和分配新项目
+- ✅ 数据来源可靠(从数据库查询)
+
+### 解决路径
+
+1. **短期**:修改前端逻辑(本次修改)
+   - 使用真实设计师列表
+   - 过滤"未分配"
+
+2. **长期**:数据质量保证
+   - 新项目创建时必须分配设计师
+   - 使用智能推荐功能
+   - 定期检查未分配项目
+
+---
+
+**修复完成!** ✨
+
+现在刷新浏览器,应该能看到:
+1. 所有真实组员都显示在甘特图中
+2. 不再显示"未分配"
+3. 可以清楚看到谁有空闲
+
+
+

+ 117 - 0
docs/task/2025102220-fix-unassigned-projects.md

@@ -0,0 +1,117 @@
+# 解决"未分配"问题和完整显示所有组员
+
+**日期**: 2025-10-24  
+**问题**: 组长端工作量负载概览图未显示全部组员,且项目负责人显示"未分配"
+
+---
+
+## 🔴 问题分析
+
+### 1. 项目负责人显示"未分配"
+
+**根本原因**: 数据库中 `Project` 表的 `assignee` 字段为空
+
+**代码位置**: `src/app/pages/team-leader/services/designer.service.ts` 第330行
+
+```typescript
+designerName: assignee?.get('name') || '未分配',
+```
+
+**数据流程**:
+```
+Parse数据库 → Project表 → assignee字段(Pointer<Profile>) 
+  ↓
+如果assignee为null → designerName = '未分配'
+```
+
+### 2. 工作量甘特图未显示所有组员
+
+**根本原因**: 只统计了**已分配项目**的设计师
+
+**代码位置**: `src/app/pages/team-leader/dashboard/dashboard.ts` 约2000行
+
+```typescript
+// ❌ 问题代码:只获取有项目的设计师
+const assigned = this.filteredProjects.filter(p => !!p.designerName);
+const designers = Array.from(new Set(assigned.map(p => p.designerName)));
+```
+
+**结果**:
+- ✅ 有项目的设计师:显示在甘特图中
+- ❌ 没有项目的设计师:不显示
+
+---
+
+## ✅ 解决方案
+
+### 方案1:修复甘特图 - 显示所有组员
+
+修改 `updateWorkloadGantt()` 方法,使用真实的设计师列表而非从项目中提取:
+
+```typescript
+// ✅ 修改后:使用真实的设计师列表
+const designers = this.realDesigners.length > 0 
+  ? this.realDesigners.map(d => d.name)
+  : Array.from(new Set(this.filteredProjects.filter(p => p.designerName && p.designerName !== '未分配').map(p => p.designerName)));
+
+// 如果没有设计师,显示提示
+if (designers.length === 0) {
+  // 显示"暂无组员数据"
+  return;
+}
+```
+
+### 方案2:项目分配功能
+
+在数据库层面为项目分配设计师:
+
+```typescript
+/**
+ * 在designer.service.ts中已有方法
+ */
+async assignProject(projectId: string, designerId: string): Promise<boolean> {
+  const project = Parse.Object.extend('Project').createWithoutData(projectId);
+  const designer = Parse.Object.extend('Profile').createWithoutData(designerId);
+  
+  project.set('assignee', designer);
+  project.set('status', '进行中');
+  
+  await project.save();
+  return true;
+}
+```
+
+### 方案3:数据库检查和修复
+
+**检查步骤**:
+1. 打开Parse Dashboard
+2. 查看 `Project` 表
+3. 检查 `assignee` 字段
+4. 对于未分配的项目,手动设置 `assignee`
+
+**SQL修复(如果有SQL访问权限)**:
+```sql
+-- 查看未分配的项目
+SELECT objectId, title, status, assignee 
+FROM Project 
+WHERE assignee IS NULL OR assignee = '';
+
+-- 批量分配给某个设计师
+UPDATE Project 
+SET assignee = {Profile的指针} 
+WHERE assignee IS NULL;
+```
+
+---
+
+## 📝 具体修改
+
+### 修改1: updateWorkloadGantt方法
+
+**文件**: `src/app/pages/team-leader/dashboard/dashboard.ts`
+
+**位置**: updateWorkloadGantt() 方法内
+
+**修改内容**:
+
+

+ 296 - 0
docs/task/2025102221-console-migration-script.md

@@ -0,0 +1,296 @@
+# 浏览器控制台快速修复脚本
+
+**使用说明**:复制下面的脚本,粘贴到浏览器控制台执行
+
+---
+
+## 🚀 快速修复脚本
+
+### 步骤1: 打开浏览器控制台
+
+1. 访问:`http://localhost:4200/admin/project-management`
+2. 按 `F12` 打开开发者工具
+3. 切换到 `Console` 标签
+
+---
+
+### 步骤2: 复制并执行以下脚本
+
+```javascript
+// ================================
+// 项目负责人批量更新脚本
+// ================================
+
+(async function() {
+  console.log('🚀 开始批量更新项目负责人...');
+  
+  try {
+    // 获取Parse实例
+    const FmodeParse = (window as any).FmodeParse || await import('fmode-ng/parse').then(m => m.FmodeParse);
+    const Parse = FmodeParse.with('nova');
+    
+    // 获取公司ID
+    const company = localStorage.getItem('company');
+    if (!company) {
+      console.error('❌ 未找到公司ID,请先登录');
+      return;
+    }
+    console.log('✅ 公司ID:', company);
+    
+    // 查询所有项目
+    const projectQuery = new Parse.Query('Project');
+    projectQuery.equalTo('company', company);
+    projectQuery.notEqualTo('isDeleted', true);
+    projectQuery.include(['assignee', 'department', 'department.leader']);
+    projectQuery.limit(1000);
+    
+    const projects = await projectQuery.find();
+    console.log(`📊 找到 ${projects.length} 个项目`);
+    
+    // 统计
+    let updated = 0;
+    let skipped = 0;
+    let failed = 0;
+    
+    // 遍历每个项目
+    for (let i = 0; i < projects.length; i++) {
+      const project = projects[i];
+      const title = project.get('title') || '未命名项目';
+      const existingAssignee = project.get('assignee');
+      
+      try {
+        // 如果已有负责人,跳过
+        if (existingAssignee) {
+          console.log(`⏭️  [${i+1}/${projects.length}] "${title}" 已有负责人: ${existingAssignee.get('name')}`);
+          skipped++;
+          continue;
+        }
+        
+        // 检查是否有项目组
+        let department = project.get('department');
+        
+        // 如果没有项目组,查找默认项目组
+        if (!department) {
+          console.log(`🔍 [${i+1}/${projects.length}] "${title}" 没有项目组,查找默认项目组...`);
+          
+          const deptQuery = new Parse.Query('Department');
+          deptQuery.equalTo('company', company);
+          deptQuery.equalTo('type', 'project');
+          deptQuery.notEqualTo('isDeleted', true);
+          deptQuery.include('leader');
+          deptQuery.ascending('createdAt');
+          deptQuery.limit(1);
+          
+          department = await deptQuery.first();
+        } else {
+          // 如果有项目组,确保加载了leader
+          await department.fetch({ include: ['leader'] });
+        }
+        
+        if (!department) {
+          console.warn(`⚠️  [${i+1}/${projects.length}] "${title}" 没有可用的项目组`);
+          failed++;
+          continue;
+        }
+        
+        // 获取组长
+        const leader = department.get('leader');
+        if (!leader) {
+          console.warn(`⚠️  [${i+1}/${projects.length}] "${title}" 的项目组没有组长`);
+          failed++;
+          continue;
+        }
+        
+        // 更新项目
+        project.set('assignee', leader);
+        project.set('department', department);
+        await project.save();
+        
+        updated++;
+        console.log(`✅ [${i+1}/${projects.length}] "${title}" 已设置负责人: ${leader.get('name')}`);
+        
+      } catch (error) {
+        console.error(`❌ [${i+1}/${projects.length}] 更新 "${title}" 失败:`, error);
+        failed++;
+      }
+    }
+    
+    // 输出结果
+    console.log('');
+    console.log('='.repeat(60));
+    console.log('🎉 批量更新完成!');
+    console.log('='.repeat(60));
+    console.log(`📊 总计: ${projects.length} 个项目`);
+    console.log(`✅ 成功更新: ${updated} 个`);
+    console.log(`⏭️  跳过(已有负责人): ${skipped} 个`);
+    console.log(`❌ 失败: ${failed} 个`);
+    console.log('='.repeat(60));
+    console.log('');
+    console.log('💡 请刷新页面查看结果(Ctrl+Shift+R)');
+    
+  } catch (error) {
+    console.error('❌ 执行失败:', error);
+  }
+})();
+```
+
+---
+
+### 步骤3: 等待执行完成
+
+控制台会输出类似信息:
+
+```
+🚀 开始批量更新项目负责人...
+✅ 公司ID: cDL6R1hgSi
+📊 找到 11 个项目
+⏭️  [1/11] "未命名案例组三期项目" 已有负责人: 汪奥
+✅ [2/11] "张家界凤凰城三期项目" 已设置负责人: 汪奥
+✅ [3/11] "日式10.6" 已设置负责人: 汪奥
+...
+============================================================
+🎉 批量更新完成!
+============================================================
+📊 总计: 11 个项目
+✅ 成功更新: 10 个
+⏭️  跳过(已有负责人): 1 个
+❌ 失败: 0 个
+============================================================
+💡 请刷新页面查看结果(Ctrl+Shift+R)
+```
+
+---
+
+### 步骤4: 刷新页面
+
+按 `Ctrl+Shift+R` 强制刷新页面,查看项目列表中的"负责人"列。
+
+**预期结果**:所有项目的"负责人"列应显示组长名字(如"汪奥"),而不是"未分配"。
+
+---
+
+## 📝 简化版脚本(如果上面的不work)
+
+如果上面的脚本报错,试试这个简化版:
+
+```javascript
+(async function() {
+  const Parse = window.FmodeParse.with('nova');
+  const company = localStorage.getItem('company');
+  
+  const query = new Parse.Query('Project');
+  query.equalTo('company', company);
+  query.notEqualTo('isDeleted', true);
+  query.doesNotExist('assignee');
+  query.limit(100);
+  
+  const projects = await query.find();
+  console.log('需要更新的项目数:', projects.length);
+  
+  // 获取第一个项目组
+  const deptQuery = new Parse.Query('Department');
+  deptQuery.equalTo('company', company);
+  deptQuery.include('leader');
+  const dept = await deptQuery.first();
+  
+  if (!dept) {
+    console.error('没有找到项目组');
+    return;
+  }
+  
+  const leader = dept.get('leader');
+  console.log('使用组长:', leader.get('name'));
+  
+  // 批量更新
+  for (const p of projects) {
+    p.set('assignee', leader);
+    p.set('department', dept);
+    await p.save();
+    console.log('✅', p.get('title'));
+  }
+  
+  console.log('完成!请刷新页面');
+})();
+```
+
+---
+
+## 🔍 验证脚本
+
+执行更新后,运行这个脚本验证结果:
+
+```javascript
+(async function() {
+  const Parse = window.FmodeParse.with('nova');
+  const company = localStorage.getItem('company');
+  
+  const query = new Parse.Query('Project');
+  query.equalTo('company', company);
+  query.notEqualTo('isDeleted', true);
+  query.include(['assignee', 'department']);
+  query.limit(20);
+  
+  const projects = await query.find();
+  
+  console.table(projects.map(p => ({
+    '项目名称': p.get('title'),
+    '负责人': p.get('assignee')?.get('name') || '未分配',
+    '项目组': p.get('department')?.get('name') || '无'
+  })));
+})();
+```
+
+预期输出表格:
+
+```
+┌─────────┬────────────────────────┬──────────┬──────────┐
+│ (index) │      项目名称          │  负责人  │  项目组  │
+├─────────┼────────────────────────┼──────────┼──────────┤
+│    0    │  '未命名案例组三期'     │  '汪奥'  │  '汪奥组'│
+│    1    │  '张家界凤凰城三期'     │  '汪奥'  │  '汪奥组'│
+│    2    │  '日式10.6'            │  '汪奥'  │  '汪奥组'│
+│   ...   │         ...            │   ...    │   ...    │
+└─────────┴────────────────────────┴──────────┴──────────┘
+```
+
+---
+
+## ⚠️ 故障排查
+
+### 错误1: `FmodeParse is not defined`
+
+**解决方法**:
+```javascript
+// 先初始化FmodeParse
+const { FmodeParse } = await import('fmode-ng/parse');
+// 然后继续执行脚本
+```
+
+### 错误2: `company is null`
+
+**解决方法**:
+```javascript
+// 手动设置公司ID
+localStorage.setItem('company', 'cDL6R1hgSi'); // 替换为你的公司ID
+```
+
+### 错误3: 权限错误
+
+**解决方法**:
+- 确保已登录管理员账号
+- 检查Parse ACL权限配置
+
+---
+
+## 🎯 执行后检查
+
+1. **控制台日志**:确认所有项目都成功更新
+2. **项目列表**:刷新页面,检查"负责人"列
+3. **Parse Dashboard**:查看Project表的assignee字段
+4. **项目详情页**:点击任意项目,查看负责人信息
+
+---
+
+**现在就执行脚本,一键修复所有项目的负责人!** 🚀
+
+

+ 424 - 0
docs/task/2025102221-fix-project-assignee-and-spaces.md

@@ -0,0 +1,424 @@
+# 修复项目负责人和空间场景问题
+
+**日期**: 2025-10-24  
+**问题**: 
+1. 分配设计师时没有空间场景可选
+2. 项目列表中负责人显示"未分配",应该显示组长名字
+
+---
+
+## 🔍 问题分析
+
+### 问题1: 空间场景为空
+
+**现象**:  
+在 `http://localhost:4200/admin/project-detail/APwk78jnrh/order` 分配设计师时,"指派空间场景"没有选项。
+
+**原因**:  
+`team-assign.component.ts` 通过 `ProductSpaceService.getProjectProductSpaces()` 获取项目空间:
+
+```typescript
+// team-assign.component.ts 第94-107行
+async loadProjectSpaces(): Promise<void> {
+  if (!this.project) return;
+  
+  try {
+    this.loadingSpaces = true;
+    const projectId = this.project.id || '';
+    // 从Product表查询该项目的空间
+    this.projectSpaces = await this.productSpaceService.getProjectProductSpaces(projectId);
+  } catch (err) {
+    console.error('加载项目空间失败:', err);
+  } finally {
+    this.loadingSpaces = false;
+    this.cdr.markForCheck();
+  }
+}
+```
+
+`ProductSpaceService.getProjectProductSpaces()` 从 `Product` 表查询:
+
+```typescript
+// product-space.service.ts 第125-143行
+async getProjectProductSpaces(projectId: string): Promise<Project[]> {
+  try {
+    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();
+    return results.map(product => this.parseProductData(product));
+
+  } catch (error) {
+    console.error('获取项目产品空间失败:', error);
+    return [];
+  }
+}
+```
+
+**根本原因**: 项目ID为 `APwk78jnrh` 的项目在 `Product` 表中没有任何记录。
+
+---
+
+### 问题2: 项目负责人显示"未分配"
+
+**现象**:  
+项目列表中"负责人"列显示"未分配",应该显示组长名字。
+
+**数据流**:
+
+```
+Parse数据库 Project表
+  ↓
+  assignee字段 (Pointer<Profile>) = null
+  ↓
+project-management.ts 第107行
+  assignee: json.assigneeName || '未分配'
+  ↓
+前端显示 "未分配"
+```
+
+**根本原因**: 项目创建时,`assignee` 字段没有被设置。按业务逻辑,项目的负责人应该是项目组的组长(`department.leader`)。
+
+---
+
+## ✅ 解决方案
+
+### 方案1: 空间场景问题
+
+**方案A: 在"订单分配"阶段创建空间(推荐)**
+
+在 `stage-order.component.ts` 的订单分配阶段,用户填写空间信息后自动创建 `Product` 记录。
+
+**位置**: `src/modules/project/pages/project-detail/stages/stage-order.component.ts`
+
+**逻辑**:
+```typescript
+async saveSpaces() {
+  // 遍历用户添加的空间
+  for (const space of this.spaces) {
+    // 调用 ProductSpaceService.createProductSpace() 创建Product记录
+    await this.productSpaceService.createProductSpace(this.project.id, {
+      name: space.name,
+      type: space.type,
+      area: space.area,
+      priority: space.priority,
+      complexity: space.complexity,
+      estimatedBudget: space.budget
+    });
+  }
+}
+```
+
+**方案B: 在分配设计师时动态创建空间**
+
+如果没有空间,显示提示:"请先在订单分配阶段添加空间"。
+
+**方案C: 提供手动添加空间的入口**
+
+在分配设计师弹窗中添加"添加空间"按钮。
+
+---
+
+### 方案2: 项目负责人问题
+
+**关键修改**: 在选择项目组(Department)时,自动将组长(`department.leader`)设置为项目的 `assignee`。
+
+#### 修改1: team-assign.component.ts
+
+**位置**: `src/modules/project/components/team-assign/team-assign.component.ts` 第128-134行
+
+**修改 `selectDepartment` 方法**:
+
+```typescript
+async selectDepartment(department: FmodeObject) {
+  this.selectedDepartment = department;
+  this.selectedDesigner = null;
+  this.departmentMembers = [];
+
+  // ✅ 新增:自动设置组长为项目负责人
+  const leader = department.get('leader');
+  if (leader && this.project) {
+    try {
+      // 更新项目的assignee字段为组长
+      this.project.set('assignee', leader);
+      this.project.set('department', department);
+      await this.project.save();
+      console.log('✅ 项目负责人已设置为组长:', leader.get('name'));
+    } catch (error) {
+      console.error('❌ 设置项目负责人失败:', error);
+    }
+  }
+
+  await this.loadDepartmentMembers(department);
+}
+```
+
+**说明**:
+- 当选择项目组时,自动获取组长(`department.leader`)
+- 将组长设置为项目的 `assignee` 字段
+- 同时设置项目的 `department` 字段
+- 保存到数据库
+
+#### 修改2: 项目创建时的默认逻辑
+
+**位置**: `src/app/pages/admin/services/project.service.ts` 第65-110行
+
+**在 `createProject` 方法中添加逻辑**:
+
+```typescript
+async createProject(data: {
+  title: string;
+  customerId?: string;
+  assigneeId?: string; // 可以是组长ID
+  departmentId?: string; // 新增:项目组ID
+  status?: string;
+  currentStage?: string;
+  deadline?: Date;
+  data?: any;
+}): Promise<FmodeObject> {
+  const projectData: any = {
+    title: data.title,
+    status: data.status || '待分配',
+    currentStage: data.currentStage || '订单分配'
+  };
+
+  // 设置客户指针
+  if (data.customerId) {
+    projectData.customer = {
+      __type: 'Pointer',
+      className: 'ContactInfo',
+      objectId: data.customerId
+    };
+  }
+
+  // ✅ 新增:如果提供了项目组,获取组长作为默认负责人
+  if (data.departmentId) {
+    const departmentQuery = new Parse.Query('Department');
+    departmentQuery.include('leader');
+    const department = await departmentQuery.get(data.departmentId);
+    
+    if (department) {
+      projectData.department = department.toPointer();
+      
+      // 获取组长
+      const leader = department.get('leader');
+      if (leader && !data.assigneeId) {
+        projectData.assignee = leader.toPointer();
+        console.log('✅ 项目负责人默认为组长:', leader.get('name'));
+      }
+    }
+  }
+
+  // 设置负责人指针(如果明确指定)
+  if (data.assigneeId) {
+    projectData.assignee = {
+      __type: 'Pointer',
+      className: 'Profile',
+      objectId: data.assigneeId
+    };
+  }
+
+  if (data.deadline) {
+    projectData.deadline = data.deadline;
+  }
+
+  if (data.data) {
+    projectData.data = data.data;
+  }
+
+  const project = this.adminData.createObject('Project', projectData);
+  return await this.adminData.save(project);
+}
+```
+
+---
+
+## 📊 数据库结构
+
+### Project 表
+
+| 字段 | 类型 | 说明 | 新增/修改 |
+|------|------|------|----------|
+| `department` | Pointer<Department> | 项目组 | ✅ 确保填充 |
+| `assignee` | Pointer<Profile> | 项目负责人(组长) | ✅ 自动设置为组长 |
+| `title` | String | 项目名称 | 已有 |
+| `customer` | Pointer<ContactInfo> | 客户 | 已有 |
+| `status` | String | 项目状态 | 已有 |
+| `currentStage` | String | 当前阶段 | 已有 |
+
+### Product 表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `project` | Pointer<Project> | 所属项目 |
+| `productName` | String | 空间名称(如"客厅") |
+| `productType` | String | 空间类型(如"living_room") |
+| `space` | Object | 空间详细信息 |
+| `space.area` | Number | 面积 |
+| `space.priority` | Number | 优先级 |
+| `space.complexity` | String | 复杂度 |
+| `quotation` | Object | 报价信息 |
+| `requirements` | Object | 需求信息 |
+| `profile` | Pointer<Profile> | 负责该空间的设计师 |
+
+### Department 表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `name` | String | 项目组名称 |
+| `leader` | Pointer<Profile> | 组长 |
+| `type` | String | 'project' (项目组) |
+| `company` | String | 公司ID |
+
+### Profile 表
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `name` | String | 姓名 |
+| `roleName` | String | '组长' 或 '组员' |
+| `department` | String | 部门ID |
+| `company` | String | 公司ID |
+
+---
+
+## 🧪 测试步骤
+
+### 测试1: 空间场景
+
+1. **进入订单分配阶段**
+   ```
+   http://localhost:4200/admin/project-detail/APwk78jnrh/order
+   ```
+
+2. **添加空间**
+   - 点击"添加空间"按钮
+   - 填写空间信息(客厅、卧室等)
+   - 保存
+
+3. **分配设计师**
+   - 选择项目组
+   - 选择设计师
+   - **预期**: 能看到刚才添加的空间列表
+   - 选择空间并确认分配
+
+### 测试2: 项目负责人
+
+1. **创建新项目时指定项目组**
+   ```
+   POST /Project
+   {
+     "title": "测试项目",
+     "departmentId": "xxx", // 项目组ID
+     "status": "待分配"
+   }
+   ```
+
+2. **验证数据库**
+   ```
+   // Parse Dashboard 查看 Project 表
+   - assignee字段应该指向组长的Profile
+   - department字段应该指向该项目组
+   ```
+
+3. **在项目详情页选择项目组**
+   - 进入项目详情页订单分配阶段
+   - 选择一个项目组
+   - **预期**: 
+     - 项目的assignee自动更新为该组长
+     - 刷新项目列表,负责人列显示组长名字
+
+4. **验证项目列表**
+   ```
+   http://localhost:4200/admin/project-management
+   ```
+   - **预期**: "负责人"列显示组长名字,而不是"未分配"
+
+---
+
+## 🎯 核心逻辑总结
+
+### 项目负责人(assignee)的设置规则
+
+1. **项目创建时**:
+   - 如果指定了 `departmentId`,自动获取组长作为 `assignee`
+   - 如果明确指定了 `assigneeId`,使用指定的人员
+
+2. **选择项目组时**:
+   - 在 `team-assign` 组件中选择项目组
+   - 自动将组长设置为项目的 `assignee`
+   - 更新 `department` 字段
+
+3. **项目列表显示**:
+   - 从 `assignee.name` 获取负责人名字
+   - 如果为空,显示"未分配"
+
+### 空间场景的创建流程
+
+1. **订单分配阶段** (stage-order):
+   - 用户填写空间信息(客厅、卧室等)
+   - 调用 `ProductSpaceService.createProductSpace()` 创建 Product 记录
+
+2. **分配设计师时** (team-assign):
+   - 从 `Product` 表查询该项目的所有空间
+   - 显示空间列表供选择
+   - 将选中的空间保存到 `ProjectTeam.data.spaces` 中
+
+---
+
+## 📁 需要修改的文件
+
+1. ✅ `src/modules/project/components/team-assign/team-assign.component.ts`
+   - 第128-134行:修改 `selectDepartment` 方法
+
+2. ✅ `src/app/pages/admin/services/project.service.ts`
+   - 第65-110行:修改 `createProject` 方法
+
+3. ⚠️ `src/modules/project/pages/project-detail/stages/stage-order.component.ts`
+   - 需要确保空间信息保存时创建 Product 记录
+
+---
+
+## 🎉 预期效果
+
+### 修改前
+
+**项目列表**:
+```
+项目名称                             | 客户      | 负责人    | 状态
+张家界凤凰城三期项目 紫空居..        | 未知客户  | 未分配    | 待分配
+```
+
+**分配设计师**:
+```
+指派空间场景
+(空)
+```
+
+### 修改后
+
+**项目列表**:
+```
+项目名称                             | 客户      | 负责人    | 状态
+张家界凤凰城三期项目 紫空居..        | 张先生    | 汪奥      | 进行中
+```
+
+**分配设计师**:
+```
+指派空间场景 *
+☑ 客厅
+☐ 主卧
+☐ 次卧
+☐ 厨房
+```
+
+---
+
+**修改完成后,项目负责人将自动设置为组长,且分配设计师时能看到项目的所有空间!** ✨
+
+

+ 432 - 0
docs/task/2025102221-implementation-summary.md

@@ -0,0 +1,432 @@
+# 项目负责人和空间场景问题 - 实现总结
+
+**日期**: 2025-10-24  
+**完成状态**: ✅ 已完成
+
+---
+
+## ✅ 已完成的修改
+
+### 修改1: team-assign组件 - 自动设置组长为负责人
+
+**文件**: `src/modules/project/components/team-assign/team-assign.component.ts`  
+**位置**: 第128-151行
+
+**修改内容**:
+
+```typescript
+async selectDepartment(department: FmodeObject) {
+  this.selectedDepartment = department;
+  this.selectedDesigner = null;
+  this.departmentMembers = [];
+
+  // ✅ 自动设置组长为项目负责人
+  const leader = department.get('leader');
+  if (leader && this.project) {
+    try {
+      // 更新项目的assignee字段为组长
+      this.project.set('assignee', leader);
+      this.project.set('department', department);
+      await this.project.save();
+      console.log('✅ 项目负责人已设置为组长:', leader.get('name'));
+      
+      // 触发界面更新
+      this.cdr.markForCheck();
+    } catch (error) {
+      console.error('❌ 设置项目负责人失败:', error);
+    }
+  }
+
+  await this.loadDepartmentMembers(department);
+}
+```
+
+**效果**: 
+- 在项目详情页选择项目组时,自动将组长设置为项目的 `assignee`
+- 同时设置项目的 `department` 字段
+- 保存到数据库,刷新后数据持久化
+
+---
+
+### 修改2: ProjectService - 项目创建逻辑
+
+**文件**: `src/app/pages/admin/services/project.service.ts`  
+**位置**: 第4-6行(导入)、第70-137行(createProject方法)
+
+**2.1 添加Parse导入**:
+
+```typescript
+import { FmodeParse } from 'fmode-ng/parse';
+
+const Parse = FmodeParse.with('nova');
+```
+
+**2.2 修改createProject方法**:
+
+```typescript
+async createProject(data: {
+  title: string;
+  customerId?: string;
+  assigneeId?: string;
+  departmentId?: string; // ✅ 新增:项目组ID
+  status?: string;
+  currentStage?: string;
+  deadline?: Date;
+  data?: any;
+}): Promise<FmodeObject> {
+  const projectData: any = {
+    title: data.title,
+    status: data.status || '待分配',
+    currentStage: data.currentStage || '订单分配'
+  };
+
+  // 设置客户指针
+  if (data.customerId) {
+    projectData.customer = {
+      __type: 'Pointer',
+      className: 'ContactInfo',
+      objectId: data.customerId
+    };
+  }
+
+  // ✅ 新增:如果提供了项目组,获取组长作为默认负责人
+  if (data.departmentId) {
+    try {
+      const departmentQuery = new Parse.Query('Department');
+      departmentQuery.include('leader');
+      const department = await departmentQuery.get(data.departmentId);
+      
+      if (department) {
+        projectData.department = department.toPointer();
+        
+        // 获取组长
+        const leader = department.get('leader');
+        if (leader && !data.assigneeId) {
+          // 如果没有明确指定负责人,使用组长
+          projectData.assignee = leader.toPointer();
+          console.log('✅ 项目负责人默认为组长:', leader.get('name'));
+        }
+      }
+    } catch (error) {
+      console.error('❌ 获取项目组失败:', error);
+    }
+  }
+
+  // 设置负责人指针(如果明确指定,覆盖组长)
+  if (data.assigneeId) {
+    projectData.assignee = {
+      __type: 'Pointer',
+      className: 'Profile',
+      objectId: data.assigneeId
+    };
+  }
+
+  if (data.deadline) {
+    projectData.deadline = data.deadline;
+  }
+
+  if (data.data) {
+    projectData.data = data.data;
+  }
+
+  const project = this.adminData.createObject('Project', projectData);
+  return await this.adminData.save(project);
+}
+```
+
+**效果**:
+- 创建项目时可以指定 `departmentId`
+- 自动获取该项目组的组长作为默认负责人
+- 如果明确指定了 `assigneeId`,优先使用指定的人员
+
+---
+
+### 修改3: ProjectService - 查询时包含department和leader
+
+**文件**: `src/app/pages/admin/services/project.service.ts`  
+**位置**: 第17-44行(findProjects)、第57-67行(getProject)
+
+**3.1 修改findProjects**:
+
+```typescript
+async findProjects(options?: {
+  status?: string;
+  keyword?: string;
+  skip?: number;
+  limit?: number;
+}): Promise<FmodeObject[]> {
+  return await this.adminData.findAll('Project', {
+    include: ['customer', 'assignee', 'department', 'department.leader'], // ✅ 添加department和leader
+    skip: options?.skip || 0,
+    limit: options?.limit || 20,
+    descending: 'updatedAt',
+    additionalQuery: query => {
+      if (options?.status) {
+        query.equalTo('status', options.status);
+      }
+      if (options?.keyword) {
+        const kw = options.keyword.trim();
+        if (kw) {
+          // 搜索项目标题
+          query.matches('title', new RegExp(kw, 'i'));
+        }
+      }
+    }
+  });
+}
+```
+
+**3.2 修改getProject**:
+
+```typescript
+async getProject(objectId: string): Promise<FmodeObject | null> {
+  return await this.adminData.getById('Project', objectId, [
+    'customer',
+    'assignee',
+    'department',
+    'department.leader' // ✅ 添加department和leader
+  ]);
+}
+```
+
+**效果**:
+- 查询项目时自动加载 `department` 和 `department.leader` 关联数据
+- 为后续显示组长信息提供数据基础
+
+---
+
+### 修改4: ProjectService - toJSON方法优化
+
+**文件**: `src/app/pages/admin/services/project.service.ts`  
+**位置**: 第231-257行
+
+**修改内容**:
+
+```typescript
+toJSON(project: FmodeObject): any {
+  const json = this.adminData.toJSON(project);
+
+  // 处理关联对象
+  if (json.customer && typeof json.customer === 'object') {
+    json.customerName = json.customer.name || '';
+    json.customerId = json.customer.objectId;
+  }
+
+  // ✅ 处理负责人:优先使用assignee,如果为空则使用department.leader
+  if (json.assignee && typeof json.assignee === 'object') {
+    json.assigneeName = json.assignee.name || '';
+    json.assigneeId = json.assignee.objectId;
+  } else if (json.department && typeof json.department === 'object') {
+    // 如果没有assignee,但有department和leader,使用leader
+    const leader = json.department.leader;
+    if (leader && typeof leader === 'object') {
+      json.assigneeName = leader.name || '';
+      json.assigneeId = leader.objectId;
+    }
+  }
+
+  return json;
+}
+```
+
+**效果**:
+- 项目列表显示负责人时,优先使用 `assignee.name`
+- 如果 `assignee` 为空,自动使用 `department.leader.name`
+- 确保项目列表中不会显示"未分配",而是显示组长名字
+
+---
+
+## 📊 数据流图
+
+### 创建项目流程
+
+```
+用户创建项目
+  ↓
+指定departmentId
+  ↓
+createProject()查询Department
+  ↓
+获取department.leader
+  ↓
+设置project.assignee = leader
+  ↓
+保存到数据库
+  ↓
+项目列表显示组长名字
+```
+
+### 选择项目组流程
+
+```
+用户在项目详情页
+  ↓
+选择项目组(team-assign组件)
+  ↓
+selectDepartment()自动触发
+  ↓
+获取department.leader
+  ↓
+更新project.assignee = leader
+  ↓
+保存到数据库
+  ↓
+刷新项目列表,负责人更新
+```
+
+### 显示负责人流程
+
+```
+加载项目列表
+  ↓
+include: ['assignee', 'department', 'department.leader']
+  ↓
+toJSON()转换
+  ↓
+如果有assignee → 显示assignee.name
+如果没有assignee但有department.leader → 显示leader.name
+否则 → 显示"未分配"
+  ↓
+项目列表展示
+```
+
+---
+
+## 🧪 测试验证
+
+### 测试场景1: 在项目详情页选择项目组
+
+**步骤**:
+1. 访问项目详情页:`http://localhost:4200/admin/project-detail/APwk78jnrh/order`
+2. 在"设计师分配"卡片中选择一个项目组
+3. 观察控制台输出:`✅ 项目负责人已设置为组长: xxx`
+4. 刷新项目管理页面:`http://localhost:4200/admin/project-management`
+5. 验证该项目的"负责人"列显示组长名字
+
+**预期结果**:
+- ✅ 选择项目组后,项目的 `assignee` 自动设置为组长
+- ✅ 项目列表中"负责人"列显示组长名字
+- ✅ 不再显示"未分配"
+
+---
+
+### 测试场景2: 创建新项目并指定项目组
+
+**步骤**:
+1. 调用 `projectService.createProject()` 创建项目:
+   ```typescript
+   await projectService.createProject({
+     title: '测试项目',
+     departmentId: 'xxx项目组ID',
+     status: '待分配'
+   });
+   ```
+2. 查看控制台输出:`✅ 项目负责人默认为组长: xxx`
+3. 在项目管理页面查看新项目
+4. 验证"负责人"列显示组长名字
+
+**预期结果**:
+- ✅ 项目创建时自动设置组长为负责人
+- ✅ `project.assignee` 指向组长的Profile
+- ✅ `project.department` 指向该项目组
+
+---
+
+### 测试场景3: 验证空间场景问题
+
+**当前状态**: 
+- ⚠️ 项目ID `APwk78jnrh` 在 `Product` 表中没有记录
+- ⚠️ 分配设计师时没有空间可选
+
+**解决方案**: 
+1. 在订单分配阶段(stage-order)添加空间时,确保调用 `ProductSpaceService.createProductSpace()` 创建 Product 记录
+2. 或者在Parse Dashboard手动添加Product记录:
+   - 进入 `Product` 表
+   - 添加新行
+   - 设置 `project` 字段为项目指针
+   - 设置 `productName` 为 "客厅"、"卧室" 等
+   - 设置 `productType` 为 "living_room"、"bedroom" 等
+   - 保存
+
+---
+
+## 🎯 核心改进
+
+### 改进1: 自动化负责人分配
+
+**修改前**:
+- 项目创建时 `assignee` 为空
+- 需要手动设置负责人
+- 项目列表显示"未分配"
+
+**修改后**:
+- 选择项目组时自动设置组长为负责人
+- 创建项目时可指定项目组,自动获取组长
+- 项目列表始终显示组长名字
+
+---
+
+### 改进2: 优雅降级
+
+**数据获取优先级**:
+1. **最高优先级**: 明确指定的 `assignee`
+2. **次优先级**: 项目组的 `department.leader`
+3. **兜底**: 显示"未分配"
+
+**代码实现**:
+```typescript
+// toJSON方法中的逻辑
+if (json.assignee && typeof json.assignee === 'object') {
+  // 优先使用assignee
+  json.assigneeName = json.assignee.name || '';
+} else if (json.department && typeof json.department === 'object') {
+  // 降级使用department.leader
+  const leader = json.department.leader;
+  if (leader && typeof leader === 'object') {
+    json.assigneeName = leader.name || '';
+  }
+}
+```
+
+---
+
+## 📁 修改文件清单
+
+| 文件 | 修改内容 | 状态 |
+|------|---------|------|
+| `src/modules/project/components/team-assign/team-assign.component.ts` | 选择项目组时自动设置组长为负责人 | ✅ 完成 |
+| `src/app/pages/admin/services/project.service.ts` | 添加Parse导入 | ✅ 完成 |
+| `src/app/pages/admin/services/project.service.ts` | 修改createProject方法,支持departmentId | ✅ 完成 |
+| `src/app/pages/admin/services/project.service.ts` | 修改findProjects,include department | ✅ 完成 |
+| `src/app/pages/admin/services/project.service.ts` | 修改getProject,include department | ✅ 完成 |
+| `src/app/pages/admin/services/project.service.ts` | 修改toJSON,优先显示leader | ✅ 完成 |
+| `docs/task/2025102221-fix-project-assignee-and-spaces.md` | 问题分析文档 | ✅ 完成 |
+| `docs/task/2025102221-implementation-summary.md` | 实现总结文档 | ✅ 完成 |
+
+---
+
+## 🎉 总结
+
+### 解决的问题
+
+1. ✅ **项目负责人显示"未分配"** → 现在自动显示组长名字
+2. ⚠️ **分配设计师时没有空间场景** → 需要在订单分配阶段创建Product记录
+
+### 核心逻辑
+
+1. **选择项目组** → 自动设置组长为负责人
+2. **创建项目** → 指定项目组时,自动获取组长
+3. **显示负责人** → 优先assignee,降级使用leader
+4. **数据加载** → 始终include department和leader
+
+### 用户体验提升
+
+- **无需手动分配负责人**:选择项目组即可
+- **项目列表清晰**:始终显示真实负责人姓名
+- **数据一致性**:负责人与项目组关联
+
+---
+
+**修改完成!现在刷新浏览器测试,项目列表应该能正确显示组长名字了!** 🚀
+
+

+ 296 - 0
docs/task/2025102221-migration-guide.md

@@ -0,0 +1,296 @@
+# 项目负责人数据迁移指南
+
+**日期**: 2025-10-24  
+**问题**: 项目列表中的"负责人"列显示"未分配"
+
+---
+
+## 🔍 问题原因
+
+现有项目在数据库中还没有 `assignee` 或 `department` 字段,导致显示"未分配"。
+
+---
+
+## ✅ 解决方案
+
+### 方案1: 使用迁移脚本(推荐)
+
+**步骤**:
+
+1. **打开浏览器控制台**
+   - 访问:`http://localhost:4200/admin/project-management`
+   - 按 `F12` 打开开发者工具
+   - 切换到"Console"标签
+
+2. **运行迁移脚本**
+   ```javascript
+   // 1. 检查项目状态
+   const ProjectMigrationService = await import('./src/app/pages/admin/services/project-migration.service').then(m => m.ProjectMigrationService);
+   const migrationService = new ProjectMigrationService();
+   
+   // 查看需要迁移的项目数量
+   const status = await migrationService.checkProjectsStatus();
+   console.log('需要迁移的项目:', status.needsMigration);
+   
+   // 2. 执行迁移
+   const result = await migrationService.migrateProjectAssignees();
+   console.log('迁移结果:', result);
+   ```
+
+3. **刷新页面**
+   - 按 `Ctrl+Shift+R` 强制刷新
+   - 查看项目列表,"负责人"列应显示组长名字
+
+---
+
+### 方案2: Angular注入方式(如果方案1不work)
+
+**步骤**:
+
+1. **修改项目管理组件,添加迁移按钮**
+
+在 `src/app/pages/admin/project-management/project-management.ts` 中添加:
+
+```typescript
+import { ProjectMigrationService } from '../services/project-migration.service';
+
+export class ProjectManagement {
+  constructor(
+    private projectService: ProjectService,
+    private migrationService: ProjectMigrationService // ✅ 添加
+  ) {}
+  
+  // ✅ 添加迁移方法
+  async migrateProjects() {
+    const confirmed = confirm('确定要批量更新所有项目的负责人吗?');
+    if (!confirmed) return;
+    
+    try {
+      const result = await this.migrationService.migrateProjectAssignees();
+      alert(`迁移完成!\n总计: ${result.total}\n成功: ${result.success}\n失败: ${result.failed}`);
+      
+      // 刷新项目列表
+      await this.loadProjects();
+    } catch (error) {
+      alert('迁移失败: ' + error);
+    }
+  }
+}
+```
+
+2. **在HTML中添加按钮**
+
+在 `src/app/pages/admin/project-management/project-management.html` 顶部添加:
+
+```html
+<button (click)="migrateProjects()" class="btn btn-warning">
+  🔧 批量更新负责人
+</button>
+```
+
+3. **重启开发服务器**
+   ```bash
+   npm start
+   ```
+
+4. **点击按钮执行迁移**
+
+---
+
+### 方案3: Parse Dashboard 手动修复
+
+如果上述方案都不work,可以在Parse Dashboard中手动修复:
+
+**步骤**:
+
+1. **打开Parse Dashboard**
+
+2. **进入Project表**
+
+3. **找到一个项目**
+
+4. **编辑该项目**:
+   - 找到 `department` 列,选择一个Department
+   - 找到 `assignee` 列,选择该Department的leader
+   - 保存
+
+5. **重复步骤3-4**,直到所有项目都有负责人
+
+---
+
+## 🧪 验证方法
+
+### 1. 检查数据库
+
+在浏览器控制台执行:
+
+```javascript
+// 使用FmodeParse直接查询
+const Parse = FmodeParse.with('nova');
+const query = new Parse.Query('Project');
+query.include(['assignee', 'department', 'department.leader']);
+query.limit(5);
+const projects = await query.find();
+
+projects.forEach(p => {
+  console.log('项目:', p.get('title'));
+  console.log('- assignee:', p.get('assignee')?.get('name'));
+  console.log('- department:', p.get('department')?.get('name'));
+  console.log('- leader:', p.get('department')?.get('leader')?.get('name'));
+});
+```
+
+### 2. 检查项目列表
+
+1. 访问:`http://localhost:4200/admin/project-management`
+2. 查看"负责人"列
+3. **预期**:应显示组长名字,而不是"未分配"
+
+---
+
+## 📝 迁移脚本逻辑
+
+```
+开始迁移
+  ↓
+查询所有项目
+  ↓
+遍历每个项目
+  ↓
+已有assignee? → 跳过
+  ↓ No
+有department? → 使用其leader作为assignee
+  ↓ No
+查找默认项目组 → 使用其leader作为assignee
+  ↓
+保存项目
+  ↓
+下一个项目
+```
+
+---
+
+## 🔧 迁移脚本特性
+
+1. **安全性**:
+   - 已有assignee的项目不会被覆盖
+   - 支持回滚(手动删除assignee字段)
+
+2. **智能分配**:
+   - 优先使用项目的department.leader
+   - 如果没有department,使用默认项目组的leader
+   - 如果项目组没有leader,标记为失败
+
+3. **详细日志**:
+   - 每个项目的处理状态
+   - 成功/失败统计
+   - 错误原因
+
+4. **批量处理**:
+   - 一次处理最多1000个项目
+   - 支持断点续传(重新运行只处理未完成的)
+
+---
+
+## ⚠️ 注意事项
+
+### 执行前
+
+1. **备份数据**:
+   - 在Parse Dashboard中导出Project表数据
+   - 保存备份,以防需要回滚
+
+2. **检查项目组**:
+   - 确保至少有一个项目组(Department表)
+   - 确保项目组有组长(leader字段)
+
+### 执行中
+
+1. **不要关闭浏览器**:迁移过程中保持页面打开
+2. **观察控制台**:查看进度和错误信息
+3. **网络稳定**:确保网络连接正常
+
+### 执行后
+
+1. **验证数据**:检查几个项目的assignee字段
+2. **刷新页面**:强制刷新浏览器缓存
+3. **查看日志**:确认成功/失败数量
+
+---
+
+## 🎯 预期结果
+
+### 迁移前
+
+```
+Project表
+- project1: { title: "项目A", assignee: null, department: null }
+- project2: { title: "项目B", assignee: null, department: null }
+```
+
+项目列表显示:
+```
+项目A    | 未分配
+项目B    | 未分配
+```
+
+### 迁移后
+
+```
+Project表
+- project1: { 
+    title: "项目A", 
+    assignee: Pointer<Profile>汪奥, 
+    department: Pointer<Department>汪奥组 
+  }
+- project2: { 
+    title: "项目B", 
+    assignee: Pointer<Profile>汪奥, 
+    department: Pointer<Department>汪奥组 
+  }
+```
+
+项目列表显示:
+```
+项目A    | 汪奥
+项目B    | 汪奥
+```
+
+---
+
+## 📚 相关文件
+
+- 迁移服务:`src/app/pages/admin/services/project-migration.service.ts`
+- 项目服务:`src/app/pages/admin/services/project.service.ts`
+- 分配组件:`src/modules/project/components/team-assign/team-assign.component.ts`
+
+---
+
+## 💡 常见问题
+
+### Q1: 迁移后仍显示"未分配"
+
+**A**: 
+1. 强制刷新浏览器(Ctrl+Shift+R)
+2. 检查浏览器缓存
+3. 验证数据库中的assignee字段是否已设置
+
+### Q2: 部分项目迁移失败
+
+**A**: 
+1. 查看控制台错误信息
+2. 检查失败项目是否有可用的项目组
+3. 手动在Parse Dashboard中修复
+
+### Q3: 没有可用的项目组
+
+**A**: 
+1. 先创建项目组(Department表)
+2. 设置项目组的leader字段
+3. 重新运行迁移脚本
+
+---
+
+**准备好了吗?开始迁移吧!** 🚀
+
+

+ 239 - 0
docs/task/2025102221-quick-guide.md

@@ -0,0 +1,239 @@
+# 快速使用指南 - 项目负责人和空间场景
+
+**日期**: 2025-10-24
+
+---
+
+## 🎯 已修复的问题
+
+1. ✅ **项目列表负责人显示"未分配"** → 现在自动显示组长名字
+2. ⚠️ **分配设计师时没有空间场景可选** → 需要先添加空间
+
+---
+
+## 📝 使用说明
+
+### 1. 如何让项目显示组长作为负责人
+
+**方法1: 在项目详情页选择项目组(推荐)**
+
+1. 访问项目详情页:
+   ```
+   http://localhost:4200/admin/project-detail/{项目ID}/order
+   ```
+
+2. 在"设计师分配"卡片中,点击选择一个项目组
+
+3. **自动效果**:
+   - ✅ 项目的 `assignee` 自动设置为该项目组的组长
+   - ✅ 控制台输出:`✅ 项目负责人已设置为组长: xxx`
+   - ✅ 数据保存到数据库
+
+4. 刷新项目管理页面:
+   ```
+   http://localhost:4200/admin/project-management
+   ```
+
+5. **验证**:该项目的"负责人"列应显示组长名字
+
+---
+
+**方法2: 创建项目时指定项目组**
+
+```typescript
+await projectService.createProject({
+  title: '新项目',
+  departmentId: '项目组ID', // ✅ 指定项目组ID
+  customerId: '客户ID',
+  status: '待分配'
+});
+```
+
+效果:自动将该项目组的组长设置为项目负责人。
+
+---
+
+### 2. 如何添加空间场景
+
+**问题**:分配设计师时,"指派空间场景"没有选项。
+
+**原因**:该项目在 `Product` 表中没有空间记录。
+
+**解决方案A: 在订单分配阶段添加空间(推荐)**
+
+1. 进入项目详情页的"订单分配"阶段
+2. 找到"空间配置"或类似区域
+3. 点击"添加空间"按钮
+4. 填写空间信息:
+   - 空间名称:客厅、卧室、厨房等
+   - 空间类型:living_room、bedroom、kitchen等
+   - 面积、预算等
+5. **保存** → 系统会调用 `ProductSpaceService.createProductSpace()` 创建记录
+
+---
+
+**解决方案B: Parse Dashboard 手动添加**
+
+1. 打开Parse Dashboard
+
+2. 进入 `Product` 表
+
+3. 点击"Add Row"添加新行
+
+4. 设置字段:
+   ```
+   project: Pointer<Project> → 选择项目
+   productName: "客厅"
+   productType: "living_room"
+   space: {
+     "spaceName": "客厅",
+     "area": 30,
+     "priority": 5,
+     "complexity": "medium"
+   }
+   status: "not_started"
+   ```
+
+5. 保存
+
+6. 刷新项目详情页,现在分配设计师时应该能看到"客厅"选项了
+
+---
+
+## 🧪 测试步骤
+
+### 测试1: 验证负责人显示
+
+1. **打开项目详情页**
+   ```
+   http://localhost:4200/admin/project-detail/APwk78jnrh/order
+   ```
+
+2. **选择项目组**
+   - 在"设计师分配"区域
+   - 选择任意项目组(如"汪奥组")
+
+3. **查看控制台**
+   - 应该看到:`✅ 项目负责人已设置为组长: 汪奥`
+
+4. **刷新项目列表**
+   ```
+   http://localhost:4200/admin/project-management
+   ```
+
+5. **验证**
+   - "负责人"列应显示"汪奥"(组长名字)
+   - 不再显示"未分配"
+
+---
+
+### 测试2: 验证空间场景
+
+1. **添加空间**(使用解决方案A或B)
+
+2. **刷新项目详情页**
+   ```
+   http://localhost:4200/admin/project-detail/APwk78jnrh/order
+   ```
+
+3. **选择项目组和设计师**
+   - 选择项目组
+   - 选择一个设计师
+
+4. **查看弹窗**
+   - "指派空间场景"区域应该显示空间列表
+   - 例如:☑ 客厅、☐ 卧室、☐ 厨房
+
+5. **选择空间并确认分配**
+
+---
+
+## 💡 重要提示
+
+### 关于项目负责人
+
+- **自动规则**:选择项目组时,自动将组长设置为项目负责人
+- **优先级**:
+  1. 明确指定的 `assignee`(最高优先级)
+  2. 项目组的 `department.leader`(自动设置)
+  3. "未分配"(兜底)
+
+### 关于空间场景
+
+- **数据来源**:从 `Product` 表查询
+- **创建时机**:订单分配阶段添加空间
+- **必要性**:分配设计师前必须先有空间
+
+---
+
+## 🔧 排查问题
+
+### 问题1: 项目列表仍显示"未分配"
+
+**可能原因**:
+- 项目没有 `department` 字段
+- 项目组没有 `leader` 字段
+
+**解决方法**:
+1. 访问项目详情页,选择一个项目组
+2. 或在Parse Dashboard中手动设置:
+   ```
+   Project表 → 找到该项目
+   → department字段 → 选择一个Department
+   → assignee字段 → 选择该Department的leader
+   → 保存
+   ```
+
+---
+
+### 问题2: 没有空间场景可选
+
+**可能原因**:
+- `Product` 表中该项目没有记录
+
+**解决方法**:
+1. 在项目详情页的订单分配阶段添加空间
+2. 或使用Parse Dashboard手动添加Product记录(见解决方案B)
+
+---
+
+### 问题3: 控制台报错
+
+**如果看到**:
+```
+❌ 设置项目负责人失败: ...
+```
+
+**检查**:
+- 项目组是否有组长(`department.leader` 不为空)
+- Parse权限配置是否正确
+- 网络连接是否正常
+
+---
+
+## 📚 相关文档
+
+- 详细分析:`docs/task/2025102221-fix-project-assignee-and-spaces.md`
+- 实现总结:`docs/task/2025102221-implementation-summary.md`
+
+---
+
+## 🎉 总结
+
+### 核心改进
+
+1. **自动化**:选择项目组 → 自动设置组长为负责人
+2. **智能降级**:优先使用assignee,没有则使用leader
+3. **数据完整**:查询时include department和leader
+
+### 用户体验
+
+- ✅ 项目列表清晰显示负责人
+- ✅ 无需手动分配负责人
+- ✅ 数据一致性保证
+
+---
+
+**修改完成!刷新浏览器测试吧!** 🚀
+
+

+ 343 - 0
docs/task/2025102221-simple-solution.md

@@ -0,0 +1,343 @@
+# 简单方案:通过 department.leader 显示负责人
+
+**日期**: 2025-10-24  
+**核心思路**: 不修改数据库,只通过 `department.leader` 动态获取组长信息
+
+---
+
+## 🎯 方案对比
+
+### 方案1: 在 Project 表存储 assignee(之前的方案)
+
+```
+Project表
+├─ assignee: Pointer<Profile>  ← 需要迁移数据
+├─ department: Pointer<Department>
+└─ ...
+```
+
+**缺点**:
+- ❌ 需要迁移现有项目数据
+- ❌ 数据冗余(assignee 和 department.leader 重复)
+- ❌ 数据一致性问题(组长变更时需要同步更新)
+
+---
+
+### 方案2: 动态从 department.leader 获取(推荐)✅
+
+```
+Project表
+├─ department: Pointer<Department>  ← 只需要这个
+└─ ...
+
+Department表
+├─ leader: Pointer<Profile>  ← 从这里获取组长
+└─ ...
+```
+
+**优点**:
+- ✅ **不需要迁移数据**
+- ✅ 数据一致性好(组长只存在一个地方)
+- ✅ 逻辑清晰(项目属于项目组,项目组有组长)
+
+---
+
+## ✅ 实现方案
+
+### 修改1: 确保查询时包含 department.leader
+
+**文件**: `src/app/pages/admin/services/project.service.ts`
+
+已修改(第27行):
+```typescript
+async findProjects(options?: {
+  status?: string;
+  keyword?: string;
+  skip?: number;
+  limit?: number;
+}): Promise<FmodeObject[]> {
+  return await this.adminData.findAll('Project', {
+    include: ['customer', 'assignee', 'department', 'department.leader'], // ✅ 包含leader
+    skip: options?.skip || 0,
+    limit: options?.limit || 20,
+    descending: 'updatedAt',
+    // ...
+  });
+}
+```
+
+---
+
+### 修改2: toJSON 方法优先使用 department.leader
+
+**文件**: `src/app/pages/admin/services/project.service.ts`
+
+已修改(第243-256行):
+```typescript
+toJSON(project: FmodeObject): any {
+  const json = this.adminData.toJSON(project);
+
+  // 处理客户
+  if (json.customer && typeof json.customer === 'object') {
+    json.customerName = json.customer.name || '';
+    json.customerId = json.customer.objectId;
+  }
+
+  // ✅ 处理负责人:优先使用assignee,如果为空则使用department.leader
+  if (json.assignee && typeof json.assignee === 'object') {
+    // 如果有明确指定的assignee,使用它
+    json.assigneeName = json.assignee.name || '';
+    json.assigneeId = json.assignee.objectId;
+    json.assigneeRole = json.assignee.roleName || '';
+  } else if (json.department && typeof json.department === 'object') {
+    // 如果没有assignee,使用department的leader(组长)
+    const leader = json.department.leader;
+    if (leader && typeof leader === 'object') {
+      json.assigneeName = leader.name || '';
+      json.assigneeId = leader.objectId;
+      json.assigneeRole = '组长';  // ✅ 标记为组长
+    }
+  }
+
+  return json;
+}
+```
+
+---
+
+## 🔧 需要做的事情
+
+### 唯一需要做的:确保项目有 department 字段
+
+**检查方法**(在浏览器控制台执行):
+
+```javascript
+(async function() {
+  const Parse = window.FmodeParse.with('nova');
+  const company = localStorage.getItem('company');
+  
+  const query = new Parse.Query('Project');
+  query.equalTo('company', company);
+  query.notEqualTo('isDeleted', true);
+  query.include('department', 'department.leader');
+  query.limit(20);
+  
+  const projects = await query.find();
+  
+  let hasDept = 0;
+  let noDept = 0;
+  
+  projects.forEach(p => {
+    if (p.get('department')) {
+      hasDept++;
+    } else {
+      noDept++;
+    }
+  });
+  
+  console.log(`✅ 有department: ${hasDept} 个`);
+  console.log(`❌ 没有department: ${noDept} 个`);
+  
+  if (noDept > 0) {
+    console.log('\n⚠️ 有项目没有department,需要分配项目组');
+  } else {
+    console.log('\n🎉 所有项目都有department,可以直接显示组长!');
+  }
+})();
+```
+
+---
+
+### 如果有项目没有 department,执行以下脚本分配默认项目组:
+
+```javascript
+(async function() {
+  const Parse = window.FmodeParse.with('nova');
+  const company = localStorage.getItem('company');
+  
+  console.log('🚀 开始为项目分配默认项目组...');
+  
+  // 1. 查找没有department的项目
+  const projectQuery = new Parse.Query('Project');
+  projectQuery.equalTo('company', company);
+  projectQuery.notEqualTo('isDeleted', true);
+  projectQuery.doesNotExist('department');
+  projectQuery.limit(1000);
+  
+  const projects = await projectQuery.find();
+  console.log(`📊 找到 ${projects.length} 个没有项目组的项目`);
+  
+  if (projects.length === 0) {
+    console.log('✅ 所有项目都已有项目组!');
+    return;
+  }
+  
+  // 2. 获取默认项目组(第一个项目组)
+  const deptQuery = new Parse.Query('Department');
+  deptQuery.equalTo('company', company);
+  deptQuery.equalTo('type', 'project');
+  deptQuery.notEqualTo('isDeleted', true);
+  deptQuery.include('leader');
+  deptQuery.ascending('createdAt');
+  
+  const dept = await deptQuery.first();
+  
+  if (!dept) {
+    console.error('❌ 没有找到项目组,请先创建项目组');
+    return;
+  }
+  
+  const leader = dept.get('leader');
+  console.log(`✅ 使用默认项目组: ${dept.get('name')}, 组长: ${leader?.get('name') || '无'}`);
+  
+  // 3. 批量分配
+  let success = 0;
+  let failed = 0;
+  
+  for (let i = 0; i < projects.length; i++) {
+    const project = projects[i];
+    const title = project.get('title') || '未命名项目';
+    
+    try {
+      project.set('department', dept);
+      await project.save();
+      success++;
+      console.log(`✅ [${i+1}/${projects.length}] "${title}" 已分配到 ${dept.get('name')}`);
+    } catch (error) {
+      failed++;
+      console.error(`❌ [${i+1}/${projects.length}] "${title}" 失败:`, error);
+    }
+  }
+  
+  console.log('\n' + '='.repeat(60));
+  console.log('🎉 分配完成!');
+  console.log('='.repeat(60));
+  console.log(`📊 总计: ${projects.length} | ✅ 成功: ${success} | ❌ 失败: ${failed}`);
+  console.log('='.repeat(60));
+  console.log('\n💡 请刷新页面(Ctrl+Shift+R)查看结果');
+})();
+```
+
+---
+
+## 🎯 完整流程
+
+### 步骤1: 检查项目状态
+
+打开 `http://localhost:4200/admin/project-management`,按 F12,执行:
+
+```javascript
+(async function() {
+  const Parse = window.FmodeParse.with('nova');
+  const company = localStorage.getItem('company');
+  
+  const query = new Parse.Query('Project');
+  query.equalTo('company', company);
+  query.include('department', 'department.leader');
+  query.limit(20);
+  
+  const projects = await query.find();
+  
+  console.table(projects.map(p => ({
+    '项目名称': p.get('title'),
+    '有项目组': p.get('department') ? '✅' : '❌',
+    '组长': p.get('department')?.get('leader')?.get('name') || '无'
+  })));
+})();
+```
+
+---
+
+### 步骤2: 如果有项目缺少 department,执行分配脚本
+
+复制上面的"为项目分配默认项目组"脚本,粘贴到控制台执行。
+
+---
+
+### 步骤3: 刷新页面验证
+
+按 `Ctrl+Shift+R` 刷新页面,查看项目列表的"负责人"列。
+
+**预期结果**:
+- 所有项目的"负责人"列应显示组长名字
+- 不再显示"未分配"
+
+---
+
+## 📊 数据结构
+
+### Project 表(需要的字段)
+
+```javascript
+{
+  objectId: "APwk78jnrh",
+  title: "张家界凤凰城三期项目",
+  department: Pointer<Department> { objectId: "xxx" },  // ✅ 只需要这个
+  company: "cDL6R1hgSi",
+  status: "进行中",
+  // 不需要 assignee 字段
+}
+```
+
+### Department 表
+
+```javascript
+{
+  objectId: "xxx",
+  name: "汪奥组",
+  type: "project",
+  leader: Pointer<Profile> { objectId: "yyy" },  // ✅ 组长信息存在这里
+  company: "cDL6R1hgSi"
+}
+```
+
+### Profile 表
+
+```javascript
+{
+  objectId: "yyy",
+  name: "汪奥",
+  roleName: "组长",
+  company: "cDL6R1hgSi"
+}
+```
+
+---
+
+## 🎉 优势总结
+
+### 方案2(推荐)vs 方案1(之前)
+
+| 对比项 | 方案1(存储assignee) | 方案2(使用leader)✅ |
+|--------|---------------------|---------------------|
+| 需要迁移数据 | ❌ 是 | ✅ 否 |
+| 数据一致性 | ❌ 可能不一致 | ✅ 始终一致 |
+| 逻辑复杂度 | ❌ 较复杂 | ✅ 简单 |
+| 查询性能 | ✅ 稍快 | ✅ 同样快 |
+| 维护成本 | ❌ 高 | ✅ 低 |
+
+---
+
+## 💡 为什么这个方案更好?
+
+1. **逻辑清晰**
+   - 项目 → 属于项目组
+   - 项目组 → 有组长
+   - 项目的负责人 = 项目组的组长
+
+2. **数据一致性**
+   - 组长信息只存在一个地方(Department.leader)
+   - 组长变更时,所有项目的负责人自动更新
+
+3. **无需迁移**
+   - 只需确保项目有 `department` 字段
+   - 不需要为每个项目设置 `assignee` 字段
+
+4. **符合业务逻辑**
+   - 项目负责人就是项目组的组长
+   - 不需要额外的 `assignee` 字段
+
+---
+
+**现在执行检查脚本,看看是否所有项目都有 department!** 🚀
+

+ 6 - 4
npminstall-debug.log

@@ -4,8 +4,8 @@
   pkgs: [
     {
       name: 'fmode-ng',
-      version: '0.0.216',
-      type: 'version',
+      version: '^0.0.227',
+      type: 'range',
       alias: undefined,
       arg: [Result]
     }
@@ -15,7 +15,7 @@
   cacheDir: '/home/ryan/.npminstall_tarball',
   env: {
     npm_config_registry: 'https://registry.npmmirror.com',
-    npm_config_argv: '{"remain":[],"cooked":["--fix-bug-versions","--china","--userconfig=/home/ryan/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","fmode-ng@0.0.216"],"original":["--fix-bug-versions","--china","--userconfig=/home/ryan/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","fmode-ng@0.0.216"]}',
+    npm_config_argv: '{"remain":[],"cooked":["--fix-bug-versions","--china","--userconfig=/home/ryan/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","-S","fmode-ng@^0.0.227"],"original":["--fix-bug-versions","--china","--userconfig=/home/ryan/.cnpmrc","--disturl=https://cdn.npmmirror.com/binaries/node","--registry=https://registry.npmmirror.com","-S","fmode-ng@^0.0.227"]}',
     npm_config_user_agent: 'npminstall/7.12.0 npm/? node/v20.19.4 linux x64',
     npm_config_cache: '/home/ryan/.npminstall_tarball',
     NODE: '/usr/local/bin/node',
@@ -53,6 +53,7 @@
     npm_config_sharp_libvips_binary_host: 'https://cdn.npmmirror.com/binaries/sharp-libvips',
     npm_config_robotjs_binary_host: 'https://cdn.npmmirror.com/binaries/robotjs',
     npm_config_gl_binary_host: 'https://cdn.npmmirror.com/binaries/gl',
+    RIPGREP_PREBUILT_BINARIES_MIRROR: 'https://registry.npmmirror.com/-/binary/ripgrep-prebuilt',
     npm_rootpath: '/home/ryan/workspace/nova/yss-project',
     INIT_CWD: '/home/ryan/workspace/nova/yss-project'
   },
@@ -86,7 +87,8 @@
       npm_config_sharp_binary_host: 'https://cdn.npmmirror.com/binaries/sharp',
       npm_config_sharp_libvips_binary_host: 'https://cdn.npmmirror.com/binaries/sharp-libvips',
       npm_config_robotjs_binary_host: 'https://cdn.npmmirror.com/binaries/robotjs',
-      npm_config_gl_binary_host: 'https://cdn.npmmirror.com/binaries/gl'
+      npm_config_gl_binary_host: 'https://cdn.npmmirror.com/binaries/gl',
+      RIPGREP_PREBUILT_BINARIES_MIRROR: 'https://registry.npmmirror.com/-/binary/ripgrep-prebuilt'
     },
     '@ali/s2': { host: 'https://cdn.npmmirror.com/binaries/looksgood-s2' },
     sharp: { replaceHostFiles: [Array], replaceHostMap: [Object] },

+ 1 - 1
package.json

@@ -68,7 +68,7 @@
     "echarts": "^6.0.0",
     "esdk-obs-browserjs": "^3.25.6",
     "eventemitter3": "^5.0.1",
-    "fmode-ng": "^0.0.224",
+    "fmode-ng": "^0.0.227",
     "highlight.js": "^11.11.1",
     "ionicons": "^8.0.13",
     "jquery": "^3.7.1",

+ 19 - 1
rules/schemas.md

@@ -76,6 +76,7 @@ TABLE(Profile, "Profile\n员工档案表") {
 TABLE(ContactInfo, "ContactInfo\n客户信息表") {
     FIELD(objectId, String)
     FIELD(name, String)
+    FIELD(realname, String)
     FIELD(mobile, String)
     FIELD(company, Pointer→Company)
     FIELD(external_userid, String)
@@ -358,7 +359,8 @@ GroupChat "n" --> "1" Project : 关联项目(可选)
 | 字段名 | 类型 | 必填 | 说明 | 示例值 |
 |--------|------|------|------|--------|
 | objectId | String | 是 | 主键ID | "contact001" |
-| name | String | 是 | 客户姓名 | "李四" |
+| name | String | 是 | 客户微信名 | "唯美家装老李" |
+| realname | String | 是 | 客户真实姓名 | "李四" |
 | mobile | String | 否 | 手机号 | "13900139000" |
 | company | Pointer | 是 | 所属企业 | → Company |
 | external_userid | String | 否 | 企微外部联系人ID | "wmxxx" |
@@ -854,6 +856,22 @@ const totalPaid = payments.reduce((sum, payment) => {
 
 ---
 
+### 9.问卷数据说明
+
+
+## 11.问卷模块
+
+SurveyLog 问卷结果表
+- contact Pointer<ContactInfo> 提交联系人
+- project Pointer<Project> 关联项目
+- profile Pointer<Profile> 提交员工
+- company Pointer<Company> 所属帐套 localStorage.get("company")
+- data Object 问卷的结果存储
+- type String
+   - survey-project 项目问卷
+   - survey-contact 联系人问卷
+   - survey-profile 员工问卷
+
 ## Product表统一空间管理的优势
 
 ### 1. 行业语义清晰

+ 15 - 8
src/app/app.routes.ts

@@ -1,12 +1,12 @@
 import { Routes } from '@angular/router';
-// import { WxworkAuthGuard } from 'fmode-ng'; // 临时禁用以解决初始化问题
+import { WxworkAuthGuard } from './wxwork-auth-guard'; 
 
 export const routes: Routes = [
   // 客服路由
   {
     path: 'customer-service',
     loadComponent: () => import('./pages/customer-service/customer-service-layout/customer-service-layout').then(m => m.CustomerServiceLayout),
-    // canActivate: [WxworkAuthGuard], // 临时禁用
+    canActivate: [WxworkAuthGuard], 
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -61,7 +61,7 @@ export const routes: Routes = [
   // 设计师路由
   {
     path: 'designer',
-    // canActivate: [WxworkAuthGuard], // 临时禁用
+    canActivate: [WxworkAuthGuard], 
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -85,7 +85,7 @@ export const routes: Routes = [
   // 组长路由
   {
     path: 'team-leader',
-    // canActivate: [WxworkAuthGuard], // 临时禁用用于开发测试
+    canActivate: [WxworkAuthGuard], 
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -125,7 +125,7 @@ export const routes: Routes = [
   // 财务路由
   {
     path: 'finance',
-    // canActivate: [WxworkAuthGuard], // 临时禁用
+    canActivate: [WxworkAuthGuard], 
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -160,7 +160,7 @@ export const routes: Routes = [
   {
     path: 'hr',
     loadComponent: () => import('./pages/hr/hr-layout/hr-layout').then(m => m.HrLayout),
-    // canActivate: [WxworkAuthGuard], // 临时禁用
+    canActivate: [WxworkAuthGuard], 
     children: [
       {
         path: 'dashboard',
@@ -185,7 +185,7 @@ export const routes: Routes = [
   {
     path: 'admin',
     loadComponent: () => import('./pages/admin/admin-layout/admin-layout').then(m => m.AdminLayout),
-    // canActivate: [WxworkAuthGuard], // 临时禁用
+    canActivate: [WxworkAuthGuard], 
     children: [
       { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
       {
@@ -310,7 +310,7 @@ export const routes: Routes = [
   // 2. 网页端: 通过 contactId/projectId 直接加载,配合 profileId 参数
   {
     path: 'wxwork/:cid',
-    // canActivate: [WxworkAuthGuard], // 可选:使用路由守卫强制认证
+    canActivate: [WxworkAuthGuard],
     children: [
       // 项目预加载页(企微上下文入口)
       {
@@ -319,6 +319,13 @@ export const routes: Routes = [
         title: '加载项目'
       },
 
+      // 项目问卷页
+      {
+        path: 'survey/project/:projectId',
+        loadComponent: () => import('../modules/project/pages/project-survey/project-survey.component').then(m => m.ProjectSurveyComponent),
+        title: '项目需求调查'
+      },
+
       // 客户画像页
       // 路由规则:
       // - 企微端: /wxwork/:cid/contact/:contactId?externalUserId=xxx

+ 14 - 11
src/app/pages/admin/customers/customers.html

@@ -30,17 +30,24 @@
       <div>客户名称</div>
       <div>手机号</div>
       <div>企微ID</div>
+      <div>类型</div>
       <div>来源</div>
       <div>创建时间</div>
     </div>
     <div class="table row clickable" *ngFor="let c of filtered" (click)="openCustomerDetail(c)">
       <div class="name">
-        <div class="title">{{ c.name }}</div>
+        <div class="title">
+          <img [src]="(c.get('data')?.avatar || '/assets/images/default-avatar.svg')" alt="" style="width:24px;height:24px;border-radius:50%;"/>
+          <span>{{ c.get('name') || c.get('data')?.name }}</span>
+        </div>
       </div>
-      <div>{{ c.mobile }}</div>
-      <div>{{ c.external_userid || '-' }}</div>
-      <div>{{ c.source || '-' }}</div>
-      <div>{{ c.createdAt ? (c.createdAt | date:'yyyy-MM-dd') : '-' }}</div>
+      <div>{{ c.get('mobile') || '-' }}</div>
+      <div>{{ c.get('external_userid') || '-' }}</div>
+      <div>
+        <span class="tag" [class.vip]="c.get('data')?.external_contact?.type===1" [class.svip]="c.get('data')?.external_contact?.type===2">{{ c.get('data')?.external_contact?.type===2 ? '企业成员' : '外部联系人' }}</span>
+      </div>
+      <div>{{ c.get('source') || '-' }}</div>
+      <div>{{ c.get('createdAt') ? (c.get('createdAt') | date:'yyyy-MM-dd') : '-' }}</div>
     </div>
     <div class="empty" *ngIf="filtered.length === 0">
       @if (loading()) {
@@ -51,15 +58,11 @@
     </div>
   </div>
 
-  <!-- 客户详情面板 -->
+  <!-- 覆盖层弹出 contact 详情 -->
   <div class="customer-panel-overlay" *ngIf="showCustomerPanel" (click)="closeCustomerPanel()">
     <div class="customer-panel" (click)="$event.stopPropagation()">
-      <div class="panel-header">
-        <h2>客户详情</h2>
-        <button class="close-btn" (click)="closeCustomerPanel()">×</button>
-      </div>
       <div class="panel-body">
-        <app-contact *ngIf="selectedCustomer" [customer]="selectedCustomer"></app-contact>
+        <app-contact *ngIf="selectedCustomer" [customer]="selectedCustomer" [currentUser]="currentUserForContact" [embeddedMode]="true" [projectIdFilter]="panelProjectId" (close)="closeCustomerPanel(true)"></app-contact>
       </div>
     </div>
   </div>

+ 10 - 174
src/app/pages/admin/customers/customers.scss

@@ -2,178 +2,14 @@
 .page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-title{font-size:20px;margin:0 0 6px}.page-description{color:#64748b;margin:0}.btn{padding:8px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;cursor:pointer}.btn.primary{background:#165DFF;color:#fff;border-color:#165DFF}
 .stats-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.stat-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06);padding:16px}.stat-label{color:#64748b;font-size:12px}.stat-value{font-size:22px;font-weight:700;margin-top:6px}
 .toolbar{display:flex;justify-content:space-between;align-items:center;background:#fff;padding:12px 16px;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06);margin-bottom:12px}.search input{width:320px;padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px}.filters{display:flex;gap:8px;align-items:center}.filters select{padding:8px;border:1px solid #e5e7eb;border-radius:8px}
-.table-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06)}.table{display:grid;grid-template-columns:2.2fr 1.1fr 1.4fr .8fr .9fr .8fr 1.2fr 1.1fr 1fr;align-items:center;padding:12px 16px;border-bottom:1px solid #f1f5f9}.table.header{color:#64748b;font-weight:600;background:#f8fafc;border-top-left-radius:12px;border-top-right-radius:12px}.table.row{background:#fff}.table .name .title{font-weight:600}.tag{display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#4f46e5;font-weight:600;font-size:12px}.tag.vip{background:#fff7ed;color:#f97316}.tag.svip{background:#fff1f2;color:#ef4444}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:#94a3b8}.dot.green{background:#10b981}.dot.gray{background:#94a3b8}.actions{display:flex;gap:6px;justify-content:flex-end}.icon{border:1px solid #e5e7eb;background:#fff;border-radius:8px;padding:6px 8px;cursor:pointer}.icon.danger{color:#ef4444;border-color:#fecaca}
+.table-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06)}.table{display:grid;grid-template-columns:2fr 1.2fr 1.6fr 1fr 1fr 1.1fr;align-items:center;padding:12px 16px;border-bottom:1px solid #f1f5f9}.table.header{color:#64748b;font-weight:600;background:#f8fafc;border-top-left-radius:12px;border-top-right-radius:12px}.table.row{background:#fff}.table .name .title{font-weight:600;display:flex;align-items:center;gap:6px}
+.tag{display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#4f46e5;font-weight:600;font-size:12px}.tag.vip{background:#fff7ed;color:#f97316}.tag.svip{background:#fff1f2;color:#ef4444}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:#94a3b8}.dot.green{background:#10b981}.dot.gray{background:#94a3b8}.actions{display:flex;gap:6px;justify-content:flex-end}.icon{border:1px solid #e5e7eb;background:#fff;border-radius:8px;padding:6px 8px;cursor:pointer}.icon.danger{color:#ef4444;border-color:#fecaca}
 .empty{padding:24px;text-align:center;color:#94a3b8}
-@media (max-width: 992px){.stats-cards{grid-template-columns:repeat(2,1fr)}.search input{width:100%}.toolbar{flex-direction:column;gap:10px;align-items:flex-start}}
-
-/* 侧边面板通用样式(与设计师页面保持一致) */
-.panel-overlay {
-  position: fixed;
-  inset: 0;
-  background: rgba(0, 0, 0, 0.45);
-  display: flex;
-  justify-content: flex-end;
-  z-index: 1000;
-}
-
-.side-panel {
-  width: 560px;
-  height: 100%;
-  background: #fff;
-  box-shadow: -4px 0 16px rgba(0,0,0,0.08);
-  display: flex;
-  flex-direction: column;
-  animation: slideIn .2s ease;
-}
-
-@keyframes slideIn {
-  from { transform: translateX(24px); opacity: 0; }
-  to { transform: translateX(0); opacity: 1; }
-}
-
-.panel-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 16px 20px;
-  border-bottom: 1px solid #f0f0f0;
-
-  h3 { margin: 0; font-size: 18px; font-weight: 600; }
-  .close-btn { border: none; background: transparent; font-size: 20px; cursor: pointer; }
-}
-
-.panel-content {
-  padding: 16px 20px;
-  overflow: auto;
-  flex: 1;
-
-  .detail-section {
-    display: flex;
-    margin-bottom: 12px;
-    label { width: 92px; color: #888; }
-    span { color: #333; }
-  }
-
-  .customer-form {
-    .form-group { margin-bottom: 12px; display: flex; flex-direction: column; }
-    label { margin-bottom: 6px; color: #666; }
-    input, select, textarea { padding: 8px 10px; border: 1px solid #e5e6eb; border-radius: 6px; }
-    textarea { min-height: 88px; resize: vertical; }
-  }
-}
-
-.panel-footer {
-  padding: 12px 20px;
-  border-top: 1px solid #f0f0f0;
-  display: flex;
-  justify-content: flex-end;
-  gap: 10px;
-
-  .btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #e5e6eb; background: #fff; cursor: pointer; }
-  .btn.primary { background: #3a7afe; color: #fff; border-color: #3a7afe; }
-}
-/* 可点击行样式 */
-.table.row.clickable {
-  cursor: pointer;
-  transition: background-color 0.2s;
-
-  &:hover {
-    background-color: #f8fafc;
-  }
-}
-
-/* 客户详情面板样式 */
-.customer-panel-overlay {
-  position: fixed;
-  inset: 0;
-  background: rgba(0, 0, 0, 0.5);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 2000;
-  animation: fadeIn 0.2s ease-out;
-}
-
-.customer-panel {
-  width: 90%;
-  max-width: 900px;
-  max-height: 90vh;
-  background: white;
-  border-radius: 12px;
-  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
-  display: flex;
-  flex-direction: column;
-  animation: slideUp 0.3s ease-out;
-
-  .panel-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    padding: 20px 24px;
-    border-bottom: 1px solid #e5e7eb;
-
-    h2 {
-      margin: 0;
-      font-size: 20px;
-      font-weight: 600;
-      color: #1f2937;
-    }
-
-    .close-btn {
-      background: none;
-      border: none;
-      font-size: 28px;
-      color: #6b7280;
-      cursor: pointer;
-      line-height: 1;
-      padding: 0;
-      width: 32px;
-      height: 32px;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      border-radius: 6px;
-      transition: all 0.2s;
-
-      &:hover {
-        background: #f3f4f6;
-        color: #1f2937;
-      }
-    }
-  }
-
-  .panel-body {
-    flex: 1;
-    overflow-y: auto;
-    padding: 0;
-  }
-}
-
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-}
-
-@keyframes slideUp {
-  from {
-    transform: translateY(20px);
-    opacity: 0;
-  }
-  to {
-    transform: translateY(0);
-    opacity: 1;
-  }
-}
-
-@media (max-width: 768px) {
-  .customer-panel {
-    width: 100%;
-    max-width: 100%;
-    max-height: 100vh;
-    border-radius: 0;
-  }
-}
+@media (max-width: 992px){.stats-cards{grid-template-columns:repeat(2,1fr)}.search input{width:100%}.toolbar{flex-direction:column;gap:10px;align-items:flex-start}.table{grid-template-columns:1.6fr 1fr 1.4fr .9fr .9fr 1fr}}
+
+/* 客户详情面板样式(覆盖层弹出) */
+.customer-panel-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:2000;animation:fadeIn .2s ease-out}
+.customer-panel{width:90%;max-width:900px;max-height:90vh;background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.15);display:flex;flex-direction:column;animation:slideUp .3s ease-out;overflow:hidden}
+.panel-body{flex:1;overflow:auto}
+@keyframes fadeIn{from{opacity:0}to{opacity:1}}
+@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}

+ 31 - 62
src/app/pages/admin/customers/customers.ts

@@ -3,17 +3,9 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { CustomerService } from '../services/customer.service';
 import { FmodeObject } from 'fmode-ng/core';
+import { FmodeParse } from 'fmode-ng/parse';
 import { CustomerProfileComponent } from '../../../../modules/project/pages/contact/contact.component';
 
-interface Customer {
-  id: string;
-  name: string;
-  mobile: string;
-  external_userid?: string;
-  source?: string;
-  createdAt?: Date;
-}
-
 @Component({
   selector: 'app-admin-customers',
   standalone: true,
@@ -22,51 +14,28 @@ interface Customer {
   styleUrl: './customers.scss'
 })
 export class Customers implements OnInit {
-  // 统计
   total = signal(0);
   loading = signal(false);
+  customers = signal<FmodeObject[]>([]);
 
-  // 数据
-  customers = signal<Customer[]>([]);
-  customerObjects: Map<string, FmodeObject> = new Map(); // 存储Parse对象
-
-  // 客户详情面板
   showCustomerPanel = false;
   selectedCustomer: FmodeObject | null = null;
+  currentUserForContact: FmodeObject | null = null;
+  panelProjectId: string | null = null;
 
   constructor(private customerService: CustomerService) {}
 
   ngOnInit(): void {
     this.loadCustomers();
+    this.setupCurrentUserForContact();
   }
 
   async loadCustomers(): Promise<void> {
     this.loading.set(true);
     try {
       const custs = await this.customerService.findCustomers();
-
-      // 清空之前的对象映射
-      this.customerObjects.clear();
-
-      const custList: Customer[] = custs.map(c => {
-        const json = this.customerService.toJSON(c);
-        const customerId = json.objectId;
-
-        // 保存Parse对象以便后续使用
-        this.customerObjects.set(customerId, c);
-
-        return {
-          id: customerId,
-          name: json.name || '未知客户',
-          mobile: json.mobile || '',
-          external_userid: json.external_userid,
-          source: json.source,
-          createdAt: json.createdAt?.iso || json.createdAt
-        };
-      });
-
-      this.customers.set(custList);
-      this.total.set(custList.length);
+      this.customers.set(custs);
+      this.total.set(custs.length);
     } catch (error) {
       console.error('加载客户列表失败:', error);
     } finally {
@@ -74,37 +43,39 @@ export class Customers implements OnInit {
     }
   }
 
-  // 打开客户详情面板
-  openCustomerDetail(customer: Customer) {
-    const customerObj = this.customerObjects.get(customer.id);
-    if (customerObj) {
-      this.selectedCustomer = customerObj;
-      this.showCustomerPanel = true;
+  setupCurrentUserForContact() {
+    const companyId = localStorage.getItem('company');
+    if (companyId) {
+      const Company = (FmodeParse.with('nova') as any).Object.extend('Company');
+      const company = new Company();
+      company.id = companyId;
+      const companyPtr = company.toPointer();
+      this.currentUserForContact = { get: (key: string) => key === 'company' ? companyPtr : (key === 'roleName' ? '管理员' : null) } as any;
     }
   }
 
-  // 关闭客户详情面板
-  closeCustomerPanel() {
+  openCustomerDetail(customer: FmodeObject) {
+    this.selectedCustomer = customer;
+    this.showCustomerPanel = true;
+  }
+
+  async closeCustomerPanel(refresh: boolean = false) {
     this.showCustomerPanel = false;
     this.selectedCustomer = null;
+    if (refresh) await this.loadCustomers();
   }
 
-  // 筛选
   keyword = signal('');
   status = signal<'all' | 'active' | 'inactive'>('all');
   level = signal<'all' | 'normal' | 'vip' | 'svip'>('all');
 
-  // 面板状态
-  showPanel = false;
-  panelMode: 'add' | 'detail' | 'edit' = 'add';
-  currentCustomer: Customer | null = null;
-  formModel: Partial<Customer> = {};
-
   get filtered() {
     const kw = this.keyword().trim().toLowerCase();
     return this.customers().filter(c => {
-      const m1 = !kw || c.name.toLowerCase().includes(kw) || c.mobile.includes(kw);
-      return m1;
+      if (!kw) return true;
+      const name = (c.get('name') || c.get('data')?.name || '').toLowerCase();
+      const mobile = c.get('mobile') || '';
+      return name.includes(kw) || mobile.includes(kw);
     });
   }
 
@@ -112,15 +83,14 @@ export class Customers implements OnInit {
     this.keyword.set('');
   }
 
-  // 导出当前筛选客户为 CSV
   exportCustomers() {
     const header = ['客户名称','手机号','企微ID','来源','创建时间'];
     const rows = this.filtered.map(c => [
-      c.name,
-      c.mobile,
-      c.external_userid || '',
-      c.source || '',
-      c.createdAt instanceof Date ? c.createdAt.toISOString().slice(0, 10) : String(c.createdAt || '')
+      c.get('name') || c.get('data')?.name || '',
+      c.get('mobile') || '',
+      c.get('external_userid') || '',
+      c.get('source') || '',
+      (c.get('createdAt') instanceof Date) ? (c.get('createdAt') as Date).toISOString().slice(0, 10) : String(c.get('createdAt') || '')
     ]);
     this.downloadCSV('客户列表.csv', [header, ...rows]);
   }
@@ -140,5 +110,4 @@ export class Customers implements OnInit {
     a.click();
     URL.revokeObjectURL(url);
   }
-
 }

+ 50 - 18
src/app/pages/admin/employees/employees.html

@@ -64,19 +64,27 @@
       </thead>
       <tbody>
         <tr *ngFor="let emp of filtered" [class.disabled]="emp.isDisabled">
-          <td>{{ emp.name }}</td>
+          <td>
+            <div style="display:flex;align-items:center;gap:8px;">
+              <img [src]="emp.avatar || '/assets/images/default-avatar.svg'" alt="" style="width:28px;height:28px;border-radius:50%;"/>
+              <div>
+                <div style="font-weight:600;">{{ emp.name }}</div>
+                <div style="font-size:12px;color:#888;" *ngIf="emp.position">{{ emp.position }}</div>
+              </div>
+            </div>
+          </td>
           <td>{{ emp.mobile }}</td>
           <td>{{ emp.userid }}</td>
           <td><span class="badge">{{ emp.roleName }}</span></td>
           <td>
             @if(emp.roleName=="客服"){
               客服部
-            }@else if(emp.roleName=="管理员"){
+            } @else if(emp.roleName=="管理员") {
               总部
-            }@else{
+            } @else {
               {{ emp.department }}
             }
-           </td>
+          </td>
           <td><span [class]="'status ' + (emp.isDisabled ? 'disabled' : 'active')">{{ emp.isDisabled ? '已禁用' : '正常' }}</span></td>
           <td>
             <button class="btn-icon" (click)="viewEmployee(emp)" title="查看">👁</button>
@@ -103,20 +111,44 @@
       </div>
       <div class="panel-body" *ngIf="currentEmployee">
         <div *ngIf="panelMode === 'detail'" class="detail-view">
-          <div class="detail-item"><label>姓名</label><div>{{ currentEmployee.name }}</div></div>
-          <div class="detail-item"><label>手机号</label><div>{{ currentEmployee.mobile }}</div></div>
-          <div class="detail-item"><label>企微ID</label><div>{{ currentEmployee.userid }}</div></div>
-          <div class="detail-item"><label>身份</label><div>{{ currentEmployee.roleName }}</div></div>
-          <div class="detail-item"><label>部门</label><div>
-          @if(currentEmployee.roleName=="客服"){
-            客服部
-          }@else if(currentEmployee.roleName=="管理员"){
-            总部
-          }@else{
-            {{ currentEmployee.department }}
-          }
-          </div></div>
-          <div class="detail-item"><label>状态</label><div>{{ currentEmployee.isDisabled ? '已禁用' : '正常' }}</div></div>
+          <div class="detail-row">
+            <img [src]="currentEmployee.avatar || '/assets/images/default-avatar.svg'" class="avatar"/>
+            <div class="title-block">
+              <div class="name">{{ currentEmployee.name }}</div>
+              <div class="position" *ngIf="currentEmployee.position">{{ currentEmployee.position }}</div>
+            </div>
+          </div>
+          <div class="grid">
+            <div class="detail-item"><label>手机号</label><div>{{ currentEmployee.mobile || '-' }}</div></div>
+            <div class="detail-item"><label>邮箱</label><div>{{ currentEmployee.email || '-' }}</div></div>
+            <div class="detail-item"><label>企微ID</label><div>{{ currentEmployee.userid || '-' }}</div></div>
+            <div class="detail-item"><label>身份</label><div>{{ currentEmployee.roleName }}</div></div>
+            <div class="detail-item"><label>部门</label><div>
+              @if(currentEmployee.roleName=="客服") {
+                客服部
+              } @else if(currentEmployee.roleName=="管理员") {
+                总部
+              } @else {
+                {{ currentEmployee.department }}
+              }
+            </div></div>
+            <div class="detail-item"><label>入职</label><div>{{ currentEmployee.joinDate || '-' }}</div></div>
+            <div class="detail-item"><label>状态</label><div>{{ currentEmployee.isDisabled ? '已禁用' : '正常' }}</div></div>
+          </div>
+          <div class="skills" *ngIf="currentEmployee.skills?.length">
+            <label>技能</label>
+            <div class="tags">
+              <span class="tag" *ngFor="let s of currentEmployee.skills">{{ s }}</span>
+            </div>
+          </div>
+          <div class="workload" *ngIf="currentEmployee.workload">
+            <label>工作量</label>
+            <div class="grid">
+              <div class="detail-item"><label>当前项目</label><div>{{ currentEmployee.workload?.currentProjects || 0 }}</div></div>
+              <div class="detail-item"><label>已完成</label><div>{{ currentEmployee.workload?.completedProjects || 0 }}</div></div>
+              <div class="detail-item"><label>平均质量</label><div>{{ currentEmployee.workload?.averageQuality || 0 }}</div></div>
+            </div>
+          </div>
         </div>
         <div *ngIf="panelMode === 'edit'" class="form-view">
           <div class="form-group">

+ 16 - 44
src/app/pages/admin/employees/employees.scss

@@ -233,48 +233,20 @@
     }
   }
 
-  .panel-body {
-    flex: 1;
-    overflow-y: auto;
-    padding: 20px;
-  }
-
-  .panel-footer {
-    padding: 16px 20px;
-    border-top: 1px solid #f0f0f0;
-    display: flex;
-    justify-content: flex-end;
-    gap: 12px;
-  }
-}
-
-.form-group {
-  margin-bottom: 20px;
-
-  label {
-    display: block;
-    margin-bottom: 8px;
-    font-weight: 500;
-  }
-
-  .form-control {
-    width: 100%;
-    padding: 8px 12px;
-    border: 1px solid #ddd;
-    border-radius: 4px;
-  }
-}
-
-.detail-item {
-  margin-bottom: 20px;
-
-  label {
-    display: block;
-    color: #999;
-    margin-bottom: 8px;
-  }
-
-  div {
-    font-size: 16px;
-  }
+  .panel-body{padding:20px;overflow:auto}
+  .detail-view{display:flex;flex-direction:column;gap:16px}
+  .detail-row{display:flex;align-items:center;gap:12px;margin-bottom:8px}
+  .avatar{width:64px;height:64px;border-radius:50%;object-fit:cover;border:1px solid #eee}
+  .title-block .name{font-size:18px;font-weight:600}
+  .title-block .position{font-size:12px;color:#888}
+  .grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
+  .detail-item{background:#fafafa;border:1px solid #f0f0f0;border-radius:8px;padding:10px}
+  .detail-item label{display:block;color:#666;font-size:12px;margin-bottom:6px}
+  .detail-item div{font-size:14px;color:#333}
+  .skills{margin-top:6px}
+  .tags{display:flex;gap:6px;flex-wrap:wrap}
+  .tag{padding:4px 8px;border-radius:12px;background:#eef2ff;color:#4f46e5;font-size:12px}
+  .workload label{display:block;color:#666;font-size:12px;margin-bottom:6px}
+  .panel-header h2{display:flex;align-items:center;gap:8px}
+  .panel-header h2::before{content:"👤"}
 }

+ 33 - 7
src/app/pages/admin/employees/employees.ts

@@ -14,6 +14,15 @@ interface Employee {
   departmentId?: string;
   isDisabled?: boolean;
   createdAt?: Date;
+  // 新增展示字段
+  avatar?: string;
+  email?: string;
+  position?: string;
+  gender?: string;
+  level?: string;
+  skills?: string[];
+  joinDate?: Date | string;
+  workload?: { currentProjects?: number; completedProjects?: number; averageQuality?: number };
 }
 
 interface Department {
@@ -74,16 +83,31 @@ export class Employees implements OnInit {
 
       const empList: Employee[] = emps.map(e => {
         const json = this.employeeService.toJSON(e);
+        const data = (e as any).get ? ((e as any).get('data') || {}) : {};
+        const workload = data.workload || {};
+        const wxwork = data.wxworkInfo || {};
         return {
           id: json.objectId,
-          name: json.name || '未知',
-          mobile: json.mobile || '',
-          userid: json.userid || '',
+          name: json.name || data.name || '未知',
+          mobile: json.mobile || wxwork.mobile || '',
+          userid: json.userid || wxwork.userid || '',
           roleName: json.roleName || '未分配',
           department: e.get("department")?.get("name") || '未分配',
           departmentId: e.get("department")?.id,
           isDisabled: json.isDisabled || false,
-          createdAt: json.createdAt
+          createdAt: json.createdAt,
+          avatar: data.avatar || wxwork.avatar || '',
+          email: data.email || '',
+          position: wxwork.position || '',
+          gender: data.gender || '',
+          level: data.level || '',
+          skills: Array.isArray(data.skills) ? data.skills : [],
+          joinDate: data.joinDate || '',
+          workload: {
+            currentProjects: workload.currentProjects || 0,
+            completedProjects: workload.completedProjects || 0,
+            averageQuality: workload.averageQuality || 0
+          }
         };
       });
 
@@ -133,9 +157,11 @@ export class Employees implements OnInit {
     if (kw) {
       list = list.filter(
         e =>
-          e.name.toLowerCase().includes(kw) ||
-          e.mobile.includes(kw) ||
-          e.userid.toLowerCase().includes(kw)
+          (e.name || '').toLowerCase().includes(kw) ||
+          (e.mobile || '').includes(kw) ||
+          (e.userid || '').toLowerCase().includes(kw) ||
+          (e.email || '').toLowerCase().includes(kw) ||
+          (e.position || '').toLowerCase().includes(kw)
       );
     }
 

+ 247 - 0
src/app/pages/admin/services/project-migration.service.ts

@@ -0,0 +1,247 @@
+import { Injectable } from '@angular/core';
+import { FmodeParse } from 'fmode-ng/parse';
+
+const Parse = FmodeParse.with('nova');
+
+/**
+ * 项目数据迁移服务
+ * 用于批量更新现有项目的负责人信息
+ */
+@Injectable({
+  providedIn: 'root'
+})
+export class ProjectMigrationService {
+  
+  /**
+   * 批量更新项目负责人
+   * 为所有没有assignee的项目设置组长为负责人
+   */
+  async migrateProjectAssignees(): Promise<{
+    total: number;
+    success: number;
+    failed: number;
+    details: any[];
+  }> {
+    const company = localStorage.getItem('company');
+    if (!company) {
+      console.error('❌ 未找到公司ID');
+      return { total: 0, success: 0, failed: 0, details: [] };
+    }
+
+    try {
+      console.log('🚀 开始迁移项目负责人数据...');
+      
+      // 1. 查询所有没有assignee或department的项目
+      const projectQuery = new Parse.Query('Project');
+      projectQuery.equalTo('company', company);
+      projectQuery.notEqualTo('isDeleted', true);
+      projectQuery.limit(1000);
+      
+      const projects = await projectQuery.find();
+      console.log(`📊 找到 ${projects.length} 个项目`);
+
+      const results = {
+        total: projects.length,
+        success: 0,
+        failed: 0,
+        details: [] as any[]
+      };
+
+      // 2. 遍历每个项目
+      for (const project of projects) {
+        const projectId = project.id;
+        const projectTitle = project.get('title') || '未命名项目';
+        const existingAssignee = project.get('assignee');
+        const existingDepartment = project.get('department');
+
+        try {
+          // 如果已经有assignee,跳过
+          if (existingAssignee) {
+            console.log(`⏭️  项目 "${projectTitle}" 已有负责人,跳过`);
+            results.details.push({
+              projectId,
+              title: projectTitle,
+              status: 'skipped',
+              reason: '已有负责人'
+            });
+            continue;
+          }
+
+          // 如果有department,使用其leader
+          if (existingDepartment) {
+            const dept = existingDepartment;
+            await dept.fetch({ include: ['leader'] });
+            const leader = dept.get('leader');
+            
+            if (leader) {
+              project.set('assignee', leader);
+              await project.save();
+              results.success++;
+              console.log(`✅ 项目 "${projectTitle}" 已设置负责人: ${leader.get('name')}`);
+              results.details.push({
+                projectId,
+                title: projectTitle,
+                status: 'success',
+                assignee: leader.get('name')
+              });
+            } else {
+              results.failed++;
+              console.warn(`⚠️  项目 "${projectTitle}" 的项目组没有组长`);
+              results.details.push({
+                projectId,
+                title: projectTitle,
+                status: 'failed',
+                reason: '项目组没有组长'
+              });
+            }
+            continue;
+          }
+
+          // 如果既没有assignee也没有department,尝试查找默认项目组
+          console.log(`🔍 项目 "${projectTitle}" 没有项目组,尝试查找默认项目组...`);
+          
+          // 查找第一个项目组
+          const deptQuery = new Parse.Query('Department');
+          deptQuery.equalTo('company', company);
+          deptQuery.equalTo('type', 'project');
+          deptQuery.notEqualTo('isDeleted', true);
+          deptQuery.include('leader');
+          deptQuery.ascending('createdAt');
+          deptQuery.limit(1);
+          
+          const defaultDept = await deptQuery.first();
+          
+          if (defaultDept) {
+            const leader = defaultDept.get('leader');
+            if (leader) {
+              project.set('department', defaultDept);
+              project.set('assignee', leader);
+              await project.save();
+              results.success++;
+              console.log(`✅ 项目 "${projectTitle}" 已分配到默认项目组,负责人: ${leader.get('name')}`);
+              results.details.push({
+                projectId,
+                title: projectTitle,
+                status: 'success',
+                assignee: leader.get('name'),
+                department: defaultDept.get('name')
+              });
+            } else {
+              results.failed++;
+              console.warn(`⚠️  默认项目组没有组长`);
+              results.details.push({
+                projectId,
+                title: projectTitle,
+                status: 'failed',
+                reason: '默认项目组没有组长'
+              });
+            }
+          } else {
+            results.failed++;
+            console.warn(`⚠️  项目 "${projectTitle}" 无法找到项目组`);
+            results.details.push({
+              projectId,
+              title: projectTitle,
+              status: 'failed',
+              reason: '没有可用的项目组'
+            });
+          }
+
+        } catch (error) {
+          results.failed++;
+          console.error(`❌ 更新项目 "${projectTitle}" 失败:`, error);
+          results.details.push({
+            projectId,
+            title: projectTitle,
+            status: 'error',
+            error: (error as Error).message
+          });
+        }
+      }
+
+      console.log('🎉 迁移完成!');
+      console.log(`📊 总计: ${results.total}, 成功: ${results.success}, 失败: ${results.failed}`);
+      
+      return results;
+
+    } catch (error) {
+      console.error('❌ 迁移失败:', error);
+      throw error;
+    }
+  }
+
+  /**
+   * 为单个项目设置负责人
+   */
+  async setProjectAssignee(projectId: string, assigneeId: string): Promise<boolean> {
+    try {
+      const projectQuery = new Parse.Query('Project');
+      const project = await projectQuery.get(projectId);
+      
+      const assigneeQuery = new Parse.Query('Profile');
+      const assignee = await assigneeQuery.get(assigneeId);
+      
+      project.set('assignee', assignee);
+      await project.save();
+      
+      console.log('✅ 项目负责人设置成功');
+      return true;
+    } catch (error) {
+      console.error('❌ 设置项目负责人失败:', error);
+      return false;
+    }
+  }
+
+  /**
+   * 检查项目数据状态
+   */
+  async checkProjectsStatus(): Promise<{
+    total: number;
+    hasAssignee: number;
+    hasDepartment: number;
+    needsMigration: number;
+  }> {
+    const company = localStorage.getItem('company');
+    if (!company) {
+      console.error('❌ 未找到公司ID');
+      return { total: 0, hasAssignee: 0, hasDepartment: 0, needsMigration: 0 };
+    }
+
+    try {
+      const projectQuery = new Parse.Query('Project');
+      projectQuery.equalTo('company', company);
+      projectQuery.notEqualTo('isDeleted', true);
+      projectQuery.include('assignee');
+      projectQuery.include('department');
+      projectQuery.limit(1000);
+      
+      const projects = await projectQuery.find();
+      
+      let hasAssignee = 0;
+      let hasDepartment = 0;
+      let needsMigration = 0;
+      
+      projects.forEach(project => {
+        if (project.get('assignee')) hasAssignee++;
+        if (project.get('department')) hasDepartment++;
+        if (!project.get('assignee')) needsMigration++;
+      });
+      
+      const status = {
+        total: projects.length,
+        hasAssignee,
+        hasDepartment,
+        needsMigration
+      };
+      
+      console.log('📊 项目状态检查:', status);
+      return status;
+      
+    } catch (error) {
+      console.error('❌ 检查失败:', error);
+      throw error;
+    }
+  }
+}
+
+

+ 43 - 4
src/app/pages/admin/services/project.service.ts

@@ -1,6 +1,9 @@
 import { Injectable } from '@angular/core';
 import { AdminDataService } from './admin-data.service';
 import { FmodeObject } from 'fmode-ng/core';
+import { FmodeParse } from 'fmode-ng/parse';
+
+const Parse = FmodeParse.with('nova');
 
 /**
  * 项目管理数据服务
@@ -21,7 +24,7 @@ export class ProjectService {
     limit?: number;
   }): Promise<FmodeObject[]> {
     return await this.adminData.findAll('Project', {
-      include: ['contact', 'assignee'],  // 修正:使用 contact 而不是 customer
+      include: ['customer', 'assignee', 'department', 'department.leader'], // ✅ 添加department和leader
       skip: options?.skip || 0,
       limit: options?.limit || 20,
       descending: 'updatedAt',
@@ -56,8 +59,10 @@ export class ProjectService {
    */
   async getProject(objectId: string): Promise<FmodeObject | null> {
     return await this.adminData.getById('Project', objectId, [
-      'contact',  // 修正:使用 contact 而不是 customer
-      'assignee'
+      'customer',
+      'assignee',
+      'department',
+      'department.leader' // ✅ 添加department和leader
     ]);
   }
 
@@ -68,6 +73,7 @@ export class ProjectService {
     title: string;
     customerId?: string;
     assigneeId?: string;
+    departmentId?: string; // ✅ 新增:项目组ID
     status?: string;
     currentStage?: string;
     deadline?: Date;
@@ -88,7 +94,30 @@ export class ProjectService {
       };
     }
 
-    // 设置负责人指针
+    // ✅ 新增:如果提供了项目组,获取组长作为默认负责人
+    if (data.departmentId) {
+      try {
+        const departmentQuery = new Parse.Query('Department');
+        departmentQuery.include('leader');
+        const department = await departmentQuery.get(data.departmentId);
+        
+        if (department) {
+          projectData.department = department.toPointer();
+          
+          // 获取组长
+          const leader = department.get('leader');
+          if (leader && !data.assigneeId) {
+            // 如果没有明确指定负责人,使用组长
+            projectData.assignee = leader.toPointer();
+            console.log('✅ 项目负责人默认为组长:', leader.get('name'));
+          }
+        }
+      } catch (error) {
+        console.error('❌ 获取项目组失败:', error);
+      }
+    }
+
+    // 设置负责人指针(如果明确指定,覆盖组长)
     if (data.assigneeId) {
       projectData.assignee = {
         __type: 'Pointer',
@@ -211,9 +240,19 @@ export class ProjectService {
       json.customerId = json.contact.objectId;
     }
 
+    // ✅ 处理负责人:优先使用assignee,如果为空则使用department.leader
     if (json.assignee && typeof json.assignee === 'object') {
       json.assigneeName = json.assignee.name || '';
       json.assigneeId = json.assignee.objectId;
+      json.assigneeRole = json.assignee.roleName || '';
+    } else if (json.department && typeof json.department === 'object') {
+      // 如果没有assignee,但有department和leader,使用leader(组长)
+      const leader = json.department.leader;
+      if (leader && typeof leader === 'object') {
+        json.assigneeName = leader.name || '';
+        json.assigneeId = leader.objectId;
+        json.assigneeRole = '组长';
+      }
     }
 
     return json;

+ 12 - 5
src/app/pages/team-leader/dashboard/dashboard.html

@@ -379,15 +379,22 @@
                 {{ selectedEmployeeDetail.currentProjects }} 个
               </span>
             </div>
-            @if (selectedEmployeeDetail.projectNames.length > 0) {
+            @if (selectedEmployeeDetail.projectData.length > 0) {
               <div class="project-list">
                 <span class="project-label">核心项目:</span>
                 <div class="project-tags">
-                  @for (projectName of selectedEmployeeDetail.projectNames; track $index) {
-                    <span class="project-tag">{{ projectName }}</span>
+                  @for (project of selectedEmployeeDetail.projectData; track project.id) {
+                    <span class="project-tag clickable" 
+                          (click)="navigateToProjectFromPanel(project.id)"
+                          title="点击查看项目详情">
+                      {{ project.name }}
+                      <svg class="icon-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                        <path d="M7 17L17 7M17 7H7M17 7V17"/>
+                      </svg>
+                    </span>
                   }
-                  @if (selectedEmployeeDetail.currentProjects > selectedEmployeeDetail.projectNames.length) {
-                    <span class="project-tag more">+{{ selectedEmployeeDetail.currentProjects - selectedEmployeeDetail.projectNames.length }}</span>
+                  @if (selectedEmployeeDetail.currentProjects > selectedEmployeeDetail.projectData.length) {
+                    <span class="project-tag more">+{{ selectedEmployeeDetail.currentProjects - selectedEmployeeDetail.projectData.length }}</span>
                   }
                 </div>
               </div>

+ 36 - 0
src/app/pages/team-leader/dashboard/dashboard.scss

@@ -1231,9 +1231,45 @@
               border-radius: 16px;
               font-size: 12px;
               font-weight: 500;
+              transition: all 0.2s ease;
+              
+              &.clickable {
+                cursor: pointer;
+                display: inline-flex;
+                align-items: center;
+                gap: 4px;
+                background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+                box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
+                
+                .icon-arrow {
+                  width: 14px;
+                  height: 14px;
+                  stroke-width: 2.5;
+                  opacity: 0;
+                  transform: translateX(-4px);
+                  transition: all 0.2s ease;
+                }
+                
+                &:hover {
+                  background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
+                  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
+                  transform: translateY(-2px);
+                  
+                  .icon-arrow {
+                    opacity: 1;
+                    transform: translateX(0);
+                  }
+                }
+                
+                &:active {
+                  transform: translateY(0);
+                  box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3);
+                }
+              }
               
               &.more {
                 background: #94a3b8;
+                cursor: default;
               }
             }
           }

+ 314 - 53
src/app/pages/team-leader/dashboard/dashboard.ts

@@ -68,6 +68,7 @@ interface EmployeeDetail {
   name: string;
   currentProjects: number; // 当前负责项目数
   projectNames: string[]; // 项目名称列表(用于显示)
+  projectData: Array<{ id: string; name: string }>; // 项目数据(包含ID和名称,用于跳转)
   leaveRecords: LeaveRecord[]; // 未来7天请假记录
   redMarkExplanation: string; // 红色标记说明
 }
@@ -85,7 +86,6 @@ export class Dashboard implements OnInit, OnDestroy {
   projects: Project[] = [];
   filteredProjects: Project[] = [];
   todoTasks: TodoTask[] = [];
-  overdueProjects: Project[] = [];
   urgentPinnedProjects: Project[] = [];
   showAlert: boolean = false;
   selectedProjectId: string = '';
@@ -93,6 +93,9 @@ export class Dashboard implements OnInit, OnDestroy {
   // 真实设计师数据(从fmode-ng获取)
   realDesigners: any[] = [];
   
+  // 设计师工作量映射(从 ProjectTeam 表)
+  designerWorkloadMap: Map<string, any[]> = new Map(); // designerId/name -> projects[]
+  
   // 智能推荐相关
   showSmartMatch: boolean = false;
   selectedProject: any = null;
@@ -110,7 +113,6 @@ export class Dashboard implements OnInit, OnDestroy {
   private readonly MAX_SUGGESTIONS = 8; // 建议最大条数
   private isSearchFocused: boolean = false; // 是否处于输入聚焦态
   // 新增:临期项目与筛选状态
-  dueSoonProjects: Project[] = [];
   selectedType: 'all' | 'soft' | 'hard' = 'all';
   selectedUrgency: 'all' | 'high' | 'medium' | 'low' = 'all';
   selectedStatus: 'all' | 'progress' | 'completed' | 'overdue' | 'pendingApproval' | 'pendingAssignment' | 'dueSoon' = 'all';
@@ -228,9 +230,142 @@ export class Dashboard implements OnInit, OnDestroy {
         avgRating: d.tags.history.avgRating || 0,
         experience: 0 // 暂无此字段
       }));
-      console.log('✅ 加载设计师数据成功:', this.realDesigners.length, '人');
+      
+      // 加载设计师的实际工作量
+      await this.loadDesignerWorkload();
     } catch (error) {
-      console.error('❌ 加载设计师数据失败:', error);
+      console.error('加载设计师数据失败:', error);
+    }
+  }
+  
+  /**
+   * 🔧 从 ProjectTeam 表加载每个设计师的实际工作量
+   */
+  async loadDesignerWorkload(): Promise<void> {
+    try {
+      const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
+      
+      // 查询所有 ProjectTeam 记录
+      const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
+      
+      // 先查询当前公司的所有项目
+      const projectQuery = new Parse.Query('Project');
+      projectQuery.equalTo('company', cid);
+      projectQuery.notEqualTo('isDeleted', true);
+      
+      // 查询当前公司项目的 ProjectTeam
+      const teamQuery = new Parse.Query('ProjectTeam');
+      teamQuery.matchesQuery('project', projectQuery);
+      teamQuery.notEqualTo('isDeleted', true);
+      teamQuery.include('project');
+      teamQuery.include('profile');
+      teamQuery.limit(1000);
+      
+      const teamRecords = await teamQuery.find();
+      
+      // 如果 ProjectTeam 表为空,使用降级方案
+      if (teamRecords.length === 0) {
+        await this.loadDesignerWorkloadFromProjects();
+        return;
+      }
+      
+      // 构建设计师工作量映射
+      this.designerWorkloadMap.clear();
+      
+      teamRecords.forEach((record: any) => {
+        const profile = record.get('profile');
+        const project = record.get('project');
+        
+        if (!profile || !project) {
+          return;
+        }
+        
+        const profileId = profile.id;
+        const profileName = profile.get('name') || profile.get('user')?.get?.('name') || `设计师-${profileId.slice(-4)}`;
+        
+        // 提取项目信息
+        const projectData = {
+          id: project.id,
+          name: project.get('title') || '未命名项目',
+          status: project.get('status') || '进行中',
+          currentStage: project.get('currentStage') || '未知阶段',
+          deadline: project.get('deadline'),
+          createdAt: project.get('createdAt'),
+          designerName: profileName // 设置为组员的名字
+        };
+        
+        // 添加到映射 (by ID)
+        if (!this.designerWorkloadMap.has(profileId)) {
+          this.designerWorkloadMap.set(profileId, []);
+        }
+        this.designerWorkloadMap.get(profileId)!.push(projectData);
+        
+        // 同时建立 name -> projects 的映射(用于甘特图)
+        if (!this.designerWorkloadMap.has(profileName)) {
+          this.designerWorkloadMap.set(profileName, []);
+        }
+        this.designerWorkloadMap.get(profileName)!.push(projectData);
+      });
+      
+    } catch (error) {
+      console.error('加载设计师工作量失败:', error);
+    }
+  }
+  
+  /**
+   * 🔧 降级方案:从 Project.assignee 统计工作量
+   * 当 ProjectTeam 表为空时使用
+   */
+  async loadDesignerWorkloadFromProjects(): Promise<void> {
+    try {
+      const Parse = await import('fmode-ng/parse').then(m => m.FmodeParse.with('nova'));
+      const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
+      
+      // 查询所有项目
+      const projectQuery = new Parse.Query('Project');
+      projectQuery.equalTo('company', cid);
+      projectQuery.equalTo('isDeleted', false);
+      projectQuery.include('assignee');
+      projectQuery.include('department');
+      projectQuery.limit(1000);
+      
+      const projects = await projectQuery.find();
+      
+      // 构建设计师工作量映射
+      this.designerWorkloadMap.clear();
+      
+      projects.forEach((project: any) => {
+        const assignee = project.get('assignee');
+        if (!assignee) return;
+        
+        // 只统计组员角色的项目
+        const assigneeRole = assignee.get('roleName');
+        if (assigneeRole !== '组员') {
+          return;
+        }
+        
+        const assigneeName = assignee.get('name') || assignee.get('user')?.get?.('name') || `设计师-${assignee.id.slice(-4)}`;
+        
+        // 提取项目信息
+        const projectData = {
+          id: project.id,
+          name: project.get('title') || '未命名项目',
+          status: project.get('status') || '进行中',
+          currentStage: project.get('currentStage') || '未知阶段',
+          deadline: project.get('deadline'),
+          createdAt: project.get('createdAt'),
+          designerName: assigneeName
+        };
+        
+        // 添加到映射
+        if (!this.designerWorkloadMap.has(assigneeName)) {
+          this.designerWorkloadMap.set(assigneeName, []);
+        }
+        this.designerWorkloadMap.get(assigneeName)!.push(projectData);
+      });
+      
+    } catch (error) {
+      console.error('[降级方案] 加载工作量失败:', error);
     }
   }
 
@@ -244,14 +379,12 @@ export class Dashboard implements OnInit, OnDestroy {
       // 如果有真实数据,使用真实数据
       if (realProjects && realProjects.length > 0) {
         this.projects = realProjects;
-        console.log('✅ 加载真实项目数据成功:', this.projects.length, '个项目');
       } else {
-        // 如果没有真实数据,使用模拟数据(便于开发测试)
-        console.warn('⚠️ 未找到真实项目数据,使用模拟数据');
+        // 如果没有真实数据,使用模拟数据
         this.projects = this.getMockProjects();
       }
     } catch (error) {
-      console.error('加载项目数据失败,使用模拟数据:', error);
+      console.error('加载项目数据失败:', error);
       this.projects = this.getMockProjects();
     }
     
@@ -537,15 +670,13 @@ export class Dashboard implements OnInit, OnDestroy {
       return { ...p, deadline, createdAt } as Project;
     });
 
-    // 筛选超期与临期项目
-    this.overdueProjects = this.projects.filter(project => project.isOverdue);
-    this.dueSoonProjects = this.projects.filter(project => project.dueSoon && !project.isOverdue);
+    // 筛选结果初始化为全部项目
     this.filteredProjects = [...this.projects];
 
     // 供筛选用的设计师列表
     this.designers = Array.from(new Set(this.projects.map(p => p.designerName).filter(n => !!n)));
 
-    // 显示超期提醒
+    // 显示超期提醒(使用 getter)
     if (this.overdueProjects.length > 0) {
       this.showAlert = true;
     }
@@ -946,9 +1077,8 @@ export class Dashboard implements OnInit, OnDestroy {
     
     try {
       this.recommendations = await this.designerService.getRecommendedDesigners(project, this.realDesigners);
-      console.log('✅ 智能推荐结果:', this.recommendations);
     } catch (error) {
-      console.error('智能推荐失败:', error);
+      console.error('智能推荐失败:', error);
       this.recommendations = [];
     }
   }
@@ -1848,9 +1978,9 @@ export class Dashboard implements OnInit, OnDestroy {
     if (this.workloadGanttScale === 'week') {
       // 周视图:显示未来7天
       xMin = todayTs;
-      xMax = todayTs + 7 * DAY - 1;
+      xMax = todayTs + 7 * DAY;
       xSplitNumber = 7;
-      xLabelFormatter = (val) => {
+      xLabelFormatter = (val: any) => {
         const date = new Date(val);
         const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
         return `${date.getMonth() + 1}/${date.getDate()}\n${weekDays[date.getDay()]}`;
@@ -1858,33 +1988,42 @@ export class Dashboard implements OnInit, OnDestroy {
     } else {
       // 月视图:显示未来30天
       xMin = todayTs;
-      xMax = todayTs + 30 * DAY - 1;
+      xMax = todayTs + 30 * DAY;
       xSplitNumber = 30;
-      xLabelFormatter = (val) => {
+      xLabelFormatter = (val: any) => {
         const date = new Date(val);
         return `${date.getMonth() + 1}/${date.getDate()}`;
       };
     }
 
-    // 获取所有设计师
-    const assigned = this.filteredProjects.filter(p => !!p.designerName);
-    const designers = Array.from(new Set(assigned.map(p => p.designerName)));
+    // 获取所有真实设计师
+    let designers: string[] = [];
+    
+    if (this.realDesigners && this.realDesigners.length > 0) {
+      designers = this.realDesigners.map(d => d.name);
+    } else {
+      // 降级:从已分配的项目中提取设计师
+      const assigned = this.filteredProjects.filter(p => p.designerName && p.designerName !== '未分配');
+      designers = Array.from(new Set(assigned.map(p => p.designerName)));
+    }
     
     if (designers.length === 0) {
       // 没有设计师数据,显示空状态
       const emptyOption = {
         title: {
-          text: '暂无项目数据',
+          text: '暂无组员数据',
+          subtext: '请先在系统中添加设计师(组员角色)',
           left: 'center',
           top: 'center',
-          textStyle: { fontSize: 16, color: '#9ca3af' }
+          textStyle: { fontSize: 16, color: '#9ca3af' },
+          subtextStyle: { fontSize: 13, color: '#d1d5db' }
         }
       };
       this.workloadGanttChart.setOption(emptyOption, true);
       return;
     }
     
-    // 计算每个设计师的每日工作状态
+    // 🔧 使用 ProjectTeam 表的数据(实际执行人)
     const workloadByDesigner: Record<string, any[]> = {};
     designers.forEach(name => {
       workloadByDesigner[name] = [];
@@ -1893,7 +2032,7 @@ export class Dashboard implements OnInit, OnDestroy {
     // 计算每个设计师的总负载(用于排序)
     const designerTotalLoad: Record<string, number> = {};
     designers.forEach(name => {
-      const projects = assigned.filter(p => p.designerName === name);
+      const projects = this.designerWorkloadMap.get(name) || [];
       designerTotalLoad[name] = projects.length;
     });
     
@@ -1904,7 +2043,7 @@ export class Dashboard implements OnInit, OnDestroy {
     
     // 为每个设计师生成时间段数据
     sortedDesigners.forEach((designerName, yIndex) => {
-      const designerProjects = assigned.filter(p => p.designerName === designerName);
+      const designerProjects = this.designerWorkloadMap.get(designerName) || [];
       
       // 计算每一天的状态
       const days = this.workloadGanttScale === 'week' ? 7 : 30;
@@ -1914,8 +2053,31 @@ export class Dashboard implements OnInit, OnDestroy {
         
         // 查找该天有哪些项目
         const dayProjects = designerProjects.filter(p => {
-          const pStart = p.createdAt ? new Date(p.createdAt).getTime() : dayStart;
+          // 如果项目没有 deadline,则认为项目一直在进行中
+          if (!p.deadline) {
+            return true; // 没有截止日期的项目始终显示
+          }
+          
           const pEnd = new Date(p.deadline).getTime();
+          
+          // 检查时间是否有效
+          if (isNaN(pEnd)) {
+            return true; // 如果截止日期无效,认为项目在进行中
+          }
+          
+          // 🔧 修复:对于进行中的项目(状态不是"已完成"),即使过期也显示
+          // 这样可以在甘特图中看到超期的项目
+          const isCompleted = p.status === '已完成' || p.status === '已交付';
+          if (!isCompleted) {
+            // 进行中的项目:只要截止日期还没到很久之前(比如30天前),就显示
+            const thirtyDaysAgo = todayTs - 30 * DAY;
+            if (pEnd >= thirtyDaysAgo) {
+              return true; // 30天内的项目都显示
+            }
+          }
+          
+          // 已完成的项目:正常时间范围判断
+          const pStart = p.createdAt ? new Date(p.createdAt).getTime() : dayStart;
           return !(pEnd < dayStart || pStart > dayEnd);
         });
 
@@ -2028,17 +2190,29 @@ export class Dashboard implements OnInit, OnDestroy {
         bottom: 60
       },
       xAxis: {
-        type: 'value',
+        type: 'time',
         min: xMin,
         max: xMax,
-        splitNumber: xSplitNumber,
+        boundaryGap: false,
         axisLine: { lineStyle: { color: '#e5e7eb' } },
         axisLabel: {
           color: '#6b7280',
           formatter: xLabelFormatter,
-          interval: this.workloadGanttScale === 'week' ? 0 : 4
+          interval: 0,
+          rotate: this.workloadGanttScale === 'week' ? 0 : 45,
+          showMinLabel: true,
+          showMaxLabel: true
         },
-        splitLine: { lineStyle: { color: '#f1f5f9' } }
+        axisTick: {
+          alignWithLabel: true,
+          interval: 0
+        },
+        splitLine: { 
+          show: true,
+          lineStyle: { color: '#f1f5f9' }
+        },
+        splitNumber: xSplitNumber,
+        minInterval: DAY
       },
       yAxis: {
         type: 'category',
@@ -2128,14 +2302,65 @@ export class Dashboard implements OnInit, OnDestroy {
 
   // 新增:阶段到核心阶段的映射
   private mapStageToCorePhase(stageId: string): 'order' | 'requirements' | 'delivery' | 'aftercare' {
-    // 订单分配:立项初期
-    if (stageId === 'pendingApproval' || stageId === 'pendingAssignment') return 'order';
-    // 确认需求:需求沟通 + 方案规划
-    if (stageId === 'requirement' || stageId === 'planning') return 'requirements';
-    // 交付执行:制作与评审修订过程
-    if (stageId === 'modeling' || stageId === 'rendering' || stageId === 'postProduction' || stageId === 'review' || stageId === 'revision') return 'delivery';
-    // 售后:交付完成后的跟进(当前数据以交付完成代表进入售后)
-    return 'aftercare';
+    if (!stageId) return 'order'; // 空值默认为订单分配
+    
+    // 标准化阶段名称(去除空格,转小写)
+    const normalizedStage = stageId.trim().toLowerCase();
+    
+    // 1. 订单分配阶段(英文ID + 中文名称)
+    if (normalizedStage === 'order' || 
+        normalizedStage === 'pendingapproval' || 
+        normalizedStage === 'pendingassignment' ||
+        normalizedStage === '订单分配' ||
+        normalizedStage === '待审批' ||
+        normalizedStage === '待分配') {
+      return 'order';
+    }
+    
+    // 2. 确认需求阶段(英文ID + 中文名称)
+    if (normalizedStage === 'requirements' ||
+        normalizedStage === 'requirement' || 
+        normalizedStage === 'planning' ||
+        normalizedStage === '确认需求' ||
+        normalizedStage === '需求沟通' ||
+        normalizedStage === '方案规划') {
+      return 'requirements';
+    }
+    
+    // 3. 交付执行阶段(英文ID + 中文名称)
+    if (normalizedStage === 'delivery' ||
+        normalizedStage === 'modeling' || 
+        normalizedStage === 'rendering' || 
+        normalizedStage === 'postproduction' || 
+        normalizedStage === 'review' || 
+        normalizedStage === 'revision' ||
+        normalizedStage === '交付执行' ||
+        normalizedStage === '建模' ||
+        normalizedStage === '建模阶段' ||
+        normalizedStage === '渲染' ||
+        normalizedStage === '渲染阶段' ||
+        normalizedStage === '后期制作' ||
+        normalizedStage === '评审' ||
+        normalizedStage === '修改' ||
+        normalizedStage === '修订') {
+      return 'delivery';
+    }
+    
+    // 4. 售后归档阶段(英文ID + 中文名称)
+    if (normalizedStage === 'aftercare' ||
+        normalizedStage === 'completed' ||
+        normalizedStage === 'archived' ||
+        normalizedStage === '售后归档' ||
+        normalizedStage === '售后' ||
+        normalizedStage === '归档' ||
+        normalizedStage === '已完成' ||
+        normalizedStage === '已交付') {
+      return 'aftercare';
+    }
+    
+    // 未匹配的阶段:默认为交付执行(因为大部分时间项目都在执行中)
+    console.warn(`⚠️ 未识别的阶段: "${stageId}" → 默认归类为交付执行`);
+    return 'delivery';
   }
 
   // 新增:获取核心阶段的项目
@@ -2153,16 +2378,34 @@ export class Dashboard implements OnInit, OnDestroy {
     return this.getProjectsByStage(stageId).length;
   }
 
-  // 待审批项目:currentStage === 'pendingApproval'
+  // 🔥 已延期项目
+  get overdueProjects(): Project[] {
+    return this.projects.filter(p => p.isOverdue);
+  }
+
+  // ⏳ 临期项目(3天内)
+  get dueSoonProjects(): Project[] {
+    return this.projects.filter(p => p.dueSoon && !p.isOverdue);
+  }
+
+  // 📋 待审批项目(支持中文和英文阶段名称)
   get pendingApprovalProjects(): Project[] {
-    const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
-    return src.filter(p => p.currentStage === 'pendingApproval');
+    return this.projects.filter(p => {
+      const stage = (p.currentStage || '').trim().toLowerCase();
+      return stage === 'pendingapproval' || 
+             stage === '待审批' || 
+             stage === '待确认';
+    });
   }
 
-  // 待指派项目:currentStage === 'pendingAssignment'
+  // 🎯 待分配项目(支持中文和英文阶段名称)
   get pendingAssignmentProjects(): Project[] {
-    const src = (this.filteredProjects && this.filteredProjects.length) ? this.filteredProjects : this.projects;
-    return src.filter(p => p.currentStage === 'pendingAssignment');
+    return this.projects.filter(p => {
+      const stage = (p.currentStage || '').trim().toLowerCase();
+      return stage === 'pendingassignment' || 
+             stage === '待分配' ||
+             stage === '订单分配';
+    });
   }
 
   // 智能推荐设计师
@@ -2204,7 +2447,6 @@ export class Dashboard implements OnInit, OnDestroy {
   // 查看项目详情 - 跳转到纯净的项目详情页(无管理端UI)
   viewProjectDetails(projectId: string): void {
     if (!projectId) {
-      console.warn('⚠️ 项目ID为空,无法跳转');
       return;
     }
     
@@ -2212,10 +2454,7 @@ export class Dashboard implements OnInit, OnDestroy {
     const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
     
     // 跳转到wxwork路由的项目详情页(纯净页面,无管理端侧边栏)
-    // 路由格式:/wxwork/:cid/project/:projectId/order
     this.router.navigate(['/wxwork', cid, 'project', projectId, 'order']);
-    
-    console.log('✅ 组长端跳转到纯净项目详情页:', projectId);
   }
 
   // 快速分配项目(增强:加入智能推荐)
@@ -2331,10 +2570,17 @@ export class Dashboard implements OnInit, OnDestroy {
 
   // 生成员工详情数据
   private generateEmployeeDetail(employeeName: string): EmployeeDetail {
-    // 获取该员工负责的项目
-    const employeeProjects = this.filteredProjects.filter(p => p.designerName === employeeName);
+    // 🔧 从 ProjectTeam 表获取该员工负责的项目
+    const employeeProjects = this.designerWorkloadMap.get(employeeName) || [];
     const currentProjects = employeeProjects.length;
-    const projectNames = employeeProjects.slice(0, 3).map(p => p.name); // 最多显示3个项目名称
+    
+    // 保存完整的项目数据(最多显示3个)
+    const projectData = employeeProjects.slice(0, 3).map(p => ({
+      id: p.id,
+      name: p.name
+    }));
+    
+    const projectNames = projectData.map(p => p.name); // 项目名称列表
     
     // 获取该员工的请假记录(未来7天)
     const today = new Date();
@@ -2355,6 +2601,7 @@ export class Dashboard implements OnInit, OnDestroy {
       name: employeeName,
       currentProjects,
       projectNames,
+      projectData,
       leaveRecords: employeeLeaveRecords,
       redMarkExplanation
     };
@@ -2394,6 +2641,20 @@ export class Dashboard implements OnInit, OnDestroy {
     this.selectedEmployeeDetail = null;
   }
 
+  // 从员工详情面板跳转到项目详情
+  navigateToProjectFromPanel(projectId: string): void {
+    if (!projectId) {
+      return;
+    }
+    
+    // 关闭员工详情面板
+    this.closeEmployeeDetailPanel();
+    
+    // 跳转到项目详情页(使用纯净的wxwork路由)
+    const cid = localStorage.getItem('company') || 'cDL6R1hgSi';
+    this.router.navigate(['/wxwork', cid, 'project', projectId, 'order']);
+  }
+
   // 获取请假类型显示文本
   getLeaveTypeText(leaveType?: string): string {
     const typeMap: Record<string, string> = {
@@ -2439,7 +2700,7 @@ export class Dashboard implements OnInit, OnDestroy {
       });
 
       // 检查项目繁忙情况,如果项目数>=3,也添加红色标记
-      const employeeProjects = this.filteredProjects.filter(p => p.designerName === employeeName);
+      const employeeProjects = this.designerWorkloadMap.get(employeeName) || [];
       if (employeeProjects.length >= 3) {
         // 在当前日期添加繁忙标记
         const today = new Date();

+ 36 - 19
src/app/pages/team-leader/services/designer.service.ts

@@ -35,7 +35,6 @@ export class DesignerService {
   
   constructor() {
     this.cid = localStorage.getItem('company') || '';
-    console.log('🏢 DesignerService初始化,当前公司ID:', this.cid || '(未设置)');
     this.initParse();
   }
   
@@ -46,9 +45,8 @@ export class DesignerService {
     try {
       const { FmodeParse } = await import('fmode-ng/parse');
       this.Parse = FmodeParse.with("nova");
-      console.log('✅ DesignerService: FmodeParse 初始化成功');
     } catch (error) {
-      console.error('DesignerService: FmodeParse 初始化失败:', error);
+      console.error('DesignerService: FmodeParse 初始化失败:', error);
     }
   }
   
@@ -70,27 +68,55 @@ export class DesignerService {
     if (!Parse) return [];
     
     try {
+      // 查询所有组员(设计师角色)
       const query = new Parse.Query('Profile');
       query.equalTo('company', this.cid);
-      query.equalTo('roleName', '组员'); // 设计师角色
+      query.equalTo('roleName', '组员'); // 只查询组员
       query.notEqualTo('isDeleted', true);
       query.include('department'); // 关联部门
+      query.include('user'); // 🔧 关联用户信息,尝试获取名字
       query.limit(1000);
       
       const profiles = await query.find();
       
-      console.log(`✅ 获取到 ${profiles.length} 个设计师`);
-      
-      return profiles.map((p: any) => {
+      return profiles.map((p: any, index: number) => {
         const data = p.get('data') || {};
         const tags = data.tags || this.getDefaultTags();
         
+        // 🔧 尝试多种方式获取名字(优先级从高到低)
+        let name = p.get('name'); // 1. 优先使用 Profile.name
+        
+        if (!name) {
+          // 2. 尝试从 user 获取
+          const user = p.get('user');
+          if (user) {
+            name = user.get ? user.get('name') : user.name;
+            if (!name && user.get) {
+              name = user.get('nickname') || user.get('realName');
+            }
+          }
+        }
+        
+        if (!name) {
+          // 3. 使用手机号作为备用显示
+          const mobile = p.get('mobile');
+          if (mobile) {
+            name = `设计师-${mobile.slice(-4)}`; // 显示为 "设计师-1234"
+          }
+        }
+        
+        if (!name) {
+          // 4. 最后使用 ID 后4位
+          name = `设计师-${p.id.slice(-4)}`;
+        }
+        
         return {
           id: p.id,
-          name: p.get('name'),
+          name: name,
           mobile: p.get('mobile') || '',
           department: p.get('department')?.get?.('name') || '未分组',
           departmentId: p.get('department')?.id || '',
+          roleName: p.get('roleName'),
           tags,
           data,
           profile: p
@@ -135,10 +161,9 @@ export class DesignerService {
       
       await profile.save();
       
-      console.log('✅ 设计师tags更新成功:', designerId);
       return true;
     } catch (error) {
-      console.error('更新设计师tags失败:', error);
+      console.error('更新设计师tags失败:', error);
       return false;
     }
   }
@@ -250,7 +275,6 @@ export class DesignerService {
     }
     
     try {
-      console.log('🔍 开始查询项目,company:', this.cid);
       const query = new Parse.Query('Project');
       query.equalTo('company', this.cid);
       query.notEqualTo('isDeleted', true);
@@ -259,12 +283,6 @@ export class DesignerService {
       query.limit(1000);
       
       const projects = await query.find();
-      console.log(`✅ Parse查询成功,找到 ${projects.length} 个项目`);
-      
-      if (projects.length === 0) {
-        console.warn('⚠️ 数据库中没有符合条件的项目数据');
-        console.warn('💡 提示:请确保Project表中有数据,且company字段=', this.cid);
-      }
       
       return projects.map((p: any) => this.transformProject(p));
     } catch (error) {
@@ -475,10 +493,9 @@ export class DesignerService {
       project.set('status', '进行中');
       
       await project.save();
-      console.log('✅ 项目分配成功');
       return true;
     } catch (error) {
-      console.error('项目分配失败:', error);
+      console.error('项目分配失败:', error);
       return false;
     }
   }

+ 6 - 0
src/app/wxwork-auth-guard.ts

@@ -0,0 +1,6 @@
+import { CanActivateFn } from '@angular/router';
+import { checkWeworkLogin } from 'fmode-ng/core';
+
+export const WxworkAuthGuard: CanActivateFn = async (route, state) => {
+  return checkWeworkLogin(route);
+};

+ 71 - 0
src/modules/project/components/contact-selector/contact-selector.component.html

@@ -0,0 +1,71 @@
+<div class="contact-selector" [class.disabled]="disabled">
+  <div class="loading" *ngIf="loading">正在加载客户数据...</div>
+
+  <!-- 已有客户卡片 -->
+  <div class="customer-exists" *ngIf="currentCustomer">
+    <div class="card">
+      <div class="row">
+        <div class="avatar" (click)="viewCustomerDetail()">
+          <img [src]="currentCustomer.get('data')?.avatar || '/assets/images/default-avatar.svg'" alt="" />
+        </div>
+        <div class="info" (click)="viewCustomerDetail()">
+          <div class="name">{{ currentCustomer.get('name') || currentCustomer.get('data')?.external_contact?.name || currentCustomer.get('data')?.name }}</div>
+          <div class="meta">
+            <span class="chip" *ngIf="currentCustomer.get('data')?.external_contact?.type">{{ currentCustomer.get('data')?.type === 1 ? '外部联系人' : '企业成员' }}</span>
+            <span class="chip" *ngIf="canViewSensitiveInfo && currentCustomer.get('mobile')">{{ currentCustomer.get('mobile') }}</span>
+          </div>
+        </div>
+        <div class="actions">
+          <button class="btn outline" (click)="switchToSelecting()">重选</button>
+          <button class="btn" (click)="viewCustomerDetail()">详情</button>
+          <button class="btn" (click)="refreshContactInfo(currentCustomer)">刷新</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 选择客户列表 -->
+  <div class="selecting" *ngIf="!currentCustomer">
+    <div class="toolbar">
+      <input class="search" type="text" [(ngModel)]="searchKeyword" [placeholder]="placeholder" />
+    </div>
+
+    <div class="section">
+      <div class="section-title">已建档的群聊客户</div>
+      <div class="list">
+        <div class="item" *ngFor="let c of filteredCustomers" (click)="selectExistingCustomer(c)">
+          <div class="thumb">
+            <img [src]="c.get('data')?.avatar || '/assets/images/default-avatar.svg'" alt="" />
+          </div>
+          <div class="detail">
+            <div class="title">{{ c.get('name') || c.get('data')?.name }}</div>
+            <div class="sub" *ngIf="canViewSensitiveInfo && c.get('mobile')">{{ c.get('mobile') }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="section" *ngIf="showCreateButton">
+      <div class="section-title">未建档的群聊外部联系人</div>
+      <div class="list">
+        <div class="item" *ngFor="let m of unbuiltExternalMembers">
+          <div class="thumb"><img src="/assets/images/default-avatar.svg" alt="" /></div>
+          <div class="detail">
+            <div class="title">{{ m.name || '外部客户' }}</div>
+            <div class="sub">{{ m.userid }}</div>
+          </div>
+          <div class="ops">
+            <button class="btn primary" (click)="createFromMember(m.userid)">创建并关联</button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- 客户详情侧栏弹层 -->
+  <div class="overlay" *ngIf="showCustomerPanel" (click)="closeCustomerDetail()"></div>
+  <div class="customer-panel" *ngIf="showCustomerPanel">
+    <app-contact [customer]="currentCustomer" [currentUser]="currentUser" [embeddedMode]="true" [projectIdFilter]="project?.id" (close)="closeCustomerDetail()"></app-contact>
+    <button class="close" (click)="closeCustomerDetail()">返回</button>
+  </div>
+</div>

+ 30 - 0
src/modules/project/components/contact-selector/contact-selector.component.scss

@@ -0,0 +1,30 @@
+.contact-selector { padding: 8px 0; }
+.loading { padding: 8px; color: #666; }
+.card { background: #fff; border: 1px solid #eee; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
+.row { display:flex; align-items:center; gap:12px; }
+.avatar { width:48px; height:48px; border-radius:50%; overflow:hidden; background:#f2f2f2; display:flex; align-items:center; justify-content:center; }
+.avatar img { width:100%; height:100%; object-fit:cover; }
+.info { flex:1; min-width:0; }
+.name { font-weight:600; font-size:15px; }
+.meta { margin-top:4px; color:#666; display:flex; gap:6px; flex-wrap:wrap; }
+.chip { background:#f3f6ff; color:#2b4eff; border-radius:10px; padding:2px 8px; font-size:12px; }
+.actions { display:flex; gap:8px; }
+.btn { padding:6px 10px; border:1px solid #ccc; border-radius:6px; cursor:pointer; background:#fff; }
+.btn.primary { background:#2b4eff; color:#fff; border-color:#2b4eff; }
+.btn.outline { background:#fff; }
+.toolbar { margin:8px 0; }
+.search { width:100%; padding:8px; border:1px solid #ddd; border-radius:6px; }
+.section { margin-top:12px; }
+.section-title { font-size:13px; color:#555; margin-bottom:6px; }
+.list { display:flex; flex-direction:column; gap:8px; }
+.item { display:flex; align-items:center; padding:8px; border:1px solid #eee; border-radius:8px; background:#fff; }
+.item:hover { background:#fafafa; }
+.thumb { width:36px; height:36px; border-radius:50%; overflow:hidden; background:#f2f2f2; display:flex; align-items:center; justify-content:center; }
+.thumb img { width:100%; height:100%; object-fit:cover; }
+.detail { flex:1; min-width:0; margin-left:10px; }
+.title { font-size:14px; font-weight:500; }
+.sub { font-size:12px; color:#777; }
+.ops { display:flex; align-items:center; }
+.overlay { position:fixed; inset:0; background:rgba(0,0,0,0.2); }
+.customer-panel { z-index:100;position:fixed; right:20px; top:60px; width:480px; height:80vh; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.1); overflow:auto; padding:8px; }
+.customer-panel .close { position:absolute; right:12px; top:10px; padding:6px 10px; }

+ 195 - 0
src/modules/project/components/contact-selector/contact-selector.component.ts

@@ -0,0 +1,195 @@
+import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { IonicModule } from '@ionic/angular';
+import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
+import { WxworkCorp } from 'fmode-ng/core';
+import { CustomerProfileComponent } from '../../pages/contact/contact.component';
+
+const Parse: any = FmodeParse.with('nova');
+
+@Component({
+  selector: 'app-contact-selector',
+  standalone: true,
+  imports: [CommonModule, FormsModule, IonicModule, CustomerProfileComponent],
+  templateUrl: './contact-selector.component.html',
+  styleUrls: ['./contact-selector.component.scss']
+})
+export class CustomerSelectorComponent implements OnInit {
+  @Input() project: FmodeObject | null = null;
+  @Input() groupChat: FmodeObject | null = null;
+  @Input() currentUser: FmodeObject | null = null;
+  @Input() placeholder: string = '请选择项目客户';
+  @Input() disabled: boolean = false;
+  @Input() showCreateButton: boolean = true;
+  @Output() contactSelected = new EventEmitter<{ contact: FmodeObject; isNewCustomer: boolean; action: 'selected' | 'created' | 'updated' }>();
+
+  loading: boolean = false;
+  searchKeyword: string = '';
+  currentCustomer: FmodeObject | null = null;
+  availableCustomers: FmodeObject[] = [];
+  externalMembers: Array<{ userid: string; name?: string }> = [];
+  unbuiltExternalMembers: Array<{ userid: string; name?: string }> = [];
+  showCustomerPanel: boolean = false;
+
+  get canViewSensitiveInfo(): boolean {
+    const role = this.currentUser?.get?.('roleName') || '';
+    return ['客服', '组长', '管理员'].includes(role);
+  }
+
+  async ngOnInit() {
+    await this.init();
+  }
+
+  private async init() {
+    if (!this.project || !this.groupChat) return;
+    try {
+      this.loading = true;
+      await this.checkProjectCustomer();
+      await this.loadExternalMembers();
+      await this.loadAvailableCustomers();
+      this.computeUnbuiltMembers();
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  private async checkProjectCustomer() {
+    const ptr = this.project!.get('contact');
+    if (!ptr) { this.currentCustomer = null; return; }
+    try {
+      if (ptr.id && (ptr as any).get) {
+        this.currentCustomer = ptr as any;
+      } else if (ptr.id) {
+        const query = new Parse.Query('ContactInfo');
+        this.currentCustomer = await query.get(ptr.id);
+      }
+    } catch {
+      this.currentCustomer = null;
+    }
+  }
+
+  private async loadExternalMembers() {
+    const list = this.groupChat!.get('member_list') || [];
+    const external = Array.isArray(list) ? list.filter((m: any) => m && m.type === 2) : [];
+    this.externalMembers = external.map((m: any) => ({ userid: m.userid, name: m.name }));
+  }
+
+  private async loadAvailableCustomers() {
+    const companyId = this.project!.get('company')?.id || localStorage.getItem('company');
+    if (!companyId) return;
+    const extIds = this.externalMembers.map(m => m.userid);
+    if (extIds.length === 0) { this.availableCustomers = []; return; }
+    const query = new Parse.Query('ContactInfo');
+    query.equalTo('company', companyId);
+    query.containedIn('external_userid', extIds);
+    query.notEqualTo('isDeleted', true);
+    this.availableCustomers = await query.find();
+  }
+
+  private computeUnbuiltMembers() {
+    const builtIds = new Set<string>(
+      this.availableCustomers.map((c: any) => c.get('external_userid')).filter(Boolean)
+    );
+    this.unbuiltExternalMembers = this.externalMembers.filter(m => !builtIds.has(m.userid));
+  }
+
+  private getMemberInfo(userid: string): any {
+    const list = this.groupChat!.get('member_list') || [];
+    return (list || []).find((m: any) => m && m.userid === userid) || null;
+  }
+
+  get filteredCustomers(): FmodeObject[] {
+    const kw = (this.searchKeyword || '').trim().toLowerCase();
+    const base = this.availableCustomers;
+    if (!kw) return base;
+    return base.filter(c => {
+      const name = (c.get('name') || c.get('data')?.name || '').toLowerCase();
+      const mobile = (c.get('mobile') || '').toLowerCase();
+      return name.includes(kw) || mobile.includes(kw);
+    });
+  }
+
+  async selectExistingCustomer(contact: FmodeObject) {
+    if (this.disabled || !this.project) return;
+    const nameMissing = !contact.get('name') && !contact.get('data')?.name && !contact.get('data')?.external_contact?.name;
+    const extid = contact.get('external_userid');
+    if (nameMissing && extid) {
+      await this.refreshContactInfo(contact);
+    }
+    this.project.set('contact', contact.toPointer());
+    await this.project.save();
+    this.currentCustomer = contact;
+    this.contactSelected.emit({ contact, isNewCustomer: false, action: 'selected' });
+  }
+
+  switchToSelecting() {
+    this.currentCustomer = null;
+    this.searchKeyword = '';
+  }
+
+  async createFromMember(memberUserid: string) {
+    if (this.disabled || !this.project) return;
+    const companyId = this.project.get('company')?.id || localStorage.getItem('company');
+    if (!companyId) throw new Error('无法获取企业信息');
+    const query = new Parse.Query('ContactInfo');
+    query.equalTo('external_userid', memberUserid);
+    query.equalTo('company', companyId);
+    let contactInfo = await query.first();
+    if (!contactInfo) {
+      const corp = new WxworkCorp(companyId);
+      const extData = await corp.externalContact.get(memberUserid);
+      const ext = (extData && extData.external_contact) ? extData.external_contact : {};
+      const follow = (extData && extData.follow_user) ? extData.follow_user : [];
+      const ContactInfo = Parse.Object.extend('ContactInfo');
+      contactInfo = new ContactInfo();
+      contactInfo.set('name', ext.name || this.getMemberInfo(memberUserid)?.name || '客户');
+      contactInfo.set('external_userid', memberUserid);
+      const company = new Parse.Object('Company');
+      company.id = companyId;
+      contactInfo.set('company', company.toPointer());
+      const mapped = {
+        external_contact: ext,
+        follow_user: follow,
+        member: this.getMemberInfo(memberUserid),
+        name: ext.name || this.getMemberInfo(memberUserid)?.name,
+        avatar: ext.avatar,
+        gender: ext.gender,
+        type: ext.type
+      } as any;
+      contactInfo.set('data', mapped);
+      contactInfo = await contactInfo.save();
+      await this.loadAvailableCustomers();
+      this.computeUnbuiltMembers();
+    }
+    this.project.set('contact', contactInfo.toPointer());
+    await this.project.save();
+    this.currentCustomer = contactInfo;
+    this.contactSelected.emit({ contact: contactInfo, isNewCustomer: true, action: 'created' });
+  }
+
+  async refreshContactInfo(contact: any) {
+    const externalUserId = contact.get('external_userid');
+    const companyId = this.project?.get('company')?.id || localStorage.getItem('company');
+    if (!externalUserId || !companyId) return;
+    const corp = new WxworkCorp(companyId);
+    const extData = await corp.externalContact.get(externalUserId);
+    const ext = (extData && extData.external_contact) ? extData.external_contact : {};
+    const follow = (extData && extData.follow_user) ? extData.follow_user : [];
+    if (ext.name) contact.set('name', ext.name);
+    const mapped = {
+      external_contact: ext,
+      follow_user: follow,
+      member: this.getMemberInfo(externalUserId),
+      name: ext.name || this.getMemberInfo(externalUserId)?.name,
+      avatar: ext.avatar,
+      gender: ext.gender,
+      type: ext.type
+    } as any;
+    contact.set('data', mapped);
+    await contact.save();
+  }
+
+  viewCustomerDetail() { this.showCustomerPanel = true; }
+  closeCustomerDetail() { this.showCustomerPanel = false; }
+}

+ 3 - 2
src/modules/project/components/project-bottom-card/project-bottom-card.component.scss

@@ -210,8 +210,8 @@
     padding: 10px 12px;
 
     .card-content {
-      flex-direction: column;
-      gap: 12px;
+      flex-direction: row;
+      gap: 0px;
       align-items: stretch;
     }
 
@@ -220,6 +220,7 @@
 
       .project-title {
         font-size: 16px;
+        display: none;
       }
 
       .project-meta {

+ 43 - 38
src/modules/project/components/project-files-modal/project-files-modal.component.html

@@ -18,31 +18,20 @@
           </svg>
           项目文件
         </h2>
-        <div class="file-stats">
-          <span class="stat-item">
-            <span class="stat-number">{{ totalFiles }}</span>
-            <span class="stat-label">文件</span>
-          </span>
-          <span class="stat-item">
-            <span class="stat-number">{{ formatFileSize(totalSize) }}</span>
-            <span class="stat-label">总大小</span>
-          </span>
-          @if (imageCount > 0) {
-            <span class="stat-item">
-              <span class="stat-number">{{ imageCount }}</span>
-              <span class="stat-label">图片</span>
-            </span>
-          }
-          @if (documentCount > 0) {
-            <span class="stat-item">
-              <span class="stat-number">{{ documentCount }}</span>
-              <span class="stat-label">文档</span>
-            </span>
-          }
-        </div>
-      </div>
 
-      <div class="header-right">
+         <!-- 搜索框 -->
+        <div class="search-box">
+            <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <circle cx="11" cy="11" r="8"></circle>
+              <path d="m21 21-4.35-4.35"></path>
+            </svg>
+            <input
+              type="text"
+              class="search-input"
+              placeholder="搜索文件..."
+              [(ngModel)]="searchQuery">
+        </div>
+        
         <!-- 视图切换 -->
         <div class="view-toggle">
           <button
@@ -73,27 +62,40 @@
           </button>
         </div>
 
-        <!-- 搜索框 -->
-        <div class="search-box">
-          <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
-            <circle cx="11" cy="11" r="8"></circle>
-            <path d="m21 21-4.35-4.35"></path>
-          </svg>
-          <input
-            type="text"
-            class="search-input"
-            placeholder="搜索文件..."
-            [(ngModel)]="searchQuery">
-        </div>
+       
+      </div>
 
-        <!-- 过滤器 -->
+      <div class="header-right">
+        <div class="file-stats">
+          <span class="stat-item">
+            <span class="stat-number">{{ totalFiles }}</span>
+            <span class="stat-label">文件</span>
+          </span>
+          <span class="stat-item">
+            <span class="stat-number">{{ formatFileSize(totalSize) }}</span>
+            <span class="stat-label">总大小</span>
+          </span>
+          @if (imageCount > 0) {
+            <span class="stat-item">
+              <span class="stat-number">{{ imageCount }}</span>
+              <span class="stat-label">图片</span>
+            </span>
+          }
+          @if (documentCount > 0) {
+            <span class="stat-item">
+              <span class="stat-number">{{ documentCount }}</span>
+              <span class="stat-label">文档</span>
+            </span>
+          }
+         
+        </div>
+               <!-- 过滤器 -->
         <select class="filter-select" [(ngModel)]="filterType">
           <option value="all">全部文件</option>
           <option value="images">图片</option>
           <option value="documents">文档</option>
           <option value="videos">视频</option>
         </select>
-
         <!-- 关闭按钮 -->
         <button class="close-btn" (click)="onClose()">
           <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -102,6 +104,9 @@
           </svg>
         </button>
       </div>
+
+ 
+
     </div>
 
     <!-- 模态框内容 -->

+ 74 - 99
src/modules/project/components/project-files-modal/project-files-modal.component.scss

@@ -8,7 +8,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 99;
+  z-index: 1000;
   padding: 20px;
 }
 
@@ -34,8 +34,10 @@
   flex-shrink: 0;
 
   .header-left {
-    flex: 1;
+    display:flex;
+    align-items: center;
     min-width: 0;
+    flex-direction: row;
 
     .modal-title {
       font-size: 24px;
@@ -53,44 +55,12 @@
       }
     }
 
-    .file-stats {
-      display: flex;
-      gap: 16px;
-      flex-wrap: wrap;
-
-      .stat-item {
-        display: flex;
-        flex-direction: column;
-        align-items: center;
-        min-width: 50px;
-
-        .stat-number {
-          font-size: 18px;
-          font-weight: 600;
-          color: #1f2937;
-          line-height: 1;
-        }
-
-        .stat-label {
-          font-size: 12px;
-          color: #6b7280;
-          margin-top: 2px;
-        }
-      }
-    }
-  }
-
-  .header-right {
-    display: flex;
-    align-items: center;
-    gap: 12px;
-    flex-shrink: 0;
-
+ 
+    
     .view-toggle {
       display: flex;
       border: 1px solid #e5e7eb;
       border-radius: 6px;
-      overflow: hidden;
 
       .toggle-btn {
         background: white;
@@ -165,10 +135,44 @@
         border-color: #3b82f6;
       }
     }
+  }
+
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-shrink: 0;
+   .file-stats {
+      display: flex;
+      justify-content: between;
+      align-items: center;
+
+      .stat-item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        min-width: 50px;
+
+        .stat-number {
+          font-size: 18px;
+          font-weight: 600;
+          color: #1f2937;
+          line-height: 1;
+        }
+
+        .stat-label {
+          font-size: 12px;
+          color: #6b7280;
+          margin-top: 2px;
+        }
+      }
+    }
 
     .close-btn {
       background: none;
       border: none;
+      width:32px;
+      height:32px;
       padding: 8px;
       border-radius: 6px;
       cursor: pointer;
@@ -568,7 +572,7 @@
   display: flex;
   align-items: center;
   justify-content: center;
-  z-index: 100;
+  z-index: 1001;
   padding: 20px;
 }
 
@@ -745,92 +749,63 @@
 }
 
 // 响应式设计
+// 新增:移动端全屏与紧凔标题栏覆盖(优先级高,置于文件末尾以覆盖前面的规则)
 @media (max-width: 768px) {
   .modal-overlay {
     padding: 0;
+    align-items: stretch;
+    justify-content: stretch;
   }
 
   .modal-container {
+    width: 100vw;
+    height: 100vh;
     max-height: 100vh;
     border-radius: 0;
   }
 
   .modal-header {
-    padding: 16px;
-    flex-direction: column;
-    gap: 16px;
-
-    .header-left {
-      .modal-title {
-        font-size: 20px;
-      }
-
-      .file-stats {
-        justify-content: center;
-        gap: 12px;
-      }
-    }
-
-    .header-right {
-      width: 100%;
-      flex-direction: column;
-      gap: 12px;
-
-      .search-box .search-input {
-        width: 100%;
-      }
-    }
+    padding: 0px;
+    flex-direction: column; /* 紧凑布局,保持同一行或两行 */
+    align-items: center;
+    gap: 8px;
   }
 
-  .modal-content {
-    padding: 16px;
+  .modal-header .header-left {
+    display:flex;
+    justify-content: space-between;
+    min-width: 0;
   }
 
-  .files-grid {
-    grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
-    gap: 16px;
+  .modal-header .header-left .modal-title {
+    margin: 0;
+    font-size: 18px;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
 
-  .file-card {
-    .file-info {
-      padding: 12px;
-    }
-
-    .file-footer {
-      flex-direction: column;
-      gap: 8px;
-      align-items: stretch;
-
-      .uploader-info {
-        justify-content: center;
-      }
-
-      .file-actions {
-        justify-content: center;
-      }
-    }
+  .modal-header .header-left .file-stats {
+    min-width:175px;
+    gap: 8px;
   }
 
-  .files-list {
-    .file-list-item {
-      padding: 12px;
-      gap: 12px;
+  .modal-header .header-right {
+    flex-direction: row;
+    width: 90%;
+    display: flex;
+    align-items: center;
+  }
 
-      .list-file-icon {
-        width: 40px;
-        height: 40px;
-      }
-    }
+  .modal-header .header-left .search-box .search-input {
+    width: 140px;
   }
 
-  .preview-overlay {
-    padding: 0;
+  .modal-header .header-left .filter-select {
+    width: auto;
   }
 
-  .preview-container {
-    max-width: 100vw;
-    max-height: 100vh;
-    border-radius: 0;
+  .modal-content {
+    padding: 12px;
   }
 }
 

+ 52 - 0
src/modules/project/components/project-issues-modal/project-issues-modal.component.scss

@@ -229,4 +229,56 @@
     padding: 12px;
     color: #6b7280;
   }
+}
+
+// 新增:移动端全屏与紧凑标题栏覆盖(保证手机端弹窗全屏且工具区紧凑)
+@media (max-width: 768px) {
+  .issues-modal {
+    .overlay {
+      /* 保持遮罩层覆盖全屏 */
+    }
+    .modal {
+      left: 0;
+      top: 0;
+      transform: none;
+      width: 100vw;
+      height: 100vh;
+      max-height: 100vh;
+      border-radius: 0;
+    }
+
+    .header {
+      padding: 10px 12px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      flex-wrap: wrap; /* 最多两行 */
+    }
+
+    .header .title-area {
+      flex: 1 1 auto;
+      min-width: 0;
+      gap: 8px;
+    }
+
+    .header .title-area h3 {
+      font-size: 16px;
+      margin: 0;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .tools {
+      padding: 8px 12px;
+      gap: 8px;
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+    }
+
+    .tools .search-input {
+      flex: 0 0 140px;
+    }
+  }
 }

+ 5 - 2
src/modules/project/components/project-members-modal/project-members-modal.component.html

@@ -34,9 +34,7 @@
             </span>
           }
         </div>
-      </div>
 
-      <div class="header-right">
         <!-- 环境指示器 -->
         <div class="environment-indicator">
           @if (isWxworkEnvironment) {
@@ -55,6 +53,10 @@
             </span>
           }
         </div>
+      </div>
+
+      <div class="header-right">
+        
 
         <!-- 搜索框 -->
         <div class="search-box">
@@ -180,6 +182,7 @@
                   </button>
                 }
 
+
                 <div class="status-indicator" [class]="getMemberStatusClass(member)">
                   @if (member.isInProjectTeam && member.isInGroupChat) {
                     <svg viewBox="0 0 24 24" fill="currentColor">

+ 56 - 87
src/modules/project/components/project-members-modal/project-members-modal.component.scss

@@ -34,8 +34,10 @@
   flex-shrink: 0;
 
   .header-left {
-    flex: 1;
-    min-width: 0;
+    display:flex;
+    flex-direction: row;
+    align-items: center;
+    flex-wrap:none;
 
     .modal-title {
       font-size: 24px;
@@ -90,12 +92,6 @@
     }
   }
 
-  .header-right {
-    display: flex;
-    align-items: center;
-    gap: 12px;
-    flex-shrink: 0;
-
     .environment-indicator {
       .env-badge {
         display: flex;
@@ -118,6 +114,13 @@
       }
     }
 
+  .header-right {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex-shrink: 0;
+
+ 
     .search-box {
       position: relative;
       display: flex;
@@ -526,111 +529,77 @@
 }
 
 // 响应式设计
+// 新增:移动端全屏与紧凑标题栏覆盖
 @media (max-width: 768px) {
   .modal-overlay {
     padding: 0;
+    align-items: stretch;
+    justify-content: stretch;
   }
 
+
   .modal-container {
+    width: 100vw;
+    height: 100vh;
     max-height: 100vh;
     border-radius: 0;
   }
 
   .modal-header {
-    padding: 16px;
-    flex-direction: column;
-    gap: 16px;
+    padding: 12px;
+    flex-direction: row;
+    align-items: center;
+    gap: 8px;
+    flex-wrap: wrap; /* 最多两行 */
+  }
 
-    .header-left {
-      .modal-title {
-        font-size: 20px;
-      }
+  .modal-header .header-left {
+    min-width: 0;
+    justify-content: space-between;
+    width: 100%;
+  }
 
-      .member-stats {
-        justify-content: center;
-        gap: 12px;
-      }
-    }
+  .modal-header .header-left .modal-title {
+    margin: 0;
+    font-size: 18px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
 
-    .header-right {
-      width: 100%;
-      flex-direction: column;
-      gap: 12px;
+  .modal-header .header-left .member-stats {
+    gap: 8px;
+  }
+  .environment-indicator{
+    display:none;
+  }
 
-      .search-box .search-input {
-        width: 100%;
-      }
+  .modal-header .header-right {
+    flex: 1 1 auto;
+    width: auto;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
 
-      .filter-select {
-        width: 100%;
-      }
-    }
+  .modal-header .header-right .search-box .search-input {
+    width: 140px;
   }
 
-  .modal-content {
-    padding: 16px;
+  .modal-header .header-right .filter-select {
+    width: auto;
   }
 
-  .member-card {
+  .modal-content {
     padding: 12px;
-    gap: 12px;
-
-    .member-avatar {
-      width: 40px;
-      height: 40px;
-    }
-
-    .member-info {
-      .member-name {
-        font-size: 14px;
-      }
-
-      .member-meta {
-        gap: 6px;
-        flex-wrap: wrap;
-
-        .role-badge {
-          font-size: 11px;
-          padding: 2px 6px;
-        }
-
-        .member-department {
-          font-size: 11px;
-        }
-
-        .member-status {
-          font-size: 10px;
-          padding: 2px 6px;
-        }
-      }
-
-      .add-to-group-hint {
-        font-size: 11px;
-        gap: 4px;
-      }
-    }
-
-    .member-actions {
-      flex-direction: row;
-      gap: 8px;
-
-      .action-btn {
-        padding: 6px 10px;
-        font-size: 11px;
-
-        &.add-btn {
-          span {
-            display: none;
-          }
-        }
-      }
-    }
   }
 }
 
 @media (max-width: 480px) {
   .member-card {
-    flex-direction: column;
+    flex-direction: row;
     align-items: stretch;
     gap: 12px;
 

+ 32 - 0
src/modules/project/components/project-members-modal/project-members-modal.component.ts

@@ -315,6 +315,38 @@ export class ProjectMembersModalComponent implements OnInit {
     }
   }
 
+  // 新增:移出项目(软删除 ProjectTeam)
+  async removeMemberFromProject(member: ProjectMember): Promise<void> {
+    if (!member.isInProjectTeam || !member.projectTeamId) {
+      alert('该成员未在项目团队中,无法移出项目');
+      return;
+    }
+    if (!this.project) {
+      alert('项目不存在,无法执行移出操作');
+      return;
+    }
+
+    const confirmMsg = `确定将 \"${member.name}\" 移出项目吗?`;
+    if (!confirm(confirmMsg)) return;
+
+    try {
+      this.loading = true;
+      const query = new Parse.Query('ProjectTeam');
+      const team = await query.get(member.projectTeamId);
+      team.set('isDeleted', true);
+      await team.save();
+
+      // 重新加载成员数据,保持列表与统计一致
+      await this.loadMembers();
+      alert(`✅ 已将 ${member.name} 移出项目`);
+    } catch (error) {
+      console.error('❌ 移出项目失败:', error);
+      alert(`移出失败: ${error instanceof Error ? error.message : '未知错误'}`);
+    } finally {
+      this.loading = false;
+    }
+  }
+
   getRoleBadgeClass(role: string): string {
     switch (role) {
       case '客服':

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

@@ -341,6 +341,80 @@
         </div>
       }
 
+      <!-- 内部执行分配 -->
+      @if (quotation.allocation) {
+        <div class="allocation-section">
+          <div class="allocation-header">
+            <h4>内部执行分配</h4>
+            <button class="btn-text" (click)="toggleAllocation()">
+              {{ showAllocation ? '隐藏' : '显示' }}
+            </button>
+          </div>
+
+          @if (showAllocation) {
+            <div class="allocation-list">
+              <!-- 建模阶段 -->
+              <div class="allocation-item modeling">
+                <div class="allocation-icon">
+                  <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                    <path fill="currentColor" d="M234.5 5.709C248.4 .7377 263.6 .7377 277.5 5.709L469.5 74.28C494.1 83.38 512 107.5 512 134.6C512 161.7 494.1 185.8 469.5 194.9L277.5 263.5C263.6 268.5 248.4 268.5 234.5 263.5L42.47 194.9C17.05 185.8 0 161.7 0 134.6C0 107.5 17.05 83.38 42.47 74.28L234.5 5.709zM256 65.98L82.34 128L256 190L429.7 128L256 65.98zM288 434.6L469.5 346.9C494.1 337.8 512 313.7 512 286.6V214.3C512 194.1 492.9 180.1 473.6 185.8C462.4 189.2 454.8 200.3 454.8 212.8L454.8 286.6C454.8 295.9 449.1 304.3 440.8 308.3L288 375.4V434.6zM170.4 308.3C162.1 304.3 156.3 295.9 156.3 286.6V212.8C156.3 200.3 148.6 189.2 137.4 185.8C118.1 180.1 99.13 194.1 99.13 214.3V286.6C99.13 313.7 116.2 337.8 141.6 346.9L223.9 375.4V434.6C223.9 454.9 242.1 469.8 261.4 464.1C272.6 460.7 280.2 449.6 280.2 437.1V375.4L170.4 308.3z"/>
+                  </svg>
+                </div>
+                <div class="allocation-info">
+                  <span class="allocation-name">{{ allocationRules.modeling.label }}</span>
+                  <span class="allocation-desc">{{ allocationRules.modeling.description }}</span>
+                </div>
+                <div class="allocation-values">
+                  <span class="allocation-percentage">{{ quotation.allocation.modeling.percentage }}%</span>
+                  <span class="allocation-amount">{{ formatPrice(quotation.allocation.modeling.amount) }}</span>
+                </div>
+              </div>
+
+              <!-- 软装渲染 -->
+              <div class="allocation-item decoration">
+                <div class="allocation-icon">
+                  <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                    <path fill="currentColor" d="M384 160C366.3 160 352 145.7 352 128C352 110.3 366.3 96 384 96C401.7 96 416 110.3 416 128C416 145.7 401.7 160 384 160zM384 64C348.7 64 320 92.65 320 128C320 163.3 348.7 192 384 192C419.3 192 448 163.3 448 128C448 92.65 419.3 64 384 64zM456 288H384V216C384 207.2 376.8 200 368 200H144C135.2 200 128 207.2 128 216V288H56C42.75 288 32 298.8 32 312V472C32 485.3 42.75 496 56 496H144H368H456C469.3 496 480 485.3 480 472V312C480 298.8 469.3 288 456 288zM144 464H64V320H144V464zM336 464H176V232H336V464zM448 464H368V320H448V464z"/>
+                  </svg>
+                </div>
+                <div class="allocation-info">
+                  <span class="allocation-name">{{ allocationRules.decoration.label }}</span>
+                  <span class="allocation-desc">{{ allocationRules.decoration.description }}</span>
+                </div>
+                <div class="allocation-values">
+                  <span class="allocation-percentage">{{ quotation.allocation.decoration.percentage }}%</span>
+                  <span class="allocation-amount">{{ formatPrice(quotation.allocation.decoration.amount) }}</span>
+                </div>
+              </div>
+
+              <!-- 公司分配 -->
+              <div class="allocation-item company">
+                <div class="allocation-icon">
+                  <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                    <path fill="currentColor" d="M320 32c-8.1 0-16.1 1.4-23.7 4.1L15.8 137.4C6.3 140.9 0 149.9 0 160s6.3 19.1 15.8 22.6l57.9 20.9C57.3 229.3 48 259.8 48 291.9v28.1c0 28.4-10.8 57.7-22.3 80.8c-6.5 13-13.9 25.8-22.5 37.6C0 442.7-.9 448.3 .9 453.4s6 8.9 11.2 10.2l64 16c4.2 1.1 8.7 .3 12.4-2s6.3-6.1 7.1-10.4c8.6-42.8 4.3-81.2-2.1-108.7C90.3 344.3 86 329.8 80 316.5V295.6c0-4.2 .3-8.4 1-12.4L288.1 406.6c8.4 3.2 17.4 4.8 26.6 4.8H448c17.7 0 32-14.3 32-32V256c0-17.7-14.3-32-32-32H448c-17.7 0-32-14.3-32-32s14.3-32 32-32h32c9.7 0 18.5-5.8 22.2-14.8s1.7-19.3-5.2-26.2l-64-64c-12.5-12.5-32.8-12.5-45.3 0l-64 64c-6.9 6.9-8.9 17.2-5.2 26.2s12.5 14.8 22.2 14.8h32c17.7 0 32 14.3 32 32s-14.3 32-32 32H320c-8.8 0-16 7.2-16 16v64c0 8.8 7.2 16 16 16h128v64H320z"/>
+                  </svg>
+                </div>
+                <div class="allocation-info">
+                  <span class="allocation-name">{{ allocationRules.company.label }}</span>
+                  <span class="allocation-desc">{{ allocationRules.company.description }}</span>
+                </div>
+                <div class="allocation-values">
+                  <span class="allocation-percentage">{{ quotation.allocation.company.percentage }}%</span>
+                  <span class="allocation-amount">{{ formatPrice(quotation.allocation.company.amount) }}</span>
+                </div>
+              </div>
+            </div>
+
+            <div class="allocation-note">
+              <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M256 512c141.4 0 256-114.6 256-256S397.4 0 256 0S0 114.6 0 256S114.6 512 256 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208c-17.7 0-32 14.3-32 32s14.3 32 32 32s32-14.3 32-32s-14.3-32-32-32z"/>
+              </svg>
+              <span>内部执行分配为系统自动计算,基于报价总额按固定比例拆分</span>
+            </div>
+          }
+        </div>
+      }
+
       <div class="total-section">
         <div class="total-row">
           <span class="total-label">报价总额</span>

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

@@ -783,6 +783,180 @@
       }
     }
 
+    // 内部执行分配区域
+    .allocation-section {
+      margin-top: 20px;
+      padding-top: 20px;
+      border-top: 1px solid var(--ion-color-light-shade);
+
+      .allocation-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 16px;
+
+        h4 {
+          margin: 0;
+          font-size: 16px;
+          font-weight: 600;
+          color: var(--ion-color-dark);
+        }
+
+        .btn-text {
+          background: none;
+          border: none;
+          color: var(--ion-color-primary);
+          font-size: 14px;
+          cursor: pointer;
+          padding: 4px 8px;
+          border-radius: 4px;
+          transition: background-color 0.2s;
+
+          &:hover {
+            background-color: var(--ion-color-primary-tint);
+          }
+        }
+      }
+
+      .allocation-list {
+        display: flex;
+        flex-direction: column;
+        gap: 12px;
+        margin-bottom: 16px;
+
+        .allocation-item {
+          display: flex;
+          align-items: center;
+          gap: 12px;
+          padding: 16px;
+          border-radius: 10px;
+          border-left: 4px solid;
+          background: var(--ion-color-light-tint);
+          transition: all 0.2s ease;
+
+          &:hover {
+            transform: translateY(-2px);
+            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
+          }
+
+          &.modeling {
+            border-left-color: #8B5CF6; // 紫色 - 建模
+            background: rgba(139, 92, 246, 0.05);
+
+            .allocation-icon {
+              background: rgba(139, 92, 246, 0.1);
+              color: #8B5CF6;
+            }
+          }
+
+          &.decoration {
+            border-left-color: #F59E0B; // 橙色 - 软装渲染
+            background: rgba(245, 158, 11, 0.05);
+
+            .allocation-icon {
+              background: rgba(245, 158, 11, 0.1);
+              color: #F59E0B;
+            }
+          }
+
+          &.company {
+            border-left-color: #10B981; // 绿色 - 公司分配
+            background: rgba(16, 185, 129, 0.05);
+
+            .allocation-icon {
+              background: rgba(16, 185, 129, 0.1);
+              color: #10B981;
+            }
+          }
+
+          .allocation-icon {
+            width: 48px;
+            height: 48px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 10px;
+            flex-shrink: 0;
+
+            .icon {
+              width: 24px;
+              height: 24px;
+            }
+          }
+
+          .allocation-info {
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            gap: 4px;
+
+            .allocation-name {
+              font-size: 15px;
+              font-weight: 600;
+              color: var(--ion-color-dark);
+              line-height: 1.2;
+            }
+
+            .allocation-desc {
+              font-size: 12px;
+              color: var(--ion-color-medium);
+              line-height: 1.3;
+            }
+          }
+
+          .allocation-values {
+            display: flex;
+            flex-direction: column;
+            align-items: flex-end;
+            gap: 2px;
+
+            .allocation-percentage {
+              font-size: 13px;
+              font-weight: 500;
+              color: var(--ion-color-medium-shade);
+              background: rgba(0, 0, 0, 0.04);
+              padding: 2px 8px;
+              border-radius: 12px;
+              min-width: 45px;
+              text-align: center;
+            }
+
+            .allocation-amount {
+              font-size: 20px;
+              font-weight: 700;
+              color: var(--ion-color-dark);
+              line-height: 1.2;
+            }
+          }
+        }
+      }
+
+      .allocation-note {
+        display: flex;
+        align-items: flex-start;
+        gap: 10px;
+        padding: 12px 16px;
+        background: rgba(56, 128, 255, 0.08);
+        border-radius: 8px;
+        border-left: 3px solid var(--ion-color-primary);
+
+        .icon {
+          width: 18px;
+          height: 18px;
+          color: var(--ion-color-primary);
+          flex-shrink: 0;
+          margin-top: 2px;
+        }
+
+        span {
+          flex: 1;
+          font-size: 13px;
+          color: var(--ion-color-medium-shade);
+          line-height: 1.5;
+        }
+      }
+    }
+
     .breakdown-list {
       margin-bottom: 16px;
 
@@ -877,4 +1051,74 @@
   .space-content {
     @extend .product-content;
   }
+
+  // 移动端适配
+  @media (max-width: 768px) {
+    .allocation-section {
+      .allocation-list {
+        .allocation-item {
+          flex-wrap: wrap;
+          padding: 14px;
+
+          .allocation-icon {
+            width: 40px;
+            height: 40px;
+
+            .icon {
+              width: 20px;
+              height: 20px;
+            }
+          }
+
+          .allocation-info {
+            flex: 1;
+            min-width: 0;
+
+            .allocation-name {
+              font-size: 14px;
+            }
+
+            .allocation-desc {
+              font-size: 11px;
+            }
+          }
+
+          .allocation-values {
+            width: 100%;
+            flex-direction: row;
+            justify-content: space-between;
+            align-items: center;
+            margin-top: 8px;
+            padding-top: 8px;
+            border-top: 1px solid rgba(0, 0, 0, 0.06);
+
+            .allocation-percentage {
+              font-size: 12px;
+            }
+
+            .allocation-amount {
+              font-size: 18px;
+            }
+          }
+        }
+      }
+
+      .allocation-note {
+        padding: 10px 12px;
+
+        .icon {
+          width: 16px;
+          height: 16px;
+        }
+
+        span {
+          font-size: 12px;
+        }
+      }
+    }
+
+    .product-content .process-grid {
+      grid-template-columns: 1fr;
+    }
+  }
 }

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

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { FmodeParse } from 'fmode-ng/parse';
 import { Subscription } from 'rxjs';
+import { calculateAllocation, calculateProductAllocation, ALLOCATION_RULES, type Allocation } from '../config/quotation-rules';
 
 const Parse = FmodeParse.with('nova');
 
@@ -55,6 +56,7 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
     spaces: [], // 兼容旧格式,现在基于products
     total: 0,
     spaceBreakdown: [], // 产品占比明细
+    allocation: null as Allocation | null, // 内部执行分配
     generatedAt: null,
     validUntil: null
   };
@@ -72,6 +74,10 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
 
   // UI状态
   showBreakdown: boolean = false;
+  showAllocation: boolean = false; // 显示/隐藏内部执行分配
+
+  // 分配规则配置
+  allocationRules = ALLOCATION_RULES;
 
   // 报价配置
   priceTable: any = {};
@@ -502,10 +508,32 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
       }
     }
     this.quotation.total = total;
+
+    // 自动计算内部执行分配
+    this.quotation.allocation = calculateAllocation(total);
+
+    // 更新产品级别的分配
+    this.updateProductsAllocation();
+
     this.quotationChange.emit(this.quotation);
     this.totalChange.emit(total);
   }
 
+  /**
+   * 更新所有产品的内部分配
+   */
+  private updateProductsAllocation() {
+    for (const space of this.quotation.spaces) {
+      const product = this.products.find(p => p.id === space.productId);
+      if (product) {
+        const productPrice = this.calculateSpaceSubtotal(space);
+        const quotation = product.get('quotation') || {};
+        quotation.allocation = calculateProductAllocation(productPrice);
+        product.set('quotation', quotation);
+      }
+    }
+  }
+
   /**
    * 计算空间小计(保持兼容性)
    */
@@ -878,6 +906,13 @@ export class QuotationEditorComponent implements OnInit, OnChanges, OnDestroy {
     return breakdown?.percentage || 0;
   }
 
+  /**
+   * 切换内部执行分配显示
+   */
+  toggleAllocation() {
+    this.showAllocation = !this.showAllocation;
+  }
+
   /**
    * 从传入的项目对象初始化数据
    */

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

@@ -167,6 +167,11 @@
         <button class="btn btn-outline" (click)="cancelAssignDialog()">
           取消
         </button>
+        @if (editingTeam) {
+          <button class="btn btn-danger" (click)="confirmDeleteMember()" [disabled]="saving">
+            删除成员
+          </button>
+        }
         <button
           class="btn btn-primary"
           (click)="confirmAssignDesigner()"

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

@@ -528,6 +528,11 @@
       color: white;
     }
 
+    &.btn-danger {
+      background: #e53e3e;
+      color: white;
+    }
+
     &:disabled { opacity: 0.5; cursor: not-allowed; }
   }
 }

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

@@ -130,6 +130,23 @@ export class TeamAssignComponent implements OnInit {
     this.selectedDesigner = null;
     this.departmentMembers = [];
 
+    // ✅ 自动设置组长为项目负责人
+    const leader = department.get('leader');
+    if (leader && this.project) {
+      try {
+        // 更新项目的assignee字段为组长
+        this.project.set('assignee', leader);
+        this.project.set('department', department);
+        await this.project.save();
+        console.log('✅ 项目负责人已设置为组长:', leader.get('name'));
+        
+        // 触发界面更新
+        this.cdr.markForCheck();
+      } catch (error) {
+        console.error('❌ 设置项目负责人失败:', error);
+      }
+    }
+
     await this.loadDepartmentMembers(department);
   }
 
@@ -290,6 +307,68 @@ export class TeamAssignComponent implements OnInit {
     }
   }
 
+  async confirmDeleteMember() {
+    if (!this.editingTeam) return;
+
+    const ok = window.confirm('确定要删除该成员的项目分配吗?');
+    if (!ok) return;
+
+    try {
+      this.saving = true;
+
+      // 软删除 ProjectTeam
+      this.editingTeam.set('isDeleted', true);
+      const data = this.editingTeam.get('data') || {};
+      data.deletedAt = new Date();
+      data.deletedBy = this.currentUser?.id;
+      this.editingTeam.set('data', data);
+      await this.editingTeam.save();
+
+      // 从群聊移除(静默尝试)
+      const profile = this.editingTeam.get('profile');
+      const userId = profile?.get?.('userId');
+      if (userId) {
+        await this.removeMemberFromGroupChat(userId);
+      }
+
+      alert('成员已删除');
+
+      // 刷新列表并收起弹窗
+      await this.loadProjectTeams();
+      this.showAssignDialog = false;
+      this.assigningDesigner = null;
+      this.selectedSpaces = [];
+      this.editingTeam = null;
+    } catch (err) {
+      console.error('删除成员失败:', err);
+      alert('删除失败');
+    } finally {
+      this.saving = false;
+      this.cdr.markForCheck();
+    }
+  }
+
+  async removeMemberFromGroupChat(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,
+          userIdsToRemove: [userId]
+        });
+      }
+    } catch (err) {
+      console.warn('移除群成员失败或不支持:', err);
+    }
+  }
+
   getMemberSpaces(team: FmodeObject): string {
     const spaces = team.get('data')?.spaces || [];
     return spaces.join('、') || '未分配';

+ 103 - 0
src/modules/project/config/quotation-rules.ts

@@ -423,3 +423,106 @@ export function getDefaultProcesses(projectType: '家装' | '工装', finalPrice
     }
   };
 }
+
+/**
+ * 内部执行分配规则配置
+ *
+ * 报价细项优化: 将三级报价总价自动拆分为内部执行三个阶段
+ * - 建模阶段: 10%
+ * - 软装渲染: 40%
+ * - 公司分配: 50%
+ */
+export const ALLOCATION_RULES = {
+  modeling: {
+    percentage: 0.10,
+    label: '建模阶段',
+    description: '3D模型构建',
+    color: '#8B5CF6', // 紫色
+    icon: 'cube-outline'
+  },
+  decoration: {
+    percentage: 0.40,
+    label: '软装渲染',
+    description: '软装搭配+效果图渲染',
+    color: '#F59E0B', // 橙色
+    icon: 'color-palette-outline'
+  },
+  company: {
+    percentage: 0.50,
+    label: '公司分配',
+    description: '公司运营与利润',
+    color: '#10B981', // 绿色
+    icon: 'business-outline'
+  }
+} as const;
+
+/**
+ * 内部执行分配接口定义
+ */
+export interface AllocationItem {
+  amount: number;
+  percentage: number;
+  description: string;
+  color: string;
+}
+
+export interface Allocation {
+  modeling: AllocationItem;
+  decoration: AllocationItem;
+  company: AllocationItem;
+  updatedAt: Date;
+}
+
+/**
+ * 计算内部执行分配
+ *
+ * @param totalPrice 报价总价
+ * @returns 内部执行分配对象
+ *
+ * @example
+ * ```typescript
+ * const allocation = calculateAllocation(60000);
+ * // {
+ * //   modeling: { amount: 6000, percentage: 10, ... },
+ * //   decoration: { amount: 24000, percentage: 40, ... },
+ * //   company: { amount: 30000, percentage: 50, ... }
+ * // }
+ * ```
+ */
+export function calculateAllocation(totalPrice: number): Allocation {
+  return {
+    modeling: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.modeling.percentage),
+      percentage: ALLOCATION_RULES.modeling.percentage * 100,
+      description: ALLOCATION_RULES.modeling.description,
+      color: ALLOCATION_RULES.modeling.color
+    },
+    decoration: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.decoration.percentage),
+      percentage: ALLOCATION_RULES.decoration.percentage * 100,
+      description: ALLOCATION_RULES.decoration.description,
+      color: ALLOCATION_RULES.decoration.color
+    },
+    company: {
+      amount: Math.round(totalPrice * ALLOCATION_RULES.company.percentage),
+      percentage: ALLOCATION_RULES.company.percentage * 100,
+      description: ALLOCATION_RULES.company.description,
+      color: ALLOCATION_RULES.company.color
+    },
+    updatedAt: new Date()
+  };
+}
+
+/**
+ * 计算产品级别的内部分配
+ *
+ * @param productPrice 产品报价
+ * @returns 产品内部分配对象
+ */
+export function calculateProductAllocation(productPrice: number) {
+  return {
+    modeling: Math.round(productPrice * ALLOCATION_RULES.modeling.percentage),
+    decoration: Math.round(productPrice * ALLOCATION_RULES.decoration.percentage),
+    company: Math.round(productPrice * ALLOCATION_RULES.company.percentage)
+  };
+}

+ 4 - 3
src/modules/project/pages/contact/contact.component.html

@@ -36,7 +36,7 @@
               @if (profile.basic.avatar) {
                 <img [src]="profile.basic.avatar" alt="头像" />
               } @else {
-                <svg class="icon-avatar" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M258.9 48C141.92 46.42 46.42 141.92 48 258.9c1.56 112.19 92.91 203.54 205.1 205.1 117 1.6 212.48-93.9 210.88-210.88C462.44 140.91 371.09 49.56 258.9 48zM385.32 375.25a4 4 0 01-6.14-.32 124.27 124.27 0 00-32.35-29.59C321.37 329 289.11 320 256 320s-65.37 9-90.83 25.34a124.24 124.24 0 00-32.35 29.58 4 4 0 01-6.14.32A175.32 175.32 0 0180 259c-1.63-97.31 78.22-178.76 175.57-179S432 158.81 432 256a175.32 175.32 0 01-46.68 119.25z"/><path d="M256 144c-19.72 0-37.55 7.39-50.22 20.82s-19 32-17.57 51.93C191.11 256 221.52 288 256 288s64.83-32 67.79-71.24c1.48-19.74-4.8-38.14-17.68-51.82C293.39 151.44 275.59 144 256 144z"/></svg>
+                <img src="/assets/images/default-avatar.svg" alt="头像" />
               }
             </div>
             <div class="customer-info">
@@ -193,7 +193,6 @@
                         }
                       </div>
                     </div>
-                    <svg class="icon arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M184 112l144 144-144 144"/></svg>
                   </div>
                 }
               </div>
@@ -248,7 +247,9 @@
                 @for (record of profile.followUpRecords; track $index) {
                   <div class="timeline-item">
                     <div class="timeline-dot">
-                      <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-50.22 116.82C218.45 151.39 236.28 144 256 144s37.39 7.44 50.11 20.94c12.89 13.68 19.16 32.06 17.68 51.82C320.83 256 290.43 288 256 288s-64.89-32-67.79-71.25c-1.47-19.92 4.79-38.36 17.57-51.93zM256 432a175.49 175.49 0 01-126-53.22 122.91 122.91 0 0135.14-33.44C190.63 329 222.89 320 256 320s65.37 9 90.83 25.34A122.87 122.87 0 01382 378.78 175.45 175.45 0 01256 432z"/></svg>
+                      <svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48z" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="32"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M256 176v160m80-80H176"/></svg>
+                    <span>{{ record.operator }}</span>
+                    <p>{{ record.content }}</p>
                     </div>
                     <div class="timeline-content">
                       <div class="timeline-time">{{ formatDate(record.time) }}</div>

+ 65 - 54
src/modules/project/pages/contact/contact.component.scss

@@ -438,71 +438,82 @@
 // 群聊卡片
 .groups-card {
   .groups-grid {
-    display: grid;
+    display: flex;
+    flex-direction: column;
     gap: 12px;
+  }
+  .groups-card .group-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 12px;
+    border: 1px solid var(--light-shade);
+    border-radius: 10px;
+    background: #fff;
+  }
+  .groups-card .group-info { display:flex; align-items:center; gap:10px; }
+  .groups-card .group-text h4 { margin:0; font-size:14px; font-weight:600; }
+  .groups-card .project-name { display:flex; align-items:center; gap:6px; margin:4px 0 0; font-size:12px; color:#666; }
+  .groups-card .icon.arrow { width:20px; height:20px; color:#999; }
+  
+  /* 侧栏嵌入模式的关闭按钮占位(由父侧栏提供关闭按钮) */
+  :host([embeddedMode=true]) .header .back-button { display:none; }
+  
+  background-color: var(--light-color);
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.3s;
 
-    .group-item {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      padding: 12px;
-      background-color: var(--light-color);
-      border-radius: 8px;
-      cursor: pointer;
-      transition: all 0.3s;
+  &:hover {
+    background-color: var(--light-shade);
+    transform: translateX(4px);
+  }
 
-      &:hover {
-        background-color: var(--light-shade);
-        transform: translateX(4px);
-      }
+  &:active {
+    transform: translateX(2px);
+  }
+
+  .group-info {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 1;
+    min-width: 0;
+
+    .group-text {
+      flex: 1;
+      min-width: 0;
 
-      &:active {
-        transform: translateX(2px);
+      h4 {
+        margin: 0 0 4px;
+        font-size: 15px;
+        font-weight: 600;
+        color: var(--dark-color);
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
       }
 
-      .group-info {
+      .project-name {
         display: flex;
         align-items: center;
-        gap: 12px;
-        flex: 1;
-        min-width: 0;
-
-        .group-text {
-          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;
-          }
-
-          .project-name {
-            display: flex;
-            align-items: center;
-            gap: 4px;
-            margin: 0;
-            font-size: 12px;
-            color: var(--success-color);
-
-            .icon-sm {
-              width: 14px;
-              height: 14px;
-            }
-          }
+        gap: 4px;
+        margin: 0;
+        font-size: 12px;
+        color: var(--success-color);
 
-          .no-project {
-            margin: 0;
-            font-size: 12px;
-            color: var(--medium-color);
-            font-style: italic;
-          }
+        .icon-sm {
+          width: 14px;
+          height: 14px;
         }
       }
+
+      .no-project {
+        margin: 0;
+        font-size: 12px;
+        color: var(--medium-color);
+        font-style: italic;
+      }
     }
   }
 }

+ 72 - 29
src/modules/project/pages/contact/contact.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit, Input } from '@angular/core';
+import { Component, OnInit, Input, Output, EventEmitter, HostBinding } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute } from '@angular/router';
 import { IonicModule } from '@ionic/angular';
@@ -32,7 +32,11 @@ export class CustomerProfileComponent implements OnInit {
   // 输入参数(支持组件复用)
   @Input() customer: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
-
+  // 新增:嵌入模式与项目过滤
+  @Input() embeddedMode: boolean = false;
+  @Input() projectIdFilter: string | null = null;
+  @Output() close = new EventEmitter<void>();
+  @HostBinding('class.embedded') get embeddedClass() { return this.embeddedMode; }
   // 路由参数
   cid: string = '';
   contactId: string = '';
@@ -47,6 +51,7 @@ export class CustomerProfileComponent implements OnInit {
   // 加载状态
   loading: boolean = true;
   error: string | null = null;
+  refreshing: boolean = false;
 
   // 客户数据
   contactInfo: FmodeObject | null = null;
@@ -267,6 +272,7 @@ export class CustomerProfileComponent implements OnInit {
     try {
       // 查询包含该客户的群聊
       const query = new Parse.Query('GroupChat');
+      query.include("project");
       query.equalTo('company', this.currentUser!.get('company'));
       query.notEqualTo('isDeleted', true);
 
@@ -284,18 +290,7 @@ export class CustomerProfileComponent implements OnInit {
       // 加载群聊关联的项目
       this.profile.groups = await Promise.all(
         filteredGroups.map(async (groupChat: any) => {
-          const projectPointer = groupChat.get('project');
-          let project = null;
-
-          if (projectPointer) {
-            try {
-              const pQuery = new Parse.Query('Project');
-              project = await pQuery.get(projectPointer.id);
-            } catch (err) {
-              console.error('加载项目失败:', err);
-            }
-          }
-
+          let project =  groupChat.get('project');
           return { groupChat, project };
         })
       );
@@ -326,28 +321,32 @@ export class CustomerProfileComponent implements OnInit {
    */
   async loadFollowUpRecords() {
     try {
-      // 查询沟通记录
-      const query = new Parse.Query('Communication');
-      query.equalTo('project.customer', this.contactInfo!.toPointer());
+      // 使用 ContactFollow 表,默认按项目过滤
+      const query = new Parse.Query('ContactFollow');
+      query.equalTo('contact', this.contactInfo!.toPointer());
+      query.notEqualTo('isDeleted', true);
+      if (this.projectIdFilter) {
+        const project = new Parse.Object('Project');
+        project.id = this.projectIdFilter;
+        query.equalTo('project', project.toPointer());
+      }
       query.descending('createdAt');
-      query.limit(20);
-
-      const communications = await query.find();
-
-      this.profile.followUpRecords = communications.map((comm: any) => ({
-        time: comm.get('createdAt'),
-        type: comm.get('communicationType') || 'message',
-        content: comm.get('content') || '',
-        operator: comm.get('sender')?.get('name') || '系统'
+      query.limit(50);
+
+      const records = await query.find();
+      this.profile.followUpRecords = records.map((rec: any) => ({
+        time: rec.get('createdAt'),
+        type: rec.get('type') || 'message',
+        content: rec.get('content') || '',
+        operator: rec.get('sender')?.get('name') || '系统'
       }));
 
-      // 如果没有沟通记录,从ContactInfo.data.follow_user获取
+      // 若无 ContactFollow 记录,则兼容 data.follow_user
       if (this.profile.followUpRecords.length === 0) {
         const data = this.contactInfo!.get('data') || {};
         const followUsers = data.follow_user || [];
-
         this.profile.followUpRecords = followUsers.map((fu: any) => ({
-          time: new Date(fu.createtime * 1000),
+          time: fu.createtime ? new Date(fu.createtime * 1000) : new Date(),
           type: 'follow',
           content: `${fu.userid} 添加客户`,
           operator: fu.userid
@@ -386,6 +385,11 @@ export class CustomerProfileComponent implements OnInit {
    * 返回
    */
   goBack() {
+    // 嵌入模式下不跳转,触发关闭
+    if (this.embeddedMode) {
+      this.close.emit();
+      return;
+    }
     this.router.navigate(['/wxwork', this.cid, 'project-loader']);
   }
 
@@ -459,4 +463,43 @@ export class CustomerProfileComponent implements OnInit {
     if (budget.min === budget.max) return `¥${budget.min}`;
     return `¥${budget.min} - ¥${budget.max}`;
   }
+
+  /** 刷新客户数据(基于 external_userid 拉取企微数据并保存) */
+  async refreshContactData() {
+    try {
+      if (!this.contactInfo) return;
+      const externalUserId = this.contactInfo.get('external_userid');
+      const companyId = this.currentUser?.get('company')?.id || this.contactInfo.get('company')?.id || localStorage.getItem('company');
+      if (!externalUserId || !companyId) {
+        alert('无法刷新:缺少企业或external_userid');
+        return;
+      }
+      this.refreshing = true;
+      const corp = new WxworkCorp(companyId);
+      const extData = await corp.externalContact.get(externalUserId);
+      const ext = (extData && extData.external_contact) ? extData.external_contact : {};
+      const follow = (extData && extData.follow_user) ? extData.follow_user : [];
+
+      if (ext.name) this.contactInfo.set('name', ext.name);
+      const prev = this.contactInfo.get('data') || {};
+      const mapped = {
+        ...prev,
+        external_contact: ext,
+        follow_user: follow,
+        name: ext.name,
+        avatar: ext.avatar,
+        gender: ext.gender,
+        type: ext.type
+      } as any;
+      this.contactInfo.set('data', mapped);
+      await this.contactInfo.save();
+
+      await this.buildCustomerProfile();
+    } catch (e) {
+      console.warn('刷新客户数据失败:', e);
+      alert('刷新失败,请稍后重试');
+    } finally {
+      this.refreshing = false;
+    }
+  }
 }

+ 104 - 33
src/modules/project/pages/project-detail/project-detail.component.html

@@ -51,47 +51,118 @@
 
   <!-- 项目详情内容 -->
   @if (!loading && !error && project) {
-    <!-- 客户信息快速查看卡片 -->
-    <div class="contact-quick-view">
-      <div class="card">
-        <div class="card-content">
-          <div class="contact-info">
-            <div class="avatar">
-              @if (contact?.get('data')?.avatar) {
-                <img [src]="contact?.get('data')?.avatar" alt="客户头像" />
-              } @else {
-                <svg class="icon avatar-icon" 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>
-              }
+    <!-- 客户选择组件(剥离为外部组件) -->
+    <app-contact-selector
+      [project]="project"
+      [groupChat]="groupChat"
+      [currentUser]="currentUser"
+      (contactSelected)="onContactSelected($event)">
+    </app-contact-selector>
+
+    <!-- 项目问卷卡片 -->
+    @if (contact && (currentStage=='order' || currentStage=='requirements')) {
+      <div class="survey-card">
+        <div class="survey-header">
+          <div class="survey-title">
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M336 64h32a48 48 0 0148 48v320a48 48 0 01-48 48H144a48 48 0 01-48-48V112a48 48 0 0148-48h32" opacity=".3"/>
+              <path fill="currentColor" d="M336 64h-80a48 48 0 00-96 0h-80a48 48 0 00-48 48v320a48 48 0 0048 48h224a48 48 0 0048-48V112a48 48 0 00-48-48zM256 32a16 16 0 11-16 16 16 16 0 0116-16zm112 400H144V112h224z"/>
+              <path fill="currentColor" d="M176 208h160v16H176zm0 64h160v16H176zm0 64h160v16H176z"/>
+            </svg>
+            <span>项目需求调查</span>
+          </div>
+          <div class="survey-badge" [class.filled]="surveyStatus.filled">
+            {{ surveyStatus.filled ? '已填写' : '待填写' }}
+          </div>
+        </div>
+
+        <div class="survey-content">
+          @if (surveyStatus.filled) {
+            <!-- 已填写状态 -->
+            <div class="survey-info">
+              <svg class="icon success-icon" 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 48zm108.25 138.29l-134.4 160a16 16 0 01-12 5.71h-.27a16 16 0 01-11.89-5.3l-57.6-64a16 16 0 1123.78-21.4l45.29 50.32 122.59-145.91a16 16 0 0124.5 20.58z"/>
+              </svg>
+              <div class="survey-text">
+                <p class="survey-desc">
+                  {{ surveyStatus.contact.get('realname') || surveyStatus.contact.get('name') }} 已完成需求调查
+                </p>
+                <p class="survey-meta">
+                  完成时间: {{ surveyStatus.surveyLog?.get('completedAt') | date:'yyyy-MM-dd HH:mm' }}
+                </p>
+              </div>
             </div>
-            <div class="info-text">
-              <h3>{{ contact?.get('name') || contact?.get('data')?.name || '待设置' }}</h3>
-              @if (contact && canViewCustomerPhone) {
-                <p>{{ contact.get('mobile') }}</p>
-                <p class="wechat-id">ID: {{ contact.get('data')?.wechat || contact.get('external_userid') }}</p>
-              } @else if (contact) {
-                <p class="info-limited">仅客服可查联系方式</p>
-              }
-              <div class="tags">
-                @if (contact?.get('source')) {
-                  <span class="badge badge-primary">{{ contact?.get('source') }}</span>
-                }
-                <span class="badge" [class.badge-success]="project.get('status') === '进行中'" [class.badge-warning]="project.get('status') !== '进行中'">
-                  {{ project.get('status') }}
-                </span>
+
+            <button class="button secondary" (click)="handleSurveyClick($event)">
+              <svg class="icon" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M255.66 112c-77.94 0-157.89 45.11-220.83 135.33a16 16 0 00-.27 17.77C82.92 340.8 161.8 400 255.66 400c92.84 0 173.34-59.38 221.79-135.25a16.14 16.14 0 000-17.47C428.89 172.28 347.8 112 255.66 112z" opacity=".3"/>
+                <circle cx="256" cy="256" r="80" fill="currentColor"/>
+              </svg>
+              <span>查看答卷</span>
+            </button>
+          } @else {
+            <!-- 未填写状态 -->
+            <div class="survey-info">
+              <svg class="icon pending-icon" 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 319.91a20 20 0 1120-20 20 20 0 01-20 20zm21.72-201.15l-5.74 122a16 16 0 01-32 0l-5.74-121.94v-.05a21.74 21.74 0 1143.44 0z"/>
+              </svg>
+              <div class="survey-text">
+                <p class="survey-desc">
+                  邀请 客户 填写项目需求调查表
+                </p>
+                <p class="survey-meta">
+                  了解客户需求,提供更精准的服务方案
+                </p>
               </div>
-              
             </div>
-            @if (!contact?.id && role == '客服') {
-              <button class="btn btn-sm btn-primary" (click)="selectCustomer()">
-                选择客户
+
+            @if (canEdit && groupChat) {
+              <button class="button primary" (click)="handleSurveyClick($event)">
+                <svg class="icon" viewBox="0 0 512 512">
+                  <path fill="currentColor" d="M476.59 227.05l-.16-.07L49.35 49.84A23.56 23.56 0 0027.14 52 24.65 24.65 0 0016 72.59v113.29a24 24 0 0019.52 23.57l232.93 43.07a4 4 0 010 7.86L35.53 303.45A24 24 0 0016 327v113.31A23.57 23.57 0 0026.59 460a23.94 23.94 0 0013.22 4 24.55 24.55 0 009.52-1.93L476.4 285.94l.19-.09a32 32 0 000-58.8z"/>
+                </svg>
+                <span>发送问卷</span>
               </button>
+            } @else {
+              <div class="survey-tips">
+                <svg class="icon" viewBox="0 0 512 512">
+                  <path fill="currentColor" d="M256 56C145.72 56 56 145.72 56 256s89.72 200 200 200 200-89.72 200-200S366.28 56 256 56zm0 82a26 26 0 11-26 26 26 26 0 0126-26zm48 226h-88a16 16 0 010-32h28v-88h-16a16 16 0 010-32h32a16 16 0 0116 16v104h28a16 16 0 010 32z"/>
+                </svg>
+                <span>需要在企微群聊中发送问卷</span>
+              </div>
             }
+          }
+        </div>
+
+        <!-- 问卷说明 -->
+        @if (!surveyStatus.filled) {
+        <div class="survey-footer">
+          <div class="survey-highlights">
+            <div class="highlight-item">
+              <svg class="icon" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"/>
+              </svg>
+              <span>3-5分钟</span>
+            </div>
+            <div class="highlight-item">
+              <svg class="icon" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M144 144v296a8 8 0 008 8h56V144zm144 0v304h56a8 8 0 008-8V144zm144 0v272a24 24 0 01-24 24h-40V144zM64 144v328a24 24 0 0024 24h40V144z" opacity=".3"/>
+                <path fill="currentColor" d="M496 124a12 12 0 00-12-12H432V40a24 24 0 00-24-24H104a24 24 0 00-24 24v72H28a12 12 0 00-12 12v20a12 12 0 0012 12h456a12 12 0 0012-12zm-96-12H112V48h288z"/>
+              </svg>
+              <span>8道题目</span>
+            </div>
+            <div class="highlight-item">
+              <svg class="icon" viewBox="0 0 512 512">
+                <path fill="currentColor" d="M400 192H32L16 448l16 32h448l16-32-16-256zm-280 96h-16a8 8 0 01-8-8v-16a8 8 0 018-8h16a8 8 0 018 8v16a8 8 0 01-8 8z"/>
+                <path fill="currentColor" d="M464 32H48C21.5 32 0 53.5 0 80v96h512V80c0-26.5-21.5-48-48-48zM148 140a12 12 0 01-12 12h-52a12 12 0 01-12-12v-24a12 12 0 0112-12h52a12 12 0 0112 12zm216 0a12 12 0 01-12 12H212a12 12 0 01-12-12v-24a12 12 0 0112-12h140a12 12 0 0112 12z"/>
+              </svg>
+              <span>选择题为主</span>
+            </div>
           </div>
         </div>
+        }
       </div>
-    </div>
+    }
 
     <!-- 子路由内容(各阶段组件) -->
     <div class="stage-content">

+ 303 - 0
src/modules/project/pages/project-detail/project-detail.component.scss

@@ -1001,3 +1001,306 @@
     font-size: 13px;
   }
 }
+
+// 问卷卡片样式
+.survey-card {
+  background: white;
+  border-radius: 12px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+  margin: 16px;
+  overflow: hidden;
+  transition: all 0.3s ease;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
+  }
+
+  // 头部
+  .survey-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 16px;
+    border-bottom: 1px solid var(--light-shade);
+    background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
+
+    .survey-title {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+
+      .icon {
+        width: 24px;
+        height: 24px;
+        color: var(--primary-color);
+      }
+
+      span {
+        font-size: 16px;
+        font-weight: 600;
+        color: var(--dark-color);
+      }
+    }
+
+    .survey-badge {
+      padding: 4px 12px;
+      border-radius: 12px;
+      font-size: 12px;
+      font-weight: 600;
+      background: rgba(255, 196, 9, 0.15);
+      color: var(--warning-color);
+      transition: all 0.3s ease;
+
+      &.filled {
+        background: rgba(45, 211, 111, 0.15);
+        color: var(--success-color);
+      }
+    }
+  }
+
+  // 内容区
+  .survey-content {
+    padding: 20px 16px;
+
+    .survey-info {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 16px;
+
+      .icon {
+        width: 40px;
+        height: 40px;
+        flex-shrink: 0;
+        margin-top: 2px;
+
+        &.success-icon {
+          color: var(--success-color);
+        }
+
+        &.pending-icon {
+          color: var(--warning-color);
+        }
+      }
+
+      .survey-text {
+        flex: 1;
+
+        .survey-desc {
+          margin: 0 0 6px;
+          font-size: 15px;
+          font-weight: 500;
+          color: var(--dark-color);
+          line-height: 1.5;
+        }
+
+        .survey-meta {
+          margin: 0;
+          font-size: 13px;
+          color: var(--medium-color);
+          line-height: 1.4;
+        }
+      }
+    }
+
+    .button {
+      width: 100%;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+      padding: 12px 20px;
+      border-radius: 8px;
+      font-size: 14px;
+      font-weight: 600;
+      border: none;
+      cursor: pointer;
+      transition: all 0.3s ease;
+
+      .icon {
+        width: 18px;
+        height: 18px;
+      }
+
+      &.primary {
+        background: var(--primary-color);
+        color: white;
+        box-shadow: 0 2px 8px rgba(56, 128, 255, 0.2);
+
+        &:hover {
+          background: #2f6ce5;
+          transform: translateY(-2px);
+          box-shadow: 0 4px 12px rgba(56, 128, 255, 0.3);
+        }
+
+        &:active {
+          transform: translateY(0);
+        }
+      }
+
+      &.secondary {
+        background: white;
+        color: var(--primary-color);
+        border: 2px solid var(--primary-color);
+
+        &:hover {
+          background: rgba(56, 128, 255, 0.05);
+        }
+
+        &:active {
+          transform: scale(0.98);
+        }
+      }
+    }
+
+    .survey-tips {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+      padding: 12px 16px;
+      background: rgba(255, 196, 9, 0.1);
+      border-radius: 8px;
+      color: var(--warning-color);
+      font-size: 13px;
+      font-weight: 500;
+
+      .icon {
+        width: 20px;
+        height: 20px;
+        flex-shrink: 0;
+      }
+    }
+  }
+
+  // 底部说明
+  .survey-footer {
+    padding: 12px 16px;
+    background: #f9fafb;
+    border-top: 1px solid var(--light-shade);
+
+    .survey-highlights {
+      display: flex;
+      justify-content: space-around;
+      gap: 8px;
+
+      .highlight-item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 6px;
+        flex: 1;
+        min-width: 0;
+
+        .icon {
+          width: 24px;
+          height: 24px;
+          color: var(--primary-color);
+          opacity: 0.7;
+        }
+
+        span {
+          font-size: 12px;
+          font-weight: 500;
+          color: var(--medium-color);
+          text-align: center;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+}
+
+// 移动端适配
+@media (max-width: 480px) {
+  .survey-card {
+    margin: 12px 8px;
+    border-radius: 10px;
+
+    .survey-header {
+      padding: 12px;
+
+      .survey-title {
+        gap: 8px;
+
+        .icon {
+          width: 20px;
+          height: 20px;
+        }
+
+        span {
+          font-size: 15px;
+        }
+      }
+
+      .survey-badge {
+        font-size: 11px;
+        padding: 3px 10px;
+      }
+    }
+
+    .survey-content {
+      padding: 16px 12px;
+
+      .survey-info {
+        gap: 10px;
+
+        .icon {
+          width: 36px;
+          height: 36px;
+        }
+
+        .survey-text {
+          .survey-desc {
+            font-size: 14px;
+          }
+
+          .survey-meta {
+            font-size: 12px;
+          }
+        }
+      }
+
+      .button {
+        padding: 10px 16px;
+        font-size: 13px;
+
+        .icon {
+          width: 16px;
+          height: 16px;
+        }
+      }
+
+      .survey-tips {
+        padding: 10px 12px;
+        font-size: 12px;
+
+        .icon {
+          width: 18px;
+          height: 18px;
+        }
+      }
+    }
+
+    .survey-footer {
+      padding: 10px 12px;
+
+      .survey-highlights {
+        gap: 6px;
+
+        .highlight-item {
+          gap: 4px;
+
+          .icon {
+            width: 20px;
+            height: 20px;
+          }
+
+          span {
+            font-size: 11px;
+          }
+        }
+      }
+    }
+  }
+}

+ 148 - 3
src/modules/project/pages/project-detail/project-detail.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit, Input } from '@angular/core';
+import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute, RouterModule } from '@angular/router';
 import { IonicModule } from '@ionic/angular';
@@ -10,6 +10,9 @@ import { ProjectFilesModalComponent } from '../../components/project-files-modal
 import { ProjectMembersModalComponent } from '../../components/project-members-modal/project-members-modal.component';
 import { ProjectIssuesModalComponent } from '../../components/project-issues-modal/project-issues-modal.component';
 import { ProjectIssueService } from '../../services/project-issue.service';
+import { CustomerProfileComponent } from '../contact/contact.component';
+import { FormsModule } from '@angular/forms';
+import { CustomerSelectorComponent } from '../../components/contact-selector/contact-selector.component';
 
 const Parse = FmodeParse.with('nova');
 
@@ -27,7 +30,17 @@ const Parse = FmodeParse.with('nova');
 @Component({
   selector: 'app-project-detail',
   standalone: true,
-  imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent, ProjectIssuesModalComponent],
+  imports: [
+    CommonModule,
+    IonicModule,
+    RouterModule,
+    ProjectBottomCardComponent,
+    ProjectFilesModalComponent,
+    ProjectMembersModalComponent,
+    ProjectIssuesModalComponent,
+    CustomerProfileComponent,
+    CustomerSelectorComponent
+  ],
   templateUrl: './project-detail.component.html',
   styleUrls: ['./project-detail.component.scss']
 })
@@ -78,6 +91,21 @@ export class ProjectDetailComponent implements OnInit {
   showFilesModal: boolean = false;
   showMembersModal: boolean = false;
   showIssuesModal: boolean = false;
+  // 新增:客户详情侧栏面板状态
+  showContactPanel: boolean = false;
+
+  // 问卷状态
+  surveyStatus: {
+    filled: boolean;
+    text: string;
+    icon: string;
+    surveyLog?: FmodeObject;
+    contact?: FmodeObject;
+  } = {
+    filled: false,
+    text: '发送问卷',
+    icon: 'document-text-outline'
+  };
 
   constructor(
     private router: Router,
@@ -206,6 +234,9 @@ export class ProjectDetailComponent implements OnInit {
       this.contact = this.project.get('contact');
       this.assignee = this.project.get('assignee');
 
+      // 加载问卷状态
+      await this.loadSurveyStatus();
+
       // 更新问题计数
       try {
         if (this.project?.id) {
@@ -460,13 +491,127 @@ export class ProjectDetailComponent implements OnInit {
     this.showMembersModal = false;
   }
 
+  /** 显示客户详情面板 */
+  openContactPanel() {
+    if (this.contact) {
+      this.showContactPanel = true;
+    }
+  }
+
+  /** 关闭客户详情面板 */
+  closeContactPanel() {
+    this.showContactPanel = false;
+  }
+
   /** 关闭问题模态框 */
   closeIssuesModal() {
     this.showIssuesModal = false;
-    // 关闭后更新计数(避免列表操作后的计数不一致)
     if (this.project?.id) {
       const counts = this.issueService.getCounts(this.project.id!);
       this.issueCount = counts.total;
     }
   }
+
+  /** 客户选择事件回调(接收子组件输出) */
+  onContactSelected(evt: { contact: FmodeObject; isNewCustomer: boolean; action: 'selected' | 'created' | 'updated' }) {
+    this.contact = evt.contact;
+    // 重新加载问卷状态
+    this.loadSurveyStatus();
+  }
+
+  /**
+   * 加载问卷状态
+   */
+  async loadSurveyStatus() {
+    if (!this.project?.id) return;
+
+    try {
+      const query = new Parse.Query('SurveyLog');
+      query.equalTo('project', this.project.toPointer());
+      query.equalTo('type', 'survey-project');
+      query.equalTo('isCompleted', true);
+      query.include("contact")
+      const surveyLog = await query.first();
+
+      if (surveyLog) {
+        this.surveyStatus = {
+          filled: true,
+          text: '查看问卷',
+          icon: 'checkmark-circle',
+          surveyLog,
+          contact:surveyLog?.get("contact")
+        };
+        console.log('✅ 问卷已填写');
+      } else {
+        this.surveyStatus = {
+          filled: false,
+          text: '发送问卷',
+          icon: 'document-text-outline'
+        };
+        console.log('✅ 问卷未填写');
+      }
+    } catch (err) {
+      console.error('❌ 查询问卷状态失败:', err);
+    }
+  }
+
+  /**
+   * 发送问卷
+   */
+  async sendSurvey() {
+    if (!this.groupChat || !this.wxwork) {
+      alert('无法发送问卷:未找到群聊或企微SDK未初始化');
+      return;
+    }
+
+    try {
+      const chatId = this.groupChat.get('chat_id');
+      const surveyUrl = `${document.baseURI}/wxwork/${this.cid}/survey/project/${this.project?.id}`;
+
+      await this.wxwork.ww.openExistedChatWithMsg({
+        chatId: chatId,
+        msg: {
+          msgtype: 'link',
+          link: {
+            title: '《家装效果图服务需求调查表》',
+            desc: '为让本次服务更贴合您的需求,请花3-5分钟填写简短问卷,感谢支持!',
+            url: surveyUrl,
+            imgUrl: `${document.baseURI}/assets/logo.jpg`
+          }
+        }
+      });
+
+      alert('问卷已发送到群聊!');
+    } catch (err) {
+      console.error('❌ 发送问卷失败:', err);
+      alert('发送失败,请重试');
+    }
+  }
+
+  /**
+   * 查看问卷结果
+   */
+  async viewSurvey() {
+    if (!this.surveyStatus.surveyLog) return;
+
+    // 跳转到问卷页面查看结果
+    this.router.navigate(['/wxwork', this.cid, 'survey', 'project', this.project?.id]);
+  }
+
+  /**
+   * 处理问卷点击
+   */
+  async handleSurveyClick(event: Event) {
+    event.stopPropagation();
+
+    if (this.surveyStatus.filled) {
+      // 已填写,查看结果
+      await this.viewSurvey();
+    } else {
+      // 未填写,发送问卷
+      await this.sendSurvey();
+    }
+  }
 }
+
+// duplicate inline CustomerSelectorComponent removed (we keep single declaration above)

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

@@ -44,7 +44,7 @@ export class StageOrderComponent implements OnInit {
   @Input() project: FmodeObject | null = null;
   @Input() customer: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
-  @Input() canEdit: boolean = false;
+  @Input() canEdit: boolean = true;
 
   onProjectTypeChange(){
 

+ 2 - 2
src/modules/project/pages/project-loader/project-loader.component.ts

@@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute } from '@angular/router';
 import { FormsModule } from '@angular/forms';
-import { WxworkSDK, WxworkCorp } from 'fmode-ng/social';
+import { WxworkSDK, WxworkCorp } from 'fmode-ng/core';
 import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
 
 // WxworkCurrentChat 类型定义
@@ -81,7 +81,7 @@ export class ProjectLoaderComponent implements OnInit {
   async ngOnInit() {
     // 获取路由参数
     this.route.paramMap.subscribe(async params => {
-      this.cid = params.get('cid') || '';
+      this.cid = params.get('cid') || localStorage.getItem("company") || '';
       this.appId = params.get('appId') || 'crm';
 
       if (!this.cid) {

+ 385 - 0
src/modules/project/pages/project-survey/project-survey.component.html

@@ -0,0 +1,385 @@
+<!-- 顶部导航栏 -->
+<div class="survey-header">
+  <div class="header-content">
+    @if(!currentContact?.id){
+    <button class="back-button" (click)="goBack()">
+      <svg class="icon" viewBox="0 0 512 512">
+        <path fill="currentColor" d="M244 400L100 256l144-144M120 256h292"/>
+      </svg>
+    </button>
+    }
+    <h1 class="header-title">项目需求调查</h1>
+    <div class="header-spacer"></div>
+  </div>
+</div>
+
+<!-- 主内容区 -->
+<div class="survey-container">
+  <!-- 加载状态 -->
+  @if (loading) {
+    <div class="status-view loading-view">
+      <div class="spinner">
+        <div class="spinner-circle"></div>
+      </div>
+      <p class="status-text">加载中...</p>
+    </div>
+  }
+
+  <!-- 错误状态 -->
+  @if (error && !loading && !surveyLog?.get('isCompleted')) {
+    <div class="status-view error-view">
+      <div class="error-icon-wrapper">
+        <svg class="icon error-icon" viewBox="0 0 512 512">
+          @if (isCustomerOnly) {
+            <!-- 仅限客户图标 -->
+            <path fill="currentColor" d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88c0-51.18-42.82-92-94-92s-94 40.82-94 92 42.82 92 94 92 94-40.82 94-92z" opacity=".3"/>
+            <path fill="currentColor" d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9.23 98.83 26.73 31.45 19.86 52.3 48 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432z"/>
+          } @else {
+            <!-- 常规错误图标 -->
+            <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 319.91a20 20 0 1120-20 20 20 0 01-20 20zm21.72-201.15l-5.74 122a16 16 0 01-32 0l-5.74-121.94v-.05a21.74 21.74 0 1143.44 0z"/>
+          }
+        </svg>
+      </div>
+      <h2 class="error-title">{{ isCustomerOnly ? '仅限客户填写' : '加载失败' }}</h2>
+      <p class="error-message">{{ error }}</p>
+      @if (isCustomerOnly) {
+        <div class="info-box">
+          <svg class="icon" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M256 56C145.72 56 56 145.72 56 256s89.72 200 200 200 200-89.72 200-200S366.28 56 256 56zm0 82a26 26 0 11-26 26 26 26 0 0126-26zm48 226h-88a16 16 0 010-32h28v-88h-16a16 16 0 010-32h32a16 16 0 0116 16v104h28a16 16 0 010 32z"/>
+          </svg>
+          <div class="info-text">
+            <p class="info-title">客户填写入口</p>
+            <p class="info-desc">请通过企微群聊中收到的问卷链接进入</p>
+          </div>
+        </div>
+      }
+      <button class="btn-primary" (click)="goBack()">返回</button>
+    </div>
+  }
+
+  <!-- 欢迎页 -->
+  @if (currentState === 'welcome' && !loading && !error) {
+    <div class="welcome-view">
+      <!-- 用户信息卡片 -->
+      <div class="user-card">
+        <div class="user-avatar">
+          <img [src]="currentContact?.get('avatar') || currentContact?.get('data')?.avatar || '/assets/images/default-avatar.svg'" alt="头像" />
+        </div>
+        <h2 class="user-greeting">您好, {{ currentContact?.get('realname') || currentContact?.get('name') }}</h2>
+        <p class="user-subtitle">欢迎参与需求调查</p>
+      </div>
+
+      <!-- 问卷介绍 -->
+      <div class="intro-card">
+        <div class="intro-header">
+          <svg class="icon" viewBox="0 0 512 512">
+            <path fill="currentColor" d="M336 64h32a48 48 0 0148 48v320a48 48 0 01-48 48H144a48 48 0 01-48-48V112a48 48 0 0148-48h32" opacity=".3"/>
+            <path fill="currentColor" d="M336 64h-80a48 48 0 00-96 0h-80a48 48 0 00-48 48v320a48 48 0 0048 48h224a48 48 0 0048-48V112a48 48 0 00-48-48zM256 32a16 16 0 11-16 16 16 16 0 0116-16zm112 400H144V112h224z"/>
+          </svg>
+          <h3>家装效果图服务需求调查</h3>
+        </div>
+
+        <div class="intro-body">
+          <p class="intro-text">
+            尊敬的伙伴,为让本次效果图服务更贴合您的工作节奏与核心需求,我们准备了简短选择式问卷。
+          </p>
+          <p class="intro-text">
+            您的偏好将直接帮我们校准服务方向,感谢支持!
+          </p>
+        </div>
+
+        <!-- 问卷信息标签 -->
+        <div class="info-tags">
+          <div class="info-tag">
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm61.8-104.4l-84.9-61.7c-3.1-2.3-4.9-5.9-4.9-9.7V116c0-6.6 5.4-12 12-12h32c6.6 0 12 5.4 12 12v141.7l66.8 48.6c5.4 3.9 6.5 11.4 2.6 16.8L334.6 349c-3.9 5.3-11.4 6.5-16.8 2.6z"/>
+            </svg>
+            <span>3-5分钟</span>
+          </div>
+          <div class="info-tag">
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M144 144v296a8 8 0 008 8h56V144zm144 0v304h56a8 8 0 008-8V144zm144 0v272a24 24 0 01-24 24h-40V144zM64 144v328a24 24 0 0024 24h40V144z" opacity=".3"/>
+            </svg>
+            <span>{{ effectiveQuestions.length }}道题</span>
+          </div>
+          <div class="info-tag">
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"/>
+            </svg>
+            <span>选择为主</span>
+          </div>
+        </div>
+      </div>
+
+      <!-- 开始按钮 -->
+      <button class="btn-start" (click)="startSurvey()">
+        <span>开始填写</span>
+        <svg class="icon" viewBox="0 0 512 512">
+          <path fill="currentColor" d="M294.1 256L167 129c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.3 34 0L345 239c9.1 9.1 9.3 23.7.7 33.1L201.1 417c-4.7 4.7-10.9 7-17 7s-12.3-2.3-17-7c-9.4-9.4-9.4-24.6 0-33.9l127-127.1z"/>
+        </svg>
+      </button>
+    </div>
+  }
+
+  <!-- 答题页 -->
+  @if (currentState === 'questionnaire' && !loading && !error) {
+    <div class="questionnaire-view">
+      <!-- 进度指示器 -->
+      <div class="progress-section">
+        <div class="progress-bar-wrapper">
+          <div class="progress-bar">
+            <div class="progress-fill" [style.width.%]="getProgress()"></div>
+          </div>
+        </div>
+        <div class="progress-info">
+          <span class="progress-current">{{ currentQuestionIndex + 1 }}</span>
+          <span class="progress-separator">/</span>
+          <span class="progress-total">{{ effectiveQuestions.length }}</span>
+        </div>
+      </div>
+
+      @if (getCurrentQuestion(); as question) {
+        <!-- 题目卡片 -->
+        <div class="question-card">
+          <!-- 章节标签 -->
+          <div class="section-badge">{{ question.section }}</div>
+
+          <!-- 题目内容 -->
+          <div class="question-content">
+            <h3 class="question-title">
+              <span class="question-number">{{ currentQuestionIndex + 1 }}.</span>
+              <span class="question-text">{{ question.title }}</span>
+              @if (question.required) {
+                <span class="required-star">*</span>
+              }
+            </h3>
+
+            <!-- 单选题 -->
+            @if (question.type === 'single') {
+              <div class="options-list">
+                @for (option of question.options; track option) {
+                  <div
+                    class="option-item"
+                    [class.selected]="answers[question.id] === option"
+                    (click)="selectSingleOption(option)">
+                    <div class="option-radio">
+                      <div class="radio-outer">
+                        <div class="radio-inner"></div>
+                      </div>
+                    </div>
+                    <span class="option-label">{{ option }}</span>
+                  </div>
+                }
+
+                @if (question.hasOther) {
+                  <div
+                    class="option-item"
+                    [class.selected]="answers[question.id]?.startsWith('其他')"
+                    (click)="selectSingleOption('其他')">
+                    <div class="option-radio">
+                      <div class="radio-outer">
+                        <div class="radio-inner"></div>
+                      </div>
+                    </div>
+                    <span class="option-label">其他</span>
+                  </div>
+                }
+              </div>
+
+              @if (showOtherInput) {
+                <div class="input-wrapper">
+                  <input
+                    type="text"
+                    class="text-input"
+                    [(ngModel)]="otherInput"
+                    placeholder="请输入其他内容..."
+                    autofocus />
+                </div>
+              }
+            }
+
+            <!-- 多选题 -->
+            @if (question.type === 'multiple') {
+              <div class="options-list">
+                @for (option of question.options; track option) {
+                  <div
+                    class="option-item"
+                    [class.selected]="hasMultipleOption(question.id, option)"
+                    (click)="toggleMultipleOption(option)">
+                    <div class="option-checkbox">
+                      <div class="checkbox-box">
+                        <svg class="icon checkmark" viewBox="0 0 512 512">
+                          <path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"/>
+                        </svg>
+                      </div>
+                    </div>
+                    <span class="option-label">{{ option }}</span>
+                  </div>
+                }
+
+                @if (question.hasOther) {
+                  <div
+                    class="option-item"
+                    [class.selected]="hasMultipleOptionStartsWith(question.id, '其他')"
+                    (click)="toggleMultipleOption('其他')">
+                    <div class="option-checkbox">
+                      <div class="checkbox-box">
+                        <svg class="icon checkmark" viewBox="0 0 512 512">
+                          <path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"/>
+                        </svg>
+                      </div>
+                    </div>
+                    <span class="option-label">其他</span>
+                  </div>
+                }
+              </div>
+
+              @if (showOtherInput) {
+                <div class="input-wrapper">
+                  <input
+                    type="text"
+                    class="text-input"
+                    [(ngModel)]="otherInput"
+                    placeholder="请输入其他内容..." />
+                </div>
+              }
+            }
+
+            <!-- 文本题 -->
+            @if (question.type === 'text') {
+              <div class="input-wrapper">
+                <textarea
+                  class="textarea-input"
+                  [(ngModel)]="answers[question.id]"
+                  [placeholder]="question.placeholder || '请输入...'"
+                  rows="4"></textarea>
+              </div>
+            }
+
+            <!-- 数字题 -->
+            @if (question.type === 'number') {
+              <div class="input-wrapper">
+                <input
+                  type="number"
+                  class="text-input"
+                  [(ngModel)]="answers[question.id]"
+                  [placeholder]="question.placeholder || '请输入数字...'" />
+              </div>
+            }
+          </div>
+        </div>
+
+        <!-- 导航按钮 -->
+        <div class="nav-buttons">
+          <button
+            class="btn-nav btn-prev"
+            [disabled]="currentQuestionIndex === 0"
+            (click)="previousQuestion()">
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M217.9 256L345 129c9.4-9.4 9.4-24.6 0-33.9-9.4-9.4-24.6-9.3-34 0L167 239c-9.1 9.1-9.3 23.7-.7 33.1L310.9 417c4.7 4.7 10.9 7 17 7s12.3-2.3 17-7c9.4-9.4 9.4-24.6 0-33.9L217.9 256z"/>
+            </svg>
+            <span>上一题</span>
+          </button>
+
+          <button
+            class="btn-nav btn-next"
+            (click)="nextQuestion()">
+            <span>{{ currentQuestionIndex >= effectiveQuestions.length - 1 ? '提交' : '下一题' }}</span>
+            <svg class="icon" viewBox="0 0 512 512">
+              <path fill="currentColor" d="M294.1 256L167 129c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.3 34 0L345 239c9.1 9.1 9.3 23.7.7 33.1L201.1 417c-4.7 4.7-10.9 7-17 7s-12.3-2.3-17-7c-9.4-9.4-9.4-24.6 0-33.9l127-127.1z"/>
+            </svg>
+          </button>
+        </div>
+      }
+    </div>
+  }
+
+  <!-- 结果页 -->
+  @if (currentState === 'result' && !loading && (!error || surveyLog?.get('isCompleted'))) {
+    <div class="result-view">
+      <!-- 成功图标 -->
+      <div class="success-icon-wrapper">
+        <svg class="icon success-icon" 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 48zm108.25 138.29l-134.4 160a16 16 0 01-12 5.71h-.27a16 16 0 01-11.89-5.3l-57.6-64a16 16 0 1123.78-21.4l45.29 50.32 122.59-145.91a16 16 0 0124.5 20.58z"/>
+        </svg>
+      </div>
+      @if(currentContact?.id){
+        <h2 class="result-title">问卷提交成功</h2>
+        <p class="result-subtitle">感谢您的反馈!</p>
+        <p class="result-desc">我们将根据您的选择制定服务方案</p>
+      }
+        
+      <!-- 答卷内容 -->
+      <div class="result-card">
+        <h3 class="result-card-title">问卷信息</h3>
+
+        <div class="result-list">
+          <div class="result-item">
+            <div class="result-label">核心服务</div>
+            <div class="result-value">{{ getFormattedAnswer('q1') }}</div>
+          </div>
+
+          <div class="result-item">
+            <div class="result-label">空间数量</div>
+            <div class="result-value">{{ getFormattedAnswer('q2') }}</div>
+          </div>
+
+          <div class="result-item">
+            <div class="result-label">价值侧重</div>
+            <div class="result-value">{{ getFormattedAnswer('q3') }}</div>
+          </div>
+
+          <div class="result-item">
+            <div class="result-label">技术配合</div>
+            <div class="result-value">{{ getFormattedAnswer('q4') }}</div>
+          </div>
+
+          <div class="result-item">
+            <div class="result-label">协作方式</div>
+            <div class="result-value">{{ getFormattedAnswer('q5') }}</div>
+          </div>
+
+          @if (answers['q6']) {
+            <div class="result-item">
+              <div class="result-label">注意事项</div>
+              <div class="result-value">{{ getFormattedAnswer('q6') }}</div>
+            </div>
+          }
+
+          @if (answers['q7']) {
+            <div class="result-item">
+              <div class="result-label">特殊要求</div>
+              <div class="result-value">{{ getFormattedAnswer('q7') }}</div>
+            </div>
+          }
+
+          @if (answers['q8']) {
+            <div class="result-item">
+              <div class="result-label">参考素材</div>
+              <div class="result-value">{{ getFormattedAnswer('q8') }}</div>
+            </div>
+          }
+        </div>
+
+        <div class="result-divider"></div>
+
+        <div class="result-list">
+          <div class="result-item">
+            <div class="result-label">对接人</div>
+            <div class="result-value">{{ answers['contact_name'] || currentContact?.get('realname') || '-' }}</div>
+          </div>
+
+          <div class="result-item">
+            <div class="result-label">电话</div>
+            <div class="result-value">{{ maskPhone(answers['contact_phone'] || currentContact?.get('mobile') || '') }}</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 返回按钮 -->
+      <button class="btn-primary" (click)="goBack()">
+        <span>返回项目</span>
+      </button>
+    </div>
+  }
+</div>

+ 887 - 0
src/modules/project/pages/project-survey/project-survey.component.scss

@@ -0,0 +1,887 @@
+// 项目问卷组件 - 移动端优先设计
+
+// CSS 变量
+:host {
+  --primary-color: #3880ff;
+  --success-color: #2dd36f;
+  --warning-color: #ffc409;
+  --danger-color: #eb445a;
+  --dark-color: #222428;
+  --medium-color: #92949c;
+  --light-color: #f4f5f8;
+  --white: #ffffff;
+  --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.08);
+  --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.12);
+  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.16);
+  --radius-sm: 8px;
+  --radius-md: 12px;
+  --radius-lg: 16px;
+  --transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+// 重置样式
+* {
+  box-sizing: border-box;
+  -webkit-tap-highlight-color: transparent;
+}
+
+// 顶部导航栏
+.survey-header {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 1000;
+  background: var(--white);
+  box-shadow: var(--shadow-sm);
+
+  .header-content {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 56px;
+    padding: 0 16px;
+    max-width: 640px;
+    margin: 0 auto;
+  }
+
+  .back-button {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 40px;
+    height: 40px;
+    border: none;
+    background: transparent;
+    color: var(--dark-color);
+    cursor: pointer;
+    border-radius: var(--radius-sm);
+    transition: var(--transition);
+
+    &:active {
+      background: var(--light-color);
+      transform: scale(0.95);
+    }
+
+    .icon {
+      width: 24px;
+      height: 24px;
+    }
+  }
+
+  .header-title {
+    flex: 1;
+    margin: 0 16px;
+    font-size: 18px;
+    font-weight: 600;
+    color: var(--dark-color);
+    text-align: center;
+  }
+
+  .header-spacer {
+    width: 40px;
+  }
+}
+
+// 主容器
+.survey-container {
+  min-height: 100vh;
+  height:100vh;
+  overflow-y:auto;
+  padding-top: 56px;
+  background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
+}
+
+// 状态视图(加载/错误)
+.status-view {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: calc(100vh - 56px);
+  padding: 32px 24px;
+  text-align: center;
+
+  .status-text {
+    margin: 16px 0 0;
+    font-size: 16px;
+    color: var(--medium-color);
+  }
+}
+
+// 加载动画
+.spinner {
+  width: 48px;
+  height: 48px;
+  position: relative;
+
+  .spinner-circle {
+    width: 100%;
+    height: 100%;
+    border: 4px solid var(--light-color);
+    border-top-color: var(--primary-color);
+    border-radius: 50%;
+    animation: spin 0.8s linear infinite;
+  }
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+// 错误视图
+.error-view {
+  .error-icon-wrapper {
+    width: 80px;
+    height: 80px;
+    margin-bottom: 24px;
+    border-radius: 50%;
+    background: rgba(235, 68, 90, 0.1);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .error-icon {
+      width: 48px;
+      height: 48px;
+      color: var(--danger-color);
+    }
+  }
+
+  .error-title {
+    margin: 0 0 12px;
+    font-size: 20px;
+    font-weight: 600;
+    color: var(--dark-color);
+  }
+
+  .error-message {
+    margin: 0 0 24px;
+    font-size: 15px;
+    color: var(--medium-color);
+    line-height: 1.5;
+  }
+
+  .info-box {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    padding: 16px;
+    margin-bottom: 24px;
+    background: rgba(255, 196, 9, 0.1);
+    border-radius: var(--radius-md);
+    border-left: 4px solid var(--warning-color);
+    text-align: left;
+
+    .icon {
+      width: 24px;
+      height: 24px;
+      color: var(--warning-color);
+      flex-shrink: 0;
+      margin-top: 2px;
+    }
+
+    .info-text {
+      flex: 1;
+
+      .info-title {
+        margin: 0 0 4px;
+        font-size: 14px;
+        font-weight: 600;
+        color: var(--dark-color);
+      }
+
+      .info-desc {
+        margin: 0;
+        font-size: 13px;
+        color: var(--medium-color);
+        line-height: 1.4;
+      }
+    }
+  }
+}
+
+// 欢迎页
+.welcome-view {
+  max-width: 640px;
+  margin: 0 auto;
+  padding: 24px 16px 32px;
+
+  .user-card {
+    text-align: center;
+    margin-bottom: 24px;
+    animation: fadeInUp 0.6s ease;
+
+    .user-avatar {
+      width: 80px;
+      height: 80px;
+      margin: 0 auto 16px;
+      border-radius: 50%;
+      overflow: hidden;
+      box-shadow: var(--shadow-md);
+
+      img {
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+    }
+
+    .user-greeting {
+      margin: 0 0 8px;
+      font-size: 24px;
+      font-weight: 600;
+      color: var(--dark-color);
+    }
+
+    .user-subtitle {
+      margin: 0;
+      font-size: 15px;
+      color: var(--medium-color);
+    }
+  }
+
+  .intro-card {
+    background: var(--white);
+    border-radius: var(--radius-lg);
+    padding: 24px;
+    margin-bottom: 24px;
+    box-shadow: var(--shadow-sm);
+    animation: fadeInUp 0.6s ease 0.1s both;
+
+    .intro-header {
+      display: flex;
+      align-items: flex-start;
+      gap: 12px;
+      margin-bottom: 20px;
+
+      .icon {
+        width: 28px;
+        height: 28px;
+        color: var(--primary-color);
+        flex-shrink: 0;
+      }
+
+      h3 {
+        margin: 0;
+        font-size: 18px;
+        font-weight: 600;
+        color: var(--dark-color);
+        line-height: 1.4;
+      }
+    }
+
+    .intro-body {
+      margin-bottom: 20px;
+
+      .intro-text {
+        margin: 0 0 12px;
+        font-size: 15px;
+        color: var(--dark-color);
+        line-height: 1.6;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    .info-tags {
+      display: flex;
+      justify-content: space-around;
+      gap: 12px;
+      padding-top: 20px;
+      border-top: 1px solid var(--light-color);
+
+      .info-tag {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        gap: 8px;
+        flex: 1;
+
+        .icon {
+          width: 24px;
+          height: 24px;
+          color: var(--primary-color);
+          opacity: 0.8;
+        }
+
+        span {
+          font-size: 13px;
+          font-weight: 500;
+          color: var(--medium-color);
+        }
+      }
+    }
+  }
+
+  .btn-start {
+    width: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+    height: 56px;
+    background: var(--primary-color);
+    color: var(--white);
+    border: none;
+    border-radius: var(--radius-md);
+    font-size: 16px;
+    font-weight: 600;
+    box-shadow: var(--shadow-md);
+    cursor: pointer;
+    transition: var(--transition);
+    animation: fadeInUp 0.6s ease 0.2s both;
+
+    &:active {
+      transform: translateY(2px);
+      box-shadow: var(--shadow-sm);
+    }
+
+    .icon {
+      width: 20px;
+      height: 20px;
+    }
+  }
+}
+
+// 答题页
+.questionnaire-view {
+  max-width: 640px;
+  margin: 0 auto;
+  padding: 24px 16px 32px;
+
+  .progress-section {
+    margin-bottom: 24px;
+
+    .progress-bar-wrapper {
+      margin-bottom: 12px;
+
+      .progress-bar {
+        height: 6px;
+        background: var(--light-color);
+        border-radius: 3px;
+        overflow: hidden;
+
+        .progress-fill {
+          height: 100%;
+          background: linear-gradient(90deg, var(--primary-color) 0%, #5a9cff 100%);
+          border-radius: 3px;
+          transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
+        }
+      }
+    }
+
+    .progress-info {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      font-size: 14px;
+
+      .progress-current {
+        font-size: 18px;
+        font-weight: 600;
+        color: var(--primary-color);
+      }
+
+      .progress-separator {
+        color: var(--medium-color);
+      }
+
+      .progress-total {
+        color: var(--medium-color);
+      }
+    }
+  }
+
+  .question-card {
+    background: var(--white);
+    border-radius: var(--radius-lg);
+    padding: 24px;
+    margin-bottom: 20px;
+    box-shadow: var(--shadow-sm);
+    animation: slideInRight 0.4s ease;
+
+    .section-badge {
+      display: inline-block;
+      padding: 4px 12px;
+      margin-bottom: 16px;
+      background: rgba(56, 128, 255, 0.1);
+      color: var(--primary-color);
+      border-radius: 12px;
+      font-size: 12px;
+      font-weight: 600;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+    }
+
+    .question-content {
+      .question-title {
+        margin: 0 0 20px;
+        font-size: 17px;
+        font-weight: 600;
+        color: var(--dark-color);
+        line-height: 1.5;
+
+        .question-number {
+          color: var(--primary-color);
+          margin-right: 8px;
+        }
+
+        .question-text {
+          display: inline;
+        }
+
+        .required-star {
+          color: var(--danger-color);
+          margin-left: 4px;
+        }
+      }
+
+      .options-list {
+        display: flex;
+        flex-direction: column;
+        gap: 12px;
+
+        .option-item {
+          display: flex;
+          align-items: center;
+          gap: 12px;
+          padding: 16px;
+          background: var(--white);
+          border: 2px solid var(--light-color);
+          border-radius: var(--radius-md);
+          cursor: pointer;
+          transition: var(--transition);
+
+          &:active {
+            transform: scale(0.98);
+          }
+
+          &.selected {
+            border-color: var(--primary-color);
+            background: rgba(56, 128, 255, 0.05);
+
+            .option-radio .radio-outer {
+              border-color: var(--primary-color);
+
+              .radio-inner {
+                transform: scale(1);
+                background: var(--primary-color);
+              }
+            }
+
+            .option-checkbox .checkbox-box {
+              border-color: var(--primary-color);
+              background: var(--primary-color);
+
+              .checkmark {
+                opacity: 1;
+                transform: scale(1);
+              }
+            }
+
+            .option-label {
+              color: var(--primary-color);
+              font-weight: 600;
+            }
+          }
+
+          .option-radio {
+            flex-shrink: 0;
+
+            .radio-outer {
+              width: 24px;
+              height: 24px;
+              border: 2px solid var(--medium-color);
+              border-radius: 50%;
+              display: flex;
+              align-items: center;
+              justify-content: center;
+              transition: var(--transition);
+
+              .radio-inner {
+                width: 12px;
+                height: 12px;
+                border-radius: 50%;
+                transform: scale(0);
+                transition: var(--transition);
+              }
+            }
+          }
+
+          .option-checkbox {
+            flex-shrink: 0;
+
+            .checkbox-box {
+              width: 24px;
+              height: 24px;
+              border: 2px solid var(--medium-color);
+              border-radius: 6px;
+              display: flex;
+              align-items: center;
+              justify-content: center;
+              transition: var(--transition);
+
+              .checkmark {
+                width: 14px;
+                height: 14px;
+                color: var(--white);
+                opacity: 0;
+                transform: scale(0);
+                transition: var(--transition);
+              }
+            }
+          }
+
+          .option-label {
+            flex: 1;
+            font-size: 15px;
+            color: var(--dark-color);
+            line-height: 1.4;
+            transition: var(--transition);
+          }
+        }
+      }
+
+      .input-wrapper {
+        margin-top: 12px;
+
+        .text-input,
+        .textarea-input {
+          width: 100%;
+          padding: 14px 16px;
+          border: 2px solid var(--light-color);
+          border-radius: var(--radius-md);
+          font-size: 15px;
+          color: var(--dark-color);
+          background: var(--white);
+          font-family: inherit;
+          transition: var(--transition);
+
+          &:focus {
+            outline: none;
+            border-color: var(--primary-color);
+            box-shadow: 0 0 0 4px rgba(56, 128, 255, 0.1);
+          }
+
+          &::placeholder {
+            color: var(--medium-color);
+          }
+        }
+
+        .textarea-input {
+          resize: vertical;
+          min-height: 100px;
+          line-height: 1.5;
+        }
+      }
+    }
+  }
+
+  .nav-buttons {
+    display: flex;
+    gap: 12px;
+
+    .btn-nav {
+      flex: 1;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 8px;
+      height: 50px;
+      border: none;
+      border-radius: var(--radius-md);
+      font-size: 15px;
+      font-weight: 600;
+      cursor: pointer;
+      transition: var(--transition);
+
+      .icon {
+        width: 18px;
+        height: 18px;
+      }
+
+      &.btn-prev {
+        background: var(--white);
+        color: var(--dark-color);
+        border: 2px solid var(--light-color);
+
+        &:active:not(:disabled) {
+          transform: translateX(-2px);
+          background: var(--light-color);
+        }
+
+        &:disabled {
+          opacity: 0.4;
+          cursor: not-allowed;
+        }
+      }
+
+      &.btn-next {
+        background: var(--primary-color);
+        color: var(--white);
+        box-shadow: var(--shadow-sm);
+
+        &:active {
+          transform: translateX(2px);
+          box-shadow: none;
+        }
+      }
+    }
+  }
+}
+
+// 结果页
+.result-view {
+  max-width: 640px;
+  margin: 0 auto;
+  padding: 32px 16px;
+  text-align: center;
+
+  .success-icon-wrapper {
+    width: 80px;
+    height: 80px;
+    margin: 0 auto 24px;
+    border-radius: 50%;
+    background: rgba(45, 211, 111, 0.1);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    animation: scaleIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+
+    .success-icon {
+      width: 48px;
+      height: 48px;
+      color: var(--success-color);
+    }
+  }
+
+  .result-title {
+    margin: 0 0 8px;
+    font-size: 24px;
+    font-weight: 600;
+    color: var(--dark-color);
+    animation: fadeInUp 0.6s ease 0.1s both;
+  }
+
+  .result-subtitle {
+    margin: 0 0 8px;
+    font-size: 16px;
+    font-weight: 500;
+    color: var(--medium-color);
+    animation: fadeInUp 0.6s ease 0.2s both;
+  }
+
+  .result-desc {
+    margin: 0 0 32px;
+    font-size: 14px;
+    color: var(--medium-color);
+    animation: fadeInUp 0.6s ease 0.3s both;
+  }
+
+  .result-card {
+    background: var(--white);
+    border-radius: var(--radius-lg);
+    padding: 24px;
+    margin-bottom: 24px;
+    box-shadow: var(--shadow-sm);
+    text-align: left;
+    animation: fadeInUp 0.6s ease 0.4s both;
+
+    .result-card-title {
+      margin: 0 0 20px;
+      font-size: 16px;
+      font-weight: 600;
+      color: var(--dark-color);
+      text-align: center;
+    }
+
+    .result-list {
+      .result-item {
+        display: flex;
+        gap: 12px;
+        padding: 12px 0;
+        border-bottom: 1px solid var(--light-color);
+
+        &:first-child {
+          padding-top: 0;
+        }
+
+        &:last-child {
+          padding-bottom: 0;
+          border-bottom: none;
+        }
+
+        .result-label {
+          min-width: 80px;
+          font-size: 14px;
+          font-weight: 600;
+          color: var(--medium-color);
+          flex-shrink: 0;
+        }
+
+        .result-value {
+          flex: 1;
+          font-size: 14px;
+          color: var(--dark-color);
+          line-height: 1.5;
+          word-break: break-word;
+        }
+      }
+    }
+
+    .result-divider {
+      height: 1px;
+      background: var(--light-color);
+      margin: 20px 0;
+    }
+  }
+
+  .btn-primary {
+    width: 100%;
+    height: 50px;
+    background: var(--primary-color);
+    color: var(--white);
+    border: none;
+    border-radius: var(--radius-md);
+    font-size: 16px;
+    font-weight: 600;
+    box-shadow: var(--shadow-sm);
+    cursor: pointer;
+    transition: var(--transition);
+    animation: fadeInUp 0.6s ease 0.5s both;
+
+    &:active {
+      transform: translateY(2px);
+      box-shadow: none;
+    }
+  }
+}
+
+// 动画
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes slideInRight {
+  from {
+    opacity: 0;
+    transform: translateX(30px);
+  }
+  to {
+    opacity: 1;
+    transform: translateX(0);
+  }
+}
+
+@keyframes scaleIn {
+  from {
+    opacity: 0;
+    transform: scale(0);
+  }
+  to {
+    opacity: 1;
+    transform: scale(1);
+  }
+}
+
+// 平板适配 (>= 768px)
+@media (min-width: 768px) {
+  .survey-header .header-content {
+    max-width: 768px;
+  }
+
+  .welcome-view,
+  .questionnaire-view,
+  .result-view {
+    max-width: 768px;
+    padding-left: 32px;
+    padding-right: 32px;
+  }
+
+  .intro-card,
+  .question-card,
+  .result-card {
+    padding: 32px;
+  }
+
+  .btn-start,
+  .btn-primary {
+    height: 56px;
+    font-size: 17px;
+  }
+
+  .nav-buttons .btn-nav {
+    height: 52px;
+  }
+}
+
+// 小屏适配 (<= 375px)
+@media (max-width: 375px) {
+  .survey-header {
+    .header-title {
+      font-size: 16px;
+    }
+  }
+
+  .welcome-view {
+    .user-greeting {
+      font-size: 20px;
+    }
+
+    .intro-card {
+      padding: 20px;
+
+      .intro-header h3 {
+        font-size: 16px;
+      }
+
+      .intro-text {
+        font-size: 14px;
+      }
+    }
+  }
+
+  .questionnaire-view {
+    .question-card {
+      padding: 20px;
+
+      .question-title {
+        font-size: 16px;
+      }
+
+      .option-item {
+        padding: 14px;
+
+        .option-label {
+          font-size: 14px;
+        }
+      }
+    }
+
+    .nav-buttons .btn-nav {
+      height: 48px;
+      font-size: 14px;
+    }
+  }
+
+  .result-view {
+    .result-title {
+      font-size: 20px;
+    }
+
+    .result-card {
+      padding: 20px;
+    }
+  }
+}

+ 544 - 0
src/modules/project/pages/project-survey/project-survey.component.ts

@@ -0,0 +1,544 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router, ActivatedRoute } from '@angular/router';
+import { IonicModule } from '@ionic/angular';
+import { FormsModule } from '@angular/forms';
+import { WxworkAuth } from 'fmode-ng/core';
+import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
+
+const Parse = FmodeParse.with('nova');
+
+type SurveyState = 'welcome' | 'questionnaire' | 'result';
+
+interface Question {
+  id: string;
+  section: string;
+  title: string;
+  type: 'single' | 'multiple' | 'text' | 'number';
+  options?: string[];
+  hasOther?: boolean;
+  required?: boolean;
+  placeholder?: string;
+  skipCondition?: (contact: any) => boolean;
+}
+
+/**
+ * 项目问卷组件
+ *
+ * 功能:
+ * 1. 外部联系人填写项目需求问卷
+ * 2. 三种状态: 欢迎页、答题页、结果页
+ * 3. 自动保存进度,支持中途退出恢复
+ * 4. 联系人信息自动补全到ContactInfo
+ *
+ * 路由: /wxwork/:cid/survey/project/:projectId
+ */
+@Component({
+  selector: 'app-project-survey',
+  standalone: true,
+  imports: [CommonModule, IonicModule, FormsModule],
+  templateUrl: './project-survey.component.html',
+  styleUrls: ['./project-survey.component.scss']
+})
+export class ProjectSurveyComponent implements OnInit {
+  // 路由参数
+  cid: string = '';
+  projectId: string = '';
+
+  // 企微授权
+  wxAuth: WxworkAuth | null = null;
+
+  // 当前状态
+  currentState: SurveyState = 'welcome';
+
+  // 加载状态
+  loading: boolean = true;
+  error: string | null = null;
+  isCustomerOnly: boolean = false; // 是否仅限客户填写
+
+  // 数据对象
+  project: FmodeObject | null = null;
+  currentContact: FmodeObject | null = null;
+  surveyLog: FmodeObject | null = null;
+
+  // 答题数据
+  answers: any = {};
+  currentQuestionIndex: number = 0;
+  questions: Question[] = [];
+  effectiveQuestions: Question[] = []; // 过滤掉跳过题目后的有效题目
+  otherInput: string = ''; // "其他"选项的输入内容
+  showOtherInput: boolean = false;
+
+  constructor(
+    private router: Router,
+    private route: ActivatedRoute
+  ) {}
+
+  async ngOnInit() {
+    // 获取路由参数
+    this.cid = this.route.snapshot.paramMap.get('cid') || '';
+    this.projectId = this.route.snapshot.paramMap.get('projectId') || '';
+
+    // 初始化题目列表
+    this.initQuestions();
+
+    // 初始化企微授权
+    await this.initWxworkAuth();
+
+    // 加载数据
+    await this.loadData();
+  }
+
+  /**
+   * 初始化题目列表
+   */
+  initQuestions() {
+    this.questions = [
+      // 一、基础需求
+      {
+        id: 'q1',
+        section: '基础需求',
+        title: '本次您需要的核心服务是?',
+        type: 'single',
+        options: ['纯效果图渲染', '效果图+技术配合'],
+        hasOther: true,
+        required: true
+      },
+      {
+        id: 'q2',
+        section: '基础需求',
+        title: '需覆盖的关键空间数量及类型?',
+        type: 'text',
+        placeholder: '例: 3个,客厅/主卧/儿童房',
+        required: true
+      },
+
+      // 二、核心侧重
+      {
+        id: 'q3',
+        section: '核心侧重',
+        title: '您更希望本次效果图突出哪些价值?(可多选2-3项)',
+        type: 'multiple',
+        options: ['细节写实度', '视觉吸引力', '风格适配性'],
+        hasOther: true,
+        required: true
+      },
+      {
+        id: 'q4',
+        section: '核心侧重',
+        title: '关于方案建议,是否需要我们技术团队配合?',
+        type: 'single',
+        options: ['需要', '暂不需要'],
+        required: true
+      },
+
+      // 三、协作节奏
+      {
+        id: 'q5',
+        section: '协作节奏',
+        title: '您偏好的服务协作方式是?',
+        type: 'single',
+        options: ['前期多沟通', '先出初版再修改', '灵活协调'],
+        required: true
+      },
+
+      // 四、特殊提醒
+      {
+        id: 'q6',
+        section: '特殊提醒',
+        title: '过往合作中,是否有需要特别注意的点?(可多选)',
+        type: 'multiple',
+        options: ['软装色调易偏差', '建模细节需盯控'],
+        hasOther: true
+      },
+      {
+        id: 'q7',
+        section: '特殊提醒',
+        title: '本次项目是否有特殊要求?(如业主禁忌、重点展示点)',
+        type: 'text',
+        placeholder: '请输入特殊要求...'
+      },
+      {
+        id: 'q8',
+        section: '特殊提醒',
+        title: '是否有参考素材?(如风格图、实景图)',
+        type: 'single',
+        options: ['有(后续群内发送)', '无(需求已清晰)']
+      },
+
+      // 联系信息(自动跳过)
+      {
+        id: 'contact_name',
+        section: '联系信息',
+        title: '对接人姓名',
+        type: 'text',
+        required: true,
+        skipCondition: (contact) => !!contact?.get('realname')
+      },
+      {
+        id: 'contact_phone',
+        section: '联系信息',
+        title: '对接人电话',
+        type: 'text',
+        required: true,
+        skipCondition: (contact) => !!contact?.get('mobile')
+      }
+    ];
+  }
+
+  /**
+   * 初始化企微授权
+   */
+  async initWxworkAuth() {
+    try {
+      if (!this.cid) {
+        throw new Error('未找到company ID (cid)');
+      }
+
+      this.wxAuth = new WxworkAuth({ cid: this.cid, appId: 'crm' });
+      console.log('✅ 企微授权初始化成功');
+    } catch (error) {
+      console.error('❌ 企微授权初始化失败:', error);
+      this.error = '初始化失败,请稍后重试';
+    }
+  }
+
+  /**
+   * 加载数据
+   */
+  async loadData() {
+    try {
+      this.loading = true;
+
+      // 1. 获取当前外部联系人
+      if (!this.wxAuth) {
+        this.isCustomerOnly = true;
+        this.error = '该问卷仅供客户填写,请通过企微群聊进入';
+        // return;
+      }
+
+      try {
+        this.currentContact = await this.wxAuth.currentContact();
+        console.log('✅ 当前联系人:', this.currentContact?.get('name'));
+
+        // 验证是否为外部联系人
+        if (!this.currentContact || !this.currentContact.id) {
+          console.warn('⚠️ 未找到外部联系人信息');
+          this.isCustomerOnly = true;
+          this.error = '该问卷仅供客户填写';
+          // return;
+        }
+      } catch (error) {
+        console.error('❌ 获取联系人失败:', error);
+        this.isCustomerOnly = true;
+        this.error = '该问卷仅供客户填写,请通过企微群聊进入';
+        // return;
+      }
+
+      // 2. 加载项目
+      if (this.projectId) {
+        const query = new Parse.Query('Project');
+        query.include('contact');
+        this.project = await query.get(this.projectId);
+        console.log('✅ 项目加载成功:', this.project.get('title'));
+      }
+
+      // 3. 过滤有效题目(根据skipCondition)
+      this.effectiveQuestions = this.questions.filter(q => {
+        if (q.skipCondition) {
+          return !q.skipCondition(this.currentContact);
+        }
+        return true;
+      });
+
+      // 4. 检查是否已填写问卷
+      await this.checkExistingSurvey();
+
+    } catch (err: any) {
+      console.error('❌ 加载失败:', err);
+      this.error = err.message || '加载失败';
+    } finally {
+      this.loading = false;
+    }
+  }
+
+  /**
+   * 检查现有问卷
+   */
+  async checkExistingSurvey() {
+    if (!this.project) return;
+
+    try {
+      const query = new Parse.Query('SurveyLog');
+      query.equalTo('project', this.project.toPointer());
+      query.equalTo('type', 'survey-project');
+
+      this.surveyLog = await query.first();
+
+      if (this.surveyLog?.get('isCompleted')) {
+        // 已完成,直接显示结果
+        this.currentState = 'result';
+        this.answers = this.surveyLog.get('data') || {};
+        console.log('✅ 问卷已完成,显示结果');
+      } else if (this.surveyLog) {
+        // 未完成,恢复进度
+        this.answers = this.surveyLog.get('data') || {};
+        console.log('✅ 恢复问卷进度,已填写', Object.keys(this.answers).length, '题');
+      }
+    } catch (err) {
+      console.error('❌ 查询问卷失败:', err);
+    }
+  }
+
+  /**
+   * 开始填写问卷
+   */
+  startSurvey() {
+    this.currentState = 'questionnaire';
+    this.currentQuestionIndex = 0;
+  }
+
+  /**
+   * 获取当前题目
+   */
+  getCurrentQuestion(): Question | null {
+    if (this.currentQuestionIndex < 0 || this.currentQuestionIndex >= this.effectiveQuestions.length) {
+      return null;
+    }
+    return this.effectiveQuestions[this.currentQuestionIndex];
+  }
+
+  /**
+   * 上一题
+   */
+  previousQuestion() {
+    if (this.currentQuestionIndex > 0) {
+      this.currentQuestionIndex--;
+      this.showOtherInput = false;
+      this.otherInput = '';
+    }
+  }
+
+  /**
+   * 下一题
+   */
+  async nextQuestion() {
+    const question = this.getCurrentQuestion();
+    if (!question) return;
+
+    // 验证必填
+    if (question.required && !this.answers[question.id]) {
+      alert('请完成当前题目');
+      return;
+    }
+
+    // 处理"其他"选项
+    if (this.showOtherInput && this.otherInput.trim()) {
+      this.answers[question.id] = '其他:' + this.otherInput.trim();
+    }
+
+    // 保存当前答案
+    await this.saveAnswer(question.id, this.answers[question.id]);
+
+    // 重置"其他"输入
+    this.showOtherInput = false;
+    this.otherInput = '';
+
+    // 检查是否最后一题
+    if (this.currentQuestionIndex >= this.effectiveQuestions.length - 1) {
+      // 最后一题,提交问卷
+      await this.completeSurvey();
+    } else {
+      // 下一题
+      this.currentQuestionIndex++;
+    }
+  }
+
+  /**
+   * 选择单选项
+   */
+  async selectSingleOption(option: string) {
+    const question = this.getCurrentQuestion();
+    if (!question) return;
+
+    this.answers[question.id] = option;
+
+    // 检查是否是"其他"选项
+    if (question.hasOther && option.startsWith('其他')) {
+      this.showOtherInput = true;
+      this.otherInput = '';
+    } else {
+      this.showOtherInput = false;
+      // 非"其他"选项,自动跳转下一题
+      await this.nextQuestion();
+    }
+  }
+
+  /**
+   * 切换多选项
+   */
+  toggleMultipleOption(option: string) {
+    const question = this.getCurrentQuestion();
+    if (!question) return;
+
+    let selected = this.answers[question.id] || [];
+    if (!Array.isArray(selected)) {
+      selected = [];
+    }
+
+    const index = selected.indexOf(option);
+    if (index > -1) {
+      selected.splice(index, 1);
+    } else {
+      selected.push(option);
+    }
+
+    this.answers[question.id] = selected;
+
+    // 检查是否选择了"其他"
+    if (question.hasOther && selected.some((s: string) => s.startsWith('其他'))) {
+      this.showOtherInput = true;
+    } else {
+      this.showOtherInput = false;
+    }
+  }
+
+  /**
+   * 检查多选答案是否包含指定选项
+   */
+  hasMultipleOption(questionId: string, option: string): boolean {
+    const answers = this.answers[questionId];
+    if (!Array.isArray(answers)) return false;
+    return answers.includes(option);
+  }
+
+  /**
+   * 检查多选答案是否包含以指定文本开头的选项
+   */
+  hasMultipleOptionStartsWith(questionId: string, prefix: string): boolean {
+    const answers = this.answers[questionId];
+    if (!Array.isArray(answers)) return false;
+    return answers.some(s => s.startsWith(prefix));
+  }
+
+  /**
+   * 保存答案
+   */
+  async saveAnswer(questionId: string, answer: any) {
+    try {
+      if (!this.surveyLog) {
+        // 首次保存,创建记录
+        const SurveyLog = Parse.Object.extend('SurveyLog');
+        this.surveyLog = new SurveyLog();
+
+        const company = new Parse.Object('Company');
+        company.id = localStorage.getItem('company') || this.cid;
+
+        this.surveyLog.set('company', company.toPointer());
+        this.surveyLog.set('project', this.project!.toPointer());
+        this.surveyLog.set('contact', this.currentContact!.toPointer());
+        this.surveyLog.set('type', 'survey-project');
+      }
+
+      // 更新答案
+      const data = this.surveyLog.get('data') || {};
+      data[questionId] = answer;
+      this.surveyLog.set('data', data);
+
+      await this.surveyLog.save();
+      console.log('✅ 答案已保存:', questionId);
+    } catch (err) {
+      console.error('❌ 保存答案失败:', err);
+    }
+  }
+
+  /**
+   * 完成问卷
+   */
+  async completeSurvey() {
+    if (!this.surveyLog) return;
+
+    try {
+      this.surveyLog.set('isCompleted', true);
+      this.surveyLog.set('completedAt', new Date());
+      await this.surveyLog.save();
+
+      // 同步联系人信息
+      await this.updateContactInfo();
+
+      // 切换到结果页
+      this.currentState = 'result';
+      console.log('✅ 问卷提交成功');
+    } catch (err) {
+      console.error('❌ 提交问卷失败:', err);
+      alert('提交失败,请重试');
+    }
+  }
+
+  /**
+   * 更新联系人信息
+   */
+  async updateContactInfo() {
+    const data = this.surveyLog?.get('data') || {};
+
+    if (!this.currentContact) return;
+
+    try {
+      let updated = false;
+
+      if (data.contact_name && !this.currentContact.get('realname')) {
+        this.currentContact.set('realname', data.contact_name);
+        updated = true;
+      }
+
+      if (data.contact_phone && !this.currentContact.get('mobile')) {
+        this.currentContact.set('mobile', data.contact_phone);
+        updated = true;
+      }
+
+      if (updated) {
+        await this.currentContact.save();
+        console.log('✅ 联系人信息已更新');
+      }
+    } catch (err) {
+      console.error('❌ 更新联系人信息失败:', err);
+    }
+  }
+
+  /**
+   * 获取进度百分比
+   */
+  getProgress(): number {
+    if (this.effectiveQuestions.length === 0) return 0;
+    return Math.round((this.currentQuestionIndex / this.effectiveQuestions.length) * 100);
+  }
+
+  /**
+   * 格式化结果
+   */
+  getFormattedAnswer(questionId: string): string {
+    const answer = this.answers[questionId];
+    if (!answer) return '-';
+
+    if (Array.isArray(answer)) {
+      return answer.join('、');
+    }
+
+    return answer.toString();
+  }
+
+  /**
+   * 脱敏手机号
+   */
+  maskPhone(phone: string): string {
+    if (!phone || phone.length < 11) return phone;
+    return phone.slice(0, 3) + '****' + phone.slice(7);
+  }
+
+  /**
+   * 返回项目详情
+   */
+  goBack() {
+      this.router.navigate(['/wxwork', this.cid, 'project', this.projectId]);
+  }
+}