|
@@ -2,7 +2,7 @@
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
-**组件名称**: `app-customer-selector`
|
|
|
+**组件名称**: `app-contact-selector`
|
|
|
**功能定位**: 基于企微群聊成员的项目客户选择组件
|
|
|
**应用场景**: 项目管理中为项目指定客户的场景,支持从群聊成员中选择外部用户并自动创建或关联ContactInfo
|
|
|
|
|
@@ -48,7 +48,7 @@ interface Project {
|
|
|
objectId: string;
|
|
|
title: string;
|
|
|
company: Pointer<Company>;
|
|
|
- customer?: Pointer<ContactInfo>; // 项目客户(可选)
|
|
|
+ contact?: Pointer<ContactInfo>; // 项目客户(可选)
|
|
|
assignee?: Pointer<Profile>; // 负责设计师
|
|
|
// ... 其他字段
|
|
|
}
|
|
@@ -82,8 +82,8 @@ interface CustomerSelectorInputs {
|
|
|
```typescript
|
|
|
interface CustomerSelectorOutputs {
|
|
|
// 客户选择事件
|
|
|
- customerSelected: EventEmitter<{
|
|
|
- customer: Parse.Object; // 选中的客户对象
|
|
|
+ contactSelected: EventEmitter<{
|
|
|
+ contact: Parse.Object; // 选中的客户对象
|
|
|
isNewCustomer: boolean; // 是否为新创建的客户
|
|
|
action: 'selected' | 'created' | 'updated'; // 操作类型
|
|
|
}>;
|
|
@@ -125,7 +125,7 @@ graph TD
|
|
|
C -->|否| E[创建新客户]
|
|
|
E --> F[设置项目客户]
|
|
|
D --> F
|
|
|
- F --> G[触发customerSelected事件]
|
|
|
+ F --> G[触发contactSelected事件]
|
|
|
G --> H[更新UI状态]
|
|
|
```
|
|
|
|
|
@@ -157,12 +157,12 @@ interface ComponentData {
|
|
|
|
|
|
#### 1.1 项目已有客户状态
|
|
|
```html
|
|
|
-<div class="customer-selector has-customer">
|
|
|
- <div class="current-customer">
|
|
|
+<div class="contact-selector has-contact">
|
|
|
+ <div class="current-contact">
|
|
|
<ion-avatar>
|
|
|
<img [src]="currentCustomerAvatar" />
|
|
|
</ion-avatar>
|
|
|
- <div class="customer-info">
|
|
|
+ <div class="contact-info">
|
|
|
<h3>{{ currentCustomer.name }}</h3>
|
|
|
<p>{{ currentCustomer.mobile }}</p>
|
|
|
</div>
|
|
@@ -175,25 +175,25 @@ interface ComponentData {
|
|
|
|
|
|
#### 1.2 选择客户状态
|
|
|
```html
|
|
|
-<div class="customer-selector selecting">
|
|
|
+<div class="contact-selector selecting">
|
|
|
<ion-searchbar
|
|
|
[(ngModel)]="searchKeyword"
|
|
|
placeholder="搜索客户姓名或手机号"
|
|
|
(ionInput)="onSearchChange($event)">
|
|
|
</ion-searchbar>
|
|
|
|
|
|
- <div class="customer-list">
|
|
|
+ <div class="contact-list">
|
|
|
<ion-item
|
|
|
- *ngFor="let customer of filteredCustomers"
|
|
|
- (click)="selectCustomer(customer)">
|
|
|
+ *ngFor="let contact of filteredCustomers"
|
|
|
+ (click)="selectCustomer(contact)">
|
|
|
<ion-avatar slot="start">
|
|
|
- <img [src]="customerAvatar(customer)" />
|
|
|
+ <img [src]="contactAvatar(contact)" />
|
|
|
</ion-avatar>
|
|
|
<ion-label>
|
|
|
- <h2>{{ customer.name }}</h2>
|
|
|
- <p>{{ customer.mobile || '未绑定手机' }}</p>
|
|
|
+ <h2>{{ contact.name }}</h2>
|
|
|
+ <p>{{ contact.mobile || '未绑定手机' }}</p>
|
|
|
</ion-label>
|
|
|
- <ion-icon name="checkmark" slot="end" *ngIf="isSelected(customer)"></ion-icon>
|
|
|
+ <ion-icon name="checkmark" slot="end" *ngIf="isSelected(contact)"></ion-icon>
|
|
|
</ion-item>
|
|
|
</div>
|
|
|
|
|
@@ -206,7 +206,7 @@ interface ComponentData {
|
|
|
|
|
|
#### 1.3 加载状态
|
|
|
```html
|
|
|
-<div class="customer-selector loading">
|
|
|
+<div class="contact-selector loading">
|
|
|
<ion-spinner name="dots"></ion-spinner>
|
|
|
<p>{{ loadingText }}</p>
|
|
|
</div>
|
|
@@ -237,7 +237,7 @@ interface ComponentData {
|
|
|
|
|
|
```typescript
|
|
|
@Component({
|
|
|
- selector: 'app-customer-selector',
|
|
|
+ selector: 'app-contact-selector',
|
|
|
standalone: true,
|
|
|
imports: [
|
|
|
CommonModule,
|
|
@@ -245,8 +245,8 @@ interface ComponentData {
|
|
|
IonicModule,
|
|
|
// 其他依赖
|
|
|
],
|
|
|
- template: './customer-selector.component.html',
|
|
|
- styleUrls: ['./customer-selector.component.scss']
|
|
|
+ template: './contact-selector.component.html',
|
|
|
+ styleUrls: ['./contact-selector.component.scss']
|
|
|
})
|
|
|
export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
// 输入输出属性
|
|
@@ -256,7 +256,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
@Input() disabled: boolean = false;
|
|
|
@Input() showCreateButton: boolean = true;
|
|
|
|
|
|
- @Output() customerSelected = new EventEmitter<CustomerSelectedEvent>();
|
|
|
+ @Output() contactSelected = new EventEmitter<CustomerSelectedEvent>();
|
|
|
@Output() loadingChange = new EventEmitter<boolean>();
|
|
|
@Output() error = new EventEmitter<ErrorEvent>();
|
|
|
|
|
@@ -305,9 +305,9 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
}
|
|
|
|
|
|
private async checkProjectCustomer(): Promise<void> {
|
|
|
- const customer = this.project.get('customer');
|
|
|
- if (customer) {
|
|
|
- this.currentCustomer = await this.parseService.fetchFullObject(customer);
|
|
|
+ const contact = this.project.get('contact');
|
|
|
+ if (contact) {
|
|
|
+ this.currentCustomer = await this.parseService.fetchFullObject(contact);
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -336,20 +336,20 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
this.availableCustomers = await query.find();
|
|
|
}
|
|
|
|
|
|
- async selectCustomer(customer: Parse.Object): Promise<void> {
|
|
|
+ async selectCustomer(contact: Parse.Object): Promise<void> {
|
|
|
this.state = ComponentState.LOADING;
|
|
|
this.loadingChange.emit(true);
|
|
|
|
|
|
try {
|
|
|
// 关联客户到项目
|
|
|
- this.project.set('customer', customer);
|
|
|
+ this.project.set('contact', contact);
|
|
|
await this.project.save();
|
|
|
|
|
|
- this.currentCustomer = customer;
|
|
|
+ this.currentCustomer = contact;
|
|
|
this.state = ComponentState.CUSTOMER_EXISTS;
|
|
|
|
|
|
- this.customerSelected.emit({
|
|
|
- customer,
|
|
|
+ this.contactSelected.emit({
|
|
|
+ contact,
|
|
|
isNewCustomer: false,
|
|
|
action: 'selected'
|
|
|
});
|
|
@@ -371,20 +371,20 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
});
|
|
|
|
|
|
modal.onDidDismiss().then(async (result) => {
|
|
|
- if (result.data?.customer) {
|
|
|
- await this.handleCustomerCreated(result.data.customer);
|
|
|
+ if (result.data?.contact) {
|
|
|
+ await this.handleCustomerCreated(result.data.contact);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
await modal.present();
|
|
|
}
|
|
|
|
|
|
- private async handleCustomerCreated(customer: Parse.Object): Promise<void> {
|
|
|
- this.currentCustomer = customer;
|
|
|
+ private async handleCustomerCreated(contact: Parse.Object): Promise<void> {
|
|
|
+ this.currentCustomer = contact;
|
|
|
this.state = ComponentState.CUSTOMER_EXISTS;
|
|
|
|
|
|
- this.customerSelected.emit({
|
|
|
- customer,
|
|
|
+ this.contactSelected.emit({
|
|
|
+ contact,
|
|
|
isNewCustomer: true,
|
|
|
action: 'created'
|
|
|
});
|
|
@@ -393,7 +393,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
async changeCustomer(): Promise<void> {
|
|
|
// 重新进入选择状态
|
|
|
this.currentCustomer = undefined;
|
|
|
- this.project.unset('customer');
|
|
|
+ this.project.unset('contact');
|
|
|
await this.project.save();
|
|
|
|
|
|
await this.loadAvailableCustomers();
|
|
@@ -417,7 +417,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
|
|
|
```typescript
|
|
|
@Component({
|
|
|
- selector: 'app-create-customer-modal',
|
|
|
+ selector: 'app-create-contact-modal',
|
|
|
standalone: true,
|
|
|
imports: [CommonModule, FormsModule, IonicModule],
|
|
|
template: `
|
|
@@ -431,12 +431,12 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
</ion-header>
|
|
|
|
|
|
<ion-content class="ion-padding">
|
|
|
- <form #customerForm="ngForm" (ngSubmit)="onSubmit()">
|
|
|
+ <form #contactForm="ngForm" (ngSubmit)="onSubmit()">
|
|
|
<ion-item>
|
|
|
<ion-label position="stacked">客户姓名 *</ion-label>
|
|
|
<ion-input
|
|
|
name="name"
|
|
|
- [(ngModel)]="customerData.name"
|
|
|
+ [(ngModel)]="contactData.name"
|
|
|
required
|
|
|
placeholder="请输入客户姓名">
|
|
|
</ion-input>
|
|
@@ -446,7 +446,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
<ion-label position="stacked">手机号码</ion-label>
|
|
|
<ion-input
|
|
|
name="mobile"
|
|
|
- [(ngModel)]="customerData.mobile"
|
|
|
+ [(ngModel)]="contactData.mobile"
|
|
|
type="tel"
|
|
|
placeholder="请输入手机号码">
|
|
|
</ion-input>
|
|
@@ -456,7 +456,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
<ion-label position="stacked">来源渠道</ion-label>
|
|
|
<ion-select
|
|
|
name="source"
|
|
|
- [(ngModel)]="customerData.source"
|
|
|
+ [(ngModel)]="contactData.source"
|
|
|
placeholder="请选择来源渠道">
|
|
|
<ion-select-option value="朋友圈">朋友圈</ion-select-option>
|
|
|
<ion-select-option value="信息流">信息流</ion-select-option>
|
|
@@ -468,7 +468,7 @@ export class CustomerSelectorComponent implements OnInit, OnChanges {
|
|
|
<ion-button
|
|
|
type="submit"
|
|
|
expand="block"
|
|
|
- [disabled]="!customerForm.valid || loading"
|
|
|
+ [disabled]="!contactForm.valid || loading"
|
|
|
class="ion-margin-top">
|
|
|
<ion-spinner *ngIf="loading" name="dots" slot="start"></ion-spinner>
|
|
|
创建客户
|
|
@@ -481,7 +481,7 @@ export class CreateCustomerModalComponent {
|
|
|
@Input() company!: Parse.Object;
|
|
|
@Input() project!: Parse.Object;
|
|
|
|
|
|
- customerData = {
|
|
|
+ contactData = {
|
|
|
name: '',
|
|
|
mobile: '',
|
|
|
source: ''
|
|
@@ -502,21 +502,21 @@ export class CreateCustomerModalComponent {
|
|
|
try {
|
|
|
// 创建ContactInfo记录
|
|
|
const ContactInfo = Parse.Object.extend('ContactInfo');
|
|
|
- const customer = new ContactInfo();
|
|
|
+ const contact = 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);
|
|
|
+ contact.set('name', this.contactData.name);
|
|
|
+ contact.set('mobile', this.contactData.mobile);
|
|
|
+ contact.set('source', this.contactData.source);
|
|
|
+ contact.set('company', this.company);
|
|
|
|
|
|
- await customer.save();
|
|
|
+ await contact.save();
|
|
|
|
|
|
// 关联到项目
|
|
|
- this.project.set('customer', customer);
|
|
|
+ this.project.set('contact', contact);
|
|
|
await this.project.save();
|
|
|
|
|
|
this.modalController.dismiss({
|
|
|
- customer,
|
|
|
+ contact,
|
|
|
action: 'created'
|
|
|
});
|
|
|
} catch (error) {
|
|
@@ -536,21 +536,21 @@ export class CreateCustomerModalComponent {
|
|
|
### 3. 样式设计
|
|
|
|
|
|
```scss
|
|
|
-.customer-selector {
|
|
|
+.contact-selector {
|
|
|
border: 1px solid var(--ion-color-light);
|
|
|
border-radius: 8px;
|
|
|
overflow: hidden;
|
|
|
background: var(--ion-background-color);
|
|
|
|
|
|
// 已有客户状态
|
|
|
- &.has-customer {
|
|
|
- .current-customer {
|
|
|
+ &.has-contact {
|
|
|
+ .current-contact {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
padding: 12px 16px;
|
|
|
gap: 12px;
|
|
|
|
|
|
- .customer-info {
|
|
|
+ .contact-info {
|
|
|
flex: 1;
|
|
|
|
|
|
h3 {
|
|
@@ -570,7 +570,7 @@ export class CreateCustomerModalComponent {
|
|
|
|
|
|
// 选择客户状态
|
|
|
&.selecting {
|
|
|
- .customer-list {
|
|
|
+ .contact-list {
|
|
|
max-height: 300px;
|
|
|
overflow-y: auto;
|
|
|
|
|
@@ -639,14 +639,14 @@ export class CreateCustomerModalComponent {
|
|
|
standalone: true,
|
|
|
imports: [CustomerSelectorComponent],
|
|
|
template: `
|
|
|
- <div class="project-customer-section">
|
|
|
+ <div class="project-contact-section">
|
|
|
<h3>项目客户</h3>
|
|
|
- <app-customer-selector
|
|
|
+ <app-contact-selector
|
|
|
[project]="project"
|
|
|
[groupChat]="groupChat"
|
|
|
- (customerSelected)="onCustomerSelected($event)"
|
|
|
+ (contactSelected)="onCustomerSelected($event)"
|
|
|
(error)="onSelectorError($event)">
|
|
|
- </app-customer-selector>
|
|
|
+ </app-contact-selector>
|
|
|
</div>
|
|
|
`
|
|
|
})
|
|
@@ -655,12 +655,12 @@ export class ProjectDetailComponent {
|
|
|
@Input() groupChat!: Parse.Object;
|
|
|
|
|
|
onCustomerSelected(event: CustomerSelectedEvent) {
|
|
|
- console.log('客户已选择:', event.customer);
|
|
|
+ console.log('客户已选择:', event.contact);
|
|
|
console.log('是否为新客户:', event.isNewCustomer);
|
|
|
|
|
|
if (event.isNewCustomer) {
|
|
|
// 新客户创建成功后的处理
|
|
|
- this.showWelcomeMessage(event.customer);
|
|
|
+ this.showWelcomeMessage(event.contact);
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -679,15 +679,15 @@ export class ProjectDetailComponent {
|
|
|
standalone: true,
|
|
|
imports: [CustomerSelectorComponent],
|
|
|
template: `
|
|
|
- <app-customer-selector
|
|
|
+ <app-contact-selector
|
|
|
[project]="project"
|
|
|
[groupChat]="groupChat"
|
|
|
placeholder="请为项目指定客户"
|
|
|
[showCreateButton]="true"
|
|
|
[filterCriteria]="filterOptions"
|
|
|
[disabled]="isProjectLocked"
|
|
|
- (customerSelected)="handleCustomerChange($event)">
|
|
|
- </app-customer-selector>
|
|
|
+ (contactSelected)="handleCustomerChange($event)">
|
|
|
+ </app-contact-selector>
|
|
|
`
|
|
|
})
|
|
|
export class ProjectSetupComponent {
|
|
@@ -755,9 +755,9 @@ 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 this.availableCustomers.filter(contact => {
|
|
|
+ const name = (contact.get('name') || '').toLowerCase();
|
|
|
+ const mobile = (contact.get('mobile') || '').toLowerCase();
|
|
|
return name.includes(lowerKeyword) || mobile.includes(lowerKeyword);
|
|
|
});
|
|
|
}
|
|
@@ -765,19 +765,19 @@ private filterCustomers(keyword: string): Parse.Object[] {
|
|
|
|
|
|
#### 2.2 缓存策略
|
|
|
```typescript
|
|
|
-private customerCache = new Map<string, Parse.Object>();
|
|
|
+private contactCache = new Map<string, Parse.Object>();
|
|
|
|
|
|
private async getCachedCustomer(externalUserId: string): Promise<Parse.Object | null> {
|
|
|
- if (this.customerCache.has(externalUserId)) {
|
|
|
- return this.customerCache.get(externalUserId)!;
|
|
|
+ if (this.contactCache.has(externalUserId)) {
|
|
|
+ return this.contactCache.get(externalUserId)!;
|
|
|
}
|
|
|
|
|
|
- const customer = await this.queryCustomerByExternalId(externalUserId);
|
|
|
- if (customer) {
|
|
|
- this.customerCache.set(externalUserId, customer);
|
|
|
+ const contact = await this.queryCustomerByExternalId(externalUserId);
|
|
|
+ if (contact) {
|
|
|
+ this.contactCache.set(externalUserId, contact);
|
|
|
}
|
|
|
|
|
|
- return customer;
|
|
|
+ return contact;
|
|
|
}
|
|
|
```
|
|
|
|
|
@@ -810,11 +810,11 @@ describe('CustomerSelectorComponent', () => {
|
|
|
component = fixture.componentInstance;
|
|
|
});
|
|
|
|
|
|
- it('should load existing customer when project has customer', async () => {
|
|
|
+ it('should load existing contact when project has contact', async () => {
|
|
|
// 测试项目已有客户的情况
|
|
|
});
|
|
|
|
|
|
- it('should show customer list when project has no customer', async () => {
|
|
|
+ it('should show contact list when project has no contact', async () => {
|
|
|
// 测试显示客户列表的情况
|
|
|
});
|
|
|
|
|
@@ -827,11 +827,11 @@ describe('CustomerSelectorComponent', () => {
|
|
|
### 2. 集成测试
|
|
|
```typescript
|
|
|
describe('Customer Integration', () => {
|
|
|
- it('should create new customer and associate with project', async () => {
|
|
|
+ it('should create new contact and associate with project', async () => {
|
|
|
// 测试创建新客户并关联到项目的完整流程
|
|
|
});
|
|
|
|
|
|
- it('should select existing customer and update project', async () => {
|
|
|
+ it('should select existing contact and update project', async () => {
|
|
|
// 测试选择现有客户并更新项目的流程
|
|
|
});
|
|
|
});
|
|
@@ -839,7 +839,7 @@ describe('Customer Integration', () => {
|
|
|
|
|
|
## 总结
|
|
|
|
|
|
-`app-customer-selector` 组件提供了完整的项目客户选择解决方案:
|
|
|
+`app-contact-selector` 组件提供了完整的项目客户选择解决方案:
|
|
|
|
|
|
✅ **智能识别**: 自动从群聊成员中识别外部用户
|
|
|
✅ **数据同步**: 支持创建和关联ContactInfo记录
|