|
|
@@ -12,234 +12,7 @@ import { ProjectIssuesModalComponent } from '../../components/project-issues-mod
|
|
|
import { ProjectIssueService } from '../../services/project-issue.service';
|
|
|
import { CustomerProfileComponent } from '../contact/contact.component';
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
-
|
|
|
-// Customer selector component declared before ProjectDetailComponent
|
|
|
-@Component({
|
|
|
- selector: 'app-contact-selector',
|
|
|
- standalone: true,
|
|
|
- imports: [CommonModule, FormsModule, IonicModule, CustomerProfileComponent],
|
|
|
- template: `
|
|
|
- <div class="contact-selector" [class.disabled]="disabled">
|
|
|
- <div class="loading" *ngIf="loading">正在加载客户数据...</div>
|
|
|
-
|
|
|
- <!-- 已有客户卡片 -->
|
|
|
- <div class="customer-exists" *ngIf="currentCustomer">
|
|
|
- <div class="card">
|
|
|
- <div class="row">
|
|
|
- <div class="avatar" (click)="viewCustomerDetail()">
|
|
|
- <img *ngIf="currentCustomer.get('data')?.avatar" [src]="currentCustomer.get('data')?.avatar" alt="" />
|
|
|
- <div class="placeholder" *ngIf="!currentCustomer.get('data')?.avatar">👤</div>
|
|
|
- </div>
|
|
|
- <div class="info" (click)="viewCustomerDetail()">
|
|
|
- <div class="name">{{ currentCustomer.get('name') || currentCustomer.get('data')?.external_contact?.name || currentCustomer.get('data')?.name }}</div>
|
|
|
- <div class="meta">
|
|
|
- <span class="chip" *ngIf="currentCustomer.get('data')?.external_contact?.type">{{ currentCustomer.get('data')?.external_contact?.type === 2 ? '外部联系人' : '企业成员' }}</span>
|
|
|
- <span class="chip" *ngIf="canViewSensitiveInfo && currentCustomer.get('mobile')">{{ currentCustomer.get('mobile') }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div class="actions">
|
|
|
- <button class="btn outline" (click)="switchToSelecting()">重新选择</button>
|
|
|
- <button class="btn" (click)="viewCustomerDetail()">查看详情</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 选择客户列表 -->
|
|
|
- <div class="selecting" *ngIf="!currentCustomer">
|
|
|
- <div class="toolbar">
|
|
|
- <input class="search" type="text" [(ngModel)]="searchKeyword" [placeholder]="placeholder" />
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="section">
|
|
|
- <div class="section-title">已建档的群聊客户</div>
|
|
|
- <div class="list">
|
|
|
- <div class="item" *ngFor="let c of filteredCustomers" (click)="selectExistingCustomer(c)">
|
|
|
- <div class="thumb">
|
|
|
- <img *ngIf="c.get('data')?.avatar" [src]="c.get('data')?.avatar" alt="" />
|
|
|
- <div class="placeholder" *ngIf="!c.get('data')?.avatar">👤</div>
|
|
|
- </div>
|
|
|
- <div class="detail">
|
|
|
- <div class="title">{{ c.get('name') }}</div>
|
|
|
- <div class="sub" *ngIf="canViewSensitiveInfo && c.get('mobile')">{{ c.get('mobile') }}</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <div class="section" *ngIf="showCreateButton">
|
|
|
- <div class="section-title">未建档的群聊外部联系人</div>
|
|
|
- <div class="list">
|
|
|
- <div class="item" *ngFor="let m of externalMembers">
|
|
|
- <div class="thumb">👤</div>
|
|
|
- <div class="detail">
|
|
|
- <div class="title">{{ m.name || '外部客户' }}</div>
|
|
|
- <div class="sub">{{ m.userid }}</div>
|
|
|
- </div>
|
|
|
- <div class="ops">
|
|
|
- <button class="btn primary" (click)="createFromMember(m.userid)">创建并关联</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 客户详情侧栏弹层 -->
|
|
|
- <div class="overlay" *ngIf="showCustomerPanel" (click)="closeCustomerDetail()"></div>
|
|
|
- <div class="customer-panel" *ngIf="showCustomerPanel">
|
|
|
- <app-contact [customer]="currentCustomer" [currentUser]="currentUser" [embeddedMode]="true" (close)="closeCustomerDetail()"></app-contact>
|
|
|
- <button class="close" (click)="closeCustomerDetail()">返回</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- `,
|
|
|
- styles: [
|
|
|
- `
|
|
|
- .contact-selector { padding: 8px 0; }
|
|
|
- .loading { padding: 8px; color: #666; }
|
|
|
- .card { background: #fff; border: 1px solid #eee; border-radius: 10px; padding: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
|
|
- .row { display:flex; align-items:center; gap:12px; }
|
|
|
- .avatar { width:48px; height:48px; border-radius:50%; overflow:hidden; background:#f2f2f2; display:flex; align-items:center; justify-content:center; }
|
|
|
- .avatar img { width:100%; height:100%; object-fit:cover; }
|
|
|
- .info { flex:1; min-width:0; }
|
|
|
- .name { font-weight:600; font-size:15px; }
|
|
|
- .meta { margin-top:4px; color:#666; display:flex; gap:6px; flex-wrap:wrap; }
|
|
|
- .chip { background:#f3f6ff; color:#2b4eff; border-radius:10px; padding:2px 8px; font-size:12px; }
|
|
|
- .actions { display:flex; gap:8px; }
|
|
|
- .btn { padding:6px 10px; border:1px solid #ccc; border-radius:6px; cursor:pointer; background:#fff; }
|
|
|
- .btn.primary { background:#2b4eff; color:#fff; border-color:#2b4eff; }
|
|
|
- .btn.outline { background:#fff; }
|
|
|
- .toolbar { margin:8px 0; }
|
|
|
- .search { width:100%; padding:8px; border:1px solid #ddd; border-radius:6px; }
|
|
|
- .section { margin-top:12px; }
|
|
|
- .section-title { font-size:13px; color:#555; margin-bottom:6px; }
|
|
|
- .list { display:flex; flex-direction:column; gap:8px; }
|
|
|
- .item { display:flex; align-items:center; padding:8px; border:1px solid #eee; border-radius:8px; background:#fff; }
|
|
|
- .item:hover { background:#fafafa; }
|
|
|
- .thumb { width:36px; height:36px; border-radius:50%; overflow:hidden; background:#f2f2f2; display:flex; align-items:center; justify-content:center; }
|
|
|
- .thumb img { width:100%; height:100%; object-fit:cover; }
|
|
|
- .detail { flex:1; min-width:0; margin-left:10px; }
|
|
|
- .title { font-size:14px; font-weight:500; }
|
|
|
- .sub { font-size:12px; color:#777; }
|
|
|
- .ops { display:flex; align-items:center; }
|
|
|
- .overlay { position:fixed; inset:0; background:rgba(0,0,0,0.2); }
|
|
|
- .customer-panel { position:fixed; right:20px; top:60px; width:480px; height:80vh; background:#fff; border:1px solid #ddd; border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,0.1); overflow:auto; padding:8px; }
|
|
|
- .customer-panel .close { position:absolute; right:12px; top:10px; padding:6px 10px; }
|
|
|
- `
|
|
|
- ]
|
|
|
-})
|
|
|
-export class CustomerSelectorComponent implements OnInit {
|
|
|
- @Input() project: FmodeObject | null = null;
|
|
|
- @Input() groupChat: FmodeObject | null = null;
|
|
|
- @Input() currentUser: FmodeObject | null = null;
|
|
|
- @Input() placeholder: string = '请选择项目客户';
|
|
|
- @Input() disabled: boolean = false;
|
|
|
- @Input() showCreateButton: boolean = true;
|
|
|
- @Output() contactSelected = new EventEmitter<{ contact: FmodeObject; isNewCustomer: boolean; action: 'selected' | 'created' | 'updated' }>();
|
|
|
-
|
|
|
- loading: boolean = false;
|
|
|
- searchKeyword: string = '';
|
|
|
- currentCustomer: FmodeObject | null = null;
|
|
|
- availableCustomers: FmodeObject[] = [];
|
|
|
- externalMembers: Array<{ userid: string; name?: string }> = [];
|
|
|
- showCustomerPanel: boolean = false;
|
|
|
-
|
|
|
- get canViewSensitiveInfo(): boolean {
|
|
|
- const role = this.currentUser?.get?.('roleName') || '';
|
|
|
- return ['客服', '组长', '管理员'].includes(role);
|
|
|
- }
|
|
|
- async ngOnInit() { await this.init(); }
|
|
|
- private async init() {
|
|
|
- if (!this.project || !this.groupChat) return;
|
|
|
- try {
|
|
|
- this.loading = true;
|
|
|
- await this.checkProjectCustomer();
|
|
|
- await this.loadExternalMembers();
|
|
|
- await this.loadAvailableCustomers();
|
|
|
- } finally { this.loading = false; }
|
|
|
- }
|
|
|
- private async checkProjectCustomer() {
|
|
|
- const ptr = this.project!.get('contact');
|
|
|
- if (!ptr) { this.currentCustomer = null; return; }
|
|
|
- try {
|
|
|
- if (ptr.id && (ptr as any).get) { this.currentCustomer = ptr as any; }
|
|
|
- else if (ptr.id) { const query = new (FmodeParse.with('nova') as any).Query('ContactInfo'); this.currentCustomer = await query.get(ptr.id); }
|
|
|
- } catch { this.currentCustomer = null; }
|
|
|
- }
|
|
|
- private async loadExternalMembers() {
|
|
|
- const list = this.groupChat!.get('member_list') || [];
|
|
|
- const external = Array.isArray(list) ? list.filter((m: any) => m && m.type === 2) : [];
|
|
|
- this.externalMembers = external.map((m: any) => ({ userid: m.userid, name: m.name }));
|
|
|
- }
|
|
|
- private async loadAvailableCustomers() {
|
|
|
- const companyId = this.project!.get('company')?.id || localStorage.getItem('company');
|
|
|
- if (!companyId) return;
|
|
|
- const extIds = this.externalMembers.map(m => m.userid);
|
|
|
- if (extIds.length === 0) { this.availableCustomers = []; return; }
|
|
|
- const query = new (FmodeParse.with('nova') as any).Query('ContactInfo');
|
|
|
- query.equalTo('company', companyId);
|
|
|
- query.containedIn('external_userid', extIds);
|
|
|
- query.notEqualTo('isDeleted', true);
|
|
|
- this.availableCustomers = await query.find();
|
|
|
- }
|
|
|
- get filteredCustomers(): FmodeObject[] {
|
|
|
- const kw = (this.searchKeyword || '').trim().toLowerCase();
|
|
|
- if (!kw) return this.availableCustomers;
|
|
|
- return this.availableCustomers.filter(c => {
|
|
|
- const name = (c.get('name') || '').toLowerCase();
|
|
|
- const mobile = (c.get('mobile') || '').toLowerCase();
|
|
|
- return name.includes(kw) || mobile.includes(kw);
|
|
|
- });
|
|
|
- }
|
|
|
- async selectExistingCustomer(contact: FmodeObject) {
|
|
|
- if (this.disabled || !this.project) return;
|
|
|
- this.project.set('contact', contact.toPointer());
|
|
|
- await this.project.save();
|
|
|
- this.currentCustomer = contact;
|
|
|
- this.contactSelected.emit({ contact, isNewCustomer: false, action: 'selected' });
|
|
|
- }
|
|
|
- switchToSelecting() { this.currentCustomer = null; this.searchKeyword = ''; }
|
|
|
- async createFromMember(memberUserid: string) {
|
|
|
- if (this.disabled || !this.project) return;
|
|
|
- const companyId = this.project.get('company')?.id || localStorage.getItem('company');
|
|
|
- if (!companyId) throw new Error('无法获取企业信息');
|
|
|
- const query = new (FmodeParse.with('nova') as any).Query('ContactInfo');
|
|
|
- query.equalTo('external_userid', memberUserid);
|
|
|
- query.equalTo('company', companyId);
|
|
|
- let contactInfo = await query.first();
|
|
|
- if (!contactInfo) {
|
|
|
- const corp = new WxworkCorp(companyId);
|
|
|
- const extData = await corp.externalContact.get(memberUserid);
|
|
|
- const ext = (extData && extData.external_contact) ? extData.external_contact : {};
|
|
|
- const follow = (extData && extData.follow_user) ? extData.follow_user : [];
|
|
|
- const ContactInfo = (FmodeParse.with('nova') as any).Object.extend('ContactInfo');
|
|
|
- contactInfo = new ContactInfo();
|
|
|
- // 顶部字段
|
|
|
- contactInfo.set('name', ext.name || '客户');
|
|
|
- contactInfo.set('external_userid', memberUserid);
|
|
|
- // 公司指针
|
|
|
- const company = new (FmodeParse.with('nova') as any).Object('Company');
|
|
|
- company.id = companyId;
|
|
|
- contactInfo.set('company', company.toPointer());
|
|
|
- // data 映射:嵌入 external_contact 与 follow_user,并兼容常用扁平字段
|
|
|
- const mapped = {
|
|
|
- external_contact: ext,
|
|
|
- follow_user: follow,
|
|
|
- name: ext.name,
|
|
|
- avatar: ext.avatar,
|
|
|
- gender: ext.gender,
|
|
|
- type: ext.type
|
|
|
- } as any;
|
|
|
- contactInfo.set('data', mapped);
|
|
|
- contactInfo = await contactInfo.save();
|
|
|
- }
|
|
|
- this.project.set('contact', contactInfo.toPointer());
|
|
|
- await this.project.save();
|
|
|
- this.currentCustomer = contactInfo;
|
|
|
- this.contactSelected.emit({ contact: contactInfo, isNewCustomer: true, action: 'created' });
|
|
|
- }
|
|
|
- viewCustomerDetail() { this.showCustomerPanel = true; }
|
|
|
- closeCustomerDetail() { this.showCustomerPanel = false; }
|
|
|
-}
|
|
|
+import { CustomerSelectorComponent } from '../../components/contact-selector/contact-selector.component';
|
|
|
|
|
|
const Parse = FmodeParse.with('nova');
|
|
|
|