组件-客户选择.md 21 KB

项目客户选择组件产品设计

概述

组件名称: app-contact-selector 功能定位: 基于企微群聊成员的项目客户选择组件 应用场景: 项目管理中为项目指定客户的场景,支持从群聊成员中选择外部用户并自动创建或关联ContactInfo

数据结构分析

1. GroupChat.member_list 数据结构

根据企微群聊管理规范,member_list 字段包含群成员信息:

interface GroupMember {
  userid: string;           // 用户ID(企业员工)或 external_userid(外部用户)
  type: number;            // 用户类型:1=企业员工 2=外部用户
  join_time: number;       // 加入时间(时间戳)
  join_scene: number;      // 加入场景
  invitor?: {              // 邀请人信息
    userid: string;
  };
}

2. ContactInfo 表结构

interface ContactInfo {
  objectId: string;
  name: string;                    // 客户姓名
  mobile?: string;                 // 手机号
  company: Pointer<Company>;     // 所属企业
  external_userid?: string;        // 企微外部联系人ID
  source?: string;                 // 来源渠道
  data?: Object;                   // 扩展数据
  isDeleted: Boolean;
  createdAt: Date;
  updatedAt: Date;
}

3. Project 表关联

interface Project {
  objectId: string;
  title: string;
  company: Pointer<Company>;
  contact?: Pointer<ContactInfo>;  // 项目客户(可选)
  assignee?: Pointer<Profile>;      // 负责设计师
  // ... 其他字段
}

组件接口设计

输入属性(@Input

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

interface CustomerSelectorOutputs {
  // 客户选择事件
  contactSelected: EventEmitter<{
    contact: Parse.Object;       // 选中的客户对象
    isNewCustomer: boolean;        // 是否为新创建的客户
    action: 'selected' | 'created' | 'updated'; // 操作类型
  }>;

  // 加载状态事件
  loadingChange: EventEmitter<boolean>;

  // 错误事件
  error: EventEmitter<{
    type: 'load_failed' | 'create_failed' | 'permission_denied';
    message: string;
    details?: any;
  }>;
}

组件功能设计

1. 核心功能流程

1.1 初始化流程

graph TD
    A[组件初始化] --> B[检查项目客户状态]
    B --> C{项目已有客户?}
    C -->|是| D[显示当前客户信息]
    C -->|否| E[加载群聊成员列表]
    E --> F[过滤外部用户 type=2]
    F --> G[显示客户选择界面]
    G --> H[等待用户选择/创建]

1.2 客户选择流程

graph TD
    A[用户选择外部用户] --> B[查询ContactInfo表]
    B --> C{客户记录存在?}
    C -->|是| D[关联现有客户]
    C -->|否| E[创建新客户]
    E --> F[设置项目客户]
    D --> F
    F --> G[触发contactSelected事件]
    G --> H[更新UI状态]

2. 组件状态管理

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 项目已有客户状态

<div class="contact-selector has-contact">
  <div class="current-contact">
    <ion-avatar>
      <img [src]="currentCustomerAvatar" />
    </ion-avatar>
    <div class="contact-info">
      <h3>{{ currentCustomer.name }}</h3>
      <p>{{ currentCustomer.mobile }}</p>
    </div>
    <ion-button (click)="changeCustomer()" fill="outline" size="small">
      更换客户
    </ion-button>
  </div>
</div>

1.2 选择客户状态

<div class="contact-selector selecting">
  <ion-searchbar
    [(ngModel)]="searchKeyword"
    placeholder="搜索客户姓名或手机号"
    (ionInput)="onSearchChange($event)">
  </ion-searchbar>

  <div class="contact-list">
    <ion-item
      *ngFor="let contact of filteredCustomers"
      (click)="selectCustomer(contact)">
      <ion-avatar slot="start">
        <img [src]="contactAvatar(contact)" />
      </ion-avatar>
      <ion-label>
        <h2>{{ contact.name }}</h2>
        <p>{{ contact.mobile || '未绑定手机' }}</p>
      </ion-label>
      <ion-icon name="checkmark" slot="end" *ngIf="isSelected(contact)"></ion-icon>
    </ion-item>
  </div>

  <ion-button (click)="createNewCustomer()" expand="block" fill="outline">
    <ion-icon name="person-add" slot="start"></ion-icon>
    创建新客户
  </ion-button>
</div>

1.3 加载状态

<div class="contact-selector loading">
  <ion-spinner name="dots"></ion-spinner>
  <p>{{ loadingText }}</p>
</div>

2. 交互设计

2.1 客户卡片展示

  • 头像: 显示客户头像,无头像时显示默认图标
  • 姓名: 客户姓名,加粗显示
  • 手机号: 客户手机号,灰色小字
  • 操作按钮: 更换客户、查看详情等

2.2 客户列表交互

  • 搜索功能: 支持按姓名、手机号搜索
  • 单选模式: 一次只能选择一个客户
  • 选中状态: 选中项显示对勾图标
  • 悬停效果: 鼠标悬停时背景色变化

2.3 创建新客户

  • 弹窗表单: 包含姓名、手机号等必填字段
  • 验证规则: 手机号格式验证、姓名长度限制
  • 自动关联: 创建后自动关联到项目

技术实现方案

1. 组件代码结构

@Component({
  selector: 'app-contact-selector',
  standalone: true,
  imports: [
    CommonModule,
    FormsModule,
    IonicModule,
    // 其他依赖
  ],
  template: './contact-selector.component.html',
  styleUrls: ['./contact-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() contactSelected = new EventEmitter<CustomerSelectedEvent>();
  @Output() loadingChange = new EventEmitter<boolean>();
  @Output() error = new EventEmitter<ErrorEvent>();

  // 组件状态
  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<void> {
    const contact = this.project.get('contact');
    if (contact) {
      this.currentCustomer = await this.parseService.fetchFullObject(contact);
    }
  }

  private async loadAvailableCustomers(): Promise<void> {
    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(contact: Parse.Object): Promise<void> {
    this.state = ComponentState.LOADING;
    this.loadingChange.emit(true);

    try {
      // 关联客户到项目
      this.project.set('contact', contact);
      await this.project.save();

      this.currentCustomer = contact;
      this.state = ComponentState.CUSTOMER_EXISTS;

      this.contactSelected.emit({
        contact,
        isNewCustomer: false,
        action: 'selected'
      });
    } catch (error) {
      this.handleError(error);
    } finally {
      this.loadingChange.emit(false);
    }
  }

  async createNewCustomer(): Promise<void> {
    // 弹出创建客户模态框
    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?.contact) {
        await this.handleCustomerCreated(result.data.contact);
      }
    });

    await modal.present();
  }

  private async handleCustomerCreated(contact: Parse.Object): Promise<void> {
    this.currentCustomer = contact;
    this.state = ComponentState.CUSTOMER_EXISTS;

    this.contactSelected.emit({
      contact,
      isNewCustomer: true,
      action: 'created'
    });
  }

  async changeCustomer(): Promise<void> {
    // 重新进入选择状态
    this.currentCustomer = undefined;
    this.project.unset('contact');
    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. 创建客户模态框

@Component({
  selector: 'app-create-contact-modal',
  standalone: true,
  imports: [CommonModule, FormsModule, IonicModule],
  template: `
    <ion-header>
      <ion-toolbar>
        <ion-title>创建新客户</ion-title>
        <ion-buttons slot="end">
          <ion-button (click)="dismiss()">取消</ion-button>
        </ion-buttons>
      </ion-toolbar>
    </ion-header>

    <ion-content class="ion-padding">
      <form #contactForm="ngForm" (ngSubmit)="onSubmit()">
        <ion-item>
          <ion-label position="stacked">客户姓名 *</ion-label>
          <ion-input
            name="name"
            [(ngModel)]="contactData.name"
            required
            placeholder="请输入客户姓名">
          </ion-input>
        </ion-item>

        <ion-item>
          <ion-label position="stacked">手机号码</ion-label>
          <ion-input
            name="mobile"
            [(ngModel)]="contactData.mobile"
            type="tel"
            placeholder="请输入手机号码">
          </ion-input>
        </ion-item>

        <ion-item>
          <ion-label position="stacked">来源渠道</ion-label>
          <ion-select
            name="source"
            [(ngModel)]="contactData.source"
            placeholder="请选择来源渠道">
            <ion-select-option value="朋友圈">朋友圈</ion-select-option>
            <ion-select-option value="信息流">信息流</ion-select-option>
            <ion-select-option value="转介绍">转介绍</ion-select-option>
            <ion-select-option value="其他">其他</ion-select-option>
          </ion-select>
        </ion-item>

        <ion-button
          type="submit"
          expand="block"
          [disabled]="!contactForm.valid || loading"
          class="ion-margin-top">
          <ion-spinner *ngIf="loading" name="dots" slot="start"></ion-spinner>
          创建客户
        </ion-button>
      </form>
    </ion-content>
  `
})
export class CreateCustomerModalComponent {
  @Input() company!: Parse.Object;
  @Input() project!: Parse.Object;

  contactData = {
    name: '',
    mobile: '',
    source: ''
  };

  loading: boolean = false;

  constructor(
    private modalController: ModalController,
    private parseService: ParseService
  ) {}

  async onSubmit(): Promise<void> {
    if (this.loading) return;

    this.loading = true;

    try {
      // 创建ContactInfo记录
      const ContactInfo = Parse.Object.extend('ContactInfo');
      const contact = new ContactInfo();

      contact.set('name', this.contactData.name);
      contact.set('mobile', this.contactData.mobile);
      contact.set('source', this.contactData.source);
      contact.set('company', this.company);

      await contact.save();

      // 关联到项目
      this.project.set('contact', contact);
      await this.project.save();

      this.modalController.dismiss({
        contact,
        action: 'created'
      });
    } catch (error) {
      console.error('创建客户失败:', error);
      // 显示错误提示
    } finally {
      this.loading = false;
    }
  }

  dismiss(): void {
    this.modalController.dismiss();
  }
}

3. 样式设计

.contact-selector {
  border: 1px solid var(--ion-color-light);
  border-radius: 8px;
  overflow: hidden;
  background: var(--ion-background-color);

  // 已有客户状态
  &.has-contact {
    .current-contact {
      display: flex;
      align-items: center;
      padding: 12px 16px;
      gap: 12px;

      .contact-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 {
    .contact-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. 基础用法

@Component({
  selector: 'app-project-detail',
  standalone: true,
  imports: [CustomerSelectorComponent],
  template: `
    <div class="project-contact-section">
      <h3>项目客户</h3>
      <app-contact-selector
        [project]="project"
        [groupChat]="groupChat"
        (contactSelected)="onCustomerSelected($event)"
        (error)="onSelectorError($event)">
      </app-contact-selector>
    </div>
  `
})
export class ProjectDetailComponent {
  @Input() project!: Parse.Object;
  @Input() groupChat!: Parse.Object;

  onCustomerSelected(event: CustomerSelectedEvent) {
    console.log('客户已选择:', event.contact);
    console.log('是否为新客户:', event.isNewCustomer);

    if (event.isNewCustomer) {
      // 新客户创建成功后的处理
      this.showWelcomeMessage(event.contact);
    }
  }

  onSelectorError(error: ErrorEvent) {
    console.error('客户选择器错误:', error);
    this.showErrorMessage(error.message);
  }
}

2. 高级配置

@Component({
  selector: 'app-project-setup',
  standalone: true,
  imports: [CustomerSelectorComponent],
  template: `
    <app-contact-selector
      [project]="project"
      [groupChat]="groupChat"
      placeholder="请为项目指定客户"
      [showCreateButton]="true"
      [filterCriteria]="filterOptions"
      [disabled]="isProjectLocked"
      (contactSelected)="handleCustomerChange($event)">
    </app-contact-selector>
  `
})
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 群聊无外部用户

if (externalUserIds.length === 0) {
  this.state = ComponentState.ERROR;
  this.error.emit({
    type: 'load_failed',
    message: '当前群聊中没有外部客户用户',
    details: { memberCount: memberList.length }
  });
  return;
}

1.2 权限不足

if (error.code === 119) {
  this.error.emit({
    type: 'permission_denied',
    message: '没有权限访问该项目的客户信息',
    details: error
  });
}

1.3 网络错误

if (error.message?.includes('Network')) {
  this.error.emit({
    type: 'load_failed',
    message: '网络连接失败,请检查网络后重试',
    details: error
  });
}

2. 降级方案

2.1 搜索功能降级

private filterCustomers(keyword: string): Parse.Object[] {
  if (!keyword) return this.availableCustomers;

  const lowerKeyword = keyword.toLowerCase();
  return this.availableCustomers.filter(contact => {
    const name = (contact.get('name') || '').toLowerCase();
    const mobile = (contact.get('mobile') || '').toLowerCase();
    return name.includes(lowerKeyword) || mobile.includes(lowerKeyword);
  });
}

2.2 缓存策略

private contactCache = new Map<string, Parse.Object>();

private async getCachedCustomer(externalUserId: string): Promise<Parse.Object | null> {
  if (this.contactCache.has(externalUserId)) {
    return this.contactCache.get(externalUserId)!;
  }

  const contact = await this.queryCustomerByExternalId(externalUserId);
  if (contact) {
    this.contactCache.set(externalUserId, contact);
  }

  return contact;
}

性能优化

1. 数据加载优化

  • 懒加载: 只在需要时加载客户详情
  • 分页加载: 大量客户时支持分页显示
  • 缓存机制: 缓存已查询的客户信息

2. UI优化

  • 虚拟滚动: 大列表时使用虚拟滚动
  • 防抖搜索: 搜索输入时进行防抖处理
  • 骨架屏: 加载时显示骨架屏提升体验

测试策略

1. 单元测试

describe('CustomerSelectorComponent', () => {
  let component: CustomerSelectorComponent;
  let fixture: ComponentFixture<CustomerSelectorComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CustomerSelectorComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(CustomerSelectorComponent);
    component = fixture.componentInstance;
  });

  it('should load existing contact when project has contact', async () => {
    // 测试项目已有客户的情况
  });

  it('should show contact list when project has no contact', async () => {
    // 测试显示客户列表的情况
  });

  it('should filter external users from member list', async () => {
    // 测试过滤外部用户功能
  });
});

2. 集成测试

describe('Customer Integration', () => {
  it('should create new contact and associate with project', async () => {
    // 测试创建新客户并关联到项目的完整流程
  });

  it('should select existing contact and update project', async () => {
    // 测试选择现有客户并更新项目的流程
  });
});

总结

app-contact-selector 组件提供了完整的项目客户选择解决方案:

智能识别: 自动从群聊成员中识别外部用户 ✅ 数据同步: 支持创建和关联ContactInfo记录 ✅ 用户体验: 流畅的选择、搜索、创建交互 ✅ 错误处理: 完善的错误处理和降级方案 ✅ 性能优化: 缓存、懒加载等性能优化策略 ✅ 扩展性: 支持自定义过滤条件和样式定制

该组件可有效解决项目管理中客户指定的痛点,提升用户操作效率。