临时更新

This commit is contained in:
hero920103 committed 2026-09-10 20:18:11 +08:00
1 parent e340d9a7d1
commit 26eaf1eddb
11 files changed
+4212

No files matched your search

@@ -0,0 +1,604 @@
<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="warning"
plain
:disabled="!multipleSelection.length"
@click="openMovePool(multipleSelection)"
>
移入公海
</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新增客户</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 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>
</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_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="280" 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="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="560px"
>
<div v-if="detailData" class="detail-content">
<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_name || '未分配' }}</el-descriptions-item>
<el-descriptions-item label="合作开始">{{ formatDate(detailData.register_time) }}</el-descriptions-item>
<el-descriptions-item label="合作到期">{{ formatDate(detailData.expire_time) }}</el-descriptions-item>
<el-descriptions-item label="客户地址" :span="2">{{ detailData.address || '-' }}</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="mobile" label="手机号" width="130" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<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="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"
/>
<!-- 移入公海 -->
<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 } from "@element-plus/icons-vue";
import {
getCrmCustomerList,
deleteCrmCustomer,
moveToPool,
} from "@/api/crm";
import { listContacts, deleteContact } from "@/api/contact";
import CustomerEdit from "./components/edit.vue";
import ContactEdit from "../contact/components/edit.vue";
import {
CUSTOMER_TYPE_OPTIONS,
STATUS_OPTIONS,
POOL_REASON_OPTIONS,
levelOptions,
typeText,
typeTag,
levelText,
levelTag,
statusText,
statusTag,
formatDate,
formatDateTime,
} 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 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;
}
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 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);
}
.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);
}
}
}
</style>