1083 lines
36 KiB
Vue
1083 lines
36 KiB
Vue
<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>
|