Explorar el Código

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

0235711 hace 2 días
padre
commit
e6af9d6a18
Se han modificado 30 ficheros con 3149 adiciones y 455 borrados
  1. 37 0
      CHANGELOG.md
  2. 649 0
      docs/prd/组件-项目问卷.md
  3. 1 1
      docs/service-integration-complete.md
  4. 6 4
      npminstall-debug.log
  5. 1 1
      package.json
  6. 19 1
      rules/schemas.md
  7. 15 8
      src/app/app.routes.ts
  8. 14 11
      src/app/pages/admin/customers/customers.html
  9. 10 174
      src/app/pages/admin/customers/customers.scss
  10. 31 62
      src/app/pages/admin/customers/customers.ts
  11. 50 18
      src/app/pages/admin/employees/employees.html
  12. 16 44
      src/app/pages/admin/employees/employees.scss
  13. 33 7
      src/app/pages/admin/employees/employees.ts
  14. 6 0
      src/app/wxwork-auth-guard.ts
  15. 71 0
      src/modules/project/components/contact-selector/contact-selector.component.html
  16. 30 0
      src/modules/project/components/contact-selector/contact-selector.component.scss
  17. 195 0
      src/modules/project/components/contact-selector/contact-selector.component.ts
  18. 15 0
      src/modules/project/components/project-members-modal/project-members-modal.component.html
  19. 32 0
      src/modules/project/components/project-members-modal/project-members-modal.component.ts
  20. 4 2
      src/modules/project/pages/contact/contact.component.html
  21. 65 54
      src/modules/project/pages/contact/contact.component.scss
  22. 72 29
      src/modules/project/pages/contact/contact.component.ts
  23. 102 33
      src/modules/project/pages/project-detail/project-detail.component.html
  24. 303 0
      src/modules/project/pages/project-detail/project-detail.component.scss
  25. 146 3
      src/modules/project/pages/project-detail/project-detail.component.ts
  26. 1 1
      src/modules/project/pages/project-detail/stages/stage-order.component.ts
  27. 2 2
      src/modules/project/pages/project-loader/project-loader.component.ts
  28. 325 0
      src/modules/project/pages/project-survey/project-survey.component.html
  29. 367 0
      src/modules/project/pages/project-survey/project-survey.component.scss
  30. 531 0
      src/modules/project/pages/project-survey/project-survey.component.ts

+ 37 - 0
CHANGELOG.md

@@ -0,0 +1,37 @@
+# 更新日志
+
+## 2025-10-24
+### 项目启动访谈问卷
+
+### 组员管理(项目详情页)
+- 增加删除组员功能,将组员移出项目组
+
+## 2025-10-23
+
+### 员工管理(后台)
+- 员工列表显示头像与职位,缺失头像自动使用统一占位图,列表更整齐。
+- 员工详情弹窗更丰富:手机号、邮箱、企微ID、身份、部门、入职时间、技能与工作量等一目了然。
+- 弹窗样式与布局优化,对齐与间距更合理,信息更易读。
+
+### 表格与界面一致性
+- 多页面的列宽与对齐优化,整体信息密度与可读性提升,浏览体验更统一。
+
+### 稳定性与兼容性
+- 刷新后列表自动同步最新数据,减少信息不一致的情况。
+- 无跟进记录时自动显示历史跟进信息,保证页面内容完整性。
+## 2025-10-22
+
+### 客户选择与详情体验
+- 项目页支持便捷选择或创建客户,已建档/未建档清晰分区,搜索更高效。
+- 新增“一键刷新客户信息”,可同步企业微信的最新资料,名单与详情保持一致。
+- 客户详情以侧栏弹窗方式展示,点击返回或遮罩即可关闭,不会跳转到错误页面。
+- 跟进记录默认显示当前项目的记录,支持切换查看该客户的全部跟进历史。
+
+### 客户信息显示优化
+- 统一头像占位图为 `/assets/images/default-avatar.svg`,列表与详情一致,缺失头像时显示更友好。
+- “所在群聊”改为纵向列表,信息展示更完整,阅读更舒适。
+
+### 客户管理(后台)
+- 客户列表增加头像、类型、名称等信息显示,点击即可查看详情。
+- 在详情中可直接刷新客户数据,确保资料实时准确。
+

+ 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注册与签名

+ 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)
       );
     }
 

+ 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 { 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; }
+}

+ 15 - 0
src/modules/project/components/project-members-modal/project-members-modal.component.html

@@ -180,6 +180,21 @@
                   </button>
                 }
 
+                <!-- 新增:移出项目 -->
+                @if (member.isInProjectTeam) {
+                  <button
+                    class="action-btn remove-btn"
+                    (click)="removeMemberFromProject(member)"
+                    title="移出项目">
+                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                      <circle cx="12" cy="12" r="10"></circle>
+                      <line x1="15" y1="9" x2="9" y2="15"></line>
+                      <line x1="9" y1="9" x2="15" y2="15"></line>
+                    </svg>
+                    <span>移出项目</span>
+                  </button>
+                }
+
                 <div class="status-indicator" [class]="getMemberStatusClass(member)">
                   @if (member.isInProjectTeam && member.isInGroupChat) {
                     <svg viewBox="0 0 24 24" fill="currentColor">

+ 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 '客服':

+ 4 - 2
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">
@@ -248,7 +248,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;
+    }
+  }
 }

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

@@ -51,47 +51,116 @@
 
   <!-- 项目详情内容 -->
   @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) {
+      <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">
+                  {{ contact.get('realname') || 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>
+
+        <!-- 问卷说明 -->
+        <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;
+          }
+        }
+      }
+    }
+  }
+}

+ 146 - 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,20 @@ 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;
+  } = {
+    filled: false,
+    text: '发送问卷',
+    icon: 'document-text-outline'
+  };
 
   constructor(
     private router: Router,
@@ -206,6 +233,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 +490,126 @@ 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 || !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
+        };
+        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) {

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

@@ -0,0 +1,325 @@
+<ion-header>
+  <ion-toolbar>
+    <ion-buttons slot="start">
+      <ion-button (click)="goBack()">
+        <ion-icon name="arrow-back"></ion-icon>
+      </ion-button>
+    </ion-buttons>
+    <ion-title>项目需求调查</ion-title>
+  </ion-toolbar>
+</ion-header>
+
+<ion-content class="survey-content">
+  <!-- 加载状态 -->
+  @if (loading) {
+    <div class="loading-container">
+      <ion-spinner name="crescent"></ion-spinner>
+      <p>加载中...</p>
+    </div>
+  }
+
+  <!-- 错误状态 -->
+  @if (error && !loading) {
+    <div class="error-container">
+      <ion-icon name="alert-circle-outline"></ion-icon>
+      <p>{{ error }}</p>
+      <ion-button (click)="goBack()">返回</ion-button>
+    </div>
+  }
+
+  <!-- 欢迎页 -->
+  @if (currentState === 'welcome' && !loading && !error) {
+    <div class="welcome-page">
+      <div class="welcome-header">
+        @if (currentContact) {
+          <div class="user-avatar">
+            <img [src]="currentContact.get('data')?.avatar || 'assets/default-avatar.png'" alt="头像" />
+          </div>
+          <h2>您好,{{ currentContact.get('realname') || currentContact.get('name') }}</h2>
+        }
+      </div>
+
+      <div class="welcome-content">
+        <h1>《家装效果图服务初次合作需求调查表》</h1>
+
+        <div class="welcome-intro">
+          <p>尊敬的伙伴:</p>
+          <p>为让本次效果图服务更贴合您的工作节奏与核心需求,我们准备了简短选择式问卷,您的偏好将直接帮我们校准服务方向,感谢支持!</p>
+        </div>
+
+        <div class="survey-info">
+          <div class="info-item">
+            <ion-icon name="time-outline"></ion-icon>
+            <span>预计用时: 3-5分钟</span>
+          </div>
+          <div class="info-item">
+            <ion-icon name="list-outline"></ion-icon>
+            <span>题目数量: {{ effectiveQuestions.length }}题</span>
+          </div>
+          <div class="info-item">
+            <ion-icon name="checkmark-circle-outline"></ion-icon>
+            <span>题型: 选择题为主</span>
+          </div>
+        </div>
+
+        <ion-button expand="block" size="large" (click)="startSurvey()" class="start-button">
+          开始填写
+        </ion-button>
+      </div>
+    </div>
+  }
+
+  <!-- 答题页 -->
+  @if (currentState === 'questionnaire' && !loading && !error) {
+    <div class="questionnaire-page">
+      <!-- 进度条 -->
+      <div class="progress-bar">
+        <div class="progress-fill" [style.width.%]="getProgress()"></div>
+      </div>
+      <div class="progress-text">
+        {{ currentQuestionIndex + 1 }} / {{ effectiveQuestions.length }}
+      </div>
+
+      @if (getCurrentQuestion(); as question) {
+        <div class="question-container">
+          <!-- 章节标题 -->
+          <div class="section-title">{{ question.section }}</div>
+
+          <!-- 题目 -->
+          <div class="question-title">
+            <span class="question-number">{{ currentQuestionIndex + 1 }}.</span>
+            {{ question.title }}
+            @if (question.required) {
+              <span class="required-mark">*</span>
+            }
+          </div>
+
+          <!-- 单选题 -->
+          @if (question.type === 'single') {
+            <div class="options-container">
+              @for (option of question.options; track option) {
+                <div
+                  class="option-item"
+                  [class.selected]="answers[question.id] === option"
+                  (click)="selectSingleOption(option)"
+                >
+                  <div class="option-radio">
+                    @if (answers[question.id] === option) {
+                      <ion-icon name="radio-button-on"></ion-icon>
+                    } @else {
+                      <ion-icon name="radio-button-off"></ion-icon>
+                    }
+                  </div>
+                  <div class="option-text">{{ option }}</div>
+                </div>
+              }
+
+              @if (question.hasOther) {
+                <div
+                  class="option-item"
+                  [class.selected]="answers[question.id]?.startsWith('其他')"
+                  (click)="selectSingleOption('其他')"
+                >
+                  <div class="option-radio">
+                    @if (answers[question.id]?.startsWith('其他')) {
+                      <ion-icon name="radio-button-on"></ion-icon>
+                    } @else {
+                      <ion-icon name="radio-button-off"></ion-icon>
+                    }
+                  </div>
+                  <div class="option-text">其他</div>
+                </div>
+              }
+            </div>
+
+            @if (showOtherInput) {
+              <div class="other-input-container">
+                <ion-input
+                  [(ngModel)]="otherInput"
+                  placeholder="请输入其他内容..."
+                  class="other-input"
+                ></ion-input>
+              </div>
+            }
+          }
+
+          <!-- 多选题 -->
+          @if (question.type === 'multiple') {
+            <div class="options-container">
+              @for (option of question.options; track option) {
+                <div
+                  class="option-item"
+                  [class.selected]="hasMultipleOption(question.id, option)"
+                  (click)="toggleMultipleOption(option)"
+                >
+                  <div class="option-checkbox">
+                    @if (hasMultipleOption(question.id, option)) {
+                      <ion-icon name="checkbox"></ion-icon>
+                    } @else {
+                      <ion-icon name="square-outline"></ion-icon>
+                    }
+                  </div>
+                  <div class="option-text">{{ option }}</div>
+                </div>
+              }
+
+              @if (question.hasOther) {
+                <div
+                  class="option-item"
+                  [class.selected]="hasMultipleOptionStartsWith(question.id, '其他')"
+                  (click)="toggleMultipleOption('其他')"
+                >
+                  <div class="option-checkbox">
+                    @if (hasMultipleOptionStartsWith(question.id, '其他')) {
+                      <ion-icon name="checkbox"></ion-icon>
+                    } @else {
+                      <ion-icon name="square-outline"></ion-icon>
+                    }
+                  </div>
+                  <div class="option-text">其他</div>
+                </div>
+              }
+            </div>
+
+            @if (showOtherInput) {
+              <div class="other-input-container">
+                <ion-input
+                  [(ngModel)]="otherInput"
+                  placeholder="请输入其他内容..."
+                  class="other-input"
+                ></ion-input>
+              </div>
+            }
+          }
+
+          <!-- 文本题 -->
+          @if (question.type === 'text') {
+            <div class="text-input-container">
+              <ion-textarea
+                [(ngModel)]="answers[question.id]"
+                [placeholder]="question.placeholder || '请输入...'"
+                rows="4"
+                class="text-input"
+              ></ion-textarea>
+            </div>
+          }
+
+          <!-- 数字题 -->
+          @if (question.type === 'number') {
+            <div class="number-input-container">
+              <ion-input
+                type="number"
+                [(ngModel)]="answers[question.id]"
+                [placeholder]="question.placeholder || '请输入数字...'"
+                class="number-input"
+              ></ion-input>
+            </div>
+          }
+        </div>
+
+        <!-- 导航按钮 -->
+        <div class="nav-buttons">
+          <ion-button
+            fill="outline"
+            (click)="previousQuestion()"
+            [disabled]="currentQuestionIndex === 0"
+          >
+            <ion-icon name="chevron-back" slot="start"></ion-icon>
+            上一题
+          </ion-button>
+
+          <ion-button
+            (click)="nextQuestion()"
+          >
+            @if (currentQuestionIndex >= effectiveQuestions.length - 1) {
+              提交
+            } @else {
+              下一题
+            }
+            <ion-icon name="chevron-forward" slot="end"></ion-icon>
+          </ion-button>
+        </div>
+      }
+    </div>
+  }
+
+  <!-- 结果页 -->
+  @if (currentState === 'result' && !loading && !error) {
+    <div class="result-page">
+      <div class="result-header">
+        <ion-icon name="checkmark-circle" color="success"></ion-icon>
+        <h2>问卷提交成功</h2>
+        <p>感谢您的反馈!</p>
+        <p>我们将根据您的选择制定服务方案</p>
+      </div>
+
+      <div class="result-content">
+        <h3>【您的答卷】</h3>
+
+        <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 class="result-divider"></div>
+
+        <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>
+
+      <ion-button expand="block" size="large" (click)="goBack()" class="back-button">
+        返回项目
+      </ion-button>
+    </div>
+  }
+</ion-content>

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

@@ -0,0 +1,367 @@
+.survey-content {
+  --background: #f5f5f5;
+}
+
+// 加载和错误状态
+.loading-container,
+.error-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 60vh;
+  padding: 2rem;
+  text-align: center;
+
+  ion-icon {
+    font-size: 4rem;
+    color: var(--ion-color-medium);
+    margin-bottom: 1rem;
+  }
+
+  p {
+    font-size: 1rem;
+    color: var(--ion-color-medium);
+    margin: 1rem 0;
+  }
+}
+
+// 欢迎页
+.welcome-page {
+  padding: 2rem 1.5rem;
+  max-width: 600px;
+  margin: 0 auto;
+
+  .welcome-header {
+    text-align: center;
+    margin-bottom: 2rem;
+
+    .user-avatar {
+      width: 80px;
+      height: 80px;
+      margin: 0 auto 1rem;
+      border-radius: 50%;
+      overflow: hidden;
+      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+
+      img {
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+    }
+
+    h2 {
+      font-size: 1.5rem;
+      font-weight: 600;
+      color: var(--ion-color-dark);
+      margin: 0;
+    }
+  }
+
+  .welcome-content {
+    background: white;
+    border-radius: 12px;
+    padding: 2rem;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+
+    h1 {
+      font-size: 1.25rem;
+      font-weight: 600;
+      color: var(--ion-color-primary);
+      margin: 0 0 1.5rem;
+      text-align: center;
+      line-height: 1.6;
+    }
+
+    .welcome-intro {
+      margin-bottom: 2rem;
+      line-height: 1.8;
+
+      p {
+        margin: 0 0 1rem;
+        color: var(--ion-color-dark);
+        font-size: 0.95rem;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    .survey-info {
+      background: #f9f9f9;
+      border-radius: 8px;
+      padding: 1.5rem;
+      margin-bottom: 2rem;
+
+      .info-item {
+        display: flex;
+        align-items: center;
+        margin-bottom: 0.75rem;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        ion-icon {
+          font-size: 1.25rem;
+          color: var(--ion-color-primary);
+          margin-right: 0.75rem;
+        }
+
+        span {
+          font-size: 0.95rem;
+          color: var(--ion-color-dark);
+        }
+      }
+    }
+
+    .start-button {
+      margin-top: 1.5rem;
+      --border-radius: 8px;
+      font-weight: 600;
+    }
+  }
+}
+
+// 答题页
+.questionnaire-page {
+  padding: 1.5rem;
+  max-width: 600px;
+  margin: 0 auto;
+
+  .progress-bar {
+    height: 4px;
+    background: #e0e0e0;
+    border-radius: 2px;
+    overflow: hidden;
+    margin-bottom: 0.5rem;
+
+    .progress-fill {
+      height: 100%;
+      background: var(--ion-color-primary);
+      transition: width 0.3s ease;
+    }
+  }
+
+  .progress-text {
+    text-align: center;
+    font-size: 0.875rem;
+    color: var(--ion-color-medium);
+    margin-bottom: 1.5rem;
+  }
+
+  .question-container {
+    background: white;
+    border-radius: 12px;
+    padding: 2rem;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+    margin-bottom: 1.5rem;
+
+    .section-title {
+      font-size: 0.875rem;
+      color: var(--ion-color-primary);
+      font-weight: 600;
+      margin-bottom: 1rem;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+    }
+
+    .question-title {
+      font-size: 1.125rem;
+      font-weight: 600;
+      color: var(--ion-color-dark);
+      margin-bottom: 1.5rem;
+      line-height: 1.6;
+
+      .question-number {
+        color: var(--ion-color-primary);
+        margin-right: 0.5rem;
+      }
+
+      .required-mark {
+        color: var(--ion-color-danger);
+        margin-left: 0.25rem;
+      }
+    }
+
+    .options-container {
+      .option-item {
+        display: flex;
+        align-items: center;
+        padding: 1rem;
+        margin-bottom: 0.75rem;
+        border: 2px solid #e0e0e0;
+        border-radius: 8px;
+        cursor: pointer;
+        transition: all 0.2s ease;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        &:hover {
+          border-color: var(--ion-color-primary-tint);
+          background: #f9f9f9;
+        }
+
+        &.selected {
+          border-color: var(--ion-color-primary);
+          background: var(--ion-color-primary-tint);
+
+          .option-text {
+            color: var(--ion-color-primary);
+            font-weight: 600;
+          }
+        }
+
+        .option-radio,
+        .option-checkbox {
+          font-size: 1.5rem;
+          margin-right: 0.75rem;
+          display: flex;
+          align-items: center;
+
+          ion-icon {
+            color: var(--ion-color-primary);
+          }
+        }
+
+        .option-text {
+          flex: 1;
+          font-size: 1rem;
+          color: var(--ion-color-dark);
+        }
+      }
+    }
+
+    .other-input-container,
+    .text-input-container,
+    .number-input-container {
+      margin-top: 1rem;
+
+      .other-input,
+      .text-input,
+      .number-input {
+        --background: #f9f9f9;
+        --padding-start: 1rem;
+        --padding-end: 1rem;
+        border-radius: 8px;
+        border: 2px solid #e0e0e0;
+      }
+    }
+  }
+
+  .nav-buttons {
+    display: flex;
+    gap: 1rem;
+
+    ion-button {
+      flex: 1;
+      --border-radius: 8px;
+      font-weight: 600;
+    }
+  }
+}
+
+// 结果页
+.result-page {
+  padding: 2rem 1.5rem;
+  max-width: 600px;
+  margin: 0 auto;
+
+  .result-header {
+    text-align: center;
+    margin-bottom: 2rem;
+
+    ion-icon {
+      font-size: 4rem;
+      margin-bottom: 1rem;
+    }
+
+    h2 {
+      font-size: 1.5rem;
+      font-weight: 600;
+      color: var(--ion-color-dark);
+      margin: 0 0 0.5rem;
+    }
+
+    p {
+      font-size: 1rem;
+      color: var(--ion-color-medium);
+      margin: 0.25rem 0;
+    }
+  }
+
+  .result-content {
+    background: white;
+    border-radius: 12px;
+    padding: 2rem;
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
+    margin-bottom: 1.5rem;
+
+    h3 {
+      font-size: 1.125rem;
+      font-weight: 600;
+      color: var(--ion-color-dark);
+      margin: 0 0 1.5rem;
+      text-align: center;
+    }
+
+    .result-item {
+      display: flex;
+      margin-bottom: 1rem;
+      padding-bottom: 1rem;
+      border-bottom: 1px solid #f0f0f0;
+
+      &:last-child {
+        margin-bottom: 0;
+        padding-bottom: 0;
+        border-bottom: none;
+      }
+
+      .result-label {
+        width: 100px;
+        font-weight: 600;
+        color: var(--ion-color-medium);
+        font-size: 0.95rem;
+        flex-shrink: 0;
+      }
+
+      .result-value {
+        flex: 1;
+        color: var(--ion-color-dark);
+        font-size: 0.95rem;
+        line-height: 1.6;
+      }
+    }
+
+    .result-divider {
+      height: 1px;
+      background: #e0e0e0;
+      margin: 1.5rem 0;
+    }
+  }
+
+  .back-button {
+    --border-radius: 8px;
+    font-weight: 600;
+  }
+}
+
+// 响应式适配
+@media (max-width: 375px) {
+  .welcome-page,
+  .questionnaire-page,
+  .result-page {
+    padding-left: 1rem;
+    padding-right: 1rem;
+  }
+
+  .welcome-page .welcome-content,
+  .questionnaire-page .question-container,
+  .result-page .result-content {
+    padding: 1.5rem;
+  }
+}

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

@@ -0,0 +1,531 @@
+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;
+
+  // 数据对象
+  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) {
+        try {
+          this.currentContact = await this.wxAuth.currentContact();
+          console.log('✅ 当前联系人:', this.currentContact?.get('name'));
+        } catch (error) {
+          console.error('❌ 获取联系人失败:', error);
+          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 || !this.currentContact) return;
+
+    try {
+      const query = new Parse.Query('SurveyLog');
+      query.equalTo('project', this.project.toPointer());
+      query.equalTo('contact', this.currentContact.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]);
+  }
+}