Files
qiji/frontend/src/views/desktop/CustomerManage.vue
T
v6ole 861a4cecff feat: 客户查重前端集成
- 新建客户时输入名称失焦后自动调用 /customers/check-duplicate
- 若有同名客户显示黄色警告条(含客户名+经理)
- 编辑模式不触发查重

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-26 15:37:44 +08:00

431 lines
21 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.
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { customersApi } from '@/api/customers'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/index'
const auth = useAuthStore()
const loading = ref(false)
const search = ref('')
const filterIndustry = ref('')
const filterService = ref('')
const filterManagerId = ref('')
const customers = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(25)
const total = ref(0)
const dialogVisible = ref(false)
const dialogTitle = ref('新建客户')
const editId = ref('')
const form = ref({
name: '', industry: '', address: '', in_use_services: '',
fee_amount: '', fee_unit: '元/月',
remarks: '',
contacts: [] as any[],
assignee_id: '' as string,
})
const existingContacts = ref<any[]>([])
const feeUnits = ['元/月', '元/年', '自定义']
const feeCustomUnit = ref('')
const detailVisible = ref(false)
const detailCustomer = ref<any>(null)
const managers = ref<any[]>([])
const selectedIds = ref<string[]>([])
const batchManagerId = ref('')
const importDialogVisible = ref(false)
const importFile = ref<File | null>(null)
const importLoading = ref(false)
const importResult = ref<any>(null)
onMounted(async () => {
await loadCustomers()
try {
const res = await api.get('/users/')
managers.value = (res.data || []).filter((u: any) => u.role !== 'leader')
} catch (_) {}
})
async function loadCustomers() {
loading.value = true
try {
const params: any = { page: currentPage.value, page_size: pageSize.value }
if (search.value) params.search = search.value
if (filterIndustry.value) params.industry = filterIndustry.value
if (filterService.value) params.service = filterService.value
if (filterManagerId.value) params.manager_id = filterManagerId.value
const res = await customersApi.list(params)
customers.value = res.data.items
total.value = res.data.total
} catch (e: any) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function onPageChange(page: number) { currentPage.value = page; loadCustomers() }
function onPageSizeChange(size: number) { pageSize.value = size; currentPage.value = 1; loadCustomers() }
function onFilterChange() { currentPage.value = 1; loadCustomers() }
function buildMonthlyFee(): string {
const amt = form.value.fee_amount.trim()
if (!amt) return ''
if (form.value.fee_unit === '自定义') {
const u = feeCustomUnit.value.trim()
return u ? amt + u : amt
}
return amt + form.value.fee_unit
}
function parseMonthlyFee(fee: string) {
feeCustomUnit.value = ''
if (!fee) { form.value.fee_amount = ''; form.value.fee_unit = '元/月'; return }
for (const u of ['元/月', '元/年']) {
if (fee.endsWith(u)) { form.value.fee_amount = fee.slice(0, -u.length).trim(); form.value.fee_unit = u; return }
}
const m = fee.match(/^(.+?)\s*([^\d]+)$/)
if (m) { form.value.fee_amount = m[1].trim(); feeCustomUnit.value = m[2].trim(); form.value.fee_unit = '自定义' }
else { form.value.fee_amount = fee; form.value.fee_unit = '自定义' }
}
function resetForm() {
form.value = { name: '', industry: '', address: '', in_use_services: '', fee_amount: '', fee_unit: '元/月', remarks: '', contacts: [], assignee_id: '' }
feeCustomUnit.value = ''
existingContacts.value = []
}
const duplicateHint = ref('')
async function checkName() {
if (!form.value.name || editId.value) { duplicateHint.value = ''; return }
try {
const res = await customersApi.checkDuplicate(form.value.name)
if (res.data?.exists) {
const dup = res.data.customers || []
duplicateHint.value = `⚠ 已存在同名客户:${dup.map((c: any) => c.name + (c.primary_manager_name ? ` (${c.primary_manager_name})` : '')).join('、')}`
} else {
duplicateHint.value = ''
}
} catch (_) { duplicateHint.value = '' }
}
function openCreate() { dialogTitle.value = '新建客户'; editId.value = ''; resetForm(); duplicateHint.value = ''; dialogVisible.value = true }
async function openEdit(customer: any) {
dialogTitle.value = '编辑客户'; editId.value = customer.id
try {
const res = await customersApi.get(customer.id)
const c = res.data
resetForm()
form.value.name = c.name; form.value.industry = c.industry; form.value.address = c.address
form.value.in_use_services = c.in_use_services; form.value.remarks = c.remarks || ''
parseMonthlyFee(c.monthly_fee)
existingContacts.value = c.contacts || []
dialogVisible.value = true
} catch (e: any) { ElMessage.error('加载客户详情失败') }
}
async function handleSubmit() {
if (!form.value.name) { ElMessage.warning('请输入单位名称'); return }
const monthly_fee = buildMonthlyFee()
try {
if (editId.value) {
const body: any = { name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks }
if (form.value.assignee_id) body.assignee_id = form.value.assignee_id
await customersApi.update(editId.value, body)
for (const c of form.value.contacts) {
if (c.name.trim()) await api.post(`/customers/${editId.value}/contacts`, { name: c.name.trim(), phone: c.phone.trim(), role_desc: c.role_desc.trim() })
}
ElMessage.success('已更新')
} else {
await customersApi.create({ name: form.value.name, industry: form.value.industry, address: form.value.address, in_use_services: form.value.in_use_services, monthly_fee, remarks: form.value.remarks, contacts: form.value.contacts, assignee_id: form.value.assignee_id || undefined })
ElMessage.success('已创建')
}
dialogVisible.value = false
await loadCustomers()
} catch (e: any) { ElMessage.error('操作失败: ' + (e.response?.data?.detail || e.message)) }
}
async function removeExistingContact(contactId: string) {
try {
await ElMessageBox.confirm('确定删除该联系人?', '确认', { type: 'warning' })
await api.delete(`/customers/${editId.value}/contacts/${contactId}`)
existingContacts.value = existingContacts.value.filter(c => c.id !== contactId)
ElMessage.success('已删除')
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
const mgrColors = ['#1C3738','#4A6741','#5B7FA5','#7B7568','#8B6F47','#6B5B4F','#3D5A5C','#5C4A3D']
function mgrColor(name: string) {
if (!name) return '#909399'
let h = 0; for (let i=0;i<name.length;i++) h = name.charCodeAt(i) + ((h<<5)-h)
return mgrColors[Math.abs(h) % mgrColors.length]
}
async function openDetail(customer: any) {
try { const res = await customersApi.get(customer.id); detailCustomer.value = res.data; detailVisible.value = true } catch (_) {}
}
async function handleDelete(customer: any) {
try {
await ElMessageBox.confirm(`确定删除客户「${customer.name}」?该操作不可恢复。`, '确认删除', { type: 'warning' })
await customersApi.delete(customer.id)
ElMessage.success('已删除')
await loadCustomers()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map(r => r.id) }
async function handleBatchAssign() {
if (!selectedIds.value.length) { ElMessage.warning('请先勾选客户'); return }
if (!batchManagerId.value) { ElMessage.warning('请选择目标客户经理'); return }
try {
await customersApi.batchAssign({ customer_ids: selectedIds.value, manager_id: batchManagerId.value })
ElMessage.success(`已将 ${selectedIds.value.length} 个客户分配给新经理`)
selectedIds.value = []; batchManagerId.value = ''
await loadCustomers()
} catch (e: any) { ElMessage.error('批量分配失败') }
}
async function handleExport() {
try {
const res = await api.get('/customers/export', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'customers.xlsx'; a.click()
URL.revokeObjectURL(url); ElMessage.success('导出成功')
} catch (e: any) { ElMessage.error('导出失败') }
}
async function downloadTemplate() {
try {
const res = await api.get('/customers/template', { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'customer_import_template.xlsx'; a.click()
URL.revokeObjectURL(url)
} catch (e: any) { ElMessage.error('下载模板失败') }
}
function openImportDialog() { importFile.value = null; importResult.value = null; importDialogVisible.value = true }
function onImportFileChange(e: Event) { const t = e.target as HTMLInputElement; if (t.files?.[0]) importFile.value = t.files[0] }
async function handleImport() {
if (!importFile.value) return
importLoading.value = true
const fd = new FormData(); fd.append('file', importFile.value)
try {
const res = await api.post('/customers/import', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
importResult.value = res.data
ElMessage.success(`导入完成:新增 ${res.data.created} 条,更新 ${res.data.updated || 0} 条`)
await loadCustomers()
} catch (e: any) { ElMessage.error('导入失败: ' + (e.response?.data?.detail || e.message)) }
finally { importLoading.value = false }
}
</script>
<template>
<div class="customer-manage" v-loading="loading">
<!-- ═══ Page Header ═══ -->
<div class="page-head">
<div class="page-head-row">
<h2 class="page-title">客户档案管理</h2>
<div class="header-btns">
<el-button v-if="auth.isDirector" @click="downloadTemplate">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
模板
</el-button>
<el-button v-if="auth.isDirector" @click="openImportDialog">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
导入
</el-button>
<el-button v-if="auth.isDirector" @click="handleExport">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
导出
</el-button>
<el-button v-if="auth.isDirector" type="primary" @click="openCreate">+ 新建客户</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Search & Filter -->
<el-card style="margin-bottom: 16px;">
<el-row :gutter="12" align="middle">
<el-col :span="5">
<el-input v-model="search" placeholder="搜索名称/行业/地址/联系人" clearable @keyup.enter="loadCustomers">
<template #append><el-button @click="onFilterChange">搜索</el-button></template>
</el-input>
</el-col>
<el-col :span="4">
<el-input v-model="filterIndustry" placeholder="行业筛选" clearable @change="onFilterChange" />
</el-col>
<el-col :span="4">
<el-input v-model="filterService" placeholder="在用业务筛选" clearable @change="onFilterChange" />
</el-col>
<el-col :span="4">
<el-select v-model="filterManagerId" placeholder="负责人筛选" clearable @change="onFilterChange" style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-col>
<el-col :span="7" v-if="auth.isDirector && selectedIds.length > 0">
<span style="margin-right:8px; color: var(--warm-gray)">已选 {{ selectedIds.length }} </span>
<el-select v-model="batchManagerId" placeholder="目标客户经理" style="width:160px">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-button type="warning" @click="handleBatchAssign" style="margin-left:8px">批量转移</el-button>
</el-col>
</el-row>
</el-card>
<!-- Customer Table -->
<el-card>
<el-table :data="customers" stripe @selection-change="onSelectionChange">
<el-table-column v-if="auth.isDirector" type="selection" width="50" />
<el-table-column type="index" label="序号" width="60" :index="(idx: number) => (currentPage - 1) * pageSize + idx + 1" />
<el-table-column prop="name" label="单位名称" min-width="180">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.name }}</el-link>
</template>
</el-table-column>
<el-table-column prop="industry" label="行业" width="100" />
<el-table-column prop="in_use_services" label="在用业务" min-width="150" />
<el-table-column label="客户经理" width="90">
<template #default="{ row }">
<el-tag :color="mgrColor(row.primary_manager_name)" effect="dark" size="small" style="border:none;color:#fff">{{ row.primary_manager_name || '-' }}</el-tag>
</template>
</el-table-column>
</el-table>
<div style="display:flex; justify-content:flex-end; margin-top:16px">
<el-pagination
v-model:current-page="currentPage" v-model:page-size="pageSize"
:page-sizes="[25, 50, 100]" :total="total"
layout="total, sizes, prev, pager, next, jumper" background
@current-change="onPageChange" @size-change="onPageSizeChange"
/>
</div>
</el-card>
<!-- Create/Edit Dialog -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="520px">
<el-form label-position="top">
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入单位名称" @blur="checkName" /><el-alert v-if="duplicateHint" :title="duplicateHint" type="warning" :closable="false" style="margin-top:6px" /></el-form-item>
<el-form-item label="所属行业"><el-input v-model="form.industry" placeholder="如: 教育、医疗、政府..." /></el-form-item>
<el-form-item label="单位地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="在用业务"><el-input v-model="form.in_use_services" placeholder="如: 云桌面、专线、视频会议" /></el-form-item>
<el-form-item label="收支费用">
<el-row :gutter="8">
<el-col :span="8"><el-input v-model="form.fee_amount" placeholder="金额" /></el-col>
<el-col :span="form.fee_unit === '自定义' ? 8 : 16">
<el-select v-model="form.fee_unit" style="width:100%" @change="form.fee_unit !== '自定义' && (feeCustomUnit = '')">
<el-option v-for="u in feeUnits" :key="u" :label="u" :value="u" />
</el-select>
</el-col>
<el-col v-if="form.fee_unit === '自定义'" :span="8"><el-input v-model="feeCustomUnit" placeholder="单位" /></el-col>
</el-row>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="备注信息..." /></el-form-item>
<el-form-item label="归属客户经理" v-if="auth.isDirector">
<el-select v-model="form.assignee_id" :placeholder="editId ? '留空则不修改' : '不选则默认分配给自己'" clearable style="width:100%">
<el-option v-for="m in managers" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
</el-form-item>
<el-form-item label="联系人">
<div v-if="editId && existingContacts.length" style="margin-bottom:8px">
<el-tag v-for="c in existingContacts" :key="c.id" closable @close="removeExistingContact(c.id)" style="margin: 2px 4px">
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-for="(c, idx) in form.contacts" :key="'new-'+idx" style="display:flex; gap:8px; margin-bottom:4px;">
<el-input v-model="c.name" placeholder="姓名" size="small" style="flex:1" />
<el-input v-model="c.phone" placeholder="电话" size="small" style="flex:1" />
<el-input v-model="c.role_desc" placeholder="角色" size="small" style="flex:1" />
<el-button size="small" @click="form.contacts.splice(idx,1)">-</el-button>
</div>
<el-button size="small" @click="form.contacts.push({name:'',phone:'',role_desc:''})">+ 添加联系人</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- Detail Dialog -->
<el-dialog v-model="detailVisible" title="客户详情" width="500px">
<template v-if="detailCustomer">
<el-descriptions :column="1" border>
<el-descriptions-item label="单位名称">{{ detailCustomer.name }}</el-descriptions-item>
<el-descriptions-item label="行业">{{ detailCustomer.industry || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ detailCustomer.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="在用业务">{{ detailCustomer.in_use_services || '-' }}</el-descriptions-item>
<el-descriptions-item label="收支费用">{{ detailCustomer.monthly_fee || '-' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ detailCustomer.remarks || '-' }}</el-descriptions-item>
<el-descriptions-item label="客户经理">{{ detailCustomer.primary_manager_name || '-' }}</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top:16px; font-family: 'ZCOOL XiaoWei', STSong, serif; color: var(--ink)">联系人</h4>
<div v-if="detailCustomer.contacts?.length">
<el-tag v-for="c in detailCustomer.contacts" :key="c.id" style="margin:4px">
{{ c.name }}{{ c.phone ? ' · '+c.phone : '' }}{{ c.role_desc ? ' ('+c.role_desc+')' : '' }}
</el-tag>
</div>
<div v-else style="color: var(--c-text-muted); text-align:center; padding:12px">暂无联系人</div>
</template>
<template #footer>
<el-button v-if="!auth.isLeader" type="primary" @click="detailVisible=false; openEdit(detailCustomer)">编辑</el-button>
<el-button v-if="auth.isDirector" type="danger" @click="detailVisible=false; handleDelete(detailCustomer)">删除</el-button>
<el-button @click="detailVisible=false">关闭</el-button>
</template>
</el-dialog>
<!-- Import Dialog -->
<el-dialog v-model="importDialogVisible" title="导入客户档案" width="480px">
<p>请使用模板格式上传 Excel 文件</p>
<el-form>
<el-form-item label="上传文件">
<input type="file" accept=".xlsx,.xls" @change="onImportFileChange" />
</el-form-item>
</el-form>
<div v-if="importResult" style="margin-top:12px">
<el-alert type="success" :closable="false">
新增 {{ importResult.created }} 更新 {{ importResult.updated || 0 }}
<template v-if="importResult.reasons?.length">
<div style="margin-top:8px;font-size:12px;max-height:200px;overflow-y:auto">
<div v-for="(r,i) in importResult.reasons.slice(0,20)" :key="i"> {{ r }}</div>
<div v-if="importResult.reasons.length > 20" style="color:var(--c-text-muted)">...还有 {{ importResult.reasons.length - 20 }} </div>
</div>
</template>
<template v-if="importResult.errors?.length"><br/> 错误{{ importResult.errors.join('; ') }}</template>
</el-alert>
</div>
<template #footer>
<el-button @click="importDialogVisible = false">关闭</el-button>
<el-button type="primary" :loading="importLoading" @click="handleImport" :disabled="!importFile">确认导入</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-head { margin-bottom: 20px; }
.page-head-row { display: flex; justify-content: space-between; align-items: center; }
.page-title {
margin: 0;
font-family: 'ZCOOL XiaoWei', STSong, serif;
font-size: 22px; font-weight: 400;
color: var(--ink); letter-spacing: 0.06em;
}
.page-rule { width: 32px; height: 3px; background: var(--gold); margin-top: 8px; }
.header-btns { display: flex; gap: 8px; }
</style>