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