组件-客户选择.md 21 KB

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

概述

组件名称: app-customer-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>;
  customer?: 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 {
  // 客户选择事件
  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;
  }>;
}

组件功能设计

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[触发customerSelected事件]
    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="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>

1.2 选择客户状态

<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>

1.3 加载状态

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

2. 交互设计

2.1 客户卡片展示

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

2.2 客户列表交互

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

2.3 创建新客户

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

技术实现方案

1. 组件代码结构

@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
    });
  }
}

2. 创建客户模态框

@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();
  }
}

3. 样式设计

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

@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);
  }
}

2. 高级配置

@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);
  }
}

错误处理与边界情况

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(customer => {
    const name = (customer.get('name') || '').toLowerCase();
    const mobile = (customer.get('mobile') || '').toLowerCase();
    return name.includes(lowerKeyword) || mobile.includes(lowerKeyword);
  });
}

2.2 缓存策略

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;
}

性能优化

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 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. 集成测试

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记录 ✅ 用户体验: 流畅的选择、搜索、创建交互 ✅ 错误处理: 完善的错误处理和降级方案 ✅ 性能优化: 缓存、懒加载等性能优化策略 ✅ 扩展性: 支持自定义过滤条件和样式定制

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