Files
zkxw/web-admin/src/views/basic-info/customer/index.vue
T

726 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 客户管理 -->
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<div class="page-container">
<section class="panel">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="姓名/电话/身份证/标签"
clearable
/>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="createTimeRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
</div>
<div class="filter-actions-row">
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增客户
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="customerName"
label="客户姓名"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="sex"
label="性别"
min-width="72"
align="center"
header-align="center"
>
<template #default="{ row }">{{ sexLabel(row.sex) }}</template>
</el-table-column>
<el-table-column
prop="idCardNo"
label="身份证号"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="age"
label="年龄"
min-width="72"
align="center"
header-align="center"
/>
<el-table-column
prop="phoneNo"
label="联系电话"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="customerTags"
label="客户标签"
min-width="140"
show-overflow-tooltip
/>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="客户姓名" prop="customerName">
<el-input v-model="formData.customerName" placeholder="必填" />
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-select v-model="formData.sex" placeholder="选择" style="width: 100%">
<el-option label="男" value="0" />
<el-option label="女" value="1" />
<el-option label="未知" value="2" />
</el-select>
</el-form-item>
<el-form-item label="身份证号" prop="idCardNo">
<el-input
v-model="formData.idCardNo"
placeholder="可选;填写后租户内不可重复"
clearable
/>
</el-form-item>
<el-form-item label="年龄">
<el-input
type="number"
step="any"
v-model="formData.age"
:min="0"
:max="150"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="formData.phoneNo" placeholder="可选" clearable />
</el-form-item>
<el-form-item label="客户标签" class="form-col-span-2">
<el-input v-model="formData.customerTags" placeholder="多个标签用英文逗号分隔" />
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.customerName || '客户详情'"
: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>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, View, User, RefreshLeft, Search, More, Edit, Delete } from '@element-plus/icons-vue'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCustomerList,
fetchCreateCustomer,
fetchUpdateCustomer,
fetchDeleteCustomer,
fetchCustomerRelations
} from '@/api/admin'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
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' })
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const createTimeRange = ref<[string, string] | null>(null)
const tableData = ref<Api.BizCustomer.CustomerItem[]>([])
const selectedRows = ref<Api.BizCustomer.CustomerItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增客户')
const isEdit = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
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 []
return [
{
title: '基本信息',
fields: [
{ label: '客户姓名', value: row.customerName },
{ label: '性别', value: sexLabel(row.sex) },
{ label: '身份证号', value: row.idCardNo },
{ label: '年龄', value: row.age },
{ label: '联系电话', value: row.phoneNo },
{ label: '客户标签', value: row.customerTags, span: true },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
]
})
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({
customerName: '',
sex: '0',
idCardNo: '',
age: null as number | null,
phoneNo: '',
customerTags: '',
orderNum: 0,
remark: ''
})
const formRules = {
customerName: [{ required: true, message: '请输入客户姓名', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.BizCustomer.CustomerItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCustomerList({
page: pagination.page,
pageSize: pagination.pageSize,
keyword: searchForm.keyword || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
createTimeEnd: createTimeRange.value?.[1] || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
createTimeRange.value = null
pagination.page = 1
loadData()
}
function resetForm() {
formData.customerName = ''
formData.sex = '0'
formData.idCardNo = ''
formData.age = null
formData.phoneNo = ''
formData.customerTags = ''
formData.orderNum = 0
formData.remark = ''
editingId.value = null
}
function handleAdd() {
isEdit.value = false
dialogTitle.value = '新增客户'
resetForm()
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
function handleEdit(row: Api.BizCustomer.CustomerItem) {
isEdit.value = true
dialogTitle.value = '编辑客户'
editingId.value = row.id
formData.customerName = row.customerName || ''
formData.sex = row.sex || '2'
formData.idCardNo = row.idCardNo || ''
formData.age = row.age ?? null
formData.phoneNo = row.phoneNo || ''
formData.customerTags = row.customerTags || ''
formData.orderNum = row.orderNum ?? 0
formData.remark = row.remark || ''
dialogVisible.value = true
formRef.value?.clearValidate?.()
}
async function handleSubmit() {
await formRef.value?.validate?.()
submitLoading.value = true
try {
const payload = {
customerName: formData.customerName.trim(),
sex: formData.sex,
idCardNo: formData.idCardNo?.trim() || undefined,
age: formData.age,
phoneNo: formData.phoneNo?.trim() || undefined,
customerTags: formData.customerTags?.trim() || undefined,
orderNum: formData.orderNum,
remark: formData.remark?.trim() || undefined
}
if (isEdit.value && editingId.value) {
await fetchUpdateCustomer(editingId.value, payload)
} else {
await fetchCreateCustomer(payload)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
dialogVisible.value = false
await loadData()
} catch {
/* 全局处理 */
} finally {
submitLoading.value = false
}
}
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
}
}
async function handleDelete(row: Api.BizCustomer.CustomerItem) {
await ElMessageBox.confirm('确定要删除该客户吗?', '提示', { type: 'warning' })
await fetchDeleteCustomer(row.id)
ElMessage.success('删除成功')
await loadData()
}
async function handleBatchDelete() {
const ids = selectedRows.value.map((r) => r.id)
if (!ids.length) return
await ElMessageBox.confirm(`确定删除选中的 ${ids.length} 条客户吗?`, '提示', {
type: 'warning'
})
await withFullscreenLoading(async () => {
for (const id of ids) {
await fetchDeleteCustomer(id)
}
ElMessage.success('批量删除成功')
selectedRows.value = []
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
[searchForm, createTimeRange],
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
)
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.biz-dialog-form {
:deep(.el-form-item) {
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>