Browse Source

feat: customers

Future 1 day ago
parent
commit
418d94e789

+ 14 - 11
src/app/pages/admin/customers/customers.html

@@ -30,17 +30,24 @@
       <div>客户名称</div>
       <div>手机号</div>
       <div>企微ID</div>
+      <div>类型</div>
       <div>来源</div>
       <div>创建时间</div>
     </div>
     <div class="table row clickable" *ngFor="let c of filtered" (click)="openCustomerDetail(c)">
       <div class="name">
-        <div class="title">{{ c.name }}</div>
+        <div class="title">
+          <img *ngIf="c.get('data')?.avatar" [src]="c.get('data')?.avatar" alt="" style="width:24px;height:24px;border-radius:50%;"/>
+          <span>{{ c.get('name') || c.get('data')?.name }}</span>
+        </div>
       </div>
-      <div>{{ c.mobile }}</div>
-      <div>{{ c.external_userid || '-' }}</div>
-      <div>{{ c.source || '-' }}</div>
-      <div>{{ c.createdAt ? (c.createdAt | date:'yyyy-MM-dd') : '-' }}</div>
+      <div>{{ c.get('mobile') || '-' }}</div>
+      <div>{{ c.get('external_userid') || '-' }}</div>
+      <div>
+        <span class="tag" [class.vip]="c.get('data')?.external_contact?.type===1" [class.svip]="c.get('data')?.external_contact?.type===2">{{ c.get('data')?.external_contact?.type===2 ? '企业成员' : '外部联系人' }}</span>
+      </div>
+      <div>{{ c.get('source') || '-' }}</div>
+      <div>{{ c.get('createdAt') ? (c.get('createdAt') | date:'yyyy-MM-dd') : '-' }}</div>
     </div>
     <div class="empty" *ngIf="filtered.length === 0">
       @if (loading()) {
@@ -51,15 +58,11 @@
     </div>
   </div>
 
-  <!-- 客户详情面板 -->
+  <!-- 覆盖层弹出 contact 详情 -->
   <div class="customer-panel-overlay" *ngIf="showCustomerPanel" (click)="closeCustomerPanel()">
     <div class="customer-panel" (click)="$event.stopPropagation()">
-      <div class="panel-header">
-        <h2>客户详情</h2>
-        <button class="close-btn" (click)="closeCustomerPanel()">×</button>
-      </div>
       <div class="panel-body">
-        <app-contact *ngIf="selectedCustomer" [customer]="selectedCustomer"></app-contact>
+        <app-contact *ngIf="selectedCustomer" [customer]="selectedCustomer" [currentUser]="currentUserForContact" [embeddedMode]="true" [projectIdFilter]="panelProjectId" (close)="closeCustomerPanel(true)"></app-contact>
       </div>
     </div>
   </div>

+ 10 - 174
src/app/pages/admin/customers/customers.scss

@@ -2,178 +2,14 @@
 .page-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.page-title{font-size:20px;margin:0 0 6px}.page-description{color:#64748b;margin:0}.btn{padding:8px 12px;border-radius:8px;border:1px solid #e5e7eb;background:#fff;cursor:pointer}.btn.primary{background:#165DFF;color:#fff;border-color:#165DFF}
 .stats-cards{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px}.stat-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06);padding:16px}.stat-label{color:#64748b;font-size:12px}.stat-value{font-size:22px;font-weight:700;margin-top:6px}
 .toolbar{display:flex;justify-content:space-between;align-items:center;background:#fff;padding:12px 16px;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06);margin-bottom:12px}.search input{width:320px;padding:8px 10px;border:1px solid #e5e7eb;border-radius:8px}.filters{display:flex;gap:8px;align-items:center}.filters select{padding:8px;border:1px solid #e5e7eb;border-radius:8px}
-.table-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06)}.table{display:grid;grid-template-columns:2.2fr 1.1fr 1.4fr .8fr .9fr .8fr 1.2fr 1.1fr 1fr;align-items:center;padding:12px 16px;border-bottom:1px solid #f1f5f9}.table.header{color:#64748b;font-weight:600;background:#f8fafc;border-top-left-radius:12px;border-top-right-radius:12px}.table.row{background:#fff}.table .name .title{font-weight:600}.tag{display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#4f46e5;font-weight:600;font-size:12px}.tag.vip{background:#fff7ed;color:#f97316}.tag.svip{background:#fff1f2;color:#ef4444}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:#94a3b8}.dot.green{background:#10b981}.dot.gray{background:#94a3b8}.actions{display:flex;gap:6px;justify-content:flex-end}.icon{border:1px solid #e5e7eb;background:#fff;border-radius:8px;padding:6px 8px;cursor:pointer}.icon.danger{color:#ef4444;border-color:#fecaca}
+.table-card{background:#fff;border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06)}.table{display:grid;grid-template-columns:2fr 1.2fr 1.6fr 1fr 1fr 1.1fr;align-items:center;padding:12px 16px;border-bottom:1px solid #f1f5f9}.table.header{color:#64748b;font-weight:600;background:#f8fafc;border-top-left-radius:12px;border-top-right-radius:12px}.table.row{background:#fff}.table .name .title{font-weight:600;display:flex;align-items:center;gap:6px}
+.tag{display:inline-block;padding:2px 8px;border-radius:999px;background:#eef2ff;color:#4f46e5;font-weight:600;font-size:12px}.tag.vip{background:#fff7ed;color:#f97316}.tag.svip{background:#fff1f2;color:#ef4444}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;background:#94a3b8}.dot.green{background:#10b981}.dot.gray{background:#94a3b8}.actions{display:flex;gap:6px;justify-content:flex-end}.icon{border:1px solid #e5e7eb;background:#fff;border-radius:8px;padding:6px 8px;cursor:pointer}.icon.danger{color:#ef4444;border-color:#fecaca}
 .empty{padding:24px;text-align:center;color:#94a3b8}
-@media (max-width: 992px){.stats-cards{grid-template-columns:repeat(2,1fr)}.search input{width:100%}.toolbar{flex-direction:column;gap:10px;align-items:flex-start}}
-
-/* 侧边面板通用样式(与设计师页面保持一致) */
-.panel-overlay {
-  position: fixed;
-  inset: 0;
-  background: rgba(0, 0, 0, 0.45);
-  display: flex;
-  justify-content: flex-end;
-  z-index: 1000;
-}
-
-.side-panel {
-  width: 560px;
-  height: 100%;
-  background: #fff;
-  box-shadow: -4px 0 16px rgba(0,0,0,0.08);
-  display: flex;
-  flex-direction: column;
-  animation: slideIn .2s ease;
-}
-
-@keyframes slideIn {
-  from { transform: translateX(24px); opacity: 0; }
-  to { transform: translateX(0); opacity: 1; }
-}
-
-.panel-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 16px 20px;
-  border-bottom: 1px solid #f0f0f0;
-
-  h3 { margin: 0; font-size: 18px; font-weight: 600; }
-  .close-btn { border: none; background: transparent; font-size: 20px; cursor: pointer; }
-}
-
-.panel-content {
-  padding: 16px 20px;
-  overflow: auto;
-  flex: 1;
-
-  .detail-section {
-    display: flex;
-    margin-bottom: 12px;
-    label { width: 92px; color: #888; }
-    span { color: #333; }
-  }
-
-  .customer-form {
-    .form-group { margin-bottom: 12px; display: flex; flex-direction: column; }
-    label { margin-bottom: 6px; color: #666; }
-    input, select, textarea { padding: 8px 10px; border: 1px solid #e5e6eb; border-radius: 6px; }
-    textarea { min-height: 88px; resize: vertical; }
-  }
-}
-
-.panel-footer {
-  padding: 12px 20px;
-  border-top: 1px solid #f0f0f0;
-  display: flex;
-  justify-content: flex-end;
-  gap: 10px;
-
-  .btn { padding: 8px 16px; border-radius: 6px; border: 1px solid #e5e6eb; background: #fff; cursor: pointer; }
-  .btn.primary { background: #3a7afe; color: #fff; border-color: #3a7afe; }
-}
-/* 可点击行样式 */
-.table.row.clickable {
-  cursor: pointer;
-  transition: background-color 0.2s;
-
-  &:hover {
-    background-color: #f8fafc;
-  }
-}
-
-/* 客户详情面板样式 */
-.customer-panel-overlay {
-  position: fixed;
-  inset: 0;
-  background: rgba(0, 0, 0, 0.5);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 2000;
-  animation: fadeIn 0.2s ease-out;
-}
-
-.customer-panel {
-  width: 90%;
-  max-width: 900px;
-  max-height: 90vh;
-  background: white;
-  border-radius: 12px;
-  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
-  display: flex;
-  flex-direction: column;
-  animation: slideUp 0.3s ease-out;
-
-  .panel-header {
-    display: flex;
-    justify-content: space-between;
-    align-items: center;
-    padding: 20px 24px;
-    border-bottom: 1px solid #e5e7eb;
-
-    h2 {
-      margin: 0;
-      font-size: 20px;
-      font-weight: 600;
-      color: #1f2937;
-    }
-
-    .close-btn {
-      background: none;
-      border: none;
-      font-size: 28px;
-      color: #6b7280;
-      cursor: pointer;
-      line-height: 1;
-      padding: 0;
-      width: 32px;
-      height: 32px;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      border-radius: 6px;
-      transition: all 0.2s;
-
-      &:hover {
-        background: #f3f4f6;
-        color: #1f2937;
-      }
-    }
-  }
-
-  .panel-body {
-    flex: 1;
-    overflow-y: auto;
-    padding: 0;
-  }
-}
-
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-}
-
-@keyframes slideUp {
-  from {
-    transform: translateY(20px);
-    opacity: 0;
-  }
-  to {
-    transform: translateY(0);
-    opacity: 1;
-  }
-}
-
-@media (max-width: 768px) {
-  .customer-panel {
-    width: 100%;
-    max-width: 100%;
-    max-height: 100vh;
-    border-radius: 0;
-  }
-}
+@media (max-width: 992px){.stats-cards{grid-template-columns:repeat(2,1fr)}.search input{width:100%}.toolbar{flex-direction:column;gap:10px;align-items:flex-start}.table{grid-template-columns:1.6fr 1fr 1.4fr .9fr .9fr 1fr}}
+
+/* 客户详情面板样式(覆盖层弹出) */
+.customer-panel-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:2000;animation:fadeIn .2s ease-out}
+.customer-panel{width:90%;max-width:900px;max-height:90vh;background:#fff;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.15);display:flex;flex-direction:column;animation:slideUp .3s ease-out;overflow:hidden}
+.panel-body{flex:1;overflow:auto}
+@keyframes fadeIn{from{opacity:0}to{opacity:1}}
+@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}

+ 31 - 62
src/app/pages/admin/customers/customers.ts

@@ -3,17 +3,9 @@ import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { CustomerService } from '../services/customer.service';
 import { FmodeObject } from 'fmode-ng/core';
+import { FmodeParse } from 'fmode-ng/parse';
 import { CustomerProfileComponent } from '../../../../modules/project/pages/contact/contact.component';
 
-interface Customer {
-  id: string;
-  name: string;
-  mobile: string;
-  external_userid?: string;
-  source?: string;
-  createdAt?: Date;
-}
-
 @Component({
   selector: 'app-admin-customers',
   standalone: true,
@@ -22,51 +14,28 @@ interface Customer {
   styleUrl: './customers.scss'
 })
 export class Customers implements OnInit {
-  // 统计
   total = signal(0);
   loading = signal(false);
+  customers = signal<FmodeObject[]>([]);
 
-  // 数据
-  customers = signal<Customer[]>([]);
-  customerObjects: Map<string, FmodeObject> = new Map(); // 存储Parse对象
-
-  // 客户详情面板
   showCustomerPanel = false;
   selectedCustomer: FmodeObject | null = null;
+  currentUserForContact: FmodeObject | null = null;
+  panelProjectId: string | null = null;
 
   constructor(private customerService: CustomerService) {}
 
   ngOnInit(): void {
     this.loadCustomers();
+    this.setupCurrentUserForContact();
   }
 
   async loadCustomers(): Promise<void> {
     this.loading.set(true);
     try {
       const custs = await this.customerService.findCustomers();
-
-      // 清空之前的对象映射
-      this.customerObjects.clear();
-
-      const custList: Customer[] = custs.map(c => {
-        const json = this.customerService.toJSON(c);
-        const customerId = json.objectId;
-
-        // 保存Parse对象以便后续使用
-        this.customerObjects.set(customerId, c);
-
-        return {
-          id: customerId,
-          name: json.name || '未知客户',
-          mobile: json.mobile || '',
-          external_userid: json.external_userid,
-          source: json.source,
-          createdAt: json.createdAt?.iso || json.createdAt
-        };
-      });
-
-      this.customers.set(custList);
-      this.total.set(custList.length);
+      this.customers.set(custs);
+      this.total.set(custs.length);
     } catch (error) {
       console.error('加载客户列表失败:', error);
     } finally {
@@ -74,37 +43,39 @@ export class Customers implements OnInit {
     }
   }
 
-  // 打开客户详情面板
-  openCustomerDetail(customer: Customer) {
-    const customerObj = this.customerObjects.get(customer.id);
-    if (customerObj) {
-      this.selectedCustomer = customerObj;
-      this.showCustomerPanel = true;
+  setupCurrentUserForContact() {
+    const companyId = localStorage.getItem('company');
+    if (companyId) {
+      const Company = (FmodeParse.with('nova') as any).Object.extend('Company');
+      const company = new Company();
+      company.id = companyId;
+      const companyPtr = company.toPointer();
+      this.currentUserForContact = { get: (key: string) => key === 'company' ? companyPtr : (key === 'roleName' ? '管理员' : null) } as any;
     }
   }
 
-  // 关闭客户详情面板
-  closeCustomerPanel() {
+  openCustomerDetail(customer: FmodeObject) {
+    this.selectedCustomer = customer;
+    this.showCustomerPanel = true;
+  }
+
+  async closeCustomerPanel(refresh: boolean = false) {
     this.showCustomerPanel = false;
     this.selectedCustomer = null;
+    if (refresh) await this.loadCustomers();
   }
 
-  // 筛选
   keyword = signal('');
   status = signal<'all' | 'active' | 'inactive'>('all');
   level = signal<'all' | 'normal' | 'vip' | 'svip'>('all');
 
-  // 面板状态
-  showPanel = false;
-  panelMode: 'add' | 'detail' | 'edit' = 'add';
-  currentCustomer: Customer | null = null;
-  formModel: Partial<Customer> = {};
-
   get filtered() {
     const kw = this.keyword().trim().toLowerCase();
     return this.customers().filter(c => {
-      const m1 = !kw || c.name.toLowerCase().includes(kw) || c.mobile.includes(kw);
-      return m1;
+      if (!kw) return true;
+      const name = (c.get('name') || c.get('data')?.name || '').toLowerCase();
+      const mobile = c.get('mobile') || '';
+      return name.includes(kw) || mobile.includes(kw);
     });
   }
 
@@ -112,15 +83,14 @@ export class Customers implements OnInit {
     this.keyword.set('');
   }
 
-  // 导出当前筛选客户为 CSV
   exportCustomers() {
     const header = ['客户名称','手机号','企微ID','来源','创建时间'];
     const rows = this.filtered.map(c => [
-      c.name,
-      c.mobile,
-      c.external_userid || '',
-      c.source || '',
-      c.createdAt instanceof Date ? c.createdAt.toISOString().slice(0, 10) : String(c.createdAt || '')
+      c.get('name') || c.get('data')?.name || '',
+      c.get('mobile') || '',
+      c.get('external_userid') || '',
+      c.get('source') || '',
+      (c.get('createdAt') instanceof Date) ? (c.get('createdAt') as Date).toISOString().slice(0, 10) : String(c.get('createdAt') || '')
     ]);
     this.downloadCSV('客户列表.csv', [header, ...rows]);
   }
@@ -140,5 +110,4 @@ export class Customers implements OnInit {
     a.click();
     URL.revokeObjectURL(url);
   }
-
 }

+ 1 - 1
src/modules/project/components/contact-selector/contact-selector.component.html

@@ -67,7 +67,7 @@
   <!-- 客户详情侧栏弹层 -->
   <div class="overlay" *ngIf="showCustomerPanel" (click)="closeCustomerDetail()"></div>
   <div class="customer-panel" *ngIf="showCustomerPanel">
-    <app-contact [customer]="currentCustomer" [currentUser]="currentUser" (close)="closeCustomerDetail()"></app-contact>
+    <app-contact [customer]="currentCustomer" [currentUser]="currentUser" [embeddedMode]="true" [projectIdFilter]="project?.id" (close)="closeCustomerDetail()"></app-contact>
     <button class="close" (click)="closeCustomerDetail()">返回</button>
   </div>
 </div>

+ 70 - 17
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,7 +32,11 @@ export class CustomerProfileComponent implements OnInit {
   // 输入参数(支持组件复用)
   @Input() customer: FmodeObject | null = null;
   @Input() currentUser: FmodeObject | null = null;
-
+  // 新增:嵌入模式与项目过滤
+  @Input() embeddedMode: boolean = false;
+  @Input() projectIdFilter: string | null = null;
+  @Output() close = new EventEmitter<void>();
+  @HostBinding('class.embedded') get embeddedClass() { return this.embeddedMode; }
   // 路由参数
   cid: string = '';
   contactId: string = '';
@@ -47,6 +51,7 @@ export class CustomerProfileComponent implements OnInit {
   // 加载状态
   loading: boolean = true;
   error: string | null = null;
+  refreshing: boolean = false;
 
   // 客户数据
   contactInfo: FmodeObject | null = null;
@@ -316,28 +321,32 @@ export class CustomerProfileComponent implements OnInit {
    */
   async loadFollowUpRecords() {
     try {
-      // 查询沟通记录
-      const query = new Parse.Query('Communication');
-      query.equalTo('project.customer', this.contactInfo!.toPointer());
+      // 使用 ContactFollow 表,默认按项目过滤
+      const query = new Parse.Query('ContactFollow');
+      query.equalTo('contact', this.contactInfo!.toPointer());
+      query.notEqualTo('isDeleted', true);
+      if (this.projectIdFilter) {
+        const project = new Parse.Object('Project');
+        project.id = this.projectIdFilter;
+        query.equalTo('project', project.toPointer());
+      }
       query.descending('createdAt');
-      query.limit(20);
-
-      const communications = await query.find();
-
-      this.profile.followUpRecords = communications.map((comm: any) => ({
-        time: comm.get('createdAt'),
-        type: comm.get('communicationType') || 'message',
-        content: comm.get('content') || '',
-        operator: comm.get('sender')?.get('name') || '系统'
+      query.limit(50);
+
+      const records = await query.find();
+      this.profile.followUpRecords = records.map((rec: any) => ({
+        time: rec.get('createdAt'),
+        type: rec.get('type') || 'message',
+        content: rec.get('content') || '',
+        operator: rec.get('sender')?.get('name') || '系统'
       }));
 
-      // 如果没有沟通记录,从ContactInfo.data.follow_user获取
+      // 若无 ContactFollow 记录,则兼容 data.follow_user
       if (this.profile.followUpRecords.length === 0) {
         const data = this.contactInfo!.get('data') || {};
         const followUsers = data.follow_user || [];
-
         this.profile.followUpRecords = followUsers.map((fu: any) => ({
-          time: new Date(fu.createtime * 1000),
+          time: fu.createtime ? new Date(fu.createtime * 1000) : new Date(),
           type: 'follow',
           content: `${fu.userid} 添加客户`,
           operator: fu.userid
@@ -376,6 +385,11 @@ export class CustomerProfileComponent implements OnInit {
    * 返回
    */
   goBack() {
+    // 嵌入模式下不跳转,触发关闭
+    if (this.embeddedMode) {
+      this.close.emit();
+      return;
+    }
     this.router.navigate(['/wxwork', this.cid, 'project-loader']);
   }
 
@@ -449,4 +463,43 @@ export class CustomerProfileComponent implements OnInit {
     if (budget.min === budget.max) return `¥${budget.min}`;
     return `¥${budget.min} - ¥${budget.max}`;
   }
+
+  /** 刷新客户数据(基于 external_userid 拉取企微数据并保存) */
+  async refreshContactData() {
+    try {
+      if (!this.contactInfo) return;
+      const externalUserId = this.contactInfo.get('external_userid');
+      const companyId = this.currentUser?.get('company')?.id || this.contactInfo.get('company')?.id || localStorage.getItem('company');
+      if (!externalUserId || !companyId) {
+        alert('无法刷新:缺少企业或external_userid');
+        return;
+      }
+      this.refreshing = true;
+      const corp = new WxworkCorp(companyId);
+      const extData = await corp.externalContact.get(externalUserId);
+      const ext = (extData && extData.external_contact) ? extData.external_contact : {};
+      const follow = (extData && extData.follow_user) ? extData.follow_user : [];
+
+      if (ext.name) this.contactInfo.set('name', ext.name);
+      const prev = this.contactInfo.get('data') || {};
+      const mapped = {
+        ...prev,
+        external_contact: ext,
+        follow_user: follow,
+        name: ext.name,
+        avatar: ext.avatar,
+        gender: ext.gender,
+        type: ext.type
+      } as any;
+      this.contactInfo.set('data', mapped);
+      await this.contactInfo.save();
+
+      await this.buildCustomerProfile();
+    } catch (e) {
+      console.warn('刷新客户数据失败:', e);
+      alert('刷新失败,请稍后重试');
+    } finally {
+      this.refreshing = false;
+    }
+  }
 }