Files
shanxia2.0/web-admin/src/views/crm/customer/CustomerDetailDialog.vue
T
2026-06-21 12:41:39 +08:00

1083 lines
36 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.
<template>
<SystemViewDialog
v-model:visible="visible"
:title="customerInfo.customerName || '客户详情'"
:icon="User"
width="calc(100vw - 100px)"
layout="split"
:code="customerInfo.customerNo"
code-label="客户编号"
:meta="viewHeroMeta"
:sections="viewSections"
:loading="detailLoading"
dialog-class="customer-view-dialog"
modal-class="order-module-dialog-mask"
append-to-body
>
<template #certificates>
<div class="cert-list">
<div v-if="customerInfo.businessLicenseImages" class="cert-block">
<div class="view-field-label">营业执照</div>
<AttachmentUploadField :model-value="customerInfo.businessLicenseImages" disabled />
</div>
<div v-if="customerInfo.businessPermitImages" class="cert-block">
<div class="view-field-label">经营许可证</div>
<AttachmentUploadField :model-value="customerInfo.businessPermitImages" disabled />
</div>
<div v-if="customerInfo.otherCertImages" class="cert-block">
<div class="view-field-label">其他证件</div>
<AttachmentUploadField :model-value="customerInfo.otherCertImages" disabled />
</div>
</div>
</template>
<template #hero-actions>
<div class="customer-view-hero-actions">
<el-button class="btn-main" type="primary" @click="handleEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button class="btn-ghost" type="danger" @click="handleDelete">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
</template>
<template #split-right>
<div class="customer-view-tabs-section">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="跟进记录" name="follow">
<div class="tab-toolbar">
<el-button class="btn-main" type="primary" size="small" @click="handleAddFollow">
<el-icon><Plus /></el-icon>添加跟进
</el-button>
</div>
<el-timeline v-if="followList.length > 0">
<el-timeline-item
v-for="(item, index) in followList"
:key="item.id || index"
:timestamp="item.createTime"
placement="top"
>
<div class="follow-card">
<div class="follow-header">
<div class="follow-header-left">
<el-tag
:type="dictStore.getTagType('crm_follow_type', item.followType)"
size="small"
>
{{ dictStore.getLabel('crm_follow_type', item.followType) }}
</el-tag>
<span class="follow-creator">{{
item.ownerUserName || item.createByName || '—'
}}</span>
</div>
<el-button
v-if="
item.ownerUserName === userStore.info.realName ||
item.createByName === userStore.info.realName ||
userStore.info.roles?.includes('admin')
"
link
type="danger"
size="small"
@click="handleDeleteFollow(item)"
>
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
<div class="follow-content">{{ item.followContent }}</div>
<div v-if="item.contactName" class="follow-meta">联系人:{{ item.contactName }}</div>
<div v-if="item.nextTime" class="follow-meta follow-meta--warn">
下次联系:{{ item.nextTime }}
</div>
<AttachmentUploadField
v-if="getFollowAttachment(item)"
:model-value="getFollowAttachment(item)"
disabled
/>
<div v-if="item.remark" class="follow-meta">备注:{{ item.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<div v-if="followTotal > 0" class="tab-pager">
<el-pagination
v-model:current-page="followQuery.pageNum"
v-model:page-size="followQuery.pageSize"
:total="followTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="getFollowList"
@current-change="getFollowList"
/>
</div>
</el-tab-pane>
<el-tab-pane label="联系人" name="contact">
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleAddContact">
<el-icon><Plus /></el-icon>添加联系人
</el-button>
</div>
</div>
</div>
<div class="list-body list-wrap">
<el-table
v-loading="contactLoading"
:data="contactList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="联系人"
prop="contactName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="性别" min-width="80" align="center" header-align="center">
<template #default="{ row }">
{{
row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '—'
}}
</template>
</el-table-column>
<el-table-column
label="手机号"
prop="mobile"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column
label="职位"
prop="position"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="角色" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contact_role', row.role)"
size="small"
>
{{ dictStore.getLabel('crm_contact_role', row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="邮箱"
prop="email"
min-width="160"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column
label="操作"
fixed="right"
min-width="260"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button
v-if="row.isPrimary !== 1"
link
type="primary"
@click="handleSetPrimary(row)"
>
<el-icon><Star /></el-icon>设为主要
</el-button>
<el-button link type="primary" @click="handleEditContact(row)">
<el-icon><Edit /></el-icon>修改
</el-button>
<el-button link type="danger" @click="handleDeleteContact(row)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="商机" name="opportunity">
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="opportunityLoading"
:data="opportunityList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="商机名称"
prop="opportunityName"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="商机阶段" min-width="120" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_opportunity_stage', row.stage)"
size="small"
>
{{ dictStore.getLabel('crm_opportunity_stage', row.stage) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="预计成交日期"
prop="expectDealDate"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="负责人"
prop="ownerUserName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="创建时间"
prop="createTime"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewOpportunity(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="合同" name="contract">
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="contractLoading"
:data="contractList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="合同编号"
prop="contractNo"
min-width="150"
align="center"
header-align="center"
/>
<el-table-column
label="合同名称"
prop="contractName"
min-width="180"
align="center"
header-align="center"
show-overflow-tooltip
/>
<el-table-column label="合同类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_type', row.contractType)"
size="small"
>
{{ dictStore.getLabel('crm_contract_type', row.contractType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="合同金额" min-width="130" align="right" header-align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="合同状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('crm_contract_status', row.status)"
size="small"
>
{{ dictStore.getLabel('crm_contract_status', row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="签订日期"
prop="signDate"
min-width="110"
align="center"
header-align="center"
/>
<el-table-column
label="负责人"
prop="ownerUserName"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewContract(row.id)">
<el-icon><View /></el-icon>查看
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="开票信息" name="invoiceInfo">
<InvoiceInfo v-if="activeTab === 'invoiceInfo'" :customer-id="customerId" ref="invoiceInfoRef" />
</el-tab-pane>
<el-tab-pane label="操作日志" name="log">
<div class="panel dialog-list-panel">
<div class="list-body list-wrap dialog-list-body-only">
<el-table
v-loading="logLoading"
:data="operLogList"
class="list-table"
stripe
empty-text=""
>
<el-table-column
label="操作时间"
prop="operTime"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
label="操作模块"
prop="title"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column label="操作类型" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag
:type="dictStore.getTagType('sys_oper_type', row.businessType)"
size="small"
>
{{ dictStore.getLabel('sys_oper_type', row.businessType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作人员"
prop="operName"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
label="操作IP"
prop="operIp"
min-width="130"
align="center"
header-align="center"
/>
<el-table-column label="操作状态" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="row.status === 0 ? 'success' : 'danger'" size="small">
{{ row.status === 0 ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="耗时" min-width="100" align="center" header-align="center">
<template #default="{ row }">{{ row.costTime }}ms</template>
</el-table-column>
<el-table-column
label="操作"
fixed="right"
min-width="120"
align="center"
header-align="center"
class-name="col-actions"
>
<template #default="{ row }">
<el-button link type="primary" @click="handleViewLogDetail(row)">
<el-icon><View /></el-icon>详情
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="logTotal > 0" class="pagination list-pager-bar">
<el-pagination
v-model:current-page="logQuery.pageNum"
v-model:page-size="logQuery.pageSize"
:total="logTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="() => getOperLogList(false)"
@current-change="() => getOperLogList(false)"
/>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
</SystemViewDialog>
<CustomerForm ref="customerFormRef" @success="getCustomerInfo" />
<FollowForm ref="followFormRef" @success="handleFollowSuccess" />
<ContactForm ref="contactFormRef" @success="handleContactSuccess" />
<OpportunityDetailDrawer v-if="opportunityDialogActive" ref="opportunityDetailRef" />
<ContractDetailDialog v-if="contractDialogActive" ref="contractDetailRef" />
</template>
<script setup lang="ts" name="CustomerDetail">
import { computed, ref, defineAsyncComponent, nextTick } from 'vue'
import { User, Edit, Delete, Plus, View, Star } from '@element-plus/icons-vue'
import SystemViewDialog, {
type ViewField,
type ViewSection
} from '@/components/business/system-view-dialog/index.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import {
fetchCustomerDetail,
fetchFollowByCustomerId,
fetchDeleteCustomerFollow,
fetchContactByCustomerId,
fetchDeleteContact,
fetchSetPrimaryContact,
fetchDeleteCustomer
} from '@/api/crm/customer'
import type { CustomerVO, CustomerFollowVO, CustomerFollowQuery, ContactVO } from '@/api/crm/customerTypes'
import { listOpportunity } from '@/api/crm/opportunity'
import type { OpportunityVO, OpportunityQuery } from '@/api/crm/opportunity/types'
import { listContract } from '@/api/crm/contract'
import type { ContractVO, ContractQuery } from '@/api/crm/contract/types'
import { list as listOperLog } from '@/api/monitor/operlog'
import type { OperLogVO } from '@/api/monitor/operlog/types'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import CustomerForm from './CustomerFormDialog.vue'
import FollowForm from './FollowFormDialog.vue'
import ContactForm from './components/ContactFormDialog.vue'
import InvoiceInfo from './components/InvoiceInfo.vue'
const OpportunityDetailDrawer = defineAsyncComponent(
() => import('@/views/crm/opportunity/components/OpportunityDetailDrawer.vue')
)
const ContractDetailDialog = defineAsyncComponent(
() => import('@/views/crm/contract/components/ContractDetailDialog.vue')
)
const dictStore = useDictStore()
const userStore = useUserStore()
const emit = defineEmits(['close'])
const visible = ref(false)
const detailLoading = ref(false)
const customerId = ref<number | string>()
const customerInfo = ref<CustomerVO>({} as CustomerVO)
const followList = ref<CustomerFollowVO[]>([])
const followTotal = ref(0)
const followQuery = ref<CustomerFollowQuery>({
customerId: undefined,
pageNum: 1,
pageSize: 10
})
const contactList = ref<ContactVO[]>([])
const contactLoading = ref(false)
const opportunityList = ref<OpportunityVO[]>([])
const opportunityLoading = ref(false)
const contractList = ref<ContractVO[]>([])
const contractLoading = ref(false)
const operLogList = ref<OperLogVO[]>([])
const operLogListFull = ref<OperLogVO[]>([])
const logTotal = ref(0)
const logLoading = ref(false)
const logQuery = ref({ pageNum: 1, pageSize: 10 })
const activeTab = ref('follow')
const customerFormRef = ref<InstanceType<typeof CustomerForm>>()
const followFormRef = ref<InstanceType<typeof FollowForm>>()
const contactFormRef = ref<InstanceType<typeof ContactForm>>()
const invoiceInfoRef = ref()
const opportunityDetailRef = ref<{ open: (id: string | number) => Promise<void> }>()
const contractDetailRef = ref<{ open: (id: string | number) => Promise<void> }>()
const opportunityDialogActive = ref(false)
const contractDialogActive = ref(false)
const isOverdue = computed(() => {
if (!customerInfo.value.nextTime) return false
return new Date(customerInfo.value.nextTime) < new Date()
})
const isToday = computed(() => {
if (!customerInfo.value.nextTime) return false
const today = new Date().toISOString().split('T')[0]
return today === customerInfo.value.nextTime.split(' ')[0]
})
const viewHeroMeta = computed(() => {
const c = customerInfo.value
const parts: string[] = []
if (c.ownerUserName) parts.push(`负责人:${c.ownerUserName}`)
if (c.status) parts.push(dictStore.getLabel('crm_customer_status', c.status))
if (c.dealStatus) parts.push(dictStore.getLabel('crm_deal_status', c.dealStatus))
return parts.join(' · ')
})
function tagField(
label: string,
dictType: string,
value?: string | null,
extra?: Partial<ViewField>
): ViewField {
return {
label,
value: value ? dictStore.getLabel(dictType, value) : '—',
tagType: value ? dictStore.getTagType(dictType, value) : undefined,
...extra
}
}
function viewTableSection(title: string, fields: ViewField[]): ViewSection {
return {
title,
fields,
fieldLayout: 'table',
tableMode: 'pair'
}
}
const viewSections = computed<ViewSection[]>(() => {
const c = customerInfo.value
if (!c?.id) return []
const address = [c.province, c.city, c.district, c.address].filter(Boolean).join(' ')
const basicFields: ViewField[] = [
tagField('客户类型', 'crm_customer_type', c.customerType),
{ label: '客户简称', value: c.shortName },
{ label: '手机号', value: c.mobile },
{ label: '固定电话', value: c.telephone },
{ label: '邮箱', value: c.email },
{ label: '客户网站', value: c.website },
tagField('所属行业', 'crm_customer_industry', c.industry),
tagField('企业规模', 'crm_company_scale', c.scale),
tagField('所在园区', 'crm_customer_park', c.parkArea),
{ label: '地址', value: address, span: true }
]
const bizFields: ViewField[] = [
tagField('客户级别', 'crm_customer_level', c.level),
tagField('客户来源', 'crm_clue_source', c.source),
tagField('客户状态', 'crm_customer_status', c.status),
tagField('成交状态', 'crm_deal_status', c.dealStatus),
{ label: '负责人', value: c.ownerUserName },
{ label: '创建人', value: c.createByName || c.createBy },
{ label: '创建时间', value: c.createTime },
{ label: '跟进天数', value: `${calculateDays(c.createTime)} 天` },
{
label: '下次联系时间',
value: c.nextTime,
tagType: isOverdue.value ? 'danger' : isToday.value ? 'warning' : undefined
}
]
if (c.dealAmount != null && c.dealAmount !== '') {
bizFields.splice(4, 0, { label: '成交金额', value: `¥${c.dealAmount}` })
}
const sections: ViewSection[] = [
viewTableSection('基本信息', basicFields),
viewTableSection('业务信息', bizFields)
]
if (c.businessLicenseImages || c.businessPermitImages || c.otherCertImages) {
sections.push({
title: '证件信息',
fields: [],
slot: 'certificates'
})
}
if (c.remark) {
sections.push(viewTableSection('备注', [{ label: '备注', value: c.remark, span: true }]))
}
return sections
})
function getFollowAttachment(item: CustomerFollowVO) {
return (item as any).attachment || item.attachments || ''
}
function unwrapListPayload(res: unknown): any[] {
const raw = (res as { data?: unknown })?.data ?? res
if (Array.isArray(raw)) return raw
if (raw && typeof raw === 'object') {
const obj = raw as { list?: unknown[]; rows?: unknown[] }
return obj.list || obj.rows || []
}
return []
}
function calculateDays(createTime?: string) {
if (!createTime) return 0
const diff = Date.now() - new Date(createTime).getTime()
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
function formatMoney(amount: number | string | undefined | null) {
if (amount == null || amount === '') return '0.00'
const num = typeof amount === 'string' ? parseFloat(amount) : amount
if (Number.isNaN(num)) return '0.00'
return num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
async function getCustomerInfo() {
if (!customerId.value) return
detailLoading.value = true
try {
const res = await fetchCustomerDetail({ id: String(customerId.value) })
const data = (res as any)?.data || res || {}
customerInfo.value = {
...data,
businessLicenseImages:
data.businessLicenseImages || data.businessLicenseImg || undefined,
businessPermitImages: data.businessPermitImages || undefined,
otherCertImages: data.otherCertImages || undefined
}
} catch (error) {
console.error(error)
customerInfo.value = {} as CustomerVO
} finally {
detailLoading.value = false
}
}
async function getFollowList() {
if (!customerId.value) return
try {
followQuery.value.customerId = customerId.value
const res = await fetchFollowByCustomerId({ customerId: String(customerId.value) })
const data = (res as any)?.data ?? res ?? {}
followList.value = data.list || data.rows || (Array.isArray(data) ? data : [])
followTotal.value = data.total ?? followList.value.length
} catch (error) {
console.error('获取跟进记录失败:', error)
followList.value = []
followTotal.value = 0
}
}
async function getContactList() {
if (!customerId.value) return
contactLoading.value = true
try {
const res = await fetchContactByCustomerId({ customerId: String(customerId.value) })
contactList.value = unwrapListPayload(res)
} catch (error) {
console.error(error)
contactList.value = []
} finally {
contactLoading.value = false
}
}
async function getOpportunityList() {
if (!customerId.value) return
opportunityLoading.value = true
try {
const query = { customerId: customerId.value, pageNum: 1, pageSize: 100 } as OpportunityQuery
const res = await listOpportunity(query)
opportunityList.value = unwrapListPayload(res)
} catch (error) {
console.error(error)
opportunityList.value = []
} finally {
opportunityLoading.value = false
}
}
async function getContractList() {
if (!customerId.value) return
contractLoading.value = true
try {
const query = { customerId: customerId.value, pageNum: 1, pageSize: 100 } as ContractQuery
const res = await listContract(query)
contractList.value = unwrapListPayload(res)
} catch (error: any) {
console.error(error)
contractList.value = []
if (error.response?.status === 403) ElMessage.error('没有查看合同的权限')
} finally {
contractLoading.value = false
}
}
async function getOperLogList(reload = false) {
if (!customerId.value) return
logLoading.value = true
try {
if (reload || !operLogListFull.value.length) {
const res = await listOperLog({ pageNum: 1, pageSize: 100 })
const payload = res as { rows?: OperLogVO[]; list?: OperLogVO[] } | OperLogVO[]
const rows = Array.isArray(payload)
? payload
: payload?.rows || payload?.list || []
const customerIdStr = String(customerId.value)
const matchCustomerId = (text?: string) => {
if (!text) return false
return (
text.includes(`"id":${customerIdStr}`) ||
text.includes(`"id":"${customerIdStr}"`) ||
text.includes(`"customerId":${customerIdStr}`) ||
text.includes(`"customerId":"${customerIdStr}"`) ||
text.includes(`[${customerIdStr}]`) ||
text.includes(`["${customerIdStr}"]`) ||
text.includes(`/${customerIdStr}`)
)
}
operLogListFull.value = rows.filter((log: OperLogVO) => {
const urlMatch =
log.operUrl?.includes('/crm/customer') && matchCustomerId(log.operUrl)
const paramMatch = matchCustomerId(log.operParam)
const resultMatch = matchCustomerId(log.jsonResult)
return urlMatch || paramMatch || resultMatch
})
operLogListFull.value.sort(
(a, b) => new Date(b.operTime || 0).getTime() - new Date(a.operTime || 0).getTime()
)
}
const start = (logQuery.value.pageNum - 1) * logQuery.value.pageSize
logTotal.value = operLogListFull.value.length
operLogList.value = operLogListFull.value.slice(start, start + logQuery.value.pageSize)
} catch (error) {
console.error(error)
operLogList.value = []
operLogListFull.value = []
logTotal.value = 0
} finally {
logLoading.value = false
}
}
function handleViewLogDetail(row: OperLogVO) {
const content = `
<div style="text-align: left; max-height: 500px; overflow-y: auto;">
<p><strong>操作模块:</strong>${row.title}</p>
<p><strong>操作人员:</strong>${row.operName}</p>
<p><strong>操作时间:</strong>${row.operTime}</p>
<p><strong>请求URL:</strong>${row.operUrl || '—'}</p>
<p><strong>请求参数:</strong></p>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto;">${row.operParam || '无'}</pre>
</div>
`
ElMessageBox.alert(content, '操作日志详情', {
dangerouslyUseHTMLString: true,
confirmButtonText: '关闭'
})
}
async function handleViewOpportunity(id: number | string) {
opportunityDialogActive.value = true
await nextTick()
opportunityDetailRef.value?.open(id)
}
async function handleViewContract(id: string | number) {
contractDialogActive.value = true
await nextTick()
contractDetailRef.value?.open(id)
}
function handleTabChange(name: string) {
if (name === 'follow') getFollowList()
else if (name === 'contact') getContactList()
else if (name === 'opportunity' && !opportunityList.value.length) getOpportunityList()
else if (name === 'contract' && !contractList.value.length) getContractList()
else if (name === 'log' && !operLogListFull.value.length) getOperLogList(true)
}
function handleEdit() {
customerFormRef.value?.open(customerId.value)
}
async function handleDelete() {
try {
await ElMessageBox.confirm('是否确认删除该客户?删除后将无法恢复。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await fetchDeleteCustomer({ ids: [String(customerId.value)] })
ElMessage.success('删除成功')
visible.value = false
emit('close')
} catch (error) {
if (error !== 'cancel') console.error(error)
}
}
function handleAddFollow() {
followFormRef.value?.open(customerId.value!)
}
function handleFollowSuccess() {
getCustomerInfo()
getFollowList()
}
async function handleDeleteFollow(item: CustomerFollowVO) {
try {
await ElMessageBox.confirm('是否确认删除该跟进记录?', '提示', { type: 'warning' })
await fetchDeleteCustomerFollow({ ids: [String(item.id)] })
ElMessage.success('删除成功')
getCustomerInfo()
getFollowList()
} catch (error) {
if (error !== 'cancel') console.error(error)
}
}
function handleAddContact() {
contactFormRef.value?.open(undefined, customerId.value)
}
function handleEditContact(row: ContactVO) {
contactFormRef.value?.open(row.id, customerId.value)
}
async function handleSetPrimary(row: ContactVO) {
try {
await ElMessageBox.confirm(`确认将「${row.contactName}」设置为主要联系人吗?`, '提示', {
type: 'warning'
})
await fetchSetPrimaryContact({ id: String(row.id) })
ElMessage.success('设置成功')
getContactList()
} catch (error) {
if (error !== 'cancel') console.error(error)
}
}
async function handleDeleteContact(row: ContactVO) {
try {
await ElMessageBox.confirm(`是否确认删除联系人「${row.contactName}」?`, '提示', {
type: 'warning'
})
await fetchDeleteContact({ ids: [String(row.id)] })
ElMessage.success('删除成功')
getCustomerInfo()
getContactList()
} catch (error) {
if (error !== 'cancel') console.error(error)
}
}
function handleContactSuccess() {
getCustomerInfo()
getContactList()
}
function open(id: number | string) {
visible.value = true
customerId.value = id
customerInfo.value = {} as CustomerVO
followList.value = []
contactList.value = []
opportunityList.value = []
contractList.value = []
operLogList.value = []
operLogListFull.value = []
activeTab.value = 'follow'
followQuery.value = { customerId: id, pageNum: 1, pageSize: 10 }
logQuery.value = { pageNum: 1, pageSize: 10 }
getCustomerInfo()
getFollowList()
}
defineExpose({ open })
</script>
<style scoped lang="scss">
.customer-view-tabs-section {
min-height: 100%;
:deep(.el-tabs) {
display: flex;
flex-direction: column;
min-height: calc(100vh - 200px);
}
:deep(.el-tabs__header) {
margin-bottom: 12px;
padding: 0 4px;
border: none;
background: transparent;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
min-height: 320px;
}
}
.tab-toolbar {
margin-bottom: 10px;
}
.tab-pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.follow-card {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.follow-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.follow-header-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.follow-creator {
font-size: 13px;
color: #1e293b;
}
.follow-content {
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: #1e293b;
}
.follow-meta {
margin-top: 8px;
font-size: 13px;
color: #1e293b;
&--warn {
color: #d97706;
}
}
.cert-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.cert-block {
.view-field-label {
margin-bottom: 8px;
}
}
:deep(.el-timeline-item__timestamp) {
color: #1e293b;
font-size: 12px;
}
</style>
<style lang="scss">
.customer-view-dialog.system-view-dialog {
&.el-dialog {
max-width: calc(100vw - 100px);
}
.view-split-left {
flex: 0 0 580px;
width: 580px;
max-width: 50%;
}
.view-split-left .view-hero {
flex-wrap: wrap;
align-items: flex-start;
}
.view-hero-actions {
flex-basis: 100%;
margin-left: 0;
margin-top: 12px;
justify-content: flex-end;
}
.customer-view-hero-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
width: 100%;
.el-button {
width: auto;
margin: 0;
justify-content: center;
}
}
.view-split-right .customer-view-tabs-section {
padding: 0;
}
}
</style>