Files
yunzerwebsiteallinone/backend/src/views/apps/erp/customer/index.vue
T
2026-09-14 16:15:44 +08:00

531 lines
19 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>
<div class="customer-page">
<div class="page-header">
<div>
<h2>客户管理</h2>
<p>管理企业客户信息,支持多维度筛选与全生命周期追踪</p>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Plus" @click="openCreate">新增客户</el-button>
<el-button type="success" :icon="Cpu" @click="smartAddVisible = true">智能添加</el-button>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索客户名称"
:prefix-icon="Search"
style="width: 220px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="客户类型">
<el-select v-model="filters.customer_type" clearable placeholder="全部" style="width: 130px">
<el-option label="企业" value="1" />
<el-option label="政府机构" value="2" />
<el-option label="国企" value="3" />
<el-option label="教育机构" value="4" />
<el-option label="个人" value="5" />
</el-select>
</el-form-item>
<el-form-item label="客户等级">
<el-select v-model="filters.customer_level" clearable placeholder="全部" style="width: 130px">
<el-option label="核心客户" value="1" />
<el-option label="重要客户" value="2" />
<el-option label="普通客户" value="3" />
<el-option label="潜在客户" value="4" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option label="正常" value="1" />
<el-option label="禁用" value="0" />
<el-option label="冻结" value="2" />
<el-option label="已注销" value="3" />
</el-select>
</el-form-item>
<el-form-item label="数据状态">
<el-select v-model="filters.is_draft" clearable placeholder="全部" style="width: 110px">
<el-option label="正式" value="0" />
<el-option label="草稿" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border>
<el-table-column prop="customer_name" label="客户名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<div class="name-cell-wrapper">
<span class="name-cell name-link" @click="openDetail(row)">{{ row.customer_name }}</span>
<el-tag v-if="row.is_draft === 1" type="warning" size="small" effect="plain">草稿</el-tag>
<!-- 同一家单位可能既是客户又是供应商(已建供应商档案) -->
<el-tag v-if="row.also_supplier" type="warning" size="small" effect="plain">也是供应商</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="customer_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="customerTypeTag(row.customer_type)" size="small">
{{ customerTypeText(row.customer_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="customer_level" label="等级" width="100" align="center">
<template #default="{ row }">
<el-tag :type="customerLevelTag(row.customer_level)" size="small" effect="plain">
{{ customerLevelText(row.customer_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="contact_person" label="联系人" width="100" />
<el-table-column prop="contact_phone" label="联系电话" width="130" />
<el-table-column prop="contact_email" label="邮箱" min-width="160" show-overflow-tooltip />
<el-table-column prop="industry" label="行业" width="100" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="update_time" label="更新时间" width="160" align="center">
<template #default="{ row }">
{{ formatDateTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactBook(row)">通讯录</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleConvertSupplier(row)">转为供应商</el-button>
<el-button link type="primary" size="small" @click="handleToggleStatus(row)">
{{ row.status === '1' ? '禁用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchList"
@current-change="fetchList"
/>
</div>
</div>
<CustomerEdit
v-model:visible="editVisible"
:edit-data="currentEditData"
@success="fetchList"
/>
<!-- 详情抽屉 -->
<el-drawer
v-model="detailVisible"
:title="detailData?.customer_name || '客户详情'"
direction="rtl"
size="560px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertSupplier(detailData)">转为供应商</el-button>
<span class="detail-actions-tip">同一家单位可能既是客户又是供应商,转化后原客户记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small" label-width="120px">
<el-descriptions-item label="客户名称" :span="2">{{ detailData.customer_name }}</el-descriptions-item>
<el-descriptions-item label="客户类型">{{ customerTypeText(detailData.customer_type) }}</el-descriptions-item>
<el-descriptions-item label="客户等级">{{ customerLevelText(detailData.customer_level) }}</el-descriptions-item>
<el-descriptions-item label="所属行业">{{ detailData.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag(detailData.status)" size="small">{{ statusText(detailData.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册资本">{{ detailData.registered_capital ? detailData.registered_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="实缴资本">{{ detailData.paid_capital ? detailData.paid_capital + ' 万元' : '-' }}</el-descriptions-item>
<el-descriptions-item label="成立日期">{{ formatDate(detailData.establish_date) }}</el-descriptions-item>
<el-descriptions-item label="行政区划">{{ detailData.administrative_division || '-' }}</el-descriptions-item>
<el-descriptions-item label="企业类型">{{ detailData.enterprise_type || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人资质">{{ detailData.taxpayer_qualification || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营范围" :span="2">{{ detailData.business_scope || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{ formatDateTime(detailData.update_time) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="联系信息" :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="联系人">{{ detailData.contact_person || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailData.contact_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系邮箱" :span="2">{{ detailData.contact_email || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户地址" :span="2">{{ detailData.address || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions :column="2" border size="small" label-width="120px" style="margin-top: 20px">
<template #title>
<div class="desc-title-row">
<span>开票信息</span>
<el-button link type="primary" size="small" @click="copyInvoiceInfo">一键复制</el-button>
</div>
</template>
<el-descriptions-item label="发票抬头" :span="2">{{ detailData.invoice_title || '-' }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ detailData.tax_number || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册电话">{{ detailData.registered_phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册地址" :span="2">{{ detailData.registered_address || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行名称">{{ detailData.bank_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="开户行账号">{{ detailData.bank_account || '-' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions title="备注信息" :column="1" border size="small" label-width="120px" style="margin-top: 20px">
<el-descriptions-item label="备注">{{ detailData.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
<!-- 通讯录 -->
<ContactBook
v-model:visible="contactBookVisible"
:company-type="'customer'"
:company-id="currentCompany?.id"
:company-name="currentCompany?.customer_name"
/>
<SmartAddCompany
v-model:visible="smartAddVisible"
type="customer"
@success="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
import { getCustomerList, deleteCustomer, toggleCustomerStatus, convertCustomerToSupplier } from '@/api/customer'
import CustomerEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
const loading = ref(false)
const tableData = ref([])
const editVisible = ref(false)
const currentEditData = ref(null)
const smartAddVisible = ref(false)
const detailVisible = ref(false)
const detailData = ref(null)
const contactBookVisible = ref(false)
const currentCompany = ref(null)
const filters = reactive({
keyword: '',
customer_type: '',
customer_level: '',
status: '',
is_draft: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
onMounted(() => {
fetchList()
})
async function fetchList() {
loading.value = true
try {
const res = await getCustomerList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters
})
if (res.code === 200) {
tableData.value = res.data.list || []
pagination.total = res.data.total || 0
}
} catch (e) {
ElMessage.error(e.message || '查询失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function resetFilters() {
filters.keyword = ''
filters.customer_type = ''
filters.customer_level = ''
filters.status = ''
filters.is_draft = ''
pagination.page = 1
fetchList()
}
function openCreate() {
currentEditData.value = null
editVisible.value = true
}
function openEdit(row) {
currentEditData.value = { ...row }
editVisible.value = true
}
function openDetail(row) {
detailData.value = { ...row }
detailVisible.value = true
}
function openContactBook(row) {
currentCompany.value = { ...row }
contactBookVisible.value = true
}
function formatDate(val) {
if (!val) return '-'
const d = new Date(val)
if (isNaN(d.getTime())) return '-'
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
}
function formatDateTime(val) {
if (!val) return '-'
const d = new Date(val)
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
}
function copyInvoiceInfo() {
const d = detailData.value
if (!d) return
const lines = [
`发票抬头:${d.invoice_title || ''}`,
`纳税人识别号:${d.tax_number || ''}`,
`注册地址:${d.registered_address || ''}`,
`注册电话:${d.registered_phone || ''}`,
`开户行名称:${d.bank_name || ''}`,
`开户行账号:${d.bank_account || ''}`
]
const text = lines.join('\n')
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success('开票信息已复制到剪贴板')
}).catch(() => {
fallbackCopy(text)
})
} else {
fallbackCopy(text)
}
}
function fallbackCopy(text) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
ElMessage.success('开票信息已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
document.body.removeChild(ta)
}
async function handleToggleStatus(row) {
const newStatus = row.status === '1' ? '0' : '1'
const action = newStatus === '1' ? '启用' : '禁用'
try {
await ElMessageBox.confirm(`确定要${action}客户「${row.customer_name}」吗?`, '提示', {
type: 'warning'
})
await toggleCustomerStatus(row.id, newStatus)
ElMessage.success(`${action}成功`)
fetchList()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e.message || '操作失败')
}
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定要删除客户「${row.customer_name}」吗?删除后不可恢复。`, '删除确认', {
type: 'warning'
})
await deleteCustomer(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (e) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
/**
* 客户转供应商:把客户的企业资料与联系人复制一套到供应商库。
* 同一家单位可能既是客户又是供应商,原客户记录保留;供应商库已有同名企业时只补同步联系人,不重复建档。
*/
async function handleConvertSupplier(row) {
try {
await ElMessageBox.confirm(
`将客户「${row.customer_name}」的企业资料与联系人复制一套到供应商库?原客户记录会保留,可在供应商管理中继续完善。`,
'转为供应商',
{ type: 'warning', confirmButtonText: '确认转化' }
)
} catch (e) {
return // 取消
}
try {
const res = await convertCustomerToSupplier(row.id)
const data = res?.data || {}
const c = data.contacts || {}
ElMessage.success(
data.existed
? `供应商库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
: `已转为供应商,并同步联系人(新增 ${c.inserted || 0})`
)
fetchList()
} catch (e) {
ElMessage.error(e.message || '转化失败')
}
}
function customerTypeText(type) {
const map = { '1': '企业', '2': '政府机构', '3': '国企', '4': '教育机构', '5':'个人' }
return map[type] || type
}
function customerTypeTag(type) {
const map = { '1': 'primary', '2': 'warning', '3': 'success', '4': 'info' }
return map[type] || 'info'
}
function customerLevelText(level) {
const map = { '1': '核心客户', '2': '重要客户', '3': '普通客户', '4': '潜在客户' }
return map[level] || level
}
function customerLevelTag(level) {
const map = { '1': 'danger', '2': 'warning', '3': 'info', '4': '' }
return map[level] || ''
}
function statusText(status) {
const map = { '0': '禁用', '1': '正常', '2': '冻结', '3': '已注销' }
return map[status] || status
}
function statusTag(status) {
const map = { '0': 'info', '1': 'success', '2': 'warning', '3': 'danger' }
return map[status] || 'info'
}
</script>
<style lang="less" scoped>
.customer-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
h2 {
margin: 0 0 6px 0;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 4px;
margin-bottom: 16px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 4px;
border: 1px solid var(--el-border-color-lighter);
}
.name-cell {
font-weight: 500;
color: var(--el-text-color-primary);
}
.name-cell-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.name-link {
cursor: pointer;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.desc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>