ryanemax 16 órája
szülő
commit
84be9c9dab

+ 632 - 91
docs/prd/组件-项目问卷.md

@@ -1,108 +1,649 @@
+# 项目问卷组件产品需求文档
 
-# 项目问卷组件及功能描述
-# 数据范式
-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 员工问卷
-
-
-# 核心组件
-- 请您根据Project.id及currentContact.id持久化该项目该联系人本次合作问卷,存储在SurveyLog表
-- 请您设计project-survey组件页面,专用于采集本项目用户的需求信息。
-- 页面需要有三种状态:欢迎阶段、答题阶段、结果报告
-    - 初始状态,欢迎阶段,通过/survey/project/:pid进入,携带WxworkAuthGuard守卫
-        - 页面初始化中采用 rules/wxwork/auth.md 外部联系人直接通过.currentContact()获取外部用户信息
-            - 此处contact填写的不同,加载和保存的SurveyLog也是不同的,有一个项目多个客户联系人填写的情况
-        - 欢迎页主要介绍问卷目标,亲切引导用户填写,帮助项目更好开展
-        - 显示当前外部联系人的currentContact加载的头像与名称
-        - 有一个开始按钮,点击后进入答题阶段
-    - 答题阶段
-        - 单屏显示一道题目,单选点击若非其他可以切换下一题,若是其他需要用户补充内容再进入下一题
-        - 若是问答题则需要用户填写完成,手动点击下一题
-        - 其中涉及到姓名、手机等选项,若ContactInfo表中已存在则隐藏,方便客户快速完成
-    - 结果展示
-        - 当查询到SurveyLog的内容完整,则默认展示结果,方便客服、组员查看。
-
-# 配合使用
-- 项目详情页的问卷发送与查看
-   - src/modules/project/pages/project-detail/project-detail.component.ts 项目详情页中,客户联系人卡片的位置,需要显示问卷的填写状态
-      - 如果已填写,打开后,弹出加载项目问卷project-survey组件,展示结果
-      - 如果未填写,提示用户点击,发送问卷,调用WxworkSDK中的ww属性进行发送
-      ``` ts
-      import { WxworkSDK, WxworkCorp, WxworkCurrentChat } from 'fmode-ng/core';
-
-      // 属性声明
-     wework: WxworkSDK;
-      // 初始化
-      let cid = localStorage.getItem("company") || "cDL6R1hgSi"
-      this.wework = new WxworkSDK({ cid: this.cid, appId: 'crm' });
-
-
-      // 具体方法
-      thi.wework.ww.openExistedChatWithMsg({
-      chatId: 'chatId123', // 当前项目详情页的groupChat.get("chat_id")
-      msg: { // 构造一个美观漂亮的链接分享,邀请客户填写
-         msgtype: 'link',
-         link: {
-            title: 'title1',
-            desc: 'desc1',
-            url: 'link1', // 参考组件路由规则
-            imgUrl: 'imgurl1' // 可用 assets/logo.jpg
-         }
+## 一、概述
+
+### 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. 本次您需要的核心服务是?  
-   □ 纯效果图渲染(仅输出可视化图像)  
-   □ 效果图+技术配合(含方案相关建议)  
-   □ 其他补充:______  
+### 一、基础需求:快速明确服务范围
+1. 本次您需要的核心服务是?
+   □ 纯效果图渲染(仅输出可视化图像)
+   □ 效果图+技术配合(含方案相关建议)
+   □ 其他补充:______
 
-2. 需覆盖的关键空间数量及类型?  
-   数量:______个(例:3个,空间类型:客厅/主卧/儿童房)  
+2. 需覆盖的关键空间数量及类型?
+   数量:______个(例:3个,空间类型:客厅/主卧/儿童房)
 
 
-## 二、核心侧重:帮我们锁定服务重点  
-3. 您更希望本次效果图突出哪些价值?(可多选,选2-3项)  
-   □ 细节写实度(如空间尺寸匹配、材质还原,贴合落地需求)  
-   □ 视觉吸引力(如氛围营造、风格亮点,方便对接业主)  
-   □ 风格适配性(精准匹配预设调性,减少后期调整)  
-   □ 其他重点:______  
+### 二、核心侧重:帮我们锁定服务重点
+3. 您更希望本次效果图突出哪些价值?(可多选,选2-3项)
+   □ 细节写实度(如空间尺寸匹配、材质还原,贴合落地需求)
+   □ 视觉吸引力(如氛围营造、风格亮点,方便对接业主)
+   □ 风格适配性(精准匹配预设调性,减少后期调整)
+   □ 其他重点:______
 
-4. 关于方案建议,是否需要我们技术团队配合?  
-   □ 需要(侧重方向:□ 材质搭配 □ 灯光布局 □ 空间优化)  
-   □ 暂不需要(已有明确方案,仅需渲染)  
+4. 关于方案建议,是否需要我们技术团队配合?
+   □ 需要(侧重方向:□ 材质搭配 □ 灯光布局 □ 空间优化)
+   □ 暂不需要(已有明确方案,仅需渲染)
 
 
-## 三、协作节奏:匹配您的沟通习惯  
-5. 您偏好的服务协作方式是?  
-   □ 前期多沟通(确认方向、细节后再推进,减少返工)  
-   □ 先出初版再修改(快速看到成果,针对性调整)  
-   □ 灵活协调(根据进度随时沟通)  
+### 三、协作节奏:匹配您的沟通习惯
+5. 您偏好的服务协作方式是?
+   □ 前期多沟通(确认方向、细节后再推进,减少返工)
+   □ 先出初版再修改(快速看到成果,针对性调整)
+   □ 灵活协调(根据进度随时沟通)
 
 
-## 四、特殊提醒:提前规避潜在偏差  
-6. 过往合作中,是否有需要特别注意的点?(可多选)  
-   □ 软装色调易偏差 □ 建模细节需盯控 □ 其他:______  
+### 四、特殊提醒:提前规避潜在偏差
+6. 过往合作中,是否有需要特别注意的点?(可多选)
+   □ 软装色调易偏差 □ 建模细节需盯控 □ 其他:______
 
-7. 本次项目是否有特殊要求?(如业主禁忌、重点展示点)  
-   ______  
+7. 本次项目是否有特殊要求?(如业主禁忌、重点展示点)
+   ______
 
-8. 是否有参考素材(如风格图、实景图)需同步?  
-   □ 有(后续群内发送) □ 无(需求已清晰)  
+8. 是否有参考素材(如风格图、实景图)需同步?
+   □ 有(后续群内发送) □ 无(需求已清晰)
 
 
-感谢您的反馈!我们将根据您的选择制定服务方案,对接人:______(姓名),电话:______,有问题可随时联系~
+感谢您的反馈!我们将根据您的选择制定服务方案,对接人:______(姓名),电话:______,有问题可随时联系~

+ 7 - 0
src/app/app.routes.ts

@@ -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

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

@@ -84,6 +84,18 @@ export class ProjectDetailComponent implements OnInit {
   // 新增:客户详情侧栏面板状态
   showContactPanel: boolean = false;
 
+  // 问卷状态
+  surveyStatus: {
+    filled: boolean;
+    text: string;
+    icon: string;
+    surveyLog?: FmodeObject;
+  } = {
+    filled: false,
+    text: '发送问卷',
+    icon: 'document-text-outline'
+  };
+
   constructor(
     private router: Router,
     private route: ActivatedRoute,
@@ -211,6 +223,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) {
@@ -489,6 +504,101 @@ export class ProjectDetailComponent implements OnInit {
   /** 客户选择事件回调(接收子组件输出) */
   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 = `${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('发送失败,请重试');
+    }
+  }
+
+  /**
+   * 查看问卷结果
+   */
+  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();
+    }
   }
 }
 

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