|
|
@@ -1,4 +1,4 @@
|
|
|
-import { Component, OnInit, Input } from '@angular/core';
|
|
|
+import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
|
|
|
import { IonicModule } from '@ionic/angular';
|
|
|
@@ -10,6 +10,236 @@ import { ProjectFilesModalComponent } from '../../components/project-files-modal
|
|
|
import { ProjectMembersModalComponent } from '../../components/project-members-modal/project-members-modal.component';
|
|
|
import { ProjectIssuesModalComponent } from '../../components/project-issues-modal/project-issues-modal.component';
|
|
|
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; }
|
|
|
+}
|
|
|
|
|
|
const Parse = FmodeParse.with('nova');
|
|
|
|
|
|
@@ -27,7 +257,7 @@ const Parse = FmodeParse.with('nova');
|
|
|
@Component({
|
|
|
selector: 'app-project-detail',
|
|
|
standalone: true,
|
|
|
- imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent, ProjectIssuesModalComponent],
|
|
|
+ imports: [CommonModule, IonicModule, RouterModule, ProjectBottomCardComponent, ProjectFilesModalComponent, ProjectMembersModalComponent, ProjectIssuesModalComponent, CustomerProfileComponent, CustomerSelectorComponent],
|
|
|
templateUrl: './project-detail.component.html',
|
|
|
styleUrls: ['./project-detail.component.scss']
|
|
|
})
|
|
|
@@ -78,6 +308,8 @@ export class ProjectDetailComponent implements OnInit {
|
|
|
showFilesModal: boolean = false;
|
|
|
showMembersModal: boolean = false;
|
|
|
showIssuesModal: boolean = false;
|
|
|
+ // 新增:客户详情侧栏面板状态
|
|
|
+ showContactPanel: boolean = false;
|
|
|
|
|
|
constructor(
|
|
|
private router: Router,
|
|
|
@@ -460,13 +692,31 @@ export class ProjectDetailComponent implements OnInit {
|
|
|
this.showMembersModal = false;
|
|
|
}
|
|
|
|
|
|
+ /** 显示客户详情面板 */
|
|
|
+ openContactPanel() {
|
|
|
+ if (this.contact) {
|
|
|
+ this.showContactPanel = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 关闭客户详情面板 */
|
|
|
+ closeContactPanel() {
|
|
|
+ this.showContactPanel = false;
|
|
|
+ }
|
|
|
+
|
|
|
/** 关闭问题模态框 */
|
|
|
closeIssuesModal() {
|
|
|
this.showIssuesModal = false;
|
|
|
- // 关闭后更新计数(避免列表操作后的计数不一致)
|
|
|
if (this.project?.id) {
|
|
|
const counts = this.issueService.getCounts(this.project.id!);
|
|
|
this.issueCount = counts.total;
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ /** 客户选择事件回调(接收子组件输出) */
|
|
|
+ onContactSelected(evt: { contact: FmodeObject; isNewCustomer: boolean; action: 'selected' | 'created' | 'updated' }) {
|
|
|
+ this.contact = evt.contact;
|
|
|
+ }
|
|
|
}
|
|
|
+
|
|
|
+// duplicate inline CustomerSelectorComponent removed (we keep single declaration above)
|