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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-07 22:36:35 +08:00
1 parent af5c6e725d
commit 818b809614
4 files changed
+296 -8

No files matched your search

+6
View File
@@ -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
@@ -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
View File
@@ -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 {
@@ -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>