主要功能:
- 新增同步管理后台界面,支持全局管理(不限租户)
- 实现租户-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>
586 lines
17 KiB
Vue
586 lines
17 KiB
Vue
<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> |