Browse Source

feat: project for silence auth

ryanemax 20 hours ago
parent
commit
9f2e625a93

+ 566 - 0
docs/project.md

@@ -0,0 +1,566 @@
+# 项目管理模块路由文档
+
+## 概述
+
+本文档描述映三色设计师项目管理系统的企微项目管理模块路由规则,支持从企微端和网页端两种方式进入。
+
+## 核心特性
+
+1. **双端入口支持**
+   - 企微端:通过企微 `external_userid` 和 `chat_id` 自动查找关联数据
+   - 网页端:通过 `contactId`/`projectId` 直接加载,配合 `profileId` 参数
+
+2. **智能授权**
+   - 企微端自动静默授权并同步 Profile
+   - 网页端通过 `localStorage` 缓存的 `Parse/ProfileId` 加载
+   - 授权过程不阻塞页面加载
+
+3. **灵活加载**
+   - 优先使用路由参数
+   - 回退到全局 ProfileService
+   - 最后尝试企微 SDK 获取
+
+## 路由规则
+
+### 1. 客户画像页
+
+**路由路径**: `/wxwork/:cid/contact/:contactId`
+
+#### 企微端进入
+
+```
+/wxwork/cDL6R1hgSi/contact/placeholder?externalUserId=wmKkHgAAF1W7xjKUCcPVdG92Mxxxxxxx
+```
+
+**参数说明**:
+- `:cid` - 公司帐套ID(必填)
+- `:contactId` - ContactInfo 的 objectId,企微端可使用占位符(如 `placeholder`)
+- `externalUserId` - 企微外部联系人ID(查询参数,企微端使用)
+
+**加载逻辑**:
+1. 通过 `externalUserId` 在 `ContactInfo` 表中查找 `external_userid` 字段匹配的记录
+2. 如果找不到,提示"未找到客户信息,请先在企微中添加该客户"
+3. 自动触发 WxworkAuth 静默授权,同步当前员工 Profile
+
+**数据要求**:
+- `ContactInfo` 必须有 `external_userid` 字段(企微端进入后台才会创建)
+
+---
+
+#### 网页端进入
+
+```
+/wxwork/cDL6R1hgSi/contact/abc123xyz?profileId=prof001
+```
+
+**参数说明**:
+- `:cid` - 公司帐套ID(必填)
+- `:contactId` - ContactInfo 的 objectId(必填,真实ID)
+- `profileId` - 当前员工 Profile ID(查询参数,可选)
+
+**加载逻辑**:
+1. 直接通过 `contactId` 从 `ContactInfo` 表加载数据
+2. 优先使用 `profileId` 参数加载当前员工
+3. 如果没有 `profileId`,从 `localStorage` 的 `Parse/ProfileId` 加载
+
+**数据要求**:
+- `ContactInfo` 必须存在且未软删除(`isDeleted != true`)
+
+---
+
+### 2. 项目详情页
+
+**路由路径**: `/wxwork/:cid/project/:projectId`
+
+#### 企微端进入
+
+```
+/wxwork/cDL6R1hgSi/project/placeholder?chatId=wrOtiJDAAAcwMTB7YmDxxxxx
+```
+
+**参数说明**:
+- `:cid` - 公司帐套ID(必填)
+- `:projectId` - Project 的 objectId,企微端可使用占位符(如 `placeholder`)
+- `chatId` - 企微群聊 chat_id(查询参数,企微端使用)
+
+**加载逻辑**:
+1. 通过 `chatId` 在 `GroupChat` 表中查找 `chat_id` 字段匹配的记录
+2. 从 `GroupChat.project` 指针获取关联的 `Project`
+3. 如果找不到项目,提示"该群聊尚未关联项目,请先在后台创建项目"
+4. 自动触发 WxworkAuth 静默授权,同步当前员工 Profile
+
+**数据要求**:
+- `GroupChat` 必须有 `chat_id` 字段(企微群聊同步后创建)
+- `GroupChat.project` 指针必须指向有效的 `Project`(后台管理员配置)
+
+---
+
+#### 网页端进入
+
+```
+/wxwork/cDL6R1hgSi/project/proj001?profileId=prof001
+```
+
+**参数说明**:
+- `:cid` - 公司帐套ID(必填)
+- `:projectId` - Project 的 objectId(必填,真实ID)
+- `profileId` - 当前员工 Profile ID(查询参数,可选)
+
+**加载逻辑**:
+1. 直接通过 `projectId` 从 `Project` 表加载数据
+2. 优先使用 `profileId` 参数加载当前员工
+3. 如果没有 `profileId`,从 `localStorage` 的 `Parse/ProfileId` 加载
+4. 自动加载 `Project.customer` 和 `Project.assignee` 关联对象
+
+**数据要求**:
+- `Project` 必须存在且未软删除(`isDeleted != true`)
+
+---
+
+## 子路由(项目详情四阶段)
+
+项目详情页包含四个子路由,对应项目管理的四个阶段:
+
+| 路径 | 组件 | 标题 | 说明 |
+|------|------|------|------|
+| `order` | StageOrderComponent | 订单分配 | 客服下单、分配设计师 |
+| `requirements` | StageRequirementsComponent | 确认需求 | 需求沟通、方案确认 |
+| `delivery` | StageDeliveryComponent | 交付执行 | 建模、软装、渲染、后期 |
+| `aftercare` | StageAftercareComponent | 售后归档 | 尾款结算、客户评价、投诉处理 |
+
+**默认路由**:
+- 根据 `Project.currentStage` 自动跳转到对应阶段
+- 例如:`currentStage = "建模"` → 跳转到 `delivery`
+
+**阶段映射**:
+```typescript
+const stageMap = {
+  '订单分配': 'order',
+  '确认需求': 'requirements',
+  '方案确认': 'requirements',
+  '建模': 'delivery',
+  '软装': 'delivery',
+  '渲染': 'delivery',
+  '后期': 'delivery',
+  '尾款结算': 'aftercare',
+  '客户评价': 'aftercare',
+  '投诉处理': 'aftercare'
+};
+```
+
+---
+
+## Profile 获取逻辑
+
+两个组件都使用统一的 `ProfileService` 来获取当前员工信息,优先级如下:
+
+### 优先级 1: 路由参数 `profileId`
+
+```typescript
+if (this.profileId) {
+  this.currentUser = await this.profileService.getProfileById(this.profileId);
+}
+```
+
+### 优先级 2: 全局服务(从 localStorage 缓存)
+
+```typescript
+if (!this.currentUser) {
+  this.currentUser = await this.profileService.getCurrentProfile(this.cid);
+}
+```
+
+**缓存逻辑**:
+1. 检查 `localStorage.getItem("Parse/ProfileId")`
+2. 如果存在,从数据库加载 Profile
+3. 如果不存在,尝试通过企微授权获取
+
+### 优先级 3: 企微 SDK(企微环境)
+
+```typescript
+if (!this.currentUser && this.wxwork) {
+  try {
+    this.currentUser = await this.wxwork.getCurrentUser();
+  } catch (err) {
+    console.warn('无法从企微SDK获取用户:', err);
+  }
+}
+```
+
+---
+
+## 企微授权集成
+
+### WxworkAuth 静默授权
+
+两个组件都在 `ngOnInit` 中调用 `initWxworkAuth()` 方法,实现不阻塞页面的静默授权:
+
+```typescript
+async initWxworkAuth() {
+  if (!this.cid) return;
+
+  try {
+    this.wxAuth = new WxworkAuth({ cid: this.cid, appId: 'crm' });
+
+    // 静默授权并同步 Profile,不阻塞页面
+    const { profile } = await this.wxAuth.authenticateAndLogin();
+
+    if (profile) {
+      this.profileService.setCurrentProfile(profile);
+    }
+  } catch (error) {
+    console.warn('企微授权失败:', error);
+    // 授权失败不影响页面加载,继续使用其他方式加载数据
+  }
+}
+```
+
+**特点**:
+- 异步执行,不阻塞 `loadData()`
+- 授权成功后自动缓存到 `localStorage`
+- 授权失败不影响页面正常加载
+- 支持自动注册和登录
+
+**授权流程**:
+1. 获取企微用户信息(`getUserInfo`)
+2. 同步到 `Profile` 或 `UserSocial` 表(`syncUserInfo`)
+3. 自动登录/注册(`autoLogin`)
+4. 缓存到 `localStorage.setItem("Parse/ProfileId", profile.id)`
+
+---
+
+## 数据表字段要求
+
+### ContactInfo(客户信息表)
+
+| 字段名 | 类型 | 必填 | 说明 | 用途 |
+|--------|------|------|------|------|
+| objectId | String | 是 | 主键ID | 网页端路由参数 |
+| external_userid | String | 否 | 企微外部联系人ID | 企微端查找依据 |
+| name | String | 是 | 客户姓名 | 显示使用 |
+| company | Pointer | 是 | 所属企业 | 租户隔离 |
+| isDeleted | Boolean | 否 | 软删除标记 | 数据过滤 |
+
+---
+
+### GroupChat(企微群聊表)
+
+| 字段名 | 类型 | 必填 | 说明 | 用途 |
+|--------|------|------|------|------|
+| objectId | String | 是 | 主键ID | 唯一标识 |
+| chat_id | String | 是 | 企微群聊ID | 企微端查找依据 |
+| name | String | 是 | 群聊名称 | 显示使用 |
+| company | Pointer | 是 | 所属企业 | 租户隔离 |
+| project | Pointer | 否 | 关联项目 | 项目加载 |
+| isDeleted | Boolean | 否 | 软删除标记 | 数据过滤 |
+
+**重要**: `GroupChat` 必须有 `chat_id` 属性才能支持企微端进入!
+
+---
+
+### Project(项目表)
+
+| 字段名 | 类型 | 必填 | 说明 | 用途 |
+|--------|------|------|------|------|
+| objectId | String | 是 | 主键ID | 网页端路由参数 |
+| title | String | 是 | 项目标题 | 显示使用 |
+| customer | Pointer | 是 | 客户 | 关联 ContactInfo |
+| assignee | Pointer | 否 | 负责设计师 | 关联 Profile |
+| currentStage | String | 是 | 当前阶段 | 默认路由跳转 |
+| company | Pointer | 是 | 所属企业 | 租户隔离 |
+| isDeleted | Boolean | 否 | 软删除标记 | 数据过滤 |
+
+---
+
+### Profile(员工档案表)
+
+| 字段名 | 类型 | 必填 | 说明 | 用途 |
+|--------|------|------|------|------|
+| objectId | String | 是 | 主键ID | 缓存和查询 |
+| name | String | 是 | 员工姓名 | 显示使用 |
+| mobile | String | 否 | 手机号 | 联系方式 |
+| company | Pointer | 是 | 所属企业 | 租户隔离 |
+| userId | String | 否 | 企微UserID | 企微同步 |
+| roleName | String | 是 | 员工角色 | 权限控制 |
+| isDeleted | Boolean | 否 | 软删除标记 | 数据过滤 |
+
+---
+
+## 使用示例
+
+### 1. 从企微群聊进入项目详情
+
+**场景**: 用户在企微群聊中点击应用卡片
+
+```typescript
+// 步骤1: 企微 SDK 获取当前群聊
+const { GroupChat } = await wxwork.getCurrentChatObject();
+const chatId = GroupChat.get('chat_id'); // 例如: "wrOtiJDAAAcwMTB7YmDxxxxx"
+
+// 步骤2: 构造路由
+const url = `/wxwork/${cid}/project/placeholder?chatId=${chatId}`;
+
+// 步骤3: 跳转
+this.router.navigateByUrl(url);
+```
+
+**后台数据准备**:
+1. 确保 `GroupChat` 表中有对应 `chat_id` 的记录
+2. 确保 `GroupChat.project` 指针指向有效的 `Project`
+3. 确保 `Project.customer` 和 `Project.assignee` 已关联
+
+---
+
+### 2. 从后台管理页面进入客户画像
+
+**场景**: 管理员在客户列表中点击查看客户详情
+
+```typescript
+// 步骤1: 获取客户 objectId 和当前员工 profileId
+const contactId = customer.id; // 例如: "abc123xyz"
+const profileId = localStorage.getItem('Parse/ProfileId'); // 例如: "prof001"
+
+// 步骤2: 构造路由
+const url = `/wxwork/${cid}/contact/${contactId}?profileId=${profileId}`;
+
+// 步骤3: 跳转
+this.router.navigateByUrl(url);
+```
+
+---
+
+### 3. 从企微外部联系人进入客户画像
+
+**场景**: 用户在企微中查看外部联系人详情
+
+```typescript
+// 步骤1: 企微 SDK 获取外部联系人
+const { Contact } = await wxwork.getCurrentChatObject();
+const externalUserId = Contact.get('external_userid'); // 例如: "wmKkHgAAF1W7xjKUCcPVdG92Mxxxxxxx"
+
+// 步骤2: 构造路由(使用占位符)
+const url = `/wxwork/${cid}/contact/placeholder?externalUserId=${externalUserId}`;
+
+// 步骤3: 跳转
+this.router.navigateByUrl(url);
+```
+
+**后台数据准备**:
+1. 确保已通过企微 API 同步外部联系人到 `ContactInfo` 表
+2. 确保 `ContactInfo.external_userid` 字段已填充
+
+---
+
+## 错误处理
+
+### 客户画像页
+
+| 错误场景 | 错误信息 | 解决方案 |
+|---------|---------|---------|
+| 企微端找不到客户 | "未找到客户信息,请先在企微中添加该客户" | 在企微中添加外部联系人,等待同步 |
+| 网页端 contactId 不存在 | "加载失败" | 检查 contactId 是否正确 |
+| 没有权限查看敏感信息 | 手机号显示为 `***` | 切换到客服/组长/管理员账号 |
+
+---
+
+### 项目详情页
+
+| 错误场景 | 错误信息 | 解决方案 |
+|---------|---------|---------|
+| 企微端群聊未关联项目 | "该群聊尚未关联项目,请先在后台创建项目" | 在后台管理页面将群聊关联到项目 |
+| 网页端 projectId 不存在 | "加载失败" | 检查 projectId 是否正确 |
+| 授权失败 | 控制台警告 | 检查企微配置,或手动传入 profileId |
+
+---
+
+## 权限控制
+
+### 客户画像页
+
+| 权限 | 角色 | 说明 |
+|------|------|------|
+| 查看基本信息 | 所有角色 | 姓名、来源、画像标签等 |
+| 查看敏感信息 | 客服、组长、管理员 | 手机号、微信号 |
+
+### 项目详情页
+
+| 权限 | 角色 | 说明 |
+|------|------|------|
+| 查看项目信息 | 所有角色 | 项目标题、阶段、进度等 |
+| 编辑项目信息 | 客服、组员、组长、管理员 | 更新阶段、上传文件等 |
+| 查看客户手机号 | 客服、组长、管理员 | 客户联系方式 |
+
+---
+
+## 技术实现细节
+
+### ProfileService 全局服务
+
+位置: `src/app/services/profile.service.ts`
+
+**核心方法**:
+1. `getCurrentProfile(cid?, forceRefresh?)` - 获取当前 Profile
+2. `getProfileById(profileId, useCache?)` - 根据 ID 获取 Profile
+3. `setCurrentProfile(profile)` - 设置当前 Profile 并缓存
+4. `clearCurrentProfile()` - 清除缓存
+6. `getCompanyProfiles(companyId, roleName?)` - 获取公司所有员工
+
+---
+
+### WxworkAuth 授权工具
+
+来源: `fmode-ng/core`
+
+**核心方法**:
+1. `getUserInfo(code?)` - 获取企微用户信息
+2. `syncUserInfo(userInfo?)` - 同步到 Profile 表
+3. `autoLogin(userInfo?)` - 自动登录/注册
+4. `authenticateAndLogin(code?)` - 一站式授权(推荐使用)
+
+**特点**:
+- 静默授权(`snsapi_base`)
+- 自动注册用户(用户名=userid,密码=userid后6位)
+- 自动同步 Profile 数据
+- 缓存到 localStorage
+
+---
+
+## 最佳实践
+
+### 1. 企微端开发
+
+```typescript
+// 始终通过企微 SDK 获取 chat_id 或 external_userid
+const { GroupChat, Contact } = await wxwork.getCurrentChatObject();
+
+// 使用占位符作为路由参数,避免提前查询
+const url = `/wxwork/${cid}/project/placeholder?chatId=${GroupChat.get('chat_id')}`;
+
+// 依赖组件内部的 WxworkAuth 自动授权
+this.router.navigateByUrl(url);
+```
+
+### 2. 网页端开发
+
+```typescript
+// 始终传递 profileId 参数,避免依赖全局缓存
+const profileId = localStorage.getItem('Parse/ProfileId');
+const url = `/wxwork/${cid}/contact/${contactId}?profileId=${profileId}`;
+
+this.router.navigateByUrl(url);
+```
+
+### 3. 数据准备
+
+**企微端**:
+1. 同步企微外部联系人到 `ContactInfo` 表(`external_userid` 必填)
+2. 同步企微群聊到 `GroupChat` 表(`chat_id` 必填)
+3. 在后台管理页面将群聊关联到项目(`GroupChat.project`)
+
+**网页端**:
+1. 确保 `ContactInfo` 和 `Project` 数据完整
+2. 确保当前员工已登录并缓存 `Parse/ProfileId`
+3. 确保员工有权限访问对应数据
+
+---
+
+## 调试技巧
+
+### 1. 检查路由参数
+
+```typescript
+console.log('cid:', this.cid);
+console.log('contactId:', this.contactId);
+console.log('externalUserId:', this.externalUserId);
+console.log('profileId:', this.profileId);
+```
+
+### 2. 检查 Profile 加载
+
+```typescript
+console.log('currentUser:', this.currentUser?.toJSON());
+console.log('role:', this.role);
+console.log('canEdit:', this.canEdit);
+```
+
+### 3. 检查数据加载
+
+```typescript
+// 客户画像页
+console.log('contactInfo:', this.contactInfo?.toJSON());
+
+// 项目详情页
+console.log('project:', this.project?.toJSON());
+console.log('groupChat:', this.groupChat?.toJSON());
+console.log('customer:', this.customer?.toJSON());
+```
+
+### 4. 检查企微授权
+
+```typescript
+// 在浏览器控制台查看缓存
+console.log('ProfileId:', localStorage.getItem('Parse/ProfileId'));
+
+// 清除缓存重新授权
+localStorage.removeItem('Parse/ProfileId');
+location.reload();
+```
+
+---
+
+## 常见问题
+
+### Q1: 企微端进入提示"未找到客户信息"
+
+**原因**: `ContactInfo` 表中没有对应 `external_userid` 的记录
+
+**解决**:
+1. 检查企微外部联系人同步是否成功
+2. 检查 `ContactInfo.external_userid` 字段是否填充
+3. 检查查询条件是否正确(company、isDeleted)
+
+---
+
+### Q2: 企微端进入提示"该群聊尚未关联项目"
+
+**原因**: `GroupChat.project` 指针为空或指向无效项目
+
+**解决**:
+1. 在后台管理页面找到对应群聊
+2. 将群聊关联到有效的项目
+3. 确保项目未软删除
+
+---
+
+### Q3: 网页端进入后看不到客户手机号
+
+**原因**: 当前员工角色没有权限
+
+**解决**:
+1. 检查 `Profile.roleName` 字段
+2. 确保角色是"客服"、"组长"或"管理员"
+3. 刷新页面重新加载权限
+
+---
+
+### Q4: 授权失败但页面正常加载
+
+**原因**: 授权过程是异步的,不会阻塞页面加载
+
+**说明**:
+- 这是正常行为,授权失败会回退到其他加载方式
+- 如果需要强制授权,可以使用路由守卫 `WxworkAuthGuard`
+
+---
+
+## 参考文档
+
+- [rules/wxwork/auth.md](../rules/wxwork/auth.md) - 企微授权方法文档
+- [rules/schemas.md](../rules/schemas.md) - 数据范式文档
+- [docs/schemas.md](./schemas.md) - 数据范式详细文档(如果存在)
+
+---
+
+**文档版本**: v1.0
+**最后更新**: 2025-10-17
+**维护者**: YSS Development Team

+ 11 - 1
docs/task/20251015-wxwork-project.md

@@ -112,4 +112,14 @@
 
 4.delivery中出现的场景来自于order阶段报价中的场景要数据动态加载,每个场景都需要建模\软装\渲染\后期阶段.您不用询问直接继续,直到完成所有. 
 
-注意使用div-scss而不是Ionic.请您合理规划整个计划任务,完成更完整更可用的项目管理模块./src/modules/project.
+注意使用div-scss而不是Ionic.请您合理规划整个计划任务,完成更完整更可用的项目管理模块./src/modules/project.
+
+
+# FAQ:修复入口路由规则
+请您参考rules/schemas.md,将src/modules/project/pages/contact/contact.component.ts和src/modules
+  /project/pages/project-detail/project-detail.component.ts增加可以通过路由参数加载必备信息进入
+  的方式且优先与企微加载(避免从网页进入企微相关函数报错)并同步修改app.routes.ts将路由规则使用写
+  在./docs/project.md.注意逻辑是企微端进入后后台才会有对应数据,而从后台进入加载的ContactInfo是有
+  external_userid属性的,从后台进入的GroupChat是有chat_id属性的.特别是注意可以使用rules/wxwork/au
+  th.md实现不影响页面加载的用户授权及Profile同步的逻辑,并在localStorage的"Parse/ProfileId"记录当
+  前profile,通过全局服务一个函数获取当前profile员工档案信息(网页端也会用同样规则通过缓存的"Parse/ProfileId"加载当前员工).请您分析需求,合理设计,完成开发.务必完成所有细节

+ 5 - 1
rules/parse.md

@@ -51,4 +51,8 @@ let user = await Parse.User.logIn("xuanshou1","123456")
 console.log(await Parse.User.current())
 // 当前用户指针
 console.log(await Parse.User.current()?.toPointer())
-```
+```
+
+# 页面数据调用
+- 请直接用FmodeObject对象作为页面渲染列表格式
+    - 模板页面中,通过obj?.get("xxx")加载属性,不需要toJSON();

+ 584 - 0
rules/wxwork/auth.md

@@ -0,0 +1,584 @@
+# 企业微信认证方法文档
+
+## 概述
+
+`WxworkAuth` 是一个灵活的企业微信认证工具类,提供可自由搭配的用户信息验证和登录方法。相比路由守卫 `WxworkAuthGuard` 的强制性限制,`WxworkAuth` 允许在页面中灵活调用,实现页面先加载、再执行用户信息同步和注册的流程。
+
+**文件位置**: `fmode-ng/core.ts`
+
+---
+
+## 主要特性
+
+1. **灵活可组合**: 提供独立的方法,可根据需求自由搭配使用
+2. **页面内调用**: 可在组件的 `constructor` 或生命周期钩子中调用
+3. **自动登录注册**: 使用 `userid` 作为用户名,后6位作为密码自动注册和登录
+4. **支持多种场景**:
+   - 只获取用户信息(不登录)
+   - 只同步用户信息到数据库(不登录)
+   - 自动登录/注册
+   - 一站式完整流程
+
+---
+
+## 快速开始
+
+### 1. 基础引入
+
+```typescript
+import { WxworkAuth } from 'fmode-ng/core';
+
+// 初始化
+const wxAuth = new WxworkAuth({
+  cid: 'cDL6R1hgSi',  // 公司帐套ID
+  appId: 'crm'        // 应用ID,可选,默认为 'crm'
+});
+```
+
+### 2. 在组件中使用(推荐场景)
+
+```typescript
+import { Component } from '@angular/core';
+import { WxworkAuth } from 'fmode-ng/core';
+
+@Component({
+  selector: 'app-my-page',
+  templateUrl: './my-page.component.html'
+})
+export class MyPageComponent {
+  wxAuth: WxworkAuth;
+  userInfo: any;
+
+  constructor() {
+    this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+
+    // 页面加载后执行用户认证
+    this.initAuth();
+  }
+
+  async initAuth() {
+    try {
+      // 方案1: 一站式认证和登录
+      const { userInfo, profile, user } = await this.wxAuth.authenticateAndLogin();
+      console.log('用户信息:', userInfo);
+      console.log('Profile ID:', profile.id);
+      console.log('登录用户:', user?.get('username'));
+
+      this.userInfo = userInfo;
+    } catch (error) {
+      console.error('认证失败:', error);
+    }
+  }
+}
+```
+
+---
+
+## API 文档
+
+### 构造函数
+
+```typescript
+constructor(options: {
+  cid: string;      // 公司帐套ID,必填
+  appId?: string;   // 应用ID,可选,默认为 'crm'
+})
+```
+
+**示例**:
+```typescript
+const wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+```
+
+---
+
+### 方法列表
+
+#### 1. `getUserInfo(code?: string): Promise<any>`
+
+获取企业微信用户信息(不登录)
+
+**参数**:
+- `code` (可选): 授权码,如果不提供则尝试从缓存或URL获取
+
+**返回值**: 企业微信用户信息对象
+```typescript
+{
+  userid?: string;           // 企业员工ID
+  external_userid?: string;  // 外部联系人ID
+  openid?: string;           // 微信OpenID
+  name?: string;             // 姓名
+  mobile?: string;           // 手机号
+  email?: string;            // 邮箱
+  avatar?: string;           // 头像URL
+  gender?: number;           // 性别 (1:男, 2:女)
+  // ... 其他字段
+}
+```
+
+**使用场景**: 只需要获取用户信息,不需要登录系统
+
+**示例**:
+```typescript
+const userInfo = await wxAuth.getUserInfo();
+console.log('用户ID:', userInfo.userid);
+console.log('姓名:', userInfo.name);
+console.log('手机:', userInfo.mobile);
+```
+
+---
+
+#### 2. `syncUserInfo(userInfo?: any): Promise<any>`
+
+同步用户信息到 `Profile`/`UserSocial` 表(不登录)
+
+**参数**:
+- `userInfo` (可选): 用户信息对象,如果不提供则自动调用 `getUserInfo()` 获取
+
+**返回值**: `Profile` 或 `UserSocial` Parse Object
+
+**使用场景**: 只需要同步用户信息到数据库,不需要登录系统
+
+**示例**:
+```typescript
+// 方式1: 自动获取用户信息并同步
+const profile = await wxAuth.syncUserInfo();
+console.log('同步成功,Profile ID:', profile.id);
+
+// 方式2: 使用已有的用户信息
+const userInfo = await wxAuth.getUserInfo();
+const profile = await wxAuth.syncUserInfo(userInfo);
+```
+
+---
+
+#### 3. `autoLogin(userInfo?: any): Promise<FmodeUser | null>`
+
+自动登录或注册用户(使用 `userid` 和后6位作为密码)
+
+**参数**:
+- `userInfo` (可选): 用户信息对象,如果不提供则自动获取
+
+**返回值**: `FmodeUser` 对象或 `null`
+
+**登录逻辑**:
+1. 检查是否已登录,如果已登录直接返回当前用户
+2. 使用 `userid` 或 `external_userid` 或 `openid` 作为用户名
+3. 使用用户名的后6位作为密码
+4. 尝试登录,如果用户不存在则自动注册
+5. 同步 `user` 指针到 `Profile`/`UserSocial`
+
+**使用场景**: 需要用户登录系统才能访问功能
+
+**示例**:
+```typescript
+const user = await wxAuth.autoLogin();
+if (user) {
+  console.log('登录成功,用户名:', user.get('username'));
+} else {
+  console.log('登录失败');
+}
+```
+
+---
+
+#### 4. `authenticateAndLogin(code?: string): Promise<{ userInfo, profile, user }>`
+
+一站式方法:获取用户信息、同步、并自动登录/注册
+
+**参数**:
+- `code` (可选): 授权码
+
+**返回值**:
+```typescript
+{
+  userInfo: any;           // 企业微信用户信息
+  profile: any;            // Profile 或 UserSocial 对象
+  user: FmodeUser | null;  // 登录的用户对象
+}
+```
+
+**执行流程**:
+1. 获取企业微信用户信息
+2. 同步到 `Profile`/`UserSocial` 表
+3. 自动登录/注册用户
+
+**使用场景**: 最常用的场景,一次调用完成所有认证流程
+
+**示例**:
+```typescript
+const { userInfo, profile, user } = await wxAuth.authenticateAndLogin();
+console.log('用户信息:', userInfo);
+console.log('Profile ID:', profile.id);
+console.log('登录用户:', user?.get('username'));
+```
+
+---
+
+#### 5. `oauth(scope?, renew?): Promise<string | null>`
+
+触发 OAuth 授权流程
+
+**参数**:
+- `scope` (可选): 授权类型
+  - `'snsapi_base'`: 静默授权(默认)
+  - `'snsapi_privateinfo'`: 敏感信息授权
+- `renew` (可选): 是否强制重新授权(清除缓存),默认 `false`
+
+**返回值**: 授权码或 `null`(如果跳转)
+
+**使用场景**: 需要手动触发授权流程
+
+**示例**:
+```typescript
+// 静默授权
+await wxAuth.oauth('snsapi_base');
+
+// 敏感信息授权
+await wxAuth.oauth('snsapi_privateinfo');
+
+// 强制重新授权
+await wxAuth.oauth('snsapi_base', true);
+```
+
+---
+
+#### 6. `loginPC(): Promise<string>`
+
+PC端登录(弹出扫码面板)
+
+**返回值**: 授权码
+
+**使用场景**: PC端扫码登录
+
+**示例**:
+```typescript
+const code = await wxAuth.loginPC();
+console.log('获得授权码:', code);
+```
+
+---
+
+#### 7. `getSDK(): WxworkSDK`
+
+获取 `WxworkSDK` 实例(用于高级操作)
+
+**返回值**: `WxworkSDK` 实例
+
+**使用场景**: 需要使用 SDK 的其他高级功能
+
+**示例**:
+```typescript
+const sdk = wxAuth.getSDK();
+
+// 获取当前聊天对象
+const { GroupChat, Contact } = await sdk.getCurrentChatObject();
+
+// 同步群聊信息
+const group = await sdk.syncGroupChat(groupInfo);
+```
+
+---
+
+## 使用场景示例
+
+### 场景1: 页面加载后自动登录
+
+```typescript
+@Component({
+  selector: 'app-dashboard',
+  templateUrl: './dashboard.component.html'
+})
+export class DashboardComponent implements OnInit {
+  wxAuth: WxworkAuth;
+  currentUser: any;
+
+  constructor() {
+    this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+  }
+
+  async ngOnInit() {
+    // 页面加载后自动认证和登录
+    const { user } = await this.wxAuth.authenticateAndLogin();
+    this.currentUser = user;
+
+    if (!user) {
+      // 处理登录失败的情况
+      console.error('用户登录失败');
+    }
+  }
+}
+```
+
+---
+
+### 场景2: 只获取用户信息显示,不登录
+
+```typescript
+@Component({
+  selector: 'app-profile-preview',
+  templateUrl: './profile-preview.component.html'
+})
+export class ProfilePreviewComponent implements OnInit {
+  wxAuth: WxworkAuth;
+  userInfo: any;
+
+  constructor() {
+    this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+  }
+
+  async ngOnInit() {
+    // 只获取用户信息,不登录
+    this.userInfo = await this.wxAuth.getUserInfo();
+    console.log('用户名:', this.userInfo.name);
+  }
+}
+```
+
+---
+
+### 场景3: 分步骤执行认证流程
+
+```typescript
+@Component({
+  selector: 'app-onboarding',
+  templateUrl: './onboarding.component.html'
+})
+export class OnboardingComponent {
+  wxAuth: WxworkAuth;
+  step = 1;
+  userInfo: any;
+  profile: any;
+
+  constructor() {
+    this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+  }
+
+  async step1_getUserInfo() {
+    this.userInfo = await this.wxAuth.getUserInfo();
+    this.step = 2;
+  }
+
+  async step2_syncProfile() {
+    this.profile = await this.wxAuth.syncUserInfo(this.userInfo);
+    this.step = 3;
+  }
+
+  async step3_login() {
+    const user = await this.wxAuth.autoLogin(this.userInfo);
+    if (user) {
+      console.log('登录成功!');
+      // 跳转到主页
+    }
+  }
+}
+```
+
+---
+
+### 场景4: 检查登录状态,未登录时才执行认证
+
+```typescript
+import { FmodeParse } from 'projects/fmode-ng/src/lib/core/parse';
+
+const Parse = FmodeParse.with("nova");
+
+async function ensureAuthenticated() {
+  const currentUser = Parse.User.current();
+
+  if (currentUser) {
+    console.log('用户已登录:', currentUser.get('username'));
+    return currentUser;
+  }
+
+  // 未登录,执行认证
+  const wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+  const { user } = await wxAuth.authenticateAndLogin();
+
+  return user;
+}
+```
+
+---
+
+## 与路由守卫的区别
+
+### `WxworkAuthGuard` (路由守卫)
+
+**特点**:
+- 在路由激活前强制执行认证
+- 阻塞页面加载,直到认证完成
+- 适合需要强制登录的路由
+
+**限制**:
+- 页面无法先加载
+- 灵活性较低
+- 无法根据页面逻辑自定义认证流程
+
+**使用方式**:
+```typescript
+const routes: Routes = [
+  {
+    path: 'protected',
+    component: ProtectedComponent,
+    canActivate: [WxworkAuthGuard]
+  }
+];
+```
+
+---
+
+### `WxworkAuth` (认证工具类)
+
+**特点**:
+- 页面可以先加载
+- 在 `constructor` 或生命周期钩子中灵活调用
+- 可根据页面逻辑自定义认证流程
+- 可选择性执行部分认证步骤
+
+**优势**:
+- 更好的用户体验(页面先显示)
+- 灵活可组合
+- 适合复杂的业务逻辑
+
+**使用方式**:
+```typescript
+// 在组件中直接调用
+const wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+await wxAuth.authenticateAndLogin();
+```
+
+---
+
+## 最佳实践
+
+### 1. 统一认证服务
+
+创建一个 Angular Service 来统一管理认证逻辑:
+
+```typescript
+import { Injectable } from '@angular/core';
+import { WxworkAuth } from 'fmode-ng/core';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class AuthService {
+  private wxAuth: WxworkAuth;
+
+  constructor() {
+    // 从环境变量或配置中获取 cid
+    this.wxAuth = new WxworkAuth({ cid: 'cDL6R1hgSi' });
+  }
+
+  async authenticate() {
+    return await this.wxAuth.authenticateAndLogin();
+  }
+
+  async getUserInfo() {
+    return await this.wxAuth.getUserInfo();
+  }
+}
+```
+
+在组件中使用:
+
+```typescript
+@Component({
+  selector: 'app-my-page',
+  templateUrl: './my-page.component.html'
+})
+export class MyPageComponent implements OnInit {
+  constructor(private authService: AuthService) {}
+
+  async ngOnInit() {
+    const { user } = await this.authService.authenticate();
+    console.log('当前用户:', user?.get('username'));
+  }
+}
+```
+
+---
+
+### 2. 错误处理
+
+```typescript
+async initAuth() {
+  try {
+    const { user } = await this.wxAuth.authenticateAndLogin();
+
+    if (!user) {
+      // 认证成功但登录失败
+      this.showMessage('登录失败,请稍后再试');
+      return;
+    }
+
+    // 登录成功
+    this.onLoginSuccess(user);
+  } catch (error: any) {
+    // 认证失败
+    console.error('认证错误:', error);
+
+    if (error.message?.includes('无法获取用户信息')) {
+      this.showMessage('无法获取用户信息,请检查网络');
+    } else {
+      this.showMessage('认证失败,请重试');
+    }
+  }
+}
+```
+
+---
+
+### 3. 配置管理
+
+使用环境变量管理不同环境的配置:
+
+```typescript
+// environment.ts
+export const environment = {
+  production: false,
+  wxwork: {
+    cid: 'cDL6R1hgSi',
+    appId: 'crm'
+  }
+};
+
+// 使用
+import { environment } from 'src/environments/environment';
+
+const wxAuth = new WxworkAuth({
+  cid: environment.wxwork.cid,
+  appId: environment.wxwork.appId
+});
+```
+
+---
+
+## 注意事项
+
+1. **cid 配置**: 确保 `cid` 在 `WxworkSDK.companyMap` 中有对应的配置
+2. **错误处理**: 所有方法都会抛出错误,请使用 `try-catch` 捕获
+3. **缓存机制**: `getUserInfo()` 会优先使用 localStorage 缓存,传入 `code` 可强制刷新
+4. **密码规则**: 自动登录使用 `userid` 后6位作为密码,确保 `userid` 长度 ≥ 6
+5. **类型转换**: `Parse.User` 需要转换为 `FmodeUser` 类型:`user as FmodeUser`
+
+---
+
+## 相关文件
+
+- **认证工具类**: `fmode-ng/core.ts`
+- **路由守卫**: `projects/fmode-ng/src/lib/social/wxwork/wxwork-auth.guard.ts`
+- **SDK**: `projects/fmode-ng/src/lib/core/social/wxwork/wxwork.sdk.ts`
+- **企业API**: `projects/fmode-ng/src/lib/core/social/wxwork/wxwork.corp.ts`
+
+---
+
+## 技术支持
+
+如遇问题,请检查:
+1. 企业微信配置是否正确(`corpId`, `agentId`, `suiteId`)
+2. 回调域名是否已配置
+3. `cid` 是否在 `companyMap` 中存在
+4. 网络请求是否正常
+5. 浏览器控制台是否有错误信息

+ 19 - 2
src/app/app.routes.ts

@@ -293,10 +293,13 @@ export const routes: Routes = [
     title: '氛围感预览图测试'
   },
 
-  // 企微项目管理模块路由 (添加WxworkAuthGuard保护)
+  // 企微项目管理模块路由
+  // 支持两种进入方式:
+  // 1. 企微端: 通过 external_userid/chat_id 自动查找对应的 ContactInfo/Project
+  // 2. 网页端: 通过 contactId/projectId 直接加载,配合 profileId 参数
   {
     path: 'wxwork/:cid',
-    // canActivate: [WxworkAuthGuard],
+    // canActivate: [WxworkAuthGuard], // 可选:使用路由守卫强制认证
     children: [
       // 项目预加载页(企微上下文入口)
       {
@@ -306,6 +309,13 @@ export const routes: Routes = [
       },
 
       // 客户画像页
+      // 路由规则:
+      // - 企微端: /wxwork/:cid/contact/:contactId?externalUserId=xxx
+      // - 网页端: /wxwork/:cid/contact/:contactId?profileId=xxx
+      // 说明:
+      // - contactId: ContactInfo 的 objectId(后台进入时使用)
+      // - externalUserId: 企微外部联系人ID(企微端进入时使用,需后台有对应数据)
+      // - profileId: 当前员工 Profile ID(网页端进入时使用)
       {
         path: 'contact/:contactId',
         loadComponent: () => import('../modules/project/pages/contact/contact.component').then(m => m.CustomerProfileComponent),
@@ -313,6 +323,13 @@ export const routes: Routes = [
       },
 
       // 项目详情页(含四阶段子路由)
+      // 路由规则:
+      // - 企微端: /wxwork/:cid/project/:projectId?chatId=xxx
+      // - 网页端: /wxwork/:cid/project/:projectId?profileId=xxx
+      // 说明:
+      // - projectId: Project 的 objectId(后台进入时使用)
+      // - chatId: 企微群聊 chat_id(企微端进入时使用,需 GroupChat.chat_id 属性)
+      // - profileId: 当前员工 Profile ID(网页端进入时使用)
       {
         path: 'project/:projectId',
         loadComponent: () => import('../modules/project/pages/project-detail/project-detail.component').then(m => m.ProjectDetailComponent),

+ 0 - 8
src/app/pages/admin/project-management/project-management.html

@@ -193,14 +193,6 @@
                 <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
               </svg>
             </button>
-            <button mat-icon-button class="action-btn" color="accent"
-                    title="编辑项目"
-                    (click)="openEditDialog(project)">
-              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
-                <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
-                <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
-              </svg>
-            </button>
           </div>
         </td>
       </ng-container>

+ 189 - 0
src/app/services/profile.service.ts

@@ -0,0 +1,189 @@
+import { Injectable } from '@angular/core';
+import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
+import { WxworkAuth } from 'fmode-ng/core';
+
+const Parse = FmodeParse.with('nova');
+
+/**
+ * 全局 Profile 服务
+ *
+ * 用途:
+ * 1. 统一管理当前登录用户的 Profile 信息
+ * 2. 支持从 localStorage 缓存读取
+ * 3. 支持企微授权自动同步
+ * 4. 提供全局单例访问
+ */
+@Injectable({
+  providedIn: 'root'
+})
+export class ProfileService {
+  private currentProfile: FmodeObject | null = null;
+  private profileCache: Map<string, FmodeObject> = new Map();
+
+  constructor() {}
+
+  /**
+   * 获取当前 Profile
+   *
+   * 逻辑优先级:
+   * 1. 内存缓存
+   * 2. localStorage 的 "Parse/ProfileId"
+   * 3. 企微授权同步
+   *
+   * @param cid 公司ID,用于企微授权(可选)
+   * @param forceRefresh 强制刷新,默认 false
+   * @returns Profile 对象或 null
+   */
+  async getCurrentProfile(cid?: string, forceRefresh = false): Promise<FmodeObject | null> {
+    try {
+      // 1. 检查内存缓存
+      if (!forceRefresh && this.currentProfile) {
+        return this.currentProfile;
+      }
+
+      // 2. 尝试从 localStorage 加载
+      const profileId = localStorage.getItem('Parse/ProfileId');
+      if (profileId) {
+        // 检查缓存
+        if (!forceRefresh && this.profileCache.has(profileId)) {
+          this.currentProfile = this.profileCache.get(profileId)!;
+          return this.currentProfile;
+        }
+
+        // 从数据库加载
+        try {
+          const query = new Parse.Query('Profile');
+          this.currentProfile = await query.get(profileId);
+          this.profileCache.set(profileId, this.currentProfile);
+          return this.currentProfile;
+        } catch (err) {
+          console.warn('Failed to load Profile from localStorage:', err);
+          // 清除无效的 profileId
+          localStorage.removeItem('Parse/ProfileId');
+        }
+      }
+
+      // 3. 如果提供了 cid,尝试通过企微授权获取
+      if (cid) {
+        const profile = await this.syncFromWxwork(cid);
+        if (profile) {
+          this.currentProfile = profile;
+          // 缓存到 localStorage
+          profile?.id&&localStorage.setItem('Parse/ProfileId', profile.id);
+          profile?.id&&this.profileCache.set(profile.id, profile);
+          return profile;
+        }
+      }
+
+      return null;
+    } catch (error) {
+      console.error('Failed to get current profile:', error);
+      return null;
+    }
+  }
+
+  /**
+   * 通过企微授权同步 Profile
+   *
+   * @param cid 公司ID
+   * @returns Profile 对象或 null
+   */
+  private async syncFromWxwork(cid: string): Promise<FmodeObject | null> {
+    try {
+      const wxAuth = new WxworkAuth({ cid, appId: 'crm' });
+
+      // 获取用户信息并同步
+      const { profile } = await wxAuth.authenticateAndLogin();
+
+      return profile;
+    } catch (error) {
+      console.error('Failed to sync profile from Wxwork:', error);
+      return null;
+    }
+  }
+
+  /**
+   * 根据 profileId 获取 Profile
+   *
+   * @param profileId Profile ID
+   * @param useCache 是否使用缓存,默认 true
+   * @returns Profile 对象或 null
+   */
+  async getProfileById(profileId: string, useCache = true): Promise<FmodeObject | null> {
+    try {
+      // 检查缓存
+      if (useCache && this.profileCache.has(profileId)) {
+        return this.profileCache.get(profileId)!;
+      }
+
+      // 从数据库加载
+      const query = new Parse.Query('Profile');
+      const profile = await query.get(profileId);
+
+      // 更新缓存
+      this.profileCache.set(profileId, profile);
+
+      return profile;
+    } catch (error) {
+      console.error('Failed to get profile by id:', error);
+      return null;
+    }
+  }
+
+  /**
+   * 设置当前 Profile
+   *
+   * @param profile Profile 对象
+   */
+  setCurrentProfile(profile: FmodeObject): void {
+    this.currentProfile = profile;
+    if(profile?.id){
+      this.profileCache.set(profile.id, profile);
+      localStorage.setItem('Parse/ProfileId', profile.id);
+    }
+  }
+
+  /**
+   * 清除当前 Profile 缓存
+   */
+  clearCurrentProfile(): void {
+    this.currentProfile = null;
+    localStorage.removeItem('Parse/ProfileId');
+  }
+
+  /**
+   * 清除所有缓存
+   */
+  clearCache(): void {
+    this.currentProfile = null;
+    this.profileCache.clear();
+    localStorage.removeItem('Parse/ProfileId');
+  }
+
+  /**
+   * 获取公司所有员工
+   *
+   * @param companyId 公司ID
+   * @param roleName 角色名称(可选)
+   * @returns Profile 列表
+   */
+  async getCompanyProfiles(companyId: string, roleName?: string): Promise<FmodeObject[]> {
+    try {
+      const query = new Parse.Query('Profile');
+      query.equalTo('company', companyId);
+      query.notEqualTo('isDeleted', true);
+
+      if (roleName) {
+        query.equalTo('roleName', roleName);
+      }
+
+      query.ascending('name');
+      query.limit(1000);
+
+      return await query.find();
+    } catch (error) {
+      console.error('Failed to get company profiles:', error);
+      return [];
+    }
+  }
+}

+ 74 - 13
src/modules/project/pages/contact/contact.component.ts

@@ -2,9 +2,10 @@ import { Component, OnInit, Input } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute } from '@angular/router';
 import { IonicModule } from '@ionic/angular';
-import { WxworkSDK, WxworkCorp } from 'fmode-ng/core';
+import { WxworkSDK, WxworkCorp, WxworkAuth } from 'fmode-ng/core';
 import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
 import { WxworkSDKService } from '../../services/wxwork-sdk.service';
+import { ProfileService } from '../../../../app/services/profile.service';
 
 const Parse = FmodeParse.with('nova');
 
@@ -36,10 +37,12 @@ export class CustomerProfileComponent implements OnInit {
   cid: string = '';
   contactId: string = '';
   profileId: string = '';
+  externalUserId: string = ''; // 从企微进入时的 external_userid
 
   // 企微SDK
   wxwork: WxworkSDK | null = null;
   wecorp: WxworkCorp | null = null;
+  wxAuth: WxworkAuth | null = null;
 
   // 加载状态
   loading: boolean = true;
@@ -101,7 +104,8 @@ export class CustomerProfileComponent implements OnInit {
   constructor(
     private router: Router,
     private route: ActivatedRoute,
-    private wxworkService: WxworkSDKService
+    private wxworkService: WxworkSDKService,
+    private profileService: ProfileService
   ) {}
 
   async ngOnInit() {
@@ -109,15 +113,40 @@ export class CustomerProfileComponent implements OnInit {
     this.cid = this.route.snapshot.paramMap.get('cid') || '';
     this.contactId = this.route.snapshot.paramMap.get('contactId') || '';
     this.profileId = this.route.snapshot.queryParamMap.get('profileId') || '';
+    this.externalUserId = this.route.snapshot.queryParamMap.get('externalUserId') || '';
 
     // 如果有Input传入,直接使用
     if (this.customer) {
       this.contactInfo = this.customer;
     }
 
+    // 初始化企微授权(不阻塞页面加载)
+    this.initWxworkAuth();
+
     await this.loadData();
   }
 
+  /**
+   * 初始化企微授权(不阻塞页面)
+   */
+  async initWxworkAuth() {
+    if (!this.cid) return;
+
+    try {
+      this.wxAuth = new WxworkAuth({ cid: this.cid, appId: 'crm' });
+
+      // 静默授权并同步 Profile,不阻塞页面
+      const { profile } = await this.wxAuth.authenticateAndLogin();
+
+      if (profile) {
+        this.profileService.setCurrentProfile(profile);
+      }
+    } catch (error) {
+      console.warn('企微授权失败:', error);
+      // 授权失败不影响页面加载,继续使用其他方式加载数据
+    }
+  }
+
   /**
    * 加载数据
    */
@@ -125,19 +154,31 @@ export class CustomerProfileComponent implements OnInit {
     try {
       this.loading = true;
 
-      // 1. 初始化SDK
-      if (!this.wxwork) {
+      // 1. 初始化SDK(用于企微API调用,不需要等待授权)
+      if (!this.wxwork && this.cid) {
         this.wxwork = new WxworkSDK({ cid: this.cid, appId: 'crm' });
         this.wecorp = new WxworkCorp(this.cid);
       }
 
-      // 2. 获取当前用户(如果没有传入
+      // 2. 获取当前用户(优先从全局服务获取
       if (!this.currentUser) {
+        // 优先级1: 使用 profileId 参数
         if (this.profileId) {
-          const query = new Parse.Query('Profile');
-          this.currentUser = await query.get(this.profileId);
-        } else {
-          this.currentUser = await this.wxwork.getCurrentUser();
+          this.currentUser = await this.profileService.getProfileById(this.profileId);
+        }
+
+        // 优先级2: 从全局服务获取当前 Profile
+        if (!this.currentUser) {
+          this.currentUser = await this.profileService.getCurrentProfile(this.cid);
+        }
+
+        // 优先级3: 企微环境下尝试从SDK获取
+        if (!this.currentUser && this.wxwork) {
+          try {
+            this.currentUser = await this.wxwork.getCurrentUser();
+          } catch (err) {
+            console.warn('无法从企微SDK获取用户:', err);
+          }
         }
       }
 
@@ -145,14 +186,34 @@ export class CustomerProfileComponent implements OnInit {
       const role = this.currentUser?.get('roleName');
       this.canViewSensitiveInfo = ['客服', '组长', '管理员'].includes(role);
 
-      // 3. 加载客户信息(如果没有传入)
+      // 3. 加载客户信息
       if (!this.contactInfo) {
-        const query = new Parse.Query('ContactInfo');
-        this.contactInfo = await query.get(this.contactId);
+        if (this.contactId) {
+          // 通过 contactId 加载(从后台进入)
+          const query = new Parse.Query('ContactInfo');
+          this.contactInfo = await query.get(this.contactId);
+        } else if (this.externalUserId) {
+          // 通过 external_userid 查找(从企微进入)
+          const companyId = this.currentUser?.get('company')?.id;
+          if (companyId) {
+            const query = new Parse.Query('ContactInfo');
+            query.equalTo('external_userid', this.externalUserId);
+            query.equalTo('company', companyId);
+            this.contactInfo = await query.first();
+
+            if (!this.contactInfo) {
+              throw new Error('未找到客户信息,请先在企微中添加该客户');
+            }
+          }
+        }
       }
 
       // 4. 构建客户画像
-      await this.buildCustomerProfile();
+      if (this.contactInfo) {
+        await this.buildCustomerProfile();
+      } else {
+        throw new Error('无法加载客户信息');
+      }
     } catch (err: any) {
       console.error('加载失败:', err);
       this.error = err.message || '加载失败';

+ 1 - 1
src/modules/project/pages/project-detail/project-detail.component.html

@@ -86,7 +86,7 @@
               }
             </div>
             <div class="info-text">
-              <h3>{{ customer?.get('name') }}</h3>
+              <h3>{{ customer?.get('name') || '待设置' }}</h3>
               @if (canViewCustomerPhone) {
                 <p>{{ customer?.get('mobile') }}</p>
               }

+ 100 - 17
src/modules/project/pages/project-detail/project-detail.component.ts

@@ -2,8 +2,9 @@ import { Component, OnInit, Input } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute, RouterModule } from '@angular/router';
 import { IonicModule } from '@ionic/angular';
-import { WxworkSDK, WxworkCorp } from 'fmode-ng/core';
+import { WxworkSDK, WxworkCorp, WxworkAuth } from 'fmode-ng/core';
 import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
+import { ProfileService } from '../../../../app/services/profile.service';
 
 const Parse = FmodeParse.with('nova');
 
@@ -36,10 +37,12 @@ export class ProjectDetailComponent implements OnInit {
   projectId: string = '';
   groupId: string = '';
   profileId: string = '';
+  chatId: string = ''; // 从企微进入时的 chat_id
 
   // 企微SDK
   wxwork: WxworkSDK | null = null;
   wecorp: WxworkCorp | null = null;
+  wxAuth: WxworkAuth | null = null;
 
   // 加载状态
   loading: boolean = true;
@@ -65,7 +68,8 @@ export class ProjectDetailComponent implements OnInit {
 
   constructor(
     private router: Router,
-    private route: ActivatedRoute
+    private route: ActivatedRoute,
+    private profileService: ProfileService
   ) {}
 
   async ngOnInit() {
@@ -74,6 +78,7 @@ export class ProjectDetailComponent implements OnInit {
     this.projectId = this.route.snapshot.paramMap.get('projectId') || '';
     this.groupId = this.route.snapshot.queryParamMap.get('groupId') || '';
     this.profileId = this.route.snapshot.queryParamMap.get('profileId') || '';
+    this.chatId = this.route.snapshot.queryParamMap.get('chatId') || '';
 
     // 监听路由变化
     this.route.firstChild?.url.subscribe((segments) => {
@@ -82,9 +87,33 @@ export class ProjectDetailComponent implements OnInit {
       }
     });
 
+    // 初始化企微授权(不阻塞页面加载)
+    this.initWxworkAuth();
+
     await this.loadData();
   }
 
+  /**
+   * 初始化企微授权(不阻塞页面)
+   */
+  async initWxworkAuth() {
+    if (!this.cid) return;
+
+    try {
+      this.wxAuth = new WxworkAuth({ cid: this.cid, appId: 'crm' });
+
+      // 静默授权并同步 Profile,不阻塞页面
+      const { profile } = await this.wxAuth.authenticateAndLogin();
+
+      if (profile) {
+        this.profileService.setCurrentProfile(profile);
+      }
+    } catch (error) {
+      console.warn('企微授权失败:', error);
+      // 授权失败不影响页面加载,继续使用其他方式加载数据
+    }
+  }
+
   /**
    * 加载数据
    */
@@ -92,19 +121,31 @@ export class ProjectDetailComponent implements OnInit {
     try {
       this.loading = true;
 
-      // 1. 初始化SDK
-      if (!this.wxwork) {
+      // 1. 初始化SDK(用于企微API调用,不需要等待授权)
+      if (!this.wxwork && this.cid) {
         this.wxwork = new WxworkSDK({ cid: this.cid, appId: 'crm' });
         this.wecorp = new WxworkCorp(this.cid);
       }
 
-      // 2. 获取当前用户(如果没有传入
+      // 2. 获取当前用户(优先从全局服务获取
       if (!this.currentUser) {
+        // 优先级1: 使用 profileId 参数
         if (this.profileId) {
-          const query = new Parse.Query('Profile');
-          this.currentUser = await query.get(this.profileId);
-        } else {
-          this.currentUser = await this.wxwork.getCurrentUser();
+          this.currentUser = await this.profileService.getProfileById(this.profileId);
+        }
+
+        // 优先级2: 从全局服务获取当前 Profile
+        if (!this.currentUser) {
+          this.currentUser = await this.profileService.getCurrentProfile(this.cid);
+        }
+
+        // 优先级3: 企微环境下尝试从SDK获取
+        if (!this.currentUser && this.wxwork) {
+          try {
+            this.currentUser = await this.wxwork.getCurrentUser();
+          } catch (err) {
+            console.warn('无法从企微SDK获取用户:', err);
+          }
         }
       }
 
@@ -113,20 +154,56 @@ export class ProjectDetailComponent implements OnInit {
       this.canEdit = ['客服', '组员', '组长', '管理员'].includes(this.role);
       this.canViewCustomerPhone = ['客服', '组长', '管理员'].includes(this.role);
 
-      // 3. 加载项目(如果没有传入)
+      // 3. 加载项目
+      if (!this.project) {
+        if (this.projectId) {
+          // 通过 projectId 加载(从后台进入)
+          const query = new Parse.Query('Project');
+          query.include('customer', 'assignee');
+          this.project = await query.get(this.projectId);
+        } else if (this.chatId) {
+          // 通过 chat_id 查找项目(从企微群聊进入)
+          const companyId = this.currentUser?.get('company')?.id;
+          if (companyId) {
+            // 先查找 GroupChat
+            const gcQuery = new Parse.Query('GroupChat');
+            gcQuery.equalTo('chat_id', this.chatId);
+            gcQuery.equalTo('company', companyId);
+            const groupChat = await gcQuery.first();
+
+            if (groupChat) {
+              this.groupChat = groupChat;
+              const projectPointer = groupChat.get('project');
+
+              if (projectPointer) {
+                const pQuery = new Parse.Query('Project');
+                pQuery.include('customer', 'assignee');
+                this.project = await pQuery.get(projectPointer.id);
+              }
+            }
+
+            if (!this.project) {
+              throw new Error('该群聊尚未关联项目,请先在后台创建项目');
+            }
+          }
+        }
+      }
+
       if (!this.project) {
-        const query = new Parse.Query('Project');
-        query.include('customer', 'assignee');
-        this.project = await query.get(this.projectId);
+        throw new Error('无法加载项目信息');
       }
 
       this.customer = this.project.get('customer');
       this.assignee = this.project.get('assignee');
 
-      // 4. 加载群聊(如果没有传入)
+      // 4. 加载群聊(如果没有传入且有groupId
       if (!this.groupChat && this.groupId) {
-        const gcQuery = new Parse.Query('GroupChat');
-        this.groupChat = await gcQuery.get(this.groupId);
+        try {
+          const gcQuery = new Parse.Query('GroupChat');
+          this.groupChat = await gcQuery.get(this.groupId);
+        } catch (err) {
+          console.warn('加载群聊失败:', err);
+        }
       }
 
       // 5. 根据项目当前阶段设置默认路由
@@ -196,7 +273,13 @@ export class ProjectDetailComponent implements OnInit {
    * 返回
    */
   goBack() {
-    this.router.navigate(['/wxwork', this.cid, 'project-loader']);
+    let ua = navigator.userAgent.toLowerCase();
+    let isWeixin = ua.indexOf("micromessenger") != -1;
+    if(isWeixin){
+      this.router.navigate(['/wxwork', this.cid, 'project-loader']);
+    }else{
+      history.back();
+    }
   }
 
   /**

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

@@ -488,7 +488,7 @@
                 <div
                   class="department-item"
                   [class.selected]="selectedDepartment?.id === dept.id"
-                  (click)="canEdit && selectDepartment(dept)">
+                  (click)="selectDepartment(dept)">
                   <h5>{{ dept.get('name') }}</h5>
                   <p>组长: {{ dept.get('leader')?.get('name') || '未指定' }}</p>
                   @if (selectedDepartment?.id === dept.id) {
@@ -521,7 +521,7 @@
                   <div
                     class="designer-item"
                     [class.selected]="selectedDesigner?.id === designer.id"
-                    (click)="canEdit && selectDesigner(designer)">
+                    (click)="selectDesigner(designer)">
                     <div class="designer-avatar">
                       @if (designer.get('data')?.avatar) {
                         <img [src]="designer.get('data').avatar" alt="设计师头像" />

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

@@ -216,7 +216,9 @@ export class StageOrderComponent implements OnInit {
 
       // 使用FmodeParse加载项目组列表(Department表)
       const deptQuery = new Parse.Query('Department');
+      deptQuery.include("leader");
       deptQuery.equalTo('type', 'project');
+      deptQuery.equalTo('company', localStorage.getItem('company'));
       deptQuery.notEqualTo('isDeleted', true);
       deptQuery.ascending('name');
       this.departments = await deptQuery.find();
@@ -415,19 +417,20 @@ export class StageOrderComponent implements OnInit {
    * 选择项目组(Department)
    */
   async selectDepartment(department: FmodeObject) {
-    if (!this.canEdit) return;
+    if (!this.canEdit && this.project?.get('assignee')?.id) return;
 
     this.selectedDepartment = department;
     this.selectedDesigner = null;
     this.departmentMembers = [];
 
-    await this.loadDepartmentMembers(department.id);
+    await this.loadDepartmentMembers(department);
   }
 
   /**
    * 加载项目组成员
    */
-  async loadDepartmentMembers(departmentId: string|undefined) {
+  async loadDepartmentMembers(department: FmodeObject) {
+    let departmentId = department.id
     if(!departmentId) return []
     try {
       this.loadingMembers = true;
@@ -440,6 +443,7 @@ export class StageOrderComponent implements OnInit {
       query.ascending('name');
 
       this.departmentMembers = await query.find();
+      this.departmentMembers.unshift(department?.get("leader"))
       return this.departmentMembers
     } catch (err) {
       console.error('加载项目组成员失败:', err);
@@ -453,7 +457,6 @@ export class StageOrderComponent implements OnInit {
    * 选择设计师
    */
   selectDesigner(designer: FmodeObject) {
-    if (!this.canEdit) return;
     this.selectedDesigner = designer;
   }