#add jira & message sync

This commit is contained in:
2025-12-02 10:16:32 +08:00
parent 5c4492d8f8
commit 2ec44b5665
49 changed files with 6633 additions and 1209 deletions

View File

@@ -0,0 +1,373 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">事件消费者同步对比</h1>
<p class="text-gray-600 mt-2">对比CRM和Agent的事件消费者消息找出缺失的消息</p>
</div>
<!-- 查询条件 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询条件</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始时间</label>
<input
v-model="startTime"
type="datetime-local"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束时间</label>
<input
v-model="endTime"
type="datetime-local"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">消息名称 (可选)</label>
<input
v-model="messageName"
type="text"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入要查询的消息名称,留空则查询所有"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">排除的消息名称 (每行一个)</label>
<textarea
v-model="excludeMessagesText"
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入要排除的消息名称,每行一个"
></textarea>
</div>
<div class="flex space-x-4">
<button
@click="compareSync"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
对比同步状态
</button>
<button
@click="exportMissing"
:disabled="loading || !compareResult"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
导出缺失消息
</button>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 对比结果 -->
<div v-if="compareResult" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">对比结果</h2>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ compareResult.crm_total }}</div>
<div class="text-sm text-blue-600">CRM消息总数</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ compareResult.agent_total }}</div>
<div class="text-sm text-green-600">Agent消息总数</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ compareResult.missing_count }}</div>
<div class="text-sm text-red-600">缺失消息数</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ compareResult.sync_rate }}%</div>
<div class="text-sm text-purple-600">同步率</div>
</div>
</div>
<!-- 按Topic统计缺失消息 -->
<div v-if="compareResult.missing_by_topic && compareResult.missing_by_topic.length > 0" class="mb-6">
<h3 class="text-lg font-semibold text-gray-700 mb-4">按Topic统计缺失消息</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Topic名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">缺失数量</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">占比</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="item in compareResult.missing_by_topic" :key="item.topic">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.topic }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
{{ item.count }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ ((item.count / compareResult.missing_count) * 100).toFixed(2) }}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 缺失消息列表 -->
<div v-if="compareResult.missing_messages.length > 0">
<h3 class="text-lg font-semibold text-gray-700 mb-4">缺失的消息详情</h3>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息体</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">创建时间</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="msg in compareResult.missing_messages" :key="msg.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ msg.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ msg.event_name }}</td>
<td class="px-6 py-4 text-sm">
<button
@click="showDetail(msg)"
class="text-blue-600 hover:text-blue-800 hover:underline"
>
查看详情
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDateTime(msg.created) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else class="bg-green-50 border border-green-200 rounded-lg p-4">
<p class="text-green-700">所有消息都已同步到Agent</p>
</div>
</div>
<!-- 消息详情弹窗 -->
<div v-if="showDetailModal" class="fixed inset-0 flex items-center justify-center z-50 p-4">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-96 overflow-y-auto">
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 px-6 py-4 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">消息详情</h3>
<button
@click="showDetailModal = false"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-6 py-4">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息ID</label>
<p class="text-sm text-gray-900 font-mono break-all">{{ selectedMessage?.msg_id }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息名称</label>
<p class="text-sm text-gray-900">{{ selectedMessage?.event_name }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">消息体</label>
<div class="bg-gray-50 rounded p-3 max-h-48 overflow-y-auto">
<pre class="text-sm text-gray-900 whitespace-pre-wrap break-words">{{ selectedMessage?.msg_body }}</pre>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">创建时间</label>
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.created) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">更新时间</label>
<p class="text-sm text-gray-900">{{ formatDateTime(selectedMessage?.updated) }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'EventConsumerSync',
data() {
return {
startTime: '',
endTime: '',
messageName: '',
excludeMessagesText: '',
compareResult: null,
loading: false,
error: '',
showDetailModal: false,
selectedMessage: null
}
},
computed: {
excludeMessages() {
return this.excludeMessagesText
.split('\n')
.map(msg => msg.trim())
.filter(msg => msg.length > 0);
}
},
methods: {
async compareSync() {
this.loading = true;
this.error = '';
try {
const payload = {
exclude_messages: this.excludeMessages
};
if (this.startTime) {
payload.start_time = this.formatDateTimeForAPI(this.startTime);
}
if (this.endTime) {
payload.end_time = this.formatDateTimeForAPI(this.endTime);
}
if (this.messageName.trim()) {
payload.message_name = this.messageName.trim();
}
const response = await fetch('/api/message-sync/compare-event-consumer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
this.compareResult = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading = false;
}
},
exportMissing() {
if (!this.compareResult || this.compareResult.missing_messages.length === 0) {
alert('没有缺失的消息可以导出');
return;
}
try {
// 准备导出数据
const exportData = {
export_time: new Date().toLocaleString(),
query_conditions: {
start_time: this.startTime || '未设置',
end_time: this.endTime || '未设置',
message_name: this.messageName || '未设置',
exclude_messages: this.excludeMessages.length > 0 ? this.excludeMessages : []
},
summary: {
crm_total: this.compareResult.crm_total,
agent_total: this.compareResult.agent_total,
missing_count: this.compareResult.missing_count,
sync_rate: this.compareResult.sync_rate + '%'
},
missing_messages: this.compareResult.missing_messages.map(msg => ({
msg_id: msg.msg_id,
event_name: msg.event_name,
msg_body: msg.msg_body,
created: msg.created,
updated: msg.updated
}))
};
// 生成JSON文件
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
// 生成文件名
const timestamp = new Date().toISOString().replace(/[:.]/g, '').slice(0, -5);
const filename = `missing_messages_${timestamp}.json`;
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
alert(`成功导出 ${this.compareResult.missing_messages.length} 条缺失消息`);
} catch (error) {
this.error = '导出失败: ' + error.message;
}
},
showDetail(message) {
this.selectedMessage = message;
this.showDetailModal = true;
},
formatDateTime(dateString) {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
},
formatDateTimeForAPI(dateTimeLocal) {
if (!dateTimeLocal) return null;
const date = new Date(dateTimeLocal);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
}
}
</script>

View File

@@ -0,0 +1,746 @@
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">消息分发异常查询</h1>
<p class="text-gray-600 mt-2">查询和管理异常的消息分发数据</p>
</div>
<!-- 筛选区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">筛选条件</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">消息ID</label>
<textarea
v-model="filters.msgId"
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
placeholder="输入消息ID支持多个用空格、换行或逗号分隔"
></textarea>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-2">国家代码</label>
<div class="relative">
<button
@click="showCountryDropdown = !showCountryDropdown"
type="button"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
>
<span class="text-sm text-gray-700">
{{ filters.countryCodes.length > 0 ? `已选 ${filters.countryCodes.length}` : '请选择国家代码' }}
</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="showCountryDropdown"
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
>
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
<button
@click="selectAllCountries"
type="button"
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
全选
</button>
<button
@click="clearAllCountries"
type="button"
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
>
清空
</button>
</div>
<div class="p-2">
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
value=""
v-model="filters.countryCodes"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-500 italic">()</span>
</label>
<label
v-for="code in availableCountryCodes"
:key="code"
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
>
<input
type="checkbox"
:value="code"
v-model="filters.countryCodes"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">{{ code }}</span>
</label>
</div>
</div>
</div>
</div>
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-2">域名</label>
<div class="relative">
<button
@click="showDomainDropdown = !showDomainDropdown"
type="button"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-left focus:ring-2 focus:ring-blue-500 bg-white flex items-center justify-between"
>
<span class="text-sm text-gray-700">
{{ filters.domains.length > 0 ? `已选 ${filters.domains.length}` : '请选择域名' }}
</span>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div
v-if="showDomainDropdown"
class="absolute z-50 mt-1 w-full bg-white border border-gray-300 rounded-lg shadow-lg max-h-80 overflow-y-auto"
>
<div class="sticky top-0 bg-gray-50 border-b border-gray-200 p-2 flex gap-2">
<button
@click="selectAllDomains"
type="button"
class="flex-1 px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
全选
</button>
<button
@click="clearAllDomains"
type="button"
class="flex-1 px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700"
>
清空
</button>
</div>
<div class="p-2">
<label class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
value=""
v-model="filters.domains"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-500 italic">()</span>
</label>
<label
v-for="domain in availableDomains"
:key="domain"
class="flex items-center px-2 py-2 hover:bg-gray-50 rounded cursor-pointer"
>
<input
type="checkbox"
:value="domain"
v-model="filters.domains"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">{{ domain }}</span>
</label>
</div>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
<select
v-model="filters.requestStatus"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option :value="null">全部</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
<option :value="3">重试中</option>
<option :value="4">超时</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
<select
v-model="filters.businessStatus"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option :value="null">全部</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
</select>
</div>
</div>
<div class="mt-4 flex justify-between items-center">
<div class="flex gap-2">
<button
@click="quickFilterCN"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
快速筛选 CN
</button>
<button
@click="quickFilterUS"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
快速筛选 US
</button>
</div>
<button
@click="loadData"
:disabled="loading"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
查询
</button>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 数据表格 -->
<div v-if="dispatches.length > 0" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-700">异常数据列表 ({{ dispatches.length }})</h2>
<div class="flex gap-2">
<button
@click="copySelectedMsgIds"
:disabled="selectedIds.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
复制消息ID ({{ selectedIds.length }})
</button>
<button
@click="showBatchUpdateModal"
:disabled="selectedIds.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
批量更新 ({{ selectedIds.length }})
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200" style="min-width: 1600px;">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left sticky left-0 bg-gray-50 z-10">
<input
type="checkbox"
@change="toggleSelectAll"
:checked="selectedIds.length === dispatches.length"
class="rounded"
/>
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">消息ID</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">国家代码</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">域名</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">事件名称</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">实体代码</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">Agent状态</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">重试次数</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">请求错误</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">回调错误</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">创建时间</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">更新时间</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase whitespace-nowrap">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="item in dispatches" :key="item.id" class="hover:bg-gray-50">
<td class="px-3 py-3 sticky left-0 bg-white z-10">
<input
type="checkbox"
:value="item.id"
v-model="selectedIds"
class="rounded"
/>
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.msg_id }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.country_code || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.domain || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.event_name }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.entity_code }}</td>
<td class="px-3 py-3 whitespace-nowrap">
<span :class="getStatusClass(item.request_status)">
{{ getStatusText(item.request_status) }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap">
<span :class="getBusinessStatusClass(item.business_status)">
{{ getBusinessStatusText(item.business_status) }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap">
<span v-if="!isUsDomain(item.domain)" :class="getConsumerStatusClass(item.consumer_status)">
{{ getConsumerStatusText(item.consumer_status) }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.retry_count }}</td>
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.request_error_message">
{{ item.request_error_message || '-' }}
</td>
<td class="px-3 py-3 text-sm text-gray-900 max-w-xs truncate" :title="item.business_error_message">
{{ item.business_error_message || '-' }}
</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.created || '-' }}</td>
<td class="px-3 py-3 text-sm text-gray-900 whitespace-nowrap">{{ item.updated || '-' }}</td>
<td class="px-3 py-3 whitespace-nowrap">
<button
@click="showDetail(item)"
class="text-blue-600 hover:text-blue-800 text-sm"
>
详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 详情弹窗 -->
<div v-if="detailItem" class="fixed inset-0 flex items-center justify-center z-50" @click.self="detailItem = null">
<div class="bg-white rounded-lg p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto border border-gray-200 shadow-xl">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-semibold">消息详情</h3>
<button @click="detailItem = null" class="text-gray-500 hover:text-gray-700">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-gray-700">消息ID</label>
<p class="text-sm text-gray-900">{{ detailItem.msg_id }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">服务名称</label>
<p class="text-sm text-gray-900">{{ detailItem.service_name }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">请求错误信息</label>
<p class="text-sm text-red-600">{{ detailItem.request_error_message || '-' }}</p>
</div>
<div>
<label class="text-sm font-medium text-gray-700">业务错误信息</label>
<p class="text-sm text-red-600">{{ detailItem.business_error_message || '-' }}</p>
</div>
</div>
<div>
<label class="text-sm font-medium text-gray-700 mb-2 block">消息体</label>
<pre class="bg-gray-50 p-4 rounded-lg text-xs overflow-x-auto">{{ formatJson(detailItem.msg_body) }}</pre>
</div>
</div>
</div>
</div>
<!-- 批量更新弹窗 -->
<div v-if="showBatchUpdate" class="fixed inset-0 flex items-center justify-center z-50" @click.self="showBatchUpdate = false">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full border border-gray-200 shadow-xl">
<h3 class="text-xl font-semibold mb-4">批量更新 ({{ selectedIds.length }} )</h3>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">请求状态</label>
<select v-model="batchUpdate.request_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
<option :value="3">重试中</option>
<option :value="4">超时</option>
<option :value="5">已取消</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">回调状态</label>
<select v-model="batchUpdate.business_status" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option :value="0">待处理</option>
<option :value="1">成功</option>
<option :value="2">失败</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">重试次数</label>
<input v-model.number="batchUpdate.retry_count" type="number" min="0" class="w-full border border-gray-300 rounded-lg px-3 py-2" placeholder="不修改" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">目标服务</label>
<select v-model="batchUpdate.target_service" class="w-full border border-gray-300 rounded-lg px-3 py-2">
<option :value="null">不修改</option>
<option v-for="route in serviceRoutes" :key="route.id" :value="route.id">
{{ route.display_name }}
</option>
</select>
</div>
</div>
<div class="flex justify-end space-x-4 mt-6">
<button @click="showBatchUpdate = false" class="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50">
取消
</button>
<button @click="executeBatchUpdate" :disabled="updating" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
确认更新
</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MessageDispatch',
data() {
return {
loading: false,
updating: false,
error: null,
availableCountryCodes: [],
availableDomains: [],
serviceRoutes: [],
showCountryDropdown: false,
showDomainDropdown: false,
filters: {
msgId: null,
countryCodes: [],
domains: [],
requestStatus: null,
businessStatus: null,
},
dispatches: [],
selectedIds: [],
detailItem: null,
showBatchUpdate: false,
batchUpdate: {
request_status: null,
business_status: null,
retry_count: null,
target_service: null,
},
};
},
mounted() {
this.loadCountryCodes();
this.loadDomains();
this.loadServiceRoutes();
document.addEventListener('click', this.handleClickOutside);
},
beforeUnmount() {
document.removeEventListener('click', this.handleClickOutside);
},
methods: {
async loadCountryCodes() {
try {
const response = await axios.get('/api/message-dispatch/country-codes');
this.availableCountryCodes = response.data.data;
} catch (err) {
console.error('加载国家代码列表失败:', err);
}
},
async loadDomains() {
try {
const response = await axios.get('/api/message-dispatch/domains');
this.availableDomains = response.data.data;
} catch (err) {
console.error('加载域名列表失败:', err);
}
},
async loadServiceRoutes() {
try {
const response = await axios.get('/api/message-dispatch/service-routes');
this.serviceRoutes = response.data.data;
} catch (err) {
console.error('加载服务路由列表失败:', err);
}
},
async loadData() {
this.loading = true;
this.error = null;
try {
const params = {};
// 处理消息ID按空格、换行、逗号分割
if (this.filters.msgId) {
const msgIds = this.filters.msgId
.split(/[\s,]+/)
.map(id => id.trim())
.filter(id => id.length > 0);
if (msgIds.length > 0) {
params.msg_ids = msgIds;
}
}
if (this.filters.countryCodes.length > 0) params.country_codes = this.filters.countryCodes;
if (this.filters.domains.length > 0) params.domains = this.filters.domains;
if (this.filters.requestStatus !== null) params.request_status = this.filters.requestStatus;
if (this.filters.businessStatus !== null) params.business_status = this.filters.businessStatus;
const response = await axios.get('/api/message-dispatch/abnormal', { params });
this.dispatches = response.data.data;
this.selectedIds = [];
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.loading = false;
}
},
selectAllCountries() {
this.filters.countryCodes = ['', ...this.availableCountryCodes];
},
clearAllCountries() {
this.filters.countryCodes = [];
},
selectAllDomains() {
this.filters.domains = ['', ...this.availableDomains];
},
clearAllDomains() {
this.filters.domains = [];
},
quickFilterCN() {
// CN筛选选择空和cnsha域名
const cnDomains = this.availableDomains.filter(domain =>
domain && domain.includes('cnsha')
);
this.filters.domains = ['', ...cnDomains];
this.loadData();
},
quickFilterUS() {
// US筛选选择us域名
const usDomains = this.availableDomains.filter(domain =>
domain && (domain.includes('partner-us') || domain.includes('.us.'))
);
this.filters.domains = usDomains;
this.loadData();
},
handleClickOutside(event) {
const dropdown = event.target.closest('.relative');
if (!dropdown || !dropdown.querySelector('button')) {
this.showCountryDropdown = false;
this.showDomainDropdown = false;
}
},
toggleSelectAll(event) {
if (event.target.checked) {
this.selectedIds = this.dispatches.map(item => item.id);
} else {
this.selectedIds = [];
}
},
copySelectedMsgIds() {
const selectedItems = this.dispatches.filter(item => this.selectedIds.includes(item.id));
const msgIds = selectedItems.map(item => `'${item.msg_id}'`).join(',');
// 使用降级方案兼容HTTP环境
const textArea = document.createElement('textarea');
textArea.value = msgIds;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
this.showToast(`已复制 ${selectedItems.length} 个消息ID到剪贴板`, 'success');
} else {
this.showToast('复制失败,请重试', 'error');
}
} catch (err) {
document.body.removeChild(textArea);
console.error('复制失败:', err);
this.showToast('复制失败,请重试', 'error');
}
},
showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${
type === 'success' ? 'bg-green-500' : 'bg-red-500'
}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 2000);
},
showDetail(item) {
this.detailItem = item;
},
showBatchUpdateModal() {
this.showBatchUpdate = true;
this.batchUpdate = {
request_status: null,
business_status: null,
retry_count: null,
target_service: null,
};
},
async executeBatchUpdate() {
this.updating = true;
this.error = null;
try {
const selectedRoute = this.batchUpdate.target_service !== null
? this.serviceRoutes.find(route => String(route.id) === String(this.batchUpdate.target_service))
: null;
const updates = this.selectedIds.map(id => {
const update = { id: String(id) };
if (this.batchUpdate.request_status !== null) update.request_status = String(this.batchUpdate.request_status);
if (this.batchUpdate.business_status !== null) update.business_status = String(this.batchUpdate.business_status);
if (this.batchUpdate.retry_count !== null) update.retry_count = String(this.batchUpdate.retry_count);
if (this.batchUpdate.target_service !== null) {
update.target_service = String(this.batchUpdate.target_service);
if (selectedRoute && selectedRoute.country_code) {
update.country_code = selectedRoute.country_code;
}
}
return update;
});
const response = await axios.post('/api/message-dispatch/batch-update', { updates });
alert(`更新完成!成功: ${response.data.data.summary.success}, 失败: ${response.data.data.summary.failure}`);
this.showBatchUpdate = false;
this.loadData();
} catch (err) {
this.error = err.response?.data?.message || err.message;
} finally {
this.updating = false;
}
},
isUsDomain(domain) {
return domain === 'partner-us.eainc.com';
},
formatJson(json) {
try {
const obj = typeof json === 'string' ? JSON.parse(json) : json;
return JSON.stringify(obj, null, 2);
} catch {
return json;
}
},
getStatusText(status) {
const map = { 0: '待处理', 1: '成功', 2: '失败', 3: '重试中', 4: '超时', 5: '已取消' };
return map[status] || '未知';
},
getStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
3: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
4: 'px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-800',
5: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
};
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
getBusinessStatusText(status) {
const map = { 0: '待处理', 1: '成功', 2: '失败' };
return map[status] || '未知';
},
getBusinessStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
};
return map[status] || 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
getConsumerStatusText(status) {
const map = {
0: '待处理',
1: '处理成功',
2: '处理中',
3: '处理失败',
4: '等待重试',
5: '处理忽略',
};
return map[status] ?? '未知';
},
getConsumerStatusClass(status) {
const map = {
0: 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800',
1: 'px-2 py-1 text-xs rounded-full bg-green-100 text-green-800',
2: 'px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800',
3: 'px-2 py-1 text-xs rounded-full bg-red-100 text-red-800',
4: 'px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800',
5: 'px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-800',
};
return map[status] ?? 'px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800';
},
},
};
</script>

View File

@@ -0,0 +1,358 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">消息同步</h1>
<p class="text-gray-600 mt-2">批量输入消息ID从crmslave数据库查询并同步到agent服务</p>
</div>
<!-- 输入区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">消息ID输入</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
消息ID列表 (每行一个ID)
</label>
<textarea
v-model="messageIdsText"
rows="8"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入消息ID每行一个&#10;例如:&#10;af7e5ca7-2779-0e9e-93d1-68c79ceffd9033&#10;bf8f6db8-3880-1f0f-a4e2-79d8adf00144"
></textarea>
<div class="text-sm text-gray-500 mt-1">
{{ messageIdsList.length }} 个消息ID
</div>
</div>
<div class="flex space-x-4">
<button
@click="queryMessages"
:disabled="loading.query || messageIdsList.length === 0"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.query" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
查询消息
</button>
<button
@click="syncMessages"
:disabled="loading.sync || !queryResults || messageIdsList.length === 0"
class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.sync" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
执行同步
</button>
<button
@click="testConnection"
:disabled="loading.test"
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<svg v-if="loading.test" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
测试连接
</button>
</div>
</div>
</div>
<!-- 错误信息 -->
<div v-if="error" class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div class="flex">
<svg class="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="text-sm font-medium text-red-800">错误</h3>
<p class="text-sm text-red-700 mt-1">{{ error }}</p>
</div>
</div>
</div>
<!-- 查询结果 -->
<div v-if="queryResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">查询结果</h2>
<!-- 统计信息 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ queryResults.stats.total_requested }}</div>
<div class="text-sm text-blue-600">请求总数</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ queryResults.stats.total_found }}</div>
<div class="text-sm text-green-600">找到记录</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ queryResults.stats.total_missing }}</div>
<div class="text-sm text-red-600">缺失记录</div>
</div>
<div class="bg-purple-50 rounded-lg p-4">
<div class="text-2xl font-bold text-purple-600">{{ Object.keys(queryResults.stats.event_types).length }}</div>
<div class="text-sm text-purple-600">事件类型</div>
</div>
</div>
<!-- 消息列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">事件类型</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">跟踪ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间戳</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="message in queryResults.messages" :key="message.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ message.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ message.event_type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500">{{ message.trace_id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatTimestamp(message.timestamp) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showMessageDetail(message)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 同步结果 -->
<div v-if="syncResults" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">同步结果</h2>
<!-- 同步统计 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-bold text-blue-600">{{ syncResults.summary.total }}</div>
<div class="text-sm text-blue-600">总计</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-bold text-green-600">{{ syncResults.summary.success }}</div>
<div class="text-sm text-green-600">成功</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-bold text-red-600">{{ syncResults.summary.failure }}</div>
<div class="text-sm text-red-600">失败</div>
</div>
</div>
<!-- 同步结果列表 -->
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消息ID</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">响应</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="result in syncResults.results" :key="result.msg_id">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">{{ result.msg_id }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span v-if="result.success" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
成功
</span>
<span v-else class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
失败
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
{{ result.success ? '同步成功' : result.error }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="showSyncDetail(result)"
class="text-blue-600 hover:text-blue-900"
>
查看详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">详细信息</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="max-h-96 overflow-y-auto">
<pre class="bg-gray-100 p-4 rounded-lg text-sm overflow-x-auto">{{ JSON.stringify(selectedDetail, null, 2) }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'MessageSync',
data() {
return {
messageIdsText: '',
queryResults: null,
syncResults: null,
loading: {
query: false,
sync: false,
test: false
},
error: '',
showDetailModal: false,
selectedDetail: null
}
},
computed: {
messageIdsList() {
return this.messageIdsText
.split('\n')
.map(id => id.trim())
.filter(id => id.length > 0);
}
},
methods: {
async queryMessages() {
if (this.messageIdsList.length === 0) {
this.error = '请输入至少一个消息ID';
return;
}
this.loading.query = true;
this.error = '';
this.queryResults = null;
try {
const response = await fetch('/api/message-sync/query', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
message_ids: this.messageIdsList
})
});
const data = await response.json();
if (data.success) {
this.queryResults = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.query = false;
}
},
async syncMessages() {
if (this.messageIdsList.length === 0) {
this.error = '请输入至少一个消息ID';
return;
}
this.loading.sync = true;
this.error = '';
this.syncResults = null;
try {
const response = await fetch('/api/message-sync/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
message_ids: this.messageIdsList
})
});
const data = await response.json();
if (data.success) {
this.syncResults = data.data;
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.sync = false;
}
},
async testConnection() {
this.loading.test = true;
this.error = '';
try {
const response = await fetch('/api/message-sync/test-connection');
const data = await response.json();
if (data.success) {
alert('数据库连接测试成功!');
} else {
this.error = data.message;
}
} catch (error) {
this.error = '网络请求失败: ' + error.message;
} finally {
this.loading.test = false;
}
},
showMessageDetail(message) {
this.selectedDetail = message;
this.showDetailModal = true;
},
showSyncDetail(result) {
this.selectedDetail = result;
this.showDetailModal = true;
},
closeDetailModal() {
this.showDetailModal = false;
this.selectedDetail = null;
},
formatTimestamp(timestamp) {
if (!timestamp) return '-';
return new Date(timestamp * 1000).toLocaleString();
}
}
}
</script>