Files
qiji/frontend/src/views/desktop/CustomerManage.vue
T
v6ole 32ca029580 fix: 个人用户编辑弹窗与我的数据保持一致—补全四模块全部字段
- 拜访: 客户经理/日期/方式/时间/拜访人/电话/相关人员/内容/需求
- 计划: 客户经理/计划日期/内容/状态
- 商机: 客户经理/产品/金额/跟进/状态/预计列收(月选择器)
- 要客: 客户经理/紧急度/描述/进展/时间/拜访人/对象

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 00:12:09 +08:00

818 lines
43 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, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
import { customersApi } from '@/api/customers'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getManagerColor as mgrColorFn, getMgrTextColor, loadManagerColors } from '@/utils/managerColor'
import api from '@/api/index'
const auth = useAuthStore()
const themeStore = useThemeStore()
const isLight = computed(() => themeStore.currentTheme === 'light')
const loading = ref(false)
const search = ref('')
const filterIndustry = ref('')
const filterService = ref('')
const filterManagerId = ref('')
const customerType = ref('unit')
const customers = ref<any[]>([])
// Individual view tab data
const indivTab = ref('visits')
const indivVisits = ref<any[]>([])
const indivPlans = ref<any[]>([])
const indivMini = ref<any[]>([])
const indivKeyVisits = ref<any[]>([])
const indivLoading = ref(false)
// Individual record edit
const indivEditVisible = ref(false)
const indivEditType = ref('')
const indivEditForm = ref<any>({})
const indivEditSaving = ref(false)
function openIndivEdit(type: string, row: any) {
indivEditType.value = type
indivEditForm.value = { ...row }
indivEditVisible.value = true
}
async function handleIndivEditSave() {
indivEditSaving.value = true
try {
const type = indivEditType.value
const f = indivEditForm.value
if (type === 'visit') {
await api.put(`/visits/${f.id}`, {
communication_content: f.communication_content,
customer_demand: f.customer_demand || '',
visitor_name: f.visitor_name || '',
visitor_phone: f.visitor_phone || '',
visit_method: f.visit_method,
time_range: f.time_range || '',
visit_date: f.visit_date,
manager_id: f.manager_id,
})
} else if (type === 'plan') {
await api.put(`/work-plans/${f.id}`, { plan_content: f.plan_content, status: f.status, plan_date: f.plan_date, manager_id: f.manager_id })
} else if (type === 'mini') {
await api.put(`/mini-business/${f.id}`, { product_type: f.product_type, amount: f.amount, follow_up_detail: f.follow_up_detail, status: f.status, expected_revenue_date: f.expected_revenue_date, manager_id: f.manager_id })
} else if (type === 'key') {
await api.put(`/key-visits/${f.id}`, { description: f.description, urgency_level: f.urgency_level, progress_status: f.progress_status, planned_date: f.planned_date, planned_visitor: f.planned_visitor, visit_target: f.visit_target, manager_id: f.manager_id })
}
ElMessage.success('已更新')
indivEditVisible.value = false
loadIndividualData()
} catch (e: any) { ElMessage.error('保存失败') }
finally { indivEditSaving.value = false }
}
async function handleIndivEditDelete() {
try {
await ElMessageBox.confirm('确定删除?', '确认', { type: 'warning' })
const type = indivEditType.value
const id = indivEditForm.value.id
if (type === 'visit') await api.delete(`/visits/${id}`)
else if (type === 'plan') await api.delete(`/work-plans/${id}`)
else if (type === 'mini') await api.delete(`/mini-business/${id}`)
else if (type === 'key') await api.delete(`/key-visits/${id}`)
ElMessage.success('已删除')
indivEditVisible.value = false
loadIndividualData()
} catch (e: any) { if (e !== 'cancel') ElMessage.error('删除失败') }
}
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 detailActiveTab = ref('visits')
const detailVisits = ref<any[]>([])
const detailPlans = ref<any[]>([])
const detailMini = ref<any[]>([])
const detailKeyVisits = ref<any[]>([])
const detailLoading = ref(false)
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 Promise.all([loadCustomers(), loadManagerColors()])
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
if (customerType.value) params.customer_type = customerType.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 switchCustomerType(type: string) {
customerType.value = type
if (type === 'unit') {
onFilterChange()
} else {
loadIndividualData()
}
}
async function loadIndividualData() {
indivLoading.value = true
try {
const [visits, plans, mini, keyVisits] = await Promise.all([
api.get('/visits/', { params: { customer_type: 'individual' } }),
api.get('/work-plans/', { params: { customer_type: 'individual' } }),
api.get('/mini-business/', { params: { customer_type: 'individual' } }),
api.get('/key-visits/', { params: { customer_type: 'individual' } }),
])
indivVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
indivPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
indivMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
indivKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
} catch (_) {}
finally { indivLoading.value = false }
}
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) {
// Handle name collision → offer merge
if (e.response?.status === 409 && e.response?.data?.detail?.preview) {
const d = e.response.data.detail
mergeSourceId.value = d.source_id
mergeSourceName.value = d.source_name
mergeTargetId.value = d.target_id
mergeTargetName.value = d.target_name
mergePreview.value = d.preview
mergeDialogVisible.value = true
return
}
ElMessage.error('操作失败: ' + (typeof e.response?.data?.detail === 'object' ? e.response.data.detail.message : (e.response?.data?.detail || e.message)))
}
}
// ── Merge ──
const mergeDialogVisible = ref(false)
const mergeSourceId = ref('')
const mergeSourceName = ref('')
const mergeTargetId = ref('')
const mergeTargetName = ref('')
const mergePreview = ref<any>({})
const mergeLoading = ref(false)
async function handleMerge() {
mergeLoading.value = true
try {
const res = await api.post(`/customers/${mergeSourceId.value}/merge`, { target_id: mergeTargetId.value })
ElMessage.success(res.data.result || '合并完成')
mergeDialogVisible.value = false
dialogVisible.value = false
await loadCustomers()
} catch (e: any) {
ElMessage.error('合并失败: ' + (e.response?.data?.detail || e.message))
} finally {
mergeLoading.value = false
}
}
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('删除失败') }
}
function mgrColor(name: string) {
return mgrColorFn(name, isLight.value)
}
function mgrTextColor(name: string) {
return getMgrTextColor(mgrColor(name))
}
async function openDetail(customer: any) {
try {
const res = await customersApi.get(customer.id)
detailCustomer.value = res.data
detailActiveTab.value = 'visits'
detailVisible.value = true
loadDetailData(customer.id)
} catch (_) {}
}
async function loadDetailData(customerId: string) {
detailLoading.value = true
try {
const [visits, plans, mini, keyVisits] = await Promise.all([
api.get('/visits/', { params: { customer_id: customerId } }),
api.get('/work-plans/', { params: { customer_id: customerId } }),
api.get('/mini-business/', { params: { customer_id: customerId } }),
api.get('/key-visits/', { params: { customer_id: customerId } }),
])
detailVisits.value = Array.isArray(visits.data) ? visits.data : (visits.data.items || [])
detailPlans.value = Array.isArray(plans.data) ? plans.data : (plans.data.items || [])
detailMini.value = Array.isArray(mini.data) ? mini.data : (mini.data.items || [])
detailKeyVisits.value = Array.isArray(keyVisits.data) ? keyVisits.data : (keyVisits.data.items || [])
} catch (_) {}
finally { detailLoading.value = false }
}
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" :disabled="customerType === 'individual'">+ 新建客户</el-button>
</div>
</div>
<div class="page-rule"></div>
</div>
<!-- Type Toggle -->
<div style="margin-bottom:14px">
<div class="type-tabs">
<button :class="['type-tab', { 'type-tab--active': customerType === 'unit' }]" @click="switchCustomerType('unit')">单位客户</button>
<button :class="['type-tab', { 'type-tab--active': customerType === 'individual' }]" @click="switchCustomerType('individual')">个人用户</button>
</div>
</div>
<!-- Individual View: 4 Tabs -->
<div v-if="customerType === 'individual'" v-loading="indivLoading">
<el-tabs v-model="indivTab">
<el-tab-pane label="拜访记录" name="visits">
<el-table :data="indivVisits" size="small" max-height="400" v-if="indivVisits.length">
<el-table-column prop="visit_date" label="日期" width="110" />
<el-table-column prop="visit_method" label="方式" width="80" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="visitor_name" label="拜访人" width="80" />
<el-table-column prop="manager_name" label="客户经理" width="80" />
<el-table-column prop="customer_name" label="客户" width="100" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('visit', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无拜访记录</div>
</el-tab-pane>
<el-tab-pane label="工作计划" name="plans">
<el-table :data="indivPlans" size="small" max-height="400" v-if="indivPlans.length">
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="plan_content" label="工作计划" min-width="250" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('plan', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无工作计划</div>
</el-tab-pane>
<el-tab-pane label="商机跟单" name="mini">
<el-table :data="indivMini" size="small" max-height="400" v-if="indivMini.length">
<el-table-column prop="product_type" label="产品类型" width="110" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('mini', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂无商机记录</div>
</el-tab-pane>
<el-tab-pane label="要客拜访" name="keyVisits">
<el-table :data="indivKeyVisits" size="small" max-height="400" v-if="indivKeyVisits.length">
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="description" label="内容" min-width="250" show-overflow-tooltip />
<el-table-column prop="urgency_level" label="紧急度" width="80" />
<el-table-column prop="customer_name" label="客户" width="120" />
<el-table-column label="操作" width="60" fixed="right"><template #default="{ row }"><el-button size="small" @click="openIndivEdit('key', row)">编辑</el-button></template></el-table-column>
</el-table>
<div v-else class="empty-tab">暂不要客记录</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- Unit View: Search + Table -->
<template v-if="customerType === 'unit'">
<!-- 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 v-column-resize @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: mgrTextColor(row.primary_manager_name) }">{{ 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="detailCustomer?.name || '客户详情'" width="800px" v-if="detailCustomer">
<!-- Basic Info Card -->
<div class="cust-info-card">
<div class="cust-info-name">{{ detailCustomer.name }}</div>
<div class="cust-info-bar">
<span class="cust-info-item">行业{{ detailCustomer.industry || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">地址{{ detailCustomer.address || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">费用{{ detailCustomer.monthly_fee || '-' }}</span>
<span class="cust-info-sep">|</span>
<span class="cust-info-item">客户经理{{ detailCustomer.primary_manager_name || '-' }}</span>
</div>
<div v-if="detailCustomer.in_use_services" class="cust-info-bar">
<span class="cust-info-item">在用业务{{ detailCustomer.in_use_services }}</span>
</div>
<div v-if="detailCustomer.contacts?.length" class="cust-info-bar">
<span class="cust-info-item">联系人{{ detailCustomer.contacts.map((c:any) => c.name + (c.phone ? ' '+c.phone : '')).join('、') }}</span>
</div>
</div>
<!-- Tabs -->
<el-tabs v-model="detailActiveTab" style="margin-top:16px">
<el-tab-pane label="拜访记录" name="visits">
<div v-loading="detailLoading">
<el-table :data="detailVisits" size="small" max-height="300" v-if="detailVisits.length">
<el-table-column prop="visit_date" label="日期" width="110" />
<el-table-column prop="visit_method" label="方式" width="80" />
<el-table-column prop="communication_content" label="沟通内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无拜访记录</div>
</div>
</el-tab-pane>
<el-tab-pane label="工作计划" name="plans">
<div v-loading="detailLoading">
<el-table :data="detailPlans" size="small" max-height="300" v-if="detailPlans.length">
<el-table-column prop="plan_date" label="计划时间" width="110" />
<el-table-column prop="plan_content" label="工作计划" min-width="220" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无工作计划</div>
</div>
</el-tab-pane>
<el-tab-pane label="商机跟单" name="mini">
<div v-loading="detailLoading">
<el-table :data="detailMini" size="small" max-height="300" v-if="detailMini.length">
<el-table-column prop="product_type" label="产品类型" width="110" />
<el-table-column prop="amount" label="金额" width="100" />
<el-table-column prop="status" label="状态" width="80" />
<el-table-column prop="follow_up_detail" label="跟进内容" min-width="180" show-overflow-tooltip />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂无商机记录</div>
</div>
</el-tab-pane>
<el-tab-pane label="要客拜访" name="keyVisits">
<div v-loading="detailLoading">
<el-table :data="detailKeyVisits" size="small" max-height="300" v-if="detailKeyVisits.length">
<el-table-column prop="planned_date" label="计划时间" width="110" />
<el-table-column prop="description" label="内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="urgency_level" label="紧急度" width="80" />
<el-table-column prop="manager_name" label="客户经理" width="90" />
</el-table>
<div v-else class="empty-tab">暂不要客记录</div>
</div>
</el-tab-pane>
</el-tabs>
<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>
</template>
<!-- Individual Record Edit Dialog -->
<el-dialog v-model="indivEditVisible" :title="'编辑' + ({visit:'拜访',plan:'计划',mini:'商机',key:'要客'}[indivEditType]||'')" width="550px">
<el-form label-position="top" v-if="indivEditForm">
<template v-if="indivEditType === 'visit'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable 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="日期"><el-date-picker v-model="indivEditForm.visit_date" type="date" style="width:100%" /></el-form-item>
<el-form-item label="拜访方式">
<div style="display:flex;gap:8px">
<el-button v-for="m in ['上门','电话','微信','出差']" :key="m" size="small" :type="indivEditForm.visit_method === m ? 'primary' : ''" @click="indivEditForm.visit_method = m">{{ m }}</el-button>
</div>
</el-form-item>
<el-form-item label="时间范围"><el-input v-model="indivEditForm.time_range" placeholder="如: 10:00-11:30" /></el-form-item>
<el-form-item label="拜访人姓名"><el-input v-model="indivEditForm.visitor_name" /></el-form-item>
<el-form-item label="拜访人电话"><el-input v-model="indivEditForm.visitor_phone" /></el-form-item>
<el-form-item label="相关人员"><el-input v-model="indivEditForm.companion_names_str" placeholder="多个姓名用、分隔" /></el-form-item>
<el-form-item label="沟通内容"><el-input v-model="indivEditForm.communication_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="客户需求"><el-input v-model="indivEditForm.customer_demand" type="textarea" :rows="2" /></el-form-item>
</template>
<template v-else-if="indivEditType === 'plan'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable 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="计划日期"><el-date-picker v-model="indivEditForm.plan_date" type="date" style="width:100%" /></el-form-item>
<el-form-item label="工作计划"><el-input v-model="indivEditForm.plan_content" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['计划中','已完成','已取消']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
</template>
<template v-else-if="indivEditType === 'mini'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable 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="产品类型"><el-input v-model="indivEditForm.product_type" /></el-form-item>
<el-form-item label="金额"><el-input v-model="indivEditForm.amount" /></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="indivEditForm.follow_up_detail" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="状态"><el-select v-model="indivEditForm.status" style="width:100%"><el-option v-for="s in ['跟进中','已签约','已流失']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
<el-form-item label="预计列收时间"><el-date-picker v-model="indivEditForm.expected_revenue_date" type="month" value-format="YYYY-MM" placeholder="选择月份" style="width:100%" /></el-form-item>
</template>
<template v-else-if="indivEditType === 'key'">
<el-form-item label="客户经理">
<el-select v-model="indivEditForm.manager_id" filterable 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 style="display:flex;gap:8px">
<el-button v-for="u in ['一般','重要','紧急']" :key="u" size="small" :type="indivEditForm.urgency_level === u ? (u==='紧急'?'danger':u==='重要'?'warning':'') : ''" @click="indivEditForm.urgency_level = u">{{ u }}</el-button>
</div>
</el-form-item>
<el-form-item label="内容描述"><el-input v-model="indivEditForm.description" type="textarea" :rows="4" /></el-form-item>
<el-form-item label="进展状态"><el-select v-model="indivEditForm.progress_status" style="width:100%"><el-option v-for="s in ['未开始','进行中','已完成']" :key="s" :label="s" :value="s" /></el-select></el-form-item>
<el-form-item label="计划拜访时间"><el-date-picker v-model="indivEditForm.planned_date" type="date" value-format="YYYY-MM-DD" style="width:100%" /></el-form-item>
<el-form-item label="计划拜访人"><el-input v-model="indivEditForm.planned_visitor" placeholder="多个用、分隔" /></el-form-item>
<el-form-item label="拜访对象"><el-input v-model="indivEditForm.visit_target" /></el-form-item>
</template>
</el-form>
<template #footer>
<el-button type="danger" @click="handleIndivEditDelete" style="float:left">删除</el-button>
<el-button @click="indivEditVisible = false">取消</el-button>
<el-button type="primary" :loading="indivEditSaving" @click="handleIndivEditSave">保存</el-button>
</template>
</el-dialog>
<!-- Merge confirmation dialog -->
<el-dialog v-model="mergeDialogVisible" title="合并客户" width="520px" :close-on-click-modal="false">
<div style="line-height:1.8">
<el-alert type="warning" :closable="false" style="margin-bottom:16px">
客户<b>{{ mergeTargetName }}</b>已存在是否将<b>{{ mergeSourceName }}</b>合并到{{ mergeTargetName }}」?
</el-alert>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="源客户">{{ mergeSourceName }}</el-descriptions-item>
<el-descriptions-item label="目标客户">{{ mergeTargetName }}</el-descriptions-item>
<el-descriptions-item label="拜访记录">{{ mergePreview.visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="工作计划">{{ mergePreview.work_plans || 0 }} </el-descriptions-item>
<el-descriptions-item label="商机跟单">{{ mergePreview.mini_business || 0 }} </el-descriptions-item>
<el-descriptions-item label="要客拜访">{{ mergePreview.key_visits || 0 }} </el-descriptions-item>
<el-descriptions-item label="联系人">{{ mergePreview.contacts || 0 }} </el-descriptions-item>
<el-descriptions-item label="经理分配">{{ mergePreview.assignments || 0 }} </el-descriptions-item>
</el-descriptions>
<div v-if="mergePreview.note" style="margin-top:12px; color:var(--amber); font-size:13px">
{{ mergePreview.note }}
</div>
<div style="margin-top:12px; color:var(--vermilion); font-size:13px">
合并后{{ mergeSourceName }}将被删除此操作不可撤销
</div>
</div>
<template #footer>
<el-button @click="mergeDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="mergeLoading" @click="handleMerge">确认合并</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: var(--font-heading);
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; }
.cust-info-bar { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--ink); padding: 2px 0; }
.cust-info-item { white-space: nowrap; }
.cust-info-sep { color: var(--warm-border); margin: 0 6px; }
.empty-tab { text-align: center; color: var(--c-text-muted); padding: 24px 0 12px; font-size: 13px; }
.type-tabs { display: inline-flex; border: 1px solid var(--warm-border); }
.type-tab { padding: 8px 22px; border: none; background: var(--surface); font-family: var(--font-heading); font-size: 14px; color: var(--warm-gray); cursor: pointer; transition: all 0.2s; letter-spacing: 0.04em; }
.type-tab:not(:last-child) { border-right: 1px solid var(--warm-border); }
.type-tab:hover { color: var(--ink); }
.type-tab--active { background: var(--ink); color: #fff; }
.type-tab--active:hover { color: #fff; }
.cust-info-card { background: var(--c-bg-light, #faf9f6); border: 1px solid var(--warm-border); border-radius: 6px; padding: 14px 18px; margin-bottom: 4px; }
.cust-info-name { font-family: var(--font-heading); font-size: 16px; color: var(--ink); letter-spacing: 0.04em; margin-bottom: 6px; }
</style>