Browse Source

feat: contact select v1.0

Future 2 days ago
parent
commit
b642a73e79

+ 0 - 0
src/modules/project/components/contact-selector/contact-selector.component.ts


+ 65 - 54
src/modules/project/pages/contact/contact.component.scss

@@ -438,71 +438,82 @@
 // 群聊卡片
 .groups-card {
   .groups-grid {
-    display: grid;
+    display: flex;
+    flex-direction: column;
     gap: 12px;
+  }
+  .groups-card .group-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 12px;
+    border: 1px solid var(--light-shade);
+    border-radius: 10px;
+    background: #fff;
+  }
+  .groups-card .group-info { display:flex; align-items:center; gap:10px; }
+  .groups-card .group-text h4 { margin:0; font-size:14px; font-weight:600; }
+  .groups-card .project-name { display:flex; align-items:center; gap:6px; margin:4px 0 0; font-size:12px; color:#666; }
+  .groups-card .icon.arrow { width:20px; height:20px; color:#999; }
+  
+  /* 侧栏嵌入模式的关闭按钮占位(由父侧栏提供关闭按钮) */
+  :host([embeddedMode=true]) .header .back-button { display:none; }
+  
+  background-color: var(--light-color);
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.3s;
 
-    .group-item {
-      display: flex;
-      align-items: center;
-      justify-content: space-between;
-      padding: 12px;
-      background-color: var(--light-color);
-      border-radius: 8px;
-      cursor: pointer;
-      transition: all 0.3s;
+  &:hover {
+    background-color: var(--light-shade);
+    transform: translateX(4px);
+  }
 
-      &:hover {
-        background-color: var(--light-shade);
-        transform: translateX(4px);
-      }
+  &:active {
+    transform: translateX(2px);
+  }
+
+  .group-info {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    flex: 1;
+    min-width: 0;
+
+    .group-text {
+      flex: 1;
+      min-width: 0;
 
-      &:active {
-        transform: translateX(2px);
+      h4 {
+        margin: 0 0 4px;
+        font-size: 15px;
+        font-weight: 600;
+        color: var(--dark-color);
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
       }
 
-      .group-info {
+      .project-name {
         display: flex;
         align-items: center;
-        gap: 12px;
-        flex: 1;
-        min-width: 0;
-
-        .group-text {
-          flex: 1;
-          min-width: 0;
-
-          h4 {
-            margin: 0 0 4px;
-            font-size: 15px;
-            font-weight: 600;
-            color: var(--dark-color);
-            white-space: nowrap;
-            overflow: hidden;
-            text-overflow: ellipsis;
-          }
-
-          .project-name {
-            display: flex;
-            align-items: center;
-            gap: 4px;
-            margin: 0;
-            font-size: 12px;
-            color: var(--success-color);
-
-            .icon-sm {
-              width: 14px;
-              height: 14px;
-            }
-          }
+        gap: 4px;
+        margin: 0;
+        font-size: 12px;
+        color: var(--success-color);
 
-          .no-project {
-            margin: 0;
-            font-size: 12px;
-            color: var(--medium-color);
-            font-style: italic;
-          }
+        .icon-sm {
+          width: 14px;
+          height: 14px;
         }
       }
+
+      .no-project {
+        margin: 0;
+        font-size: 12px;
+        color: var(--medium-color);
+        font-style: italic;
+      }
     }
   }
 }

+ 10 - 1
src/modules/project/pages/contact/contact.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit, Input } from '@angular/core';
+import { Component, OnInit, Input, Output, EventEmitter, HostBinding } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, ActivatedRoute } from '@angular/router';
 import { IonicModule } from '@ionic/angular';
@@ -32,6 +32,10 @@ export class CustomerProfileComponent implements OnInit {
   // 输入参数(支持组件复用)
   @Input() customer: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
+  // 新增:嵌入模式与关闭事件
+  @Input() embeddedMode: boolean = false;
+  @Output() close = new EventEmitter<void>();
+  @HostBinding('class.embedded') get embeddedClass() { return this.embeddedMode; }
 
   // 路由参数
   cid: string = '';
@@ -386,6 +390,11 @@ export class CustomerProfileComponent implements OnInit {
    * 返回
    */
   goBack() {
+    // 嵌入模式下,触发关闭事件而非跳转
+    if (this.embeddedMode) {
+      this.close.emit();
+      return;
+    }
     this.router.navigate(['/wxwork', this.cid, 'project-loader']);
   }
 

+ 7 - 41
src/modules/project/pages/project-detail/project-detail.component.html

@@ -51,47 +51,13 @@
 
   <!-- 项目详情内容 -->
   @if (!loading && !error && project) {
-    <!-- 客户信息快速查看卡片 -->
-    <div class="contact-quick-view">
-      <div class="card">
-        <div class="card-content">
-          <div class="contact-info">
-            <div class="avatar">
-              @if (contact?.get('data')?.avatar) {
-                <img [src]="contact?.get('data')?.avatar" alt="客户头像" />
-              } @else {
-                <svg class="icon avatar-icon" viewBox="0 0 512 512">
-                  <path fill="currentColor" d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm0 60a60 60 0 11-60 60 60 60 0 0160-60zm0 336c-63.6 0-119.92-36.47-146.39-89.68C109.74 329.09 176.24 296 256 296s146.26 33.09 146.39 58.32C376.92 407.53 319.6 444 256 444z"/>
-                </svg>
-              }
-            </div>
-            <div class="info-text">
-              <h3>{{ contact?.get('name') || contact?.get('data')?.name || '待设置' }}</h3>
-              @if (contact && canViewCustomerPhone) {
-                <p>{{ contact.get('mobile') }}</p>
-                <p class="wechat-id">ID: {{ contact.get('data')?.wechat || contact.get('external_userid') }}</p>
-              } @else if (contact) {
-                <p class="info-limited">仅客服可查联系方式</p>
-              }
-              <div class="tags">
-                @if (contact?.get('source')) {
-                  <span class="badge badge-primary">{{ contact?.get('source') }}</span>
-                }
-                <span class="badge" [class.badge-success]="project.get('status') === '进行中'" [class.badge-warning]="project.get('status') !== '进行中'">
-                  {{ project.get('status') }}
-                </span>
-              </div>
-              
-            </div>
-            @if (!contact?.id && role == '客服') {
-              <button class="btn btn-sm btn-primary" (click)="selectCustomer()">
-                选择客户
-              </button>
-            }
-          </div>
-        </div>
-      </div>
-    </div>
+    <!-- 客户选择组件(替换原 contact-quick-view) -->
+    <app-contact-selector
+      [project]="project"
+      [groupChat]="groupChat"
+      [currentUser]="currentUser"
+      (contactSelected)="onContactSelected($event)">
+    </app-contact-selector>
 
     <!-- 子路由内容(各阶段组件) -->
     <div class="stage-content">

+ 253 - 3
src/modules/project/pages/project-detail/project-detail.component.ts

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

+ 1 - 1
src/modules/project/pages/project-detail/stages/stage-order.component.ts

@@ -44,7 +44,7 @@ export class StageOrderComponent implements OnInit {
   @Input() project: FmodeObject | null = null;
   @Input() customer: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
-  @Input() canEdit: boolean = false;
+  @Input() canEdit: boolean = true;
 
   onProjectTypeChange(){