组件名称: 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记录 ✅ 用户体验: 流畅的选择、搜索、创建交互 ✅ 错误处理: 完善的错误处理和降级方案 ✅ 性能优化: 缓存、懒加载等性能优化策略 ✅ 扩展性: 支持自定义过滤条件和样式定制
该组件可有效解决项目管理中客户指定的痛点,提升用户操作效率。