组件名称: app-customer-selector
功能定位: 基于企微群聊成员的项目客户选择组件
应用场景: 项目管理中为项目指定客户的场景,支持从群聊成员中选择外部用户并自动创建或关联ContactInfo
根据企微群聊管理规范,member_list 字段包含群成员信息:
interface GroupMember {
  userid: string;           // 用户ID(企业员工)或 external_userid(外部用户)
  type: number;            // 用户类型:1=企业员工 2=外部用户
  join_time: number;       // 加入时间(时间戳)
  join_scene: number;      // 加入场景
  invitor?: {              // 邀请人信息
    userid: string;
  };
}
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;
}
interface Project {
  objectId: string;
  title: string;
  company: Pointer<Company>;
  customer?: Pointer<ContactInfo>;  // 项目客户(可选)
  assignee?: Pointer<Profile>;      // 负责设计师
  // ... 其他字段
}
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;        // 企业对象(可选,用于权限验证)
}
interface CustomerSelectorOutputs {
  // 客户选择事件
  customerSelected: EventEmitter<{
    customer: Parse.Object;       // 选中的客户对象
    isNewCustomer: boolean;        // 是否为新创建的客户
    action: 'selected' | 'created' | 'updated'; // 操作类型
  }>;
  // 加载状态事件
  loadingChange: EventEmitter<boolean>;
  // 错误事件
  error: EventEmitter<{
    type: 'load_failed' | 'create_failed' | 'permission_denied';
    message: string;
    details?: any;
  }>;
}
graph TD
    A[组件初始化] --> B[检查项目客户状态]
    B --> C{项目已有客户?}
    C -->|是| D[显示当前客户信息]
    C -->|否| E[加载群聊成员列表]
    E --> F[过滤外部用户 type=2]
    F --> G[显示客户选择界面]
    G --> H[等待用户选择/创建]
graph TD
    A[用户选择外部用户] --> B[查询ContactInfo表]
    B --> C{客户记录存在?}
    C -->|是| D[关联现有客户]
    C -->|否| E[创建新客户]
    E --> F[设置项目客户]
    D --> F
    F --> G[触发customerSelected事件]
    G --> H[更新UI状态]
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;
}
<div class="customer-selector has-customer">
  <div class="current-customer">
    <ion-avatar>
      <img [src]="currentCustomerAvatar" />
    </ion-avatar>
    <div class="customer-info">
      <h3>{{ currentCustomer.name }}</h3>
      <p>{{ currentCustomer.mobile }}</p>
    </div>
    <ion-button (click)="changeCustomer()" fill="outline" size="small">
      更换客户
    </ion-button>
  </div>
</div>
<div class="customer-selector selecting">
  <ion-searchbar
    [(ngModel)]="searchKeyword"
    placeholder="搜索客户姓名或手机号"
    (ionInput)="onSearchChange($event)">
  </ion-searchbar>
  <div class="customer-list">
    <ion-item
      *ngFor="let customer of filteredCustomers"
      (click)="selectCustomer(customer)">
      <ion-avatar slot="start">
        <img [src]="customerAvatar(customer)" />
      </ion-avatar>
      <ion-label>
        <h2>{{ customer.name }}</h2>
        <p>{{ customer.mobile || '未绑定手机' }}</p>
      </ion-label>
      <ion-icon name="checkmark" slot="end" *ngIf="isSelected(customer)"></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>
<div class="customer-selector loading">
  <ion-spinner name="dots"></ion-spinner>
  <p>{{ loadingText }}</p>
</div>
@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<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 customer = this.project.get('customer');
    if (customer) {
      this.currentCustomer = await this.parseService.fetchFullObject(customer);
    }
  }
  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(customer: Parse.Object): Promise<void> {
    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<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?.customer) {
        await this.handleCustomerCreated(result.data.customer);
      }
    });
    await modal.present();
  }
  private async handleCustomerCreated(customer: Parse.Object): Promise<void> {
    this.currentCustomer = customer;
    this.state = ComponentState.CUSTOMER_EXISTS;
    this.customerSelected.emit({
      customer,
      isNewCustomer: true,
      action: 'created'
    });
  }
  async changeCustomer(): Promise<void> {
    // 重新进入选择状态
    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
    });
  }
}
@Component({
  selector: 'app-create-customer-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 #customerForm="ngForm" (ngSubmit)="onSubmit()">
        <ion-item>
          <ion-label position="stacked">客户姓名 *</ion-label>
          <ion-input
            name="name"
            [(ngModel)]="customerData.name"
            required
            placeholder="请输入客户姓名">
          </ion-input>
        </ion-item>
        <ion-item>
          <ion-label position="stacked">手机号码</ion-label>
          <ion-input
            name="mobile"
            [(ngModel)]="customerData.mobile"
            type="tel"
            placeholder="请输入手机号码">
          </ion-input>
        </ion-item>
        <ion-item>
          <ion-label position="stacked">来源渠道</ion-label>
          <ion-select
            name="source"
            [(ngModel)]="customerData.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]="!customerForm.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;
  customerData = {
    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 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();
  }
}
.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;
    }
  }
}
@Component({
  selector: 'app-project-detail',
  standalone: true,
  imports: [CustomerSelectorComponent],
  template: `
    <div class="project-customer-section">
      <h3>项目客户</h3>
      <app-customer-selector
        [project]="project"
        [groupChat]="groupChat"
        (customerSelected)="onCustomerSelected($event)"
        (error)="onSelectorError($event)">
      </app-customer-selector>
    </div>
  `
})
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);
  }
}
@Component({
  selector: 'app-project-setup',
  standalone: true,
  imports: [CustomerSelectorComponent],
  template: `
    <app-customer-selector
      [project]="project"
      [groupChat]="groupChat"
      placeholder="请为项目指定客户"
      [showCreateButton]="true"
      [filterCriteria]="filterOptions"
      [disabled]="isProjectLocked"
      (customerSelected)="handleCustomerChange($event)">
    </app-customer-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);
  }
}
if (externalUserIds.length === 0) {
  this.state = ComponentState.ERROR;
  this.error.emit({
    type: 'load_failed',
    message: '当前群聊中没有外部客户用户',
    details: { memberCount: memberList.length }
  });
  return;
}
if (error.code === 119) {
  this.error.emit({
    type: 'permission_denied',
    message: '没有权限访问该项目的客户信息',
    details: error
  });
}
if (error.message?.includes('Network')) {
  this.error.emit({
    type: 'load_failed',
    message: '网络连接失败,请检查网络后重试',
    details: error
  });
}
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);
  });
}
private customerCache = new Map<string, Parse.Object>();
private async getCachedCustomer(externalUserId: string): Promise<Parse.Object | null> {
  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;
}
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 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 () => {
    // 测试过滤外部用户功能
  });
});
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记录 ✅ 用户体验: 流畅的选择、搜索、创建交互 ✅ 错误处理: 完善的错误处理和降级方案 ✅ 性能优化: 缓存、懒加载等性能优化策略 ✅ 扩展性: 支持自定义过滤条件和样式定制
该组件可有效解决项目管理中客户指定的痛点,提升用户操作效率。