# 项目客户选择组件产品设计 ## 概述 **组件名称**: `app-customer-selector` **功能定位**: 基于企微群聊成员的项目客户选择组件 **应用场景**: 项目管理中为项目指定客户的场景,支持从群聊成员中选择外部用户并自动创建或关联ContactInfo ## 数据结构分析 ### 1. GroupChat.member_list 数据结构 根据企微群聊管理规范,`member_list` 字段包含群成员信息: ```typescript interface GroupMember { userid: string; // 用户ID(企业员工)或 external_userid(外部用户) type: number; // 用户类型:1=企业员工 2=外部用户 join_time: number; // 加入时间(时间戳) join_scene: number; // 加入场景 invitor?: { // 邀请人信息 userid: string; }; } ``` ### 2. ContactInfo 表结构 ```typescript interface ContactInfo { objectId: string; name: string; // 客户姓名 mobile?: string; // 手机号 company: Pointer; // 所属企业 external_userid?: string; // 企微外部联系人ID source?: string; // 来源渠道 data?: Object; // 扩展数据 isDeleted: Boolean; createdAt: Date; updatedAt: Date; } ``` ### 3. Project 表关联 ```typescript interface Project { objectId: string; title: string; company: Pointer; customer?: Pointer; // 项目客户(可选) assignee?: Pointer; // 负责设计师 // ... 其他字段 } ``` ## 组件接口设计 ### 输入属性(@Input) ```typescript interface CustomerSelectorInputs { // 必填属性 project: Parse.Object; // 项目对象 groupChat: Parse.Object; // 企微群聊对象 // 可选属性 placeholder?: string; // 选择框占位文本,默认"请选择项目客户" disabled?: boolean; // 是否禁用选择,默认false showCreateButton?: boolean; // 是否显示创建新客户按钮,默认true filterCriteria?: { // 过滤条件 joinTimeAfter?: Date; // 加入时间筛选 joinScenes?: number[]; // 加入场景筛选 excludeUserIds?: string[]; // 排除的用户ID列表 }; company?: Parse.Object; // 企业对象(可选,用于权限验证) } ``` ### 输出事件(@Output) ```typescript interface CustomerSelectorOutputs { // 客户选择事件 customerSelected: EventEmitter<{ customer: Parse.Object; // 选中的客户对象 isNewCustomer: boolean; // 是否为新创建的客户 action: 'selected' | 'created' | 'updated'; // 操作类型 }>; // 加载状态事件 loadingChange: EventEmitter; // 错误事件 error: EventEmitter<{ type: 'load_failed' | 'create_failed' | 'permission_denied'; message: string; details?: any; }>; } ``` ## 组件功能设计 ### 1. 核心功能流程 #### 1.1 初始化流程 ```mermaid graph TD A[组件初始化] --> B[检查项目客户状态] B --> C{项目已有客户?} C -->|是| D[显示当前客户信息] C -->|否| E[加载群聊成员列表] E --> F[过滤外部用户 type=2] F --> G[显示客户选择界面] G --> H[等待用户选择/创建] ``` #### 1.2 客户选择流程 ```mermaid graph TD A[用户选择外部用户] --> B[查询ContactInfo表] B --> C{客户记录存在?} C -->|是| D[关联现有客户] C -->|否| E[创建新客户] E --> F[设置项目客户] D --> F F --> G[触发customerSelected事件] G --> H[更新UI状态] ``` ### 2. 组件状态管理 ```typescript enum ComponentState { LOADING = 'loading', // 加载中 CUSTOMER_EXISTS = 'exists', // 项目已有客户 SELECTING = 'selecting', // 选择客户中 CREATING = 'creating', // 创建客户中 ERROR = 'error' // 错误状态 } interface ComponentData { project: Parse.Object; groupChat: Parse.Object; currentCustomer?: Parse.Object; availableCustomers: Parse.Object[]; loading: boolean; state: ComponentState; error?: ErrorInfo; } ``` ## 用户界面设计 ### 1. 视觉状态 #### 1.1 项目已有客户状态 ```html

{{ currentCustomer.name }}

{{ currentCustomer.mobile }}

更换客户
``` #### 1.2 选择客户状态 ```html

{{ customer.name }}

{{ customer.mobile || '未绑定手机' }}

创建新客户
``` #### 1.3 加载状态 ```html

{{ loadingText }}

``` ### 2. 交互设计 #### 2.1 客户卡片展示 - **头像**: 显示客户头像,无头像时显示默认图标 - **姓名**: 客户姓名,加粗显示 - **手机号**: 客户手机号,灰色小字 - **操作按钮**: 更换客户、查看详情等 #### 2.2 客户列表交互 - **搜索功能**: 支持按姓名、手机号搜索 - **单选模式**: 一次只能选择一个客户 - **选中状态**: 选中项显示对勾图标 - **悬停效果**: 鼠标悬停时背景色变化 #### 2.3 创建新客户 - **弹窗表单**: 包含姓名、手机号等必填字段 - **验证规则**: 手机号格式验证、姓名长度限制 - **自动关联**: 创建后自动关联到项目 ## 技术实现方案 ### 1. 组件代码结构 ```typescript @Component({ selector: 'app-customer-selector', standalone: true, imports: [ CommonModule, FormsModule, IonicModule, // 其他依赖 ], template: './customer-selector.component.html', styleUrls: ['./customer-selector.component.scss'] }) export class CustomerSelectorComponent implements OnInit, OnChanges { // 输入输出属性 @Input() project!: Parse.Object; @Input() groupChat!: Parse.Object; @Input() placeholder: string = '请选择项目客户'; @Input() disabled: boolean = false; @Input() showCreateButton: boolean = true; @Output() customerSelected = new EventEmitter(); @Output() loadingChange = new EventEmitter(); @Output() error = new EventEmitter(); // 组件状态 state: ComponentState = ComponentState.LOADING; currentCustomer?: Parse.Object; availableCustomers: Parse.Object[] = []; searchKeyword: string = ''; constructor( private parseService: ParseService, private wxworkService: WxworkService, private modalController: ModalController ) {} ngOnInit() { this.initializeComponent(); } ngOnChanges(changes: SimpleChanges) { if (changes.project || changes.groupChat) { this.initializeComponent(); } } private async initializeComponent() { this.state = ComponentState.LOADING; this.loadingChange.emit(true); try { // 1. 检查项目是否已有客户 await this.checkProjectCustomer(); if (this.currentCustomer) { this.state = ComponentState.CUSTOMER_EXISTS; } else { // 2. 加载可选客户列表 await this.loadAvailableCustomers(); this.state = ComponentState.SELECTING; } } catch (error) { this.handleError(error); } finally { this.loadingChange.emit(false); } } private async checkProjectCustomer(): Promise { const customer = this.project.get('customer'); if (customer) { this.currentCustomer = await this.parseService.fetchFullObject(customer); } } private async loadAvailableCustomers(): Promise { const memberList = this.groupChat.get('member_list') || []; const company = this.project.get('company'); // 过滤外部用户(type=2) const externalMembers = memberList.filter((member: any) => member.type === 2); // 查询对应的ContactInfo记录 const externalUserIds = externalMembers.map((member: any) => member.userid); if (externalUserIds.length === 0) { this.availableCustomers = []; return; } const ContactInfo = Parse.Object.extend('ContactInfo'); const query = new Parse.Query(ContactInfo); query.containedIn('external_userid', externalUserIds); query.equalTo('company', company); query.notEqualTo('isDeleted', true); query.ascending('name'); this.availableCustomers = await query.find(); } async selectCustomer(customer: Parse.Object): Promise { this.state = ComponentState.LOADING; this.loadingChange.emit(true); try { // 关联客户到项目 this.project.set('customer', customer); await this.project.save(); this.currentCustomer = customer; this.state = ComponentState.CUSTOMER_EXISTS; this.customerSelected.emit({ customer, isNewCustomer: false, action: 'selected' }); } catch (error) { this.handleError(error); } finally { this.loadingChange.emit(false); } } async createNewCustomer(): Promise { // 弹出创建客户模态框 const modal = await this.modalController.create({ component: CreateCustomerModalComponent, componentProps: { company: this.project.get('company'), project: this.project } }); modal.onDidDismiss().then(async (result) => { if (result.data?.customer) { await this.handleCustomerCreated(result.data.customer); } }); await modal.present(); } private async handleCustomerCreated(customer: Parse.Object): Promise { this.currentCustomer = customer; this.state = ComponentState.CUSTOMER_EXISTS; this.customerSelected.emit({ customer, isNewCustomer: true, action: 'created' }); } async changeCustomer(): Promise { // 重新进入选择状态 this.currentCustomer = undefined; this.project.unset('customer'); await this.project.save(); await this.loadAvailableCustomers(); this.state = ComponentState.SELECTING; } private handleError(error: any): void { console.error('Customer selector error:', error); this.state = ComponentState.ERROR; this.error.emit({ type: 'load_failed', message: '加载客户列表失败', details: error }); } } ``` ### 2. 创建客户模态框 ```typescript @Component({ selector: 'app-create-customer-modal', standalone: true, imports: [CommonModule, FormsModule, IonicModule], template: ` 创建新客户 取消
客户姓名 * 手机号码 来源渠道 朋友圈 信息流 转介绍 其他 创建客户
` }) export class CreateCustomerModalComponent { @Input() company!: Parse.Object; @Input() project!: Parse.Object; customerData = { name: '', mobile: '', source: '' }; loading: boolean = false; constructor( private modalController: ModalController, private parseService: ParseService ) {} async onSubmit(): Promise { if (this.loading) return; this.loading = true; try { // 创建ContactInfo记录 const ContactInfo = Parse.Object.extend('ContactInfo'); const customer = new ContactInfo(); customer.set('name', this.customerData.name); customer.set('mobile', this.customerData.mobile); customer.set('source', this.customerData.source); customer.set('company', this.company); await customer.save(); // 关联到项目 this.project.set('customer', customer); await this.project.save(); this.modalController.dismiss({ customer, action: 'created' }); } catch (error) { console.error('创建客户失败:', error); // 显示错误提示 } finally { this.loading = false; } } dismiss(): void { this.modalController.dismiss(); } } ``` ### 3. 样式设计 ```scss .customer-selector { border: 1px solid var(--ion-color-light); border-radius: 8px; overflow: hidden; background: var(--ion-background-color); // 已有客户状态 &.has-customer { .current-customer { display: flex; align-items: center; padding: 12px 16px; gap: 12px; .customer-info { flex: 1; h3 { margin: 0; font-size: 16px; font-weight: 600; } p { margin: 4px 0 0; font-size: 14px; color: var(--ion-color-medium); } } } } // 选择客户状态 &.selecting { .customer-list { max-height: 300px; overflow-y: auto; ion-item { cursor: pointer; transition: background-color 0.2s; &:hover { background-color: var(--ion-color-light); } &.selected { background-color: var(--ion-color-light); ion-icon { color: var(--ion-color-primary); } } } } } // 加载状态 &.loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; ion-spinner { margin-bottom: 16px; } p { color: var(--ion-color-medium); margin: 0; } } // 错误状态 &.error { padding: 20px; text-align: center; .error-message { color: var(--ion-color-danger); margin-bottom: 16px; } ion-button { margin: 0 auto; } } } ``` ## 使用示例 ### 1. 基础用法 ```typescript @Component({ selector: 'app-project-detail', standalone: true, imports: [CustomerSelectorComponent], template: `

项目客户

` }) export class ProjectDetailComponent { @Input() project!: Parse.Object; @Input() groupChat!: Parse.Object; onCustomerSelected(event: CustomerSelectedEvent) { console.log('客户已选择:', event.customer); console.log('是否为新客户:', event.isNewCustomer); if (event.isNewCustomer) { // 新客户创建成功后的处理 this.showWelcomeMessage(event.customer); } } onSelectorError(error: ErrorEvent) { console.error('客户选择器错误:', error); this.showErrorMessage(error.message); } } ``` ### 2. 高级配置 ```typescript @Component({ selector: 'app-project-setup', standalone: true, imports: [CustomerSelectorComponent], template: ` ` }) export class ProjectSetupComponent { project: Parse.Object; groupChat: Parse.Object; isProjectLocked = false; filterOptions = { joinTimeAfter: new Date('2024-01-01'), joinScenes: [1, 2], excludeUserIds: ['user123', 'user456'] }; handleCustomerChange(event: CustomerSelectedEvent) { // 处理客户变更 this.updateProjectStatus(event.action); } } ``` ## 错误处理与边界情况 ### 1. 常见错误场景 #### 1.1 群聊无外部用户 ```typescript if (externalUserIds.length === 0) { this.state = ComponentState.ERROR; this.error.emit({ type: 'load_failed', message: '当前群聊中没有外部客户用户', details: { memberCount: memberList.length } }); return; } ``` #### 1.2 权限不足 ```typescript if (error.code === 119) { this.error.emit({ type: 'permission_denied', message: '没有权限访问该项目的客户信息', details: error }); } ``` #### 1.3 网络错误 ```typescript if (error.message?.includes('Network')) { this.error.emit({ type: 'load_failed', message: '网络连接失败,请检查网络后重试', details: error }); } ``` ### 2. 降级方案 #### 2.1 搜索功能降级 ```typescript private filterCustomers(keyword: string): Parse.Object[] { if (!keyword) return this.availableCustomers; const lowerKeyword = keyword.toLowerCase(); return this.availableCustomers.filter(customer => { const name = (customer.get('name') || '').toLowerCase(); const mobile = (customer.get('mobile') || '').toLowerCase(); return name.includes(lowerKeyword) || mobile.includes(lowerKeyword); }); } ``` #### 2.2 缓存策略 ```typescript private customerCache = new Map(); private async getCachedCustomer(externalUserId: string): Promise { if (this.customerCache.has(externalUserId)) { return this.customerCache.get(externalUserId)!; } const customer = await this.queryCustomerByExternalId(externalUserId); if (customer) { this.customerCache.set(externalUserId, customer); } return customer; } ``` ## 性能优化 ### 1. 数据加载优化 - **懒加载**: 只在需要时加载客户详情 - **分页加载**: 大量客户时支持分页显示 - **缓存机制**: 缓存已查询的客户信息 ### 2. UI优化 - **虚拟滚动**: 大列表时使用虚拟滚动 - **防抖搜索**: 搜索输入时进行防抖处理 - **骨架屏**: 加载时显示骨架屏提升体验 ## 测试策略 ### 1. 单元测试 ```typescript describe('CustomerSelectorComponent', () => { let component: CustomerSelectorComponent; let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [CustomerSelectorComponent] }).compileComponents(); fixture = TestBed.createComponent(CustomerSelectorComponent); component = fixture.componentInstance; }); it('should load existing customer when project has customer', async () => { // 测试项目已有客户的情况 }); it('should show customer list when project has no customer', async () => { // 测试显示客户列表的情况 }); it('should filter external users from member list', async () => { // 测试过滤外部用户功能 }); }); ``` ### 2. 集成测试 ```typescript describe('Customer Integration', () => { it('should create new customer and associate with project', async () => { // 测试创建新客户并关联到项目的完整流程 }); it('should select existing customer and update project', async () => { // 测试选择现有客户并更新项目的流程 }); }); ``` ## 总结 `app-customer-selector` 组件提供了完整的项目客户选择解决方案: ✅ **智能识别**: 自动从群聊成员中识别外部用户 ✅ **数据同步**: 支持创建和关联ContactInfo记录 ✅ **用户体验**: 流畅的选择、搜索、创建交互 ✅ **错误处理**: 完善的错误处理和降级方案 ✅ **性能优化**: 缓存、懒加载等性能优化策略 ✅ **扩展性**: 支持自定义过滤条件和样式定制 该组件可有效解决项目管理中客户指定的痛点,提升用户操作效率。