Files
hzhub/hzhub-admin/apps/web-antd/src/views/crm/sync/index.vue
大壮 5cb9e367df feat: 完成CRM-ERP客户同步管理模块
主要功能:
- 新增同步管理后台界面,支持全局管理(不限租户)
- 实现租户-ERP公司映射配置,支持多公司绑定
- 实现定时同步任务,可配置Cron表达式
- 实现手动同步功能,从ERP拉取客户数据更新CRM
- 支持新增经销商(ERP客户在CRM不存在时)
- 新增同步日志和预警管理

技术实现:
- 新增 crm_sync_log、crm_sync_alert、sys_tenant_company 表
- 使用 TenantHelper.ignore() 实现全局查询(忽略租户过滤)
- ERP动态API支持 ${param} 字符串替换用于IN条件
- 新增公司列表动态API配置

同步流程:
1. 获取租户-公司映射配置
2. 从ERP获取各公司客户列表
3. 匹配CRM已有经销商并更新
4. 未匹配且ERP未停用的客户新增为经销商
5. 生成差异预警(CRM维护字段)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:40:35 +00:00

586 lines
17 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 { h, onMounted, onUnmounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import {
Button,
Card,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Tabs,
message,
Popconfirm,
Badge,
Statistic,
Row,
Col,
} from 'ant-design-vue';
import { ExclamationCircleOutlined, SyncOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import {
getSyncConfig,
startSyncTask,
stopSyncTask,
setSyncCron,
executeSync,
getSyncLogs,
getSyncAlerts,
resolveAlert,
getSyncStats,
getTenants,
getTenantCompanies,
getErpCompanies,
bindCompany,
unbindCompany,
} from '#/api/crm/sync';
defineOptions({ name: 'CrmSyncManage' });
// 同步执行状态
const syncing = ref(false);
const loading = ref(false);
const logs = ref<any[]>([]);
const alerts = ref<any[]>([]);
const totalLogs = ref(0);
const totalAlerts = ref(0);
const currentPageLogs = ref(1);
const currentPageAlerts = ref(1);
const pageSize = ref(10);
const alertStatusFilter = ref('PENDING');
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
// 统计数据
const stats = ref<any>({
lastSyncTime: null,
lastSyncDuration: null,
lastSyncCount: 0,
lastSyncUpdated: 0,
lastSyncAlerts: 0,
lastSyncErrors: 0,
pendingAlerts: 0,
boundDealers: 0,
});
// 定时任务状态
const taskRunning = ref(false);
const currentCron = ref('0 0 2 * * ?');
const taskLoading = ref(false);
// 租户公司映射(全局管理)
const tenants = ref<any[]>([]);
const tenantCompanies = ref<any[]>([]);
const erpCompanies = ref<any[]>([]);
const companyLoading = ref(false);
const tenantLoading = ref(false);
const selectedTenantId = ref<string>('');
const selectedTenantName = ref<string>('');
const selectedCompanyId = ref<string>('');
const selectedCompanyName = ref<string>('');
// 状态映射
const statusMap: Record<string, { text: string; color: string }> = {
RUNNING: { text: '同步中', color: 'processing' },
COMPLETED: { text: '已完成', color: 'success' },
FAILED: { text: '失败', color: 'error' },
};
const syncTypeMap: Record<string, { text: string; color: string }> = {
SCHEDULED: { text: '定时同步', color: 'blue' },
MANUAL: { text: '手动同步', color: 'orange' },
};
const alertStatusMap: Record<string, { text: string; color: string }> = {
PENDING: { text: '待处理', color: 'warning' },
ACKNOWLEDGED: { text: '已确认', color: 'default' },
RESOLVED: { text: '已处理', color: 'success' },
IGNORED: { text: '已忽略', color: 'default' },
};
const alertTypeMap: Record<string, { text: string }> = {
CONTACT_DIFF: { text: '联系人差异' },
ADDRESS_DIFF: { text: '地址差异' },
STATUS_DIFF: { text: '状态差异' },
};
const cronOptions = [
{ label: '每 1 小时', value: '0 0 * * * ?' },
{ label: '每 2 小时', value: '0 0 0/2 * * ?' },
{ label: '每日凌晨 1 点', value: '0 0 1 * * ?' },
{ label: '每日凌晨 2 点', value: '0 0 2 * * ?' },
{ label: '每日凌晨 3 点', value: '0 0 3 * * ?' },
];
// 同步日志表格列
const logColumns = [
{
title: '状态',
key: 'status',
width: 100,
customRender: ({ record }: any) => {
const info = statusMap[record.status] || { text: '未知', color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{
title: '同步类型',
key: 'syncType',
width: 100,
customRender: ({ record }: any) => {
const info = syncTypeMap[record.syncType] || { text: record.syncType, color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{ title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 180 },
{ title: '结束时间', dataIndex: 'endTime', key: 'endTime', width: 180 },
{ title: '耗时(秒)', dataIndex: 'duration', key: 'duration', width: 80 },
{ title: '扫描', dataIndex: 'totalCount', key: 'totalCount', width: 80 },
{ title: '同步', dataIndex: 'syncedCount', key: 'syncedCount', width: 80 },
{ title: '更新', dataIndex: 'updatedCount', key: 'updatedCount', width: 80 },
{ title: '预警', dataIndex: 'alertCount', key: 'alertCount', width: 80 },
{ title: '错误', dataIndex: 'errorCount', key: 'errorCount', width: 80 },
{ title: '操作人', dataIndex: 'operator', key: 'operator', width: 100 },
];
// 预警表格列
const alertColumns = [
{
title: '状态',
key: 'status',
width: 100,
customRender: ({ record }: any) => {
const info = alertStatusMap[record.status] || { text: record.status, color: 'default' };
return h(Tag, { color: info.color }, () => info.text);
},
},
{
title: '类型',
key: 'alertType',
width: 100,
customRender: ({ record }: any) => {
const info = alertTypeMap[record.alertType] || { text: record.alertType };
return info.text;
},
},
{ title: '经销商', dataIndex: 'dealerName', key: 'dealerName', width: 150 },
{ title: 'ERP编码', dataIndex: 'customerCode', key: 'customerCode', width: 120 },
{ title: 'CRM值', dataIndex: 'crmValue', key: 'crmValue', width: 150 },
{ title: 'ERP值', dataIndex: 'erpValue', key: 'erpValue', width: 150 },
{ title: '描述', dataIndex: 'alertMessage', key: 'alertMessage', width: 200 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
{
title: '操作',
key: 'action',
width: 180,
customRender: ({ record }: any) => {
if (record.status !== 'PENDING') return null;
return h(Space, {}, () => [
h(Button, { type: 'link', size: 'small', onClick: () => handleResolveAlert(record.id, 'acknowledge') }, () => '确认'),
h(Button, { type: 'link', size: 'small', onClick: () => handleResolveAlert(record.id, 'ignore') }, () => '忽略'),
]);
},
},
];
// 手动同步
async function handleExecuteSync() {
Modal.confirm({
title: '确认手动同步',
icon: () => h(ExclamationCircleOutlined),
content: '将同步所有绑定ERP编码的经销商数据后台异步执行。确定继续吗',
okText: '确定同步',
cancelText: '取消',
async onOk() {
syncing.value = true;
try {
await executeSync();
message.success('已启动同步,请查看同步日志');
await loadLogs();
await loadStats();
startPolling();
} catch {
message.error('启动同步失败');
} finally {
syncing.value = false;
}
},
});
}
// 轮询检查同步状态
function startPolling() {
stopPolling();
pollingTimer.value = setInterval(async () => {
await loadLogs();
const runningLog = logs.value.find((l: any) => l.status === 'RUNNING');
if (!runningLog) {
stopPolling();
await loadStats();
}
}, 3000);
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value);
pollingTimer.value = null;
}
}
// 加载同步日志
async function loadLogs() {
loading.value = true;
try {
const res = await getSyncLogs({ pageNum: currentPageLogs.value, pageSize: pageSize.value });
logs.value = res?.rows ?? [];
totalLogs.value = res?.total ?? 0;
} catch (error: any) {
message.error('加载同步日志失败');
} finally {
loading.value = false;
}
}
// 加载预警列表
async function loadAlerts() {
try {
const res = await getSyncAlerts({ pageNum: currentPageAlerts.value, pageSize: pageSize.value, status: alertStatusFilter.value });
alerts.value = res?.rows ?? [];
totalAlerts.value = res?.total ?? 0;
} catch {
message.error('加载预警列表失败');
}
}
// 加载统计数据
async function loadStats() {
try {
const res = await getSyncStats();
stats.value = res ?? {};
} catch { /* ignore */ }
}
// 加载任务状态
async function loadTaskStatus() {
try {
const config = await getSyncConfig();
taskRunning.value = config.enabled;
currentCron.value = config.cron || '0 0 2 * * ?';
} catch { /* ignore */ }
}
// 加载租户公司映射
async function loadTenantCompanies() {
try {
tenantCompanies.value = await getTenantCompanies() ?? [];
} catch {
message.error('加载租户公司映射失败');
}
}
// 加载租户列表
async function loadTenants() {
tenantLoading.value = true;
try {
tenants.value = await getTenants() ?? [];
} catch {
message.error('加载租户列表失败');
} finally {
tenantLoading.value = false;
}
}
// 加载ERP公司列表
async function loadErpCompanies() {
companyLoading.value = true;
try {
erpCompanies.value = await getErpCompanies() ?? [];
} catch {
message.error('加载ERP公司列表失败');
} finally {
companyLoading.value = false;
}
}
// 启动/停止定时任务
async function handleTaskToggle(checked: boolean) {
taskLoading.value = true;
try {
if (checked) {
await startSyncTask();
message.success('定时任务已启动');
} else {
await stopSyncTask();
message.success('定时任务已停止');
}
await loadTaskStatus();
} catch {
message.error(checked ? '启动失败' : '停止失败');
} finally {
taskLoading.value = false;
}
}
// 设置Cron
async function handleCronChange(value: string) {
try {
await setSyncCron(value);
message.success('同步频率已更新');
currentCron.value = value;
} catch {
message.error('更新频率失败');
}
}
// 处理预警
async function handleResolveAlert(alertId: number, action: string) {
try {
await resolveAlert(alertId, action);
message.success('预警已处理');
await loadAlerts();
await loadStats();
} catch {
message.error('处理预警失败');
}
}
// 绑定ERP公司
async function handleBindCompany() {
if (!selectedTenantId.value) {
message.warning('请选择租户');
return;
}
if (!selectedCompanyId.value) {
message.warning('请选择要绑定的ERP公司');
return;
}
try {
await bindCompany(selectedTenantId.value, selectedTenantName.value, selectedCompanyId.value, selectedCompanyName.value);
message.success('绑定成功');
await loadTenantCompanies();
selectedTenantId.value = '';
selectedTenantName.value = '';
selectedCompanyId.value = '';
selectedCompanyName.value = '';
} catch {
message.error('绑定失败');
}
}
// 解绑ERP公司
async function handleUnbindCompany(id: number) {
try {
await unbindCompany(id);
message.success('已解绑');
await loadTenantCompanies();
} catch {
message.error('解绑失败');
}
}
// 选择租户
function handleTenantSelect(value: string) {
selectedTenantId.value = value;
const tenant = tenants.value.find((t: any) => t.tenantId === value);
selectedTenantName.value = tenant?.companyName ?? '';
}
// 选择公司
function handleCompanySelect(value: string) {
selectedCompanyId.value = value;
const company = erpCompanies.value.find((c: any) => c.companyCode === value);
selectedCompanyName.value = company?.companyName ?? '';
}
// 分页处理
function handleLogPageChange(pagination: any) {
currentPageLogs.value = pagination.current ?? 1;
pageSize.value = pagination.pageSize ?? 10;
loadLogs();
}
function handleAlertPageChange(pagination: any) {
currentPageAlerts.value = pagination.current ?? 1;
pageSize.value = pagination.pageSize ?? 10;
loadAlerts();
}
// 预警状态筛选
function handleAlertStatusChange(value: string) {
alertStatusFilter.value = value;
currentPageAlerts.value = 1;
loadAlerts();
}
onMounted(() => {
loadLogs();
loadAlerts();
loadStats();
loadTaskStatus();
loadTenants();
loadTenantCompanies();
loadErpCompanies();
});
onUnmounted(() => {
stopPolling();
});
</script>
<template>
<Page :auto-content-height="true">
<div style="overflow-y: auto; padding: 16px">
<!-- 统计卡片 -->
<Card title="同步概览" :bordered="false" style="margin-bottom: 16px">
<Row :gutter="16">
<Col :span="4">
<Statistic title="绑定经销商" :value="stats.boundDealers" />
</Col>
<Col :span="4">
<Statistic title="待处理预警" :value="stats.pendingAlerts">
<template #suffix>
<Badge v-if="stats.pendingAlerts > 0" color="warning" />
</template>
</Statistic>
</Col>
<Col :span="4">
<Statistic title="最近同步" :value="stats.lastSyncCount" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="更新数量" :value="stats.lastSyncUpdated" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="新增预警" :value="stats.lastSyncAlerts" suffix="条" />
</Col>
<Col :span="4">
<Statistic title="耗时" :value="stats.lastSyncDuration" suffix="秒" />
</Col>
</Row>
</Card>
<!-- 操作区 -->
<Card title="同步操作" :bordered="false" style="margin-bottom: 16px">
<Space>
<Button type="primary" :loading="syncing" @click="handleExecuteSync">
<template #icon><SyncOutlined /></template>
手动同步
</Button>
<span style="margin-left: 24px">启用定时同步</span>
<Switch
v-model:checked="taskRunning"
:loading="taskLoading"
checked-children=""
un-checked-children=""
@change="handleTaskToggle"
/>
<span style="margin-left: 24px">同步频率</span>
<Select
:value="currentCron"
style="width: 180px"
:options="cronOptions"
@change="handleCronChange"
/>
<Tag :color="taskRunning ? 'green' : 'default'">
{{ taskRunning ? '运行中' : '已停止' }}
</Tag>
</Space>
</Card>
<!-- 租户公司映射 -->
<Card title="租户-ERP公司映射" :bordered="false" style="margin-bottom: 16px">
<Space style="margin-bottom: 16px">
<Select
v-model:value="selectedTenantId"
placeholder="选择租户"
style="width: 250px"
show-search
:loading="tenantLoading"
:options="tenants.map((t: any) => ({ label: `${t.companyName} (${t.tenantId})`, value: t.tenantId }))"
@change="handleTenantSelect"
/>
<Select
v-model:value="selectedCompanyId"
placeholder="选择ERP公司"
style="width: 300px"
show-search
:loading="companyLoading"
:options="erpCompanies.map((c: any) => ({ label: `${c.companyName} (${c.companyCode})`, value: c.companyCode }))"
@change="handleCompanySelect"
/>
<Button type="primary" @click="handleBindCompany">绑定</Button>
</Space>
<Table
:columns="[
{ title: '租户编号', dataIndex: 'tenantId', width: 120 },
{ title: '租户名称', dataIndex: 'tenantName', width: 150 },
{ title: 'ERP公司ID', dataIndex: 'erpCompanyId', width: 150 },
{ title: 'ERP公司名称', dataIndex: 'erpCompanyName', width: 200 },
{ title: '状态', dataIndex: 'status', width: 100, customRender: ({ record }: any) => h(Tag, { color: record.status === '0' ? 'green' : 'default' }, () => record.status === '0' ? '正常' : '停用') },
{ title: '操作', key: 'action', width: 100, customRender: ({ record }: any) => h(Popconfirm, { title: '确定解绑?', onConfirm: () => handleUnbindCompany(record.id) }, () => h(Button, { type: 'link', danger: true, size: 'small' }, () => '解绑')) },
]"
:data-source="tenantCompanies"
row-key="id"
:pagination="false"
size="small"
/>
</Card>
<!-- 日志和预警Tab -->
<Card :bordered="false">
<Tabs>
<Tabs.TabPane key="logs" tab="同步日志">
<Table
:columns="logColumns"
:data-source="logs"
:loading="loading"
:pagination="{
current: currentPageLogs,
pageSize: pageSize,
total: totalLogs,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
row-key="id"
@change="handleLogPageChange"
/>
</Tabs.TabPane>
<Tabs.TabPane key="alerts" tab="预警管理">
<Space style="margin-bottom: 16px">
<span>状态筛选</span>
<Select
v-model:value="alertStatusFilter"
style="width: 150px"
:options="[
{ label: '全部', value: '' },
{ label: '待处理', value: 'PENDING' },
{ label: '已确认', value: 'ACKNOWLEDGED' },
{ label: '已处理', value: 'RESOLVED' },
{ label: '已忽略', value: 'IGNORED' },
]"
@change="handleAlertStatusChange"
/>
</Space>
<Table
:columns="alertColumns"
:data-source="alerts"
:loading="loading"
:pagination="{
current: currentPageAlerts,
pageSize: pageSize,
total: totalAlerts,
showSizeChanger: true,
showTotal: (t: number) => `共 ${t} 条`,
}"
row-key="id"
@change="handleAlertPageChange"
/>
</Tabs.TabPane>
</Tabs>
</Card>
</div>
</Page>
</template>