Przeglądaj źródła

feat(web-admin): show related family and protocols on customer detail

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 tygodni temu
rodzic
commit
818b809614

+ 6 - 0
web-admin/src/api/admin.ts

@@ -931,6 +931,12 @@ export function fetchDeleteCustomer(id: string) {
   return request.del({ url: `/web/admin/customers/${id}` })
 }
 
+export function fetchCustomerRelations(id: string) {
+  return request.get<Api.BizCustomer.CustomerRelations>({
+    url: `/web/admin/customers/${id}/relations`
+  })
+}
+
 export function fetchCellSampleTypeList(params?: {
   page?: number
   pageSize?: number

+ 8 - 1
web-admin/src/components/business/system-view-dialog/index.vue

@@ -156,7 +156,9 @@
           </aside>
         </div>
       </div>
-      <slot name="append" />
+      <div v-if="$slots.append" class="view-append">
+        <slot name="append" />
+      </div>
     </div>
 
     <template #footer>
@@ -311,6 +313,11 @@
     border-bottom: 1px solid #fecaca;
   }
 
+  .view-append {
+    padding: 16px 22px 20px;
+    border-top: 1px solid #e2e8f0;
+  }
+
   .view-hero-icon {
     width: 72px;
     height: 72px;

+ 31 - 0
web-admin/src/types/api.d.ts

@@ -510,6 +510,37 @@ declare namespace Api {
       createTime: string
       updateTime: string
     }
+    type PartyRole = 'mother' | 'father' | 'newborn'
+    type RelationBizType = 'cell_storage' | 'research_donation'
+    interface RelationMember {
+      role: PartyRole
+      customerId: string
+      name: string
+      phone: string
+      idCardNo: string
+    }
+    interface RelationGroup {
+      bizType: RelationBizType
+      protocolId: string
+      protocolNo: string
+      protocolStatus: string
+      registerDate: string
+      myRole: PartyRole
+      members: RelationMember[]
+    }
+    interface ProtocolSummary {
+      bizType: RelationBizType
+      protocolId: string
+      protocolNo: string
+      protocolStatus: string
+      registerDate: string
+      myRole: PartyRole
+    }
+    interface CustomerRelations {
+      customer: CustomerItem
+      relatedGroups: RelationGroup[]
+      protocols: ProtocolSummary[]
+    }
   }
 
   namespace BizCellSampleType {

+ 251 - 7
web-admin/src/views/basic-info/customer/index.vue

@@ -3,10 +3,6 @@
 <template>
   <div class="page-container">
     <section class="panel">
-      <div class="panel-top">
-        <h2>客户列表</h2>
-      </div>
-
       <div class="filter-block">
         <el-form :model="searchForm" class="filter-form" @submit.prevent>
           <div class="filter-grid">
@@ -57,6 +53,7 @@
             :disabled="selectedRows.length === 0"
             @click="handleBatchDelete"
           >
+            <el-icon><Delete /></el-icon>
             批量删除
           </el-button>
         </div>
@@ -253,6 +250,108 @@
         :icon="User"
         :meta="`身份证:${viewRow?.idCardNo || '—'}${viewRow?.createTime ? ' · ' + viewRow.createTime : ''}`"
         :sections="viewSections"
+        :loading="relationsLoading"
+        width="820px"
+      >
+        <template #append>
+          <div class="customer-relations" v-loading="relationsLoading">
+            <h3 class="rel-section-title">关联客户</h3>
+            <div v-if="!(relations?.relatedGroups || []).length" class="rel-empty">暂无关联协议</div>
+            <div
+              v-for="group in relations?.relatedGroups || []"
+              :key="`${group.bizType}-${group.protocolId}`"
+              class="rel-group"
+            >
+              <div class="rel-group-head">
+                <el-tag size="small" :type="group.bizType === 'cell_storage' ? 'primary' : 'success'">
+                  {{ bizTypeLabel(group.bizType) }}
+                </el-tag>
+                <button type="button" class="rel-link" @click="openProtocol(group)">
+                  {{ group.protocolNo || '—' }}
+                </button>
+                <span class="rel-muted">本客户角色:{{ roleLabel(group.myRole) }}</span>
+              </div>
+              <el-table :data="group.members" size="small" class="rel-member-table" stripe>
+                <el-table-column label="角色" min-width="90" align="center" header-align="center">
+                  <template #default="{ row }">
+                    {{ roleLabel(row.role) }}
+                    <el-tag v-if="row.customerId && row.customerId === viewRow?.id" size="small" type="info">
+                      本人
+                    </el-tag>
+                  </template>
+                </el-table-column>
+                <el-table-column label="姓名" min-width="110" align="center" header-align="center">
+                  <template #default="{ row }">
+                    <button
+                      v-if="row.customerId && row.customerId !== viewRow?.id"
+                      type="button"
+                      class="rel-link"
+                      @click="openRelatedCustomer(row)"
+                    >
+                      {{ row.name || '—' }}
+                    </button>
+                    <span v-else>{{ row.name || '—' }}</span>
+                  </template>
+                </el-table-column>
+                <el-table-column prop="phone" label="电话" min-width="120" align="center" header-align="center">
+                  <template #default="{ row }">{{ row.phone || '—' }}</template>
+                </el-table-column>
+                <el-table-column prop="idCardNo" label="身份证" min-width="160" align="center" header-align="center">
+                  <template #default="{ row }">{{ row.idCardNo || '—' }}</template>
+                </el-table-column>
+              </el-table>
+            </div>
+
+            <h3 class="rel-section-title rel-section-title--gap">关联协议</h3>
+            <div v-if="!(relations?.protocols || []).length" class="rel-empty">暂无关联协议</div>
+            <el-table
+              v-else
+              :data="relations?.protocols || []"
+              size="small"
+              class="rel-protocol-table"
+              stripe
+            >
+              <el-table-column label="协议号" min-width="140" align="center" header-align="center">
+                <template #default="{ row }">
+                  <button type="button" class="rel-link" @click="openProtocol(row)">
+                    {{ row.protocolNo || '—' }}
+                  </button>
+                </template>
+              </el-table-column>
+              <el-table-column label="类型" min-width="110" align="center" header-align="center">
+                <template #default="{ row }">
+                  <el-tag size="small" :type="row.bizType === 'cell_storage' ? 'primary' : 'success'">
+                    {{ bizTypeLabel(row.bizType) }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="状态" min-width="100" align="center" header-align="center">
+                <template #default="{ row }">
+                  <el-tag size="small" :type="protocolTagType(row)">
+                    {{ protocolLabel(row) }}
+                  </el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="日期" min-width="110" align="center" header-align="center">
+                <template #default="{ row }">{{ formatRegisterDate(row.registerDate) }}</template>
+              </el-table-column>
+              <el-table-column label="本客户角色" min-width="100" align="center" header-align="center">
+                <template #default="{ row }">{{ roleLabel(row.myRole) }}</template>
+              </el-table-column>
+            </el-table>
+          </div>
+        </template>
+      </SystemViewDialog>
+
+      <ProtocolViewDialog
+        v-model="protocolViewVisible"
+        :protocol-id="viewProtocolId"
+        show-audit-history
+      />
+      <DonationViewDialog
+        v-model="donationViewVisible"
+        :donation-id="viewDonationId"
+        show-audit-history
       />
     </section>
   </div>
@@ -268,10 +367,19 @@
     fetchCustomerList,
     fetchCreateCustomer,
     fetchUpdateCustomer,
-    fetchDeleteCustomer
+    fetchDeleteCustomer,
+    fetchCustomerRelations
   } from '@/api/admin'
   import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
-  import { sexLabel } from '@/utils/status-tag-registry'
+  import ProtocolViewDialog from '@/views/cell-storage/protocol/ProtocolViewDialog.vue'
+  import DonationViewDialog from '@/views/research-donation/list/DonationViewDialog.vue'
+  import {
+    sexLabel,
+    protocolStatusDisplayLabel,
+    protocolStatusTagType,
+    donationStatusDisplayLabel,
+    donationStatusTagType
+  } from '@/utils/status-tag-registry'
 
   defineOptions({ name: 'BizCustomer' })
 
@@ -291,6 +399,13 @@
   // ---- 查看弹窗 ----
   const viewVisible = ref(false)
   const viewRow = ref<Api.BizCustomer.CustomerItem | null>(null)
+  const relationsLoading = ref(false)
+  const relations = ref<Api.BizCustomer.CustomerRelations | null>(null)
+  const protocolViewVisible = ref(false)
+  const viewProtocolId = ref<string | null>(null)
+  const donationViewVisible = ref(false)
+  const viewDonationId = ref<string | null>(null)
+
   const viewSections = computed(() => {
     const row = viewRow.value
     if (!row) return []
@@ -310,9 +425,81 @@
       }
     ]
   })
-  function handleView(row: Api.BizCustomer.CustomerItem) {
+
+  function roleLabel(role: string) {
+    if (role === 'mother') return '母亲'
+    if (role === 'father') return '父亲'
+    if (role === 'newborn') return '子女'
+    return role || '—'
+  }
+
+  function bizTypeLabel(bizType: string) {
+    return bizType === 'research_donation' ? '科研捐赠' : '细胞存储'
+  }
+
+  function protocolLabel(row: Api.BizCustomer.ProtocolSummary) {
+    return row.bizType === 'research_donation'
+      ? donationStatusDisplayLabel(row.protocolStatus)
+      : protocolStatusDisplayLabel(row.protocolStatus)
+  }
+
+  function protocolTagType(row: Api.BizCustomer.ProtocolSummary) {
+    return row.bizType === 'research_donation'
+      ? donationStatusTagType(row.protocolStatus)
+      : protocolStatusTagType(row.protocolStatus)
+  }
+
+  function formatRegisterDate(val: string) {
+    const s = (val || '').trim()
+    if (/^\d{8}$/.test(s)) return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
+    return formatDisplayOrDash(s)
+  }
+
+  async function handleView(row: Api.BizCustomer.CustomerItem) {
     viewRow.value = row
     viewVisible.value = true
+    relationsLoading.value = true
+    relations.value = null
+    try {
+      const data = await fetchCustomerRelations(row.id)
+      relations.value = data
+      if (data?.customer) viewRow.value = data.customer
+    } catch {
+      /* 全局处理 */
+    } finally {
+      relationsLoading.value = false
+    }
+  }
+
+  function openRelatedCustomer(member: Api.BizCustomer.RelationMember) {
+    if (!member.customerId) return
+    void handleView({
+      id: member.customerId,
+      customerName: member.name || '',
+      sex: '2',
+      idCardNo: member.idCardNo || '',
+      age: null,
+      phoneNo: member.phone || '',
+      customerTags: '',
+      tenantNo: '',
+      orderNum: 0,
+      remark: '',
+      createTime: '',
+      updateTime: ''
+    })
+  }
+
+  function openProtocol(
+    row: Pick<Api.BizCustomer.ProtocolSummary, 'bizType' | 'protocolId'>
+  ) {
+    if (!row.protocolId) return
+    if (row.bizType === 'research_donation') {
+      viewDonationId.value = row.protocolId
+      donationViewVisible.value = true
+      return
+    }
+    viewProtocolId.value = row.protocolId
+    protocolViewVisible.value = true
   }
 
   const formData = reactive({
@@ -478,4 +665,61 @@
       margin-bottom: 0;
     }
   }
+
+  .customer-relations {
+    .rel-section-title {
+      margin: 0 0 10px;
+      font-size: 14px;
+      font-weight: 600;
+      color: #0f172a;
+    }
+
+    .rel-section-title--gap {
+      margin-top: 18px;
+    }
+
+    .rel-empty {
+      padding: 8px 0 4px;
+      color: var(--mint-text-muted, #64748b);
+      font-size: 13px;
+    }
+
+    .rel-group {
+      margin-bottom: 14px;
+      padding: 10px 12px;
+      border: 1px solid #e2e8f0;
+      border-radius: 8px;
+      background: #f8fafc;
+    }
+
+    .rel-group-head {
+      display: flex;
+      flex-wrap: wrap;
+      align-items: center;
+      gap: 8px;
+      margin-bottom: 8px;
+    }
+
+    .rel-muted {
+      color: var(--mint-text-muted, #64748b);
+      font-size: 12px;
+    }
+
+    .rel-link {
+      padding: 0;
+      border: 0;
+      background: transparent;
+      color: var(--primary, #10b981);
+      cursor: pointer;
+      font: inherit;
+      text-decoration: underline;
+      text-underline-offset: 2px;
+    }
+
+    .rel-member-table,
+    .rel-protocol-table {
+      width: 100%;
+      background: #fff;
+    }
+  }
 </style>