Files
yunzerwebsiteallinone/backend/src/views/apps/crm/customer/index.vue
T
2026-11-03 17:48:07 +08:00

799 lines
26 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="crm-page">
<div class="page-header">
<div>
<h2>客户管理</h2>
<p>维护客户档案与跟进人,支持将客户释放到公海由团队共享</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<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: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="客户类型">
<el-select v-model="filters.customer_type" clearable placeholder="全部" style="width: 130px">
<el-option
v-for="item in CUSTOMER_TYPE_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="客户等级">
<el-select v-model="filters.customer_level" clearable placeholder="全部" style="width: 130px">
<el-option
v-for="item in levelOptions('customer')"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option
v-for="item in STATUS_OPTIONS"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</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 v-if="multipleSelection.length" class="batch-bar">
<span class="batch-tip">已选 {{ multipleSelection.length }} 项</span>
<el-button size="small" type="primary" plain :loading="batchLoading" @click="handleBatchToggleStatus('1')">
批量启用
</el-button>
<el-button size="small" type="info" plain :loading="batchLoading" @click="handleBatchToggleStatus('0')">
批量禁用
</el-button>
<el-button size="small" type="success" plain :loading="batchLoading" @click="handleBatchConvertSupplier">
批量转供应商
</el-button>
<el-button size="small" type="warning" plain @click="openMovePool(multipleSelection)">
批量移入公海
</el-button>
<el-button size="small" type="danger" plain :loading="batchLoading" @click="handleBatchDelete">
批量删除
</el-button>
</div>
<div class="table-container" v-loading="loading">
<el-table
:data="tableData"
stripe
border
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="客户名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.customer_name }}</span>
<!-- 同一家单位可能既是客户又是供应商(已建供应商档案) -->
<el-tag v-if="row.also_supplier" size="small" type="warning" effect="plain" style="margin-left: 6px">
也是供应商
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
<template #default="{ row }">
<el-tag :type="typeTag(row.customer_type)" size="small">
{{ typeText('customer', row.customer_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="等级" width="110" align="center">
<template #default="{ row }">
<el-tag :type="levelTag(row.customer_level)" size="small" effect="plain">
{{ levelText('customer', row.customer_level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="industry" label="行业" width="120" show-overflow-tooltip />
<el-table-column prop="contact_person" label="联系人" width="100" />
<el-table-column prop="contact_phone" label="联系电话" width="130" />
<el-table-column label="负责人" width="100">
<template #default="{ row }">{{ row.owner_user_name || '未分配' }}</template>
</el-table-column>
<el-table-column label="状态" width="90" 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 label="更新时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.update_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="350" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openContact(row)">联系人</el-button>
<el-button link type="success" size="small" @click="handleConvertSupplier(row)">转为供应商</el-button>
<el-button link type="warning" size="small" @click="openMovePool([row])">移入公海</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无客户" :image-size="80" />
</template>
</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="handleSearch"
@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="700px"
>
<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">
<el-descriptions-item label="客户名称" :span="2">
{{ detailData.customer_name }}
</el-descriptions-item>
<el-descriptions-item label="客户类型">
{{ typeText('customer', detailData.customer_type) }}
</el-descriptions-item>
<el-descriptions-item label="客户等级">
{{ levelText('customer', 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.owner_user_name || '未分配' }}</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.address || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营范围" :span="2">{{ detailData.business_scope || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detailData.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(detailData.update_time) }}</el-descriptions-item>
</el-descriptions>
<el-descriptions
title="联系信息"
:column="2"
border
size="small"
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>
<el-descriptions
title="开票信息"
:column="2"
border
size="small"
style="margin-top: 20px"
>
<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"
style="margin-top: 20px"
>
<el-descriptions-item label="备注">{{ detailData.remark || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-drawer>
<!-- 联系人抽屉 -->
<el-drawer
v-model="contactVisible"
:title="`${currentRow?.customer_name || '客户'} - 联系人`"
direction="rtl"
size="820px"
>
<div class="contact-drawer">
<div class="drawer-toolbar">
<el-button type="primary" :icon="Plus" size="small" @click="openContactCreate">
新增联系人
</el-button>
<span class="count-tip">共 {{ contactList.length }} 位联系人</span>
</div>
<el-table :data="contactList" v-loading="contactLoading" stripe border size="small">
<el-table-column label="姓名" width="140">
<template #default="{ row }">
<span>{{ row.contact_name }}</span>
<el-tag
v-if="Number(row.is_primary) === 1"
type="danger"
size="small"
effect="plain"
style="margin-left: 6px"
>
主联系人
</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" width="120" show-overflow-tooltip />
<el-table-column prop="position" label="职位" width="130" show-overflow-tooltip />
<el-table-column label="手机号" width="170" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="wechat" label="微信" width="120" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" width="110" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleContactDelete(row)">
删除
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无联系人" :image-size="60" />
</template>
</el-table>
</div>
</el-drawer>
<ContactEdit
v-model:visible="contactEditVisible"
:edit-data="contactEditData"
:default-related="contactRelated"
@success="loadContacts"
/>
<SmartAddCompany
v-model:visible="smartAddVisible"
type="customer"
@success="fetchList"
/>
<!-- 移入公海 -->
<el-dialog v-model="poolVisible" title="移入客户公海" width="460px" :close-on-click-modal="false">
<el-alert
type="info"
:closable="false"
show-icon
title="移入后客户将进入公海,租户内所有成员可见,可被他人领取或分配。"
style="margin-bottom: 16px"
/>
<el-form label-width="90px">
<el-form-item label="移入客户">
<span>{{ poolTargetNames }}</span>
</el-form-item>
<el-form-item label="移入原因">
<el-select v-model="poolReason" placeholder="请选择原因" style="width: 100%">
<el-option v-for="item in POOL_REASON_OPTIONS" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="poolVisible = false">取消</el-button>
<el-button type="primary" :loading="poolSubmitting" @click="submitMovePool">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, Cpu } from "@element-plus/icons-vue";
import {
getCrmCustomerList,
deleteCrmCustomer,
toggleCrmCustomerStatus,
moveToPool,
convertCustomerToSupplier,
} from "@/api/crm";
import { listContacts, deleteContact } from "@/api/contact";
import CustomerEdit from "./components/edit.vue";
import ContactEdit from "../contact/components/edit.vue";
import SmartAddCompany from "./components/smartAddCompany.vue";
import {
CUSTOMER_TYPE_OPTIONS,
STATUS_OPTIONS,
POOL_REASON_OPTIONS,
levelOptions,
typeText,
typeTag,
levelText,
levelTag,
statusText,
statusTag,
formatDate,
formatDateTime,
formatMobiles,
} from "../dict";
const loading = ref(false);
const tableData = ref([]);
const multipleSelection = ref([]);
const editVisible = ref(false);
const currentEditData = ref(null);
const detailVisible = ref(false);
const detailData = ref(null);
// 联系人抽屉
const contactVisible = ref(false);
const contactLoading = ref(false);
const contactList = ref([]);
const currentRow = ref(null);
const contactEditVisible = ref(false);
const contactEditData = ref(null);
const smartAddVisible = ref(false);
// 移入公海
const poolVisible = ref(false);
const poolReason = ref("");
const poolTargets = ref([]);
const poolSubmitting = ref(false);
const filters = reactive({
keyword: "",
customer_type: "",
customer_level: "",
status: "",
});
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
});
const contactRelated = computed(() =>
currentRow.value
? {
related_type: 1,
related_id: currentRow.value.id,
related_name: currentRow.value.customer_name,
}
: null
);
const poolTargetNames = computed(() =>
poolTargets.value.map((item) => item.customer_name).join("、")
);
onMounted(() => {
fetchList();
});
async function fetchList() {
loading.value = true;
try {
const res = await getCrmCustomerList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
} catch (e) {
tableData.value = [];
pagination.total = 0;
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 = "";
handleSearch();
}
function handleSelectionChange(val) {
multipleSelection.value = val;
}
/* ------------------------------ 批量操作 ------------------------------ */
const batchLoading = ref(false);
/**
* 批量执行:逐个复用单条接口,单行失败不影响其他行;
* 结束后按成功 / 失败数量给出汇总提示,并刷新列表、清空选中。
*/
async function runBatch(rows, action, actionText) {
if (!rows.length) return;
batchLoading.value = true;
try {
const results = await Promise.allSettled(rows.map((row) => action(row)));
const failed = results.filter((item) => item.status === "rejected");
const okCount = rows.length - failed.length;
if (failed.length === 0) {
ElMessage.success(`${actionText}成功(${okCount} 条)`);
} else if (okCount === 0) {
ElMessage.error(`${actionText}失败:${failed[0].reason?.message || "请稍后重试"}`);
} else {
ElMessage.warning(`${actionText}完成:成功 ${okCount} 条,失败 ${failed.length} 条`);
}
multipleSelection.value = [];
fetchList();
} finally {
batchLoading.value = false;
}
}
/** 批量启用 / 禁用 */
async function handleBatchToggleStatus(status) {
const rows = multipleSelection.value;
if (!rows.length) return;
const action = status === "1" ? "启用" : "禁用";
try {
await ElMessageBox.confirm(`确定要${action}选中的 ${rows.length} 个客户吗?`, `批量${action}`, {
type: "warning",
});
} catch (e) {
return; // 取消
}
await runBatch(rows, (row) => toggleCrmCustomerStatus(row.id, status), `批量${action}`);
}
/** 批量删除(软删,不可恢复) */
async function handleBatchDelete() {
const rows = multipleSelection.value;
if (!rows.length) return;
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${rows.length} 个客户吗?删除后不可恢复。`,
"批量删除",
{ type: "warning" }
);
} catch (e) {
return; // 取消
}
await runBatch(rows, (row) => deleteCrmCustomer(row.id), "批量删除");
}
/** 批量转供应商:同名企业后端只补同步联系人,原客户记录保留 */
async function handleBatchConvertSupplier() {
const rows = multipleSelection.value;
if (!rows.length) return;
try {
await ElMessageBox.confirm(
`将选中的 ${rows.length} 个客户的企业资料与联系人复制到供应商库?原客户记录会保留。`,
"批量转供应商",
{ type: "warning" }
);
} catch (e) {
return; // 取消
}
await runBatch(rows, (row) => convertCustomerToSupplier(row.id), "批量转供应商");
}
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;
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`确定要删除客户「${row.customer_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteCrmCustomer(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 || "转化失败");
}
}
/* ------------------------------ 联系人 ------------------------------ */
async function openContact(row) {
currentRow.value = { ...row };
contactVisible.value = true;
loadContacts();
}
async function loadContacts() {
if (!currentRow.value) return;
contactLoading.value = true;
try {
const res = await listContacts({ related_type: 1, related_id: currentRow.value.id });
const data = res?.data || {};
contactList.value = Array.isArray(data) ? data : data.list || [];
} catch (e) {
contactList.value = [];
ElMessage.error(e.message || "联系人查询失败");
} finally {
contactLoading.value = false;
}
}
function openContactCreate() {
contactEditData.value = null;
contactEditVisible.value = true;
}
function openContactEdit(row) {
contactEditData.value = { ...row };
contactEditVisible.value = true;
}
async function handleContactDelete(row) {
try {
await ElMessageBox.confirm(`确定删除联系人「${row.contact_name}」吗?`, "删除确认", {
type: "warning",
});
await deleteContact({ id: row.id });
ElMessage.success("删除成功");
loadContacts();
} catch (e) {
if (e !== "cancel") {
ElMessage.error(e.message || "删除失败");
}
}
}
/* ------------------------------ 移入公海 ------------------------------ */
function openMovePool(rows) {
if (!rows.length) return;
poolTargets.value = rows;
poolReason.value = "";
poolVisible.value = true;
}
async function submitMovePool() {
if (!poolReason.value) {
ElMessage.warning("请选择移入原因");
return;
}
poolSubmitting.value = true;
try {
await moveToPool({
ids: poolTargets.value.map((item) => item.id),
reason: poolReason.value,
});
ElMessage.success("已移入公海");
poolVisible.value = false;
multipleSelection.value = [];
fetchList();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
poolSubmitting.value = false;
}
}
</script>
<style lang="less" scoped>
.crm-page {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
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);
}
.header-actions {
display: flex;
gap: 8px;
}
}
.filter-bar {
background: var(--el-bg-color);
padding: 16px 16px 0;
border-radius: 6px;
margin-bottom: 12px;
border: 1px solid var(--el-border-color-lighter);
}
.table-container {
background: var(--el-bg-color);
padding: 16px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
/* 批量操作栏:勾选行后出现 */
.batch-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
padding: 10px 16px;
border-radius: 6px;
border: 1px solid var(--el-color-primary-light-7);
background: var(--el-color-primary-light-9);
.batch-tip {
margin-right: 8px;
font-size: 13px;
color: var(--el-text-color-regular);
}
}
.name-link {
cursor: pointer;
font-weight: 500;
color: var(--el-color-primary);
&:hover {
text-decoration: underline;
}
}
.pagination {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.contact-drawer {
.drawer-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
.count-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
}
.detail-content {
padding: 4px 8px;
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-descriptions__label) {
width: 130px;
min-width: 130px;
color: var(--el-text-color-regular);
font-weight: 500;
vertical-align: top;
}
:deep(.el-descriptions__content) {
word-break: break-all;
}
}
</style>