contact.component.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import { Component, OnInit, Input, Output, EventEmitter, HostBinding } from '@angular/core';
  2. import { CommonModule } from '@angular/common';
  3. import { Router, ActivatedRoute } from '@angular/router';
  4. import { IonicModule } from '@ionic/angular';
  5. import { WxworkSDK, WxworkCorp } from 'fmode-ng/core';
  6. import { FmodeParse, FmodeObject } from 'fmode-ng/parse';
  7. import { WxworkSDKService } from '../../services/wxwork-sdk.service';
  8. import { ProfileService } from '../../../../app/services/profile.service';
  9. const Parse = FmodeParse.with('nova');
  10. /**
  11. * 客户画像组件
  12. *
  13. * 功能:
  14. * 1. 展示客户基础信息(权限控制:手机号仅客服/组长可见)
  15. * 2. 客户画像标签(风格偏好、预算、色彩氛围)
  16. * 3. 所在群聊列表(点击可跳转企微群聊)
  17. * 4. 历史项目列表
  18. * 5. 跟进记录时间线
  19. *
  20. * 路由:/wxwork/:cid/customer/:contactId
  21. */
  22. @Component({
  23. selector: 'app-contact',
  24. standalone: true,
  25. imports: [CommonModule, IonicModule],
  26. templateUrl: './contact.component.html',
  27. styleUrls: ['./contact.component.scss']
  28. })
  29. export class CustomerProfileComponent implements OnInit {
  30. // 输入参数(支持组件复用)
  31. @Input() customer: FmodeObject | null = null;
  32. @Input() currentUser: FmodeObject | null = null;
  33. // 新增:嵌入模式与项目过滤
  34. @Input() embeddedMode: boolean = false;
  35. @Input() projectIdFilter: string | null = null;
  36. @Output() close = new EventEmitter<void>();
  37. @HostBinding('class.embedded') get embeddedClass() { return this.embeddedMode; }
  38. // 路由参数
  39. cid: string = '';
  40. contactId: string = '';
  41. profileId: string = '';
  42. externalUserId: string = ''; // 从企微进入时的 external_userid
  43. // 企微SDK
  44. wxwork: WxworkSDK | null = null;
  45. wecorp: WxworkCorp | null = null;
  46. wxAuth: any = null; // WxworkAuth 实例
  47. // 加载状态
  48. loading: boolean = true;
  49. error: string | null = null;
  50. refreshing: boolean = false;
  51. // 客户数据
  52. contactInfo: FmodeObject | null = null;
  53. // 客户画像数据
  54. profile: {
  55. basic: {
  56. name: string;
  57. mobile: string;
  58. wechat: string;
  59. avatar: string;
  60. source: string;
  61. tags: string[];
  62. };
  63. preferences: {
  64. style: string[];
  65. budget: { min: number; max: number };
  66. colorAtmosphere: string;
  67. demandType: string;
  68. };
  69. groups: Array<{
  70. groupChat: FmodeObject;
  71. project: FmodeObject | null;
  72. }>;
  73. projects: FmodeObject[];
  74. followUpRecords: Array<{
  75. time: Date;
  76. type: string;
  77. content: string;
  78. operator: string;
  79. }>;
  80. } = {
  81. basic: {
  82. name: '',
  83. mobile: '',
  84. wechat: '',
  85. avatar: '',
  86. source: '',
  87. tags: []
  88. },
  89. preferences: {
  90. style: [],
  91. budget: { min: 0, max: 0 },
  92. colorAtmosphere: '',
  93. demandType: ''
  94. },
  95. groups: [],
  96. projects: [],
  97. followUpRecords: []
  98. };
  99. // 权限控制
  100. canViewSensitiveInfo: boolean = false;
  101. constructor(
  102. private router: Router,
  103. private route: ActivatedRoute,
  104. private wxworkService: WxworkSDKService,
  105. private profileService: ProfileService
  106. ) {}
  107. async ngOnInit() {
  108. // 获取路由参数
  109. this.cid = this.route.snapshot.paramMap.get('cid') || '';
  110. this.contactId = this.route.snapshot.paramMap.get('contactId') || '';
  111. this.profileId = this.route.snapshot.queryParamMap.get('profileId') || '';
  112. this.externalUserId = this.route.snapshot.queryParamMap.get('externalUserId') || '';
  113. // 如果有Input传入,直接使用
  114. if (this.customer) {
  115. this.contactInfo = this.customer;
  116. }
  117. // 初始化企微授权(不阻塞页面加载)
  118. this.initWxworkAuth();
  119. await this.loadData();
  120. }
  121. /**
  122. * 初始化企微授权(不阻塞页面)
  123. */
  124. async initWxworkAuth() {
  125. if (!this.cid) return;
  126. try {
  127. // 动态导入 WxworkAuth 避免导入错误
  128. const { WxworkAuth } = await import('fmode-ng/core');
  129. this.wxAuth = new WxworkAuth({ cid: this.cid, appId: 'crm' });
  130. // 静默授权并同步 Profile,不阻塞页面
  131. const { profile } = await this.wxAuth.authenticateAndLogin();
  132. if (profile) {
  133. this.profileService.setCurrentProfile(profile);
  134. }
  135. } catch (error) {
  136. console.warn('企微授权失败:', error);
  137. // 授权失败不影响页面加载,继续使用其他方式加载数据
  138. }
  139. }
  140. /**
  141. * 加载数据
  142. */
  143. async loadData() {
  144. try {
  145. this.loading = true;
  146. // 1. 初始化SDK(用于企微API调用,不需要等待授权)
  147. if (!this.wxwork && this.cid) {
  148. this.wxwork = new WxworkSDK({ cid: this.cid, appId: 'crm' });
  149. this.wecorp = new WxworkCorp(this.cid);
  150. }
  151. // 2. 获取当前用户(优先从全局服务获取)
  152. if (!this.currentUser) {
  153. // 优先级1: 使用 profileId 参数
  154. if (this.profileId) {
  155. this.currentUser = await this.profileService.getProfileById(this.profileId);
  156. }
  157. // 优先级2: 从全局服务获取当前 Profile
  158. if (!this.currentUser) {
  159. this.currentUser = await this.profileService.getCurrentProfile(this.cid);
  160. }
  161. // 优先级3: 企微环境下尝试从SDK获取
  162. if (!this.currentUser && this.wxwork) {
  163. try {
  164. this.currentUser = await this.wxwork.getCurrentUser();
  165. } catch (err) {
  166. console.warn('无法从企微SDK获取用户:', err);
  167. }
  168. }
  169. }
  170. // 检查权限
  171. const role = this.currentUser?.get('roleName');
  172. this.canViewSensitiveInfo = ['客服', '组长', '管理员'].includes(role);
  173. // 3. 加载客户信息
  174. if (!this.contactInfo) {
  175. if (this.contactId) {
  176. // 通过 contactId 加载(从后台进入)
  177. const query = new Parse.Query('ContactInfo');
  178. this.contactInfo = await query.get(this.contactId);
  179. } else if (this.externalUserId) {
  180. // 通过 external_userid 查找(从企微进入)
  181. const companyId = this.currentUser?.get('company')?.id;
  182. if (companyId) {
  183. const query = new Parse.Query('ContactInfo');
  184. query.equalTo('external_userid', this.externalUserId);
  185. query.equalTo('company', companyId);
  186. this.contactInfo = await query.first();
  187. if (!this.contactInfo) {
  188. throw new Error('未找到客户信息,请先在企微中添加该客户');
  189. }
  190. }
  191. }
  192. }
  193. // 4. 构建客户画像
  194. if (this.contactInfo) {
  195. await this.buildCustomerProfile();
  196. } else {
  197. throw new Error('无法加载客户信息');
  198. }
  199. } catch (err: any) {
  200. console.error('加载失败:', err);
  201. this.error = err.message || '加载失败';
  202. } finally {
  203. this.loading = false;
  204. }
  205. }
  206. /**
  207. * 构建客户画像
  208. */
  209. async buildCustomerProfile() {
  210. if (!this.contactInfo) return;
  211. const data = this.contactInfo.get('data') || {};
  212. // 基础信息
  213. this.profile.basic = {
  214. name: this.contactInfo.get('name') || '',
  215. mobile: this.canViewSensitiveInfo ? (this.contactInfo.get('mobile') || '') : '***',
  216. wechat: this.canViewSensitiveInfo ? (data.wechat || '') : '***',
  217. avatar: data.avatar || '',
  218. source: this.contactInfo.get('source') || '其他',
  219. tags: data.tags?.preferenceTags || []
  220. };
  221. // 客户画像
  222. this.profile.preferences = {
  223. style: data.tags?.preference ? [data.tags.preference] : [],
  224. budget: data.tags?.budget || { min: 0, max: 0 },
  225. colorAtmosphere: data.tags?.colorAtmosphere || '',
  226. demandType: data.demandType || ''
  227. };
  228. // 加载所在群聊
  229. await this.loadGroupChats();
  230. // 加载历史项目
  231. await this.loadProjects();
  232. // 加载跟进记录
  233. await this.loadFollowUpRecords();
  234. }
  235. /**
  236. * 加载所在群聊
  237. */
  238. async loadGroupChats() {
  239. try {
  240. // 查询包含该客户的群聊
  241. const query = new Parse.Query('GroupChat');
  242. query.include("project");
  243. query.equalTo('company', this.currentUser!.get('company'));
  244. query.notEqualTo('isDeleted', true);
  245. const groups = await query.find();
  246. // 过滤包含该客户的群聊
  247. const externalUserId = this.contactInfo!.get('external_userid');
  248. const filteredGroups = groups.filter((g: any) => {
  249. const memberList = g.get('member_list') || [];
  250. return memberList.some((m: any) =>
  251. m.type === 2 && m.userid === externalUserId
  252. );
  253. });
  254. // 加载群聊关联的项目
  255. this.profile.groups = await Promise.all(
  256. filteredGroups.map(async (groupChat: any) => {
  257. let project = groupChat.get('project');
  258. return { groupChat, project };
  259. })
  260. );
  261. } catch (err) {
  262. console.error('加载群聊失败:', err);
  263. }
  264. }
  265. /**
  266. * 加载历史项目
  267. */
  268. async loadProjects() {
  269. try {
  270. const query = new Parse.Query('Project');
  271. query.equalTo('customer', this.contactInfo!.toPointer());
  272. query.notEqualTo('isDeleted', true);
  273. query.descending('updatedAt');
  274. query.limit(10);
  275. this.profile.projects = await query.find();
  276. } catch (err) {
  277. console.error('加载项目失败:', err);
  278. }
  279. }
  280. /**
  281. * 加载跟进记录
  282. */
  283. async loadFollowUpRecords() {
  284. try {
  285. // 使用 ContactFollow 表,默认按项目过滤
  286. const query = new Parse.Query('ContactFollow');
  287. query.equalTo('contact', this.contactInfo!.toPointer());
  288. query.notEqualTo('isDeleted', true);
  289. if (this.projectIdFilter) {
  290. const project = new Parse.Object('Project');
  291. project.id = this.projectIdFilter;
  292. query.equalTo('project', project.toPointer());
  293. }
  294. query.descending('createdAt');
  295. query.limit(50);
  296. const records = await query.find();
  297. this.profile.followUpRecords = records.map((rec: any) => ({
  298. time: rec.get('createdAt'),
  299. type: rec.get('type') || 'message',
  300. content: rec.get('content') || '',
  301. operator: rec.get('sender')?.get('name') || '系统'
  302. }));
  303. // 若无 ContactFollow 记录,则兼容 data.follow_user
  304. if (this.profile.followUpRecords.length === 0) {
  305. const data = this.contactInfo!.get('data') || {};
  306. const followUsers = data.follow_user || [];
  307. this.profile.followUpRecords = followUsers.map((fu: any) => {
  308. // 处理操作员名称,避免显示企微userid
  309. let operatorName = '企微用户';
  310. if (fu.remark && fu.remark.length < 50) {
  311. operatorName = fu.remark;
  312. } else if (fu.name && fu.name.length < 50 && !fu.name.startsWith('woAs2q')) {
  313. operatorName = fu.name;
  314. }
  315. return {
  316. time: fu.createtime ? new Date(fu.createtime * 1000) : new Date(),
  317. type: 'follow',
  318. content: '添加客户',
  319. operator: operatorName
  320. };
  321. });
  322. }
  323. } catch (err) {
  324. console.error('加载跟进记录失败:', err);
  325. }
  326. }
  327. /**
  328. * 跳转到群聊
  329. */
  330. async navigateToGroupChat(chatId: string) {
  331. try {
  332. // 使用企微SDK服务跳转到群聊
  333. await this.wxworkService.openChat(chatId);
  334. } catch (error: any) {
  335. console.error('跳转群聊失败:', error);
  336. window?.fmode?.alert('跳转失败,请在企业微信中操作');
  337. }
  338. }
  339. /**
  340. * 跳转到项目详情
  341. */
  342. navigateToProject(project: FmodeObject) {
  343. this.router.navigate(['/wxwork', this.cid, 'project', project.id], {
  344. queryParams: {
  345. profileId: this.currentUser!.id
  346. }
  347. });
  348. }
  349. /**
  350. * 返回
  351. */
  352. goBack() {
  353. // 嵌入模式下不跳转,触发关闭
  354. if (this.embeddedMode) {
  355. this.close.emit();
  356. return;
  357. }
  358. this.router.navigate(['/wxwork', this.cid, 'project-loader']);
  359. }
  360. /**
  361. * 获取来源图标
  362. */
  363. getSourceIcon(source: string): string {
  364. const iconMap: any = {
  365. '朋友圈': 'logo-wechat',
  366. '信息流': 'newspaper-outline',
  367. '转介绍': 'people-outline',
  368. '其他': 'help-circle-outline'
  369. };
  370. return iconMap[source] || 'help-circle-outline';
  371. }
  372. /**
  373. * 获取项目状态类
  374. */
  375. getProjectStatusClass(status: string): string {
  376. const classMap: any = {
  377. '待分配': 'status-pending',
  378. '进行中': 'status-active',
  379. '已完成': 'status-completed',
  380. '已暂停': 'status-paused',
  381. '已取消': 'status-cancelled'
  382. };
  383. return classMap[status] || 'status-default';
  384. }
  385. /**
  386. * 获取跟进类型图标
  387. */
  388. getFollowUpIcon(type: string): string {
  389. const iconMap: any = {
  390. 'message': 'chatbubble-outline',
  391. 'call': 'call-outline',
  392. 'meeting': 'people-outline',
  393. 'email': 'mail-outline',
  394. 'follow': 'person-add-outline'
  395. };
  396. return iconMap[type] || 'ellipsis-horizontal-outline';
  397. }
  398. /**
  399. * 格式化日期
  400. */
  401. formatDate(date: Date): string {
  402. if (!date) return '';
  403. const d = new Date(date);
  404. const now = new Date();
  405. const diff = now.getTime() - d.getTime();
  406. const days = Math.floor(diff / (1000 * 60 * 60 * 24));
  407. if (days === 0) {
  408. return '今天';
  409. } else if (days === 1) {
  410. return '昨天';
  411. } else if (days < 7) {
  412. return `${days}天前`;
  413. } else {
  414. return `${d.getMonth() + 1}/${d.getDate()}`;
  415. }
  416. }
  417. /**
  418. * 格式化预算
  419. */
  420. formatBudget(budget: { min: number; max: number }): string {
  421. if (!budget || (!budget.min && !budget.max)) return '未设置';
  422. if (budget.min === budget.max) return `¥${budget.min}`;
  423. return `¥${budget.min} - ¥${budget.max}`;
  424. }
  425. /** 刷新客户数据(基于 external_userid 拉取企微数据并保存) */
  426. async refreshContactData() {
  427. try {
  428. if (!this.contactInfo) return;
  429. const externalUserId = this.contactInfo.get('external_userid');
  430. const companyId = this.currentUser?.get('company')?.id || this.contactInfo.get('company')?.id || localStorage.getItem('company');
  431. if (!externalUserId || !companyId) {
  432. window?.fmode?.alert('无法刷新:缺少企业或external_userid');
  433. return;
  434. }
  435. this.refreshing = true;
  436. const corp = new WxworkCorp(companyId);
  437. const extData = await corp.externalContact.get(externalUserId);
  438. const ext = (extData && extData.external_contact) ? extData.external_contact : {};
  439. const follow = (extData && extData.follow_user) ? extData.follow_user : [];
  440. if (ext.name) this.contactInfo.set('name', ext.name);
  441. const prev = this.contactInfo.get('data') || {};
  442. const mapped = {
  443. ...prev,
  444. external_contact: ext,
  445. follow_user: follow,
  446. name: ext.name,
  447. avatar: ext.avatar,
  448. gender: ext.gender,
  449. type: ext.type
  450. } as any;
  451. this.contactInfo.set('data', mapped);
  452. await this.contactInfo.save();
  453. await this.buildCustomerProfile();
  454. } catch (e) {
  455. console.warn('刷新客户数据失败:', e);
  456. window?.fmode?.alert('刷新失败,请稍后重试');
  457. } finally {
  458. this.refreshing = false;
  459. }
  460. }
  461. }