2192 lines
94 KiB
JavaScript
2192 lines
94 KiB
JavaScript
// 全局变量
|
||
let currentResults = null;
|
||
let currentIdenticalPage = 1;
|
||
let identicalPageSize = 10;
|
||
let filteredIdenticalResults = [];
|
||
let currentDifferencePage = 1;
|
||
let differencePageSize = 10;
|
||
let filteredDifferenceResults = [];
|
||
|
||
// 页面加载完成后初始化
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadDefaultConfig();
|
||
loadConfigGroups(); // 加载配置组列表
|
||
});
|
||
|
||
// 加载配置组列表
|
||
async function loadConfigGroups() {
|
||
try {
|
||
const response = await fetch('/api/config-groups');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const select = document.getElementById('configGroupSelect');
|
||
select.innerHTML = '<option value="">选择配置组...</option>';
|
||
|
||
result.data.forEach(group => {
|
||
const option = document.createElement('option');
|
||
option.value = group.id;
|
||
option.textContent = `${group.name} (${group.description || '无描述'})`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
if (result.data.length === 0) {
|
||
console.log('暂无配置组,数据库已就绪');
|
||
}
|
||
} else {
|
||
console.error('加载配置组失败:', result.error);
|
||
// 如果是数据库问题,显示友好提示
|
||
if (result.error && result.error.includes('table')) {
|
||
showAlert('info', '正在初始化数据库,请稍后再试');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载配置组失败:', error);
|
||
showAlert('warning', '配置组功能暂时不可用,但不影响基本查询功能');
|
||
}
|
||
}
|
||
|
||
// 加载选中的配置组
|
||
async function loadSelectedConfigGroup() {
|
||
const groupId = document.getElementById('configGroupSelect').value;
|
||
|
||
if (!groupId) {
|
||
showAlert('warning', '请先选择一个配置组');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const config = result.data;
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (config.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = config.pro_config.username || '';
|
||
document.getElementById('pro_password').value = config.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = config.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (config.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = config.test_config.port || 9042;
|
||
document.getElementById('test_username').value = config.test_config.username || '';
|
||
document.getElementById('test_password').value = config.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = config.test_config.table || '';
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = (config.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (config.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (config.query_config.exclude_fields || []).join(',');
|
||
|
||
showAlert('success', `配置组 "${config.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示保存配置组对话框
|
||
function showSaveConfigDialog() {
|
||
const modalContent = `
|
||
<div class="modal fade" id="saveConfigModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-save"></i> 保存配置组
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">配置组名称 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="configGroupName" placeholder="请输入配置组名称">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="configGroupDesc" rows="3" placeholder="请输入配置组描述(可选)"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveConfigGroup()">
|
||
<i class="fas fa-check"></i> 保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('saveConfigModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('saveConfigModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 保存配置组
|
||
async function saveConfigGroup() {
|
||
const name = document.getElementById('configGroupName').value.trim();
|
||
const description = document.getElementById('configGroupDesc').value.trim();
|
||
|
||
if (!name) {
|
||
showAlert('warning', '请输入配置组名称');
|
||
return;
|
||
}
|
||
|
||
const config = getCurrentConfig();
|
||
|
||
try {
|
||
const response = await fetch('/api/config-groups', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
description: description,
|
||
...config
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('saveConfigModal'));
|
||
modal.hide();
|
||
|
||
// 重新加载配置组列表
|
||
await loadConfigGroups();
|
||
|
||
showAlert('success', result.message);
|
||
} else {
|
||
showAlert('danger', result.error || '保存配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '保存配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示管理配置组对话框
|
||
async function showManageConfigDialog() {
|
||
try {
|
||
const response = await fetch('/api/config-groups');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
let configGroupsList = '';
|
||
|
||
if (result.data.length === 0) {
|
||
configGroupsList = '<p class="text-muted text-center">暂无配置组</p>';
|
||
} else {
|
||
result.data.forEach(group => {
|
||
const createdDate = new Date(group.created_at).toLocaleString();
|
||
const updatedDate = new Date(group.updated_at).toLocaleString();
|
||
|
||
configGroupsList += `
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<h6 class="card-title mb-1">${group.name}</h6>
|
||
<p class="card-text text-muted small mb-1">${group.description || '无描述'}</p>
|
||
<small class="text-muted">创建: ${createdDate} | 更新: ${updatedDate}</small>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-sm btn-primary me-1" onclick="loadConfigGroupById(${group.id})">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteConfigGroup(${group.id}, '${group.name}')">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="manageConfigModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-cog"></i> 配置组管理
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 500px; overflow-y: auto;">
|
||
${configGroupsList}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('manageConfigModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('manageConfigModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载配置组列表失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组列表失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 通过ID加载配置组
|
||
async function loadConfigGroupById(groupId) {
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const config = result.data;
|
||
|
||
// 填充表单数据(与loadSelectedConfigGroup相同逻辑)
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (config.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = config.pro_config.username || '';
|
||
document.getElementById('pro_password').value = config.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = config.pro_config.table || '';
|
||
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (config.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = config.test_config.port || 9042;
|
||
document.getElementById('test_username').value = config.test_config.username || '';
|
||
document.getElementById('test_password').value = config.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = config.test_config.table || '';
|
||
|
||
document.getElementById('keys').value = (config.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (config.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (config.query_config.exclude_fields || []).join(',');
|
||
|
||
// 更新下拉框选中状态
|
||
document.getElementById('configGroupSelect').value = groupId;
|
||
|
||
// 关闭管理modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('manageConfigModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', `配置组 "${config.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除配置组
|
||
async function deleteConfigGroup(groupId, groupName) {
|
||
if (!confirm(`确定要删除配置组 "${groupName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新加载配置组列表
|
||
await loadConfigGroups();
|
||
|
||
// 重新显示管理对话框
|
||
setTimeout(() => {
|
||
showManageConfigDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载默认配置
|
||
async function loadDefaultConfig() {
|
||
try {
|
||
const response = await fetch('/api/default-config');
|
||
const config = await response.json();
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = config.pro_config.hosts.join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port;
|
||
document.getElementById('pro_username').value = config.pro_config.username;
|
||
document.getElementById('pro_password').value = config.pro_config.password;
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace;
|
||
document.getElementById('pro_table').value = config.pro_config.table;
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = config.test_config.hosts.join(',');
|
||
document.getElementById('test_port').value = config.test_config.port;
|
||
document.getElementById('test_username').value = config.test_config.username;
|
||
document.getElementById('test_password').value = config.test_config.password;
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace;
|
||
document.getElementById('test_table').value = config.test_config.table;
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = config.keys.join(',');
|
||
document.getElementById('fields_to_compare').value = config.fields_to_compare.join(',');
|
||
document.getElementById('exclude_fields').value = config.exclude_fields.join(',');
|
||
|
||
showAlert('success', '默认配置加载成功');
|
||
} catch (error) {
|
||
showAlert('danger', '加载默认配置失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 获取当前配置
|
||
function getCurrentConfig() {
|
||
return {
|
||
pro_config: {
|
||
cluster_name: document.getElementById('pro_cluster_name').value,
|
||
datacenter: document.getElementById('pro_datacenter').value,
|
||
hosts: document.getElementById('pro_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('pro_port').value) || 9042,
|
||
username: document.getElementById('pro_username').value,
|
||
password: document.getElementById('pro_password').value,
|
||
keyspace: document.getElementById('pro_keyspace').value,
|
||
table: document.getElementById('pro_table').value
|
||
},
|
||
test_config: {
|
||
cluster_name: document.getElementById('test_cluster_name').value,
|
||
datacenter: document.getElementById('test_datacenter').value,
|
||
hosts: document.getElementById('test_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('test_port').value) || 9042,
|
||
username: document.getElementById('test_username').value,
|
||
password: document.getElementById('test_password').value,
|
||
keyspace: document.getElementById('test_keyspace').value,
|
||
table: document.getElementById('test_table').value
|
||
},
|
||
keys: document.getElementById('keys').value.split(',').map(k => k.trim()).filter(k => k),
|
||
fields_to_compare: document.getElementById('fields_to_compare').value
|
||
.split(',').map(f => f.trim()).filter(f => f),
|
||
exclude_fields: document.getElementById('exclude_fields').value
|
||
.split(',').map(f => f.trim()).filter(f => f),
|
||
values: document.getElementById('query_values').value
|
||
.split('\n').map(v => v.trim()).filter(v => v)
|
||
};
|
||
}
|
||
|
||
// 执行查询比对
|
||
async function executeQuery() {
|
||
const config = getCurrentConfig();
|
||
|
||
// 验证配置
|
||
if (!config.values.length) {
|
||
showAlert('warning', '请输入查询Key值');
|
||
return;
|
||
}
|
||
|
||
if (!config.keys.length) {
|
||
showAlert('warning', '请输入主键字段');
|
||
return;
|
||
}
|
||
|
||
// 显示加载动画
|
||
document.getElementById('loading').style.display = 'block';
|
||
document.getElementById('results').style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/query', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(config)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || '查询失败');
|
||
}
|
||
|
||
const results = await response.json();
|
||
currentResults = results;
|
||
displayResults(results);
|
||
|
||
} catch (error) {
|
||
showAlert('danger', '查询失败: ' + error.message);
|
||
} finally {
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 显示查询结果
|
||
function displayResults(results) {
|
||
// 显示统计信息
|
||
displayStats(results);
|
||
|
||
// 更新选项卡计数
|
||
document.getElementById('diff-count').textContent = results.differences.length;
|
||
document.getElementById('identical-count').textContent = results.identical_results.length;
|
||
|
||
// 初始化相同结果分页数据
|
||
filteredIdenticalResults = results.identical_results;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 初始化差异结果分页数据
|
||
filteredDifferenceResults = results.differences;
|
||
currentDifferencePage = 1;
|
||
|
||
// 显示各个面板内容
|
||
displayDifferences();
|
||
displayIdenticalResults();
|
||
displayComparisonSummary(results.summary);
|
||
|
||
// 显示结果区域
|
||
document.getElementById('results').style.display = 'block';
|
||
|
||
showAlert('success', `查询完成!共处理${results.total_keys}个Key,发现${results.differences.length}处差异,${results.identical_results.length}条记录完全相同`);
|
||
}
|
||
|
||
// 显示统计信息
|
||
function displayStats(results) {
|
||
const statsHtml = `
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-primary text-white">
|
||
<h3>${results.total_keys}</h3>
|
||
<p>总Key数量</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-info text-white">
|
||
<h3>${results.pro_count}</h3>
|
||
<p>生产表记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-secondary text-white">
|
||
<h3>${results.test_count}</h3>
|
||
<p>测试表记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-success text-white">
|
||
<h3>${results.identical_results.length}</h3>
|
||
<p>相同记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-warning text-white">
|
||
<h3>${results.differences.length}</h3>
|
||
<p>差异记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-danger text-white">
|
||
<h3>${Math.round((results.identical_results.length / results.total_keys) * 100)}%</h3>
|
||
<p>一致性比例</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('stats').innerHTML = statsHtml;
|
||
}
|
||
|
||
// 显示差异详情
|
||
function displayDifferences() {
|
||
const differencesContainer = document.getElementById('differences');
|
||
|
||
if (!filteredDifferenceResults.length) {
|
||
differencesContainer.innerHTML = '<p class="text-success"><i class="fas fa-check"></i> 未发现差异</p>';
|
||
return;
|
||
}
|
||
|
||
// 计算分页
|
||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||
const startIndex = (currentDifferencePage - 1) * differencePageSize;
|
||
const endIndex = startIndex + differencePageSize;
|
||
const currentPageData = filteredDifferenceResults.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changeDifferencePageSize(this.value)">
|
||
<option value="5" ${differencePageSize == 5 ? 'selected' : ''}>5条</option>
|
||
<option value="10" ${differencePageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="20" ${differencePageSize == 20 ? 'selected' : ''}>20条</option>
|
||
<option value="50" ${differencePageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="100" ${differencePageSize == 100 ? 'selected' : ''}>100条</option>
|
||
<option value="custom_diff">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customDiffPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomDifferencePageSize(this.value)" onkeypress="handleCustomDiffPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${filteredDifferenceResults.length} 条差异记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
|
||
onkeyup="searchDifferenceResults(this.value)" id="differenceSearch">
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页数据
|
||
currentPageData.forEach((diff, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
if (diff.message) {
|
||
// 记录不存在的情况
|
||
html += `
|
||
<div class="difference-item card mb-3 border-warning">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>差异 #${globalIndex}</strong>
|
||
<span class="badge bg-warning ms-2">记录缺失</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// 字段值差异的情况
|
||
const isJson = diff.is_json;
|
||
const isArray = diff.is_array;
|
||
const jsonClass = isJson ? 'json-field' : '';
|
||
|
||
html += `
|
||
<div class="difference-item card mb-3 border-danger">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>差异 #${globalIndex}</strong>
|
||
<span class="badge bg-danger ms-2">字段差异</span>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
|
||
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
|
||
data-bs-toggle="collapse" data-bs-target="#diffCollapse${globalIndex}">
|
||
<i class="fas fa-eye"></i> 查看详情
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||
<p class="mb-0"><strong>差异字段:</strong> ${diff.field}</p>
|
||
</div>
|
||
<div class="collapse" id="diffCollapse${globalIndex}">
|
||
<div class="card-body">
|
||
<div class="mb-4">
|
||
<div class="field-header mb-2">
|
||
<i class="fas fa-tag text-primary"></i>
|
||
<strong>${diff.field}</strong>
|
||
</div>
|
||
|
||
<!-- 环境对比数据行 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="field-container position-relative">
|
||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}" style="max-height: 400px; overflow-y: auto; margin: 0;">${escapeHtml(diff.pro_value)}
|
||
${escapeHtml(diff.test_value)}</pre>
|
||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||
onclick="copyToClipboard('${escapeForJs(diff.pro_value)}\\n${escapeForJs(diff.test_value)}', this)"
|
||
title="复制全部内容">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
differencesContainer.innerHTML = html;
|
||
}
|
||
|
||
// HTML转义函数,防止XSS
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 显示相同结果
|
||
function displayIdenticalResults() {
|
||
const identicalContainer = document.getElementById('identical-results');
|
||
|
||
if (!filteredIdenticalResults.length) {
|
||
identicalContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> 没有完全相同的记录</p>';
|
||
return;
|
||
}
|
||
|
||
// 计算分页
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
const startIndex = (currentIdenticalPage - 1) * identicalPageSize;
|
||
const endIndex = startIndex + identicalPageSize;
|
||
const currentPageData = filteredIdenticalResults.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changePageSize(this.value)">
|
||
<option value="5" ${identicalPageSize == 5 ? 'selected' : ''}>5条</option>
|
||
<option value="10" ${identicalPageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="20" ${identicalPageSize == 20 ? 'selected' : ''}>20条</option>
|
||
<option value="50" ${identicalPageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="100" ${identicalPageSize == 100 ? 'selected' : ''}>100条</option>
|
||
<option value="custom">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomPageSize(this.value)" onkeypress="handleCustomPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${filteredIdenticalResults.length} 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键或字段内容..."
|
||
onkeyup="searchIdenticalResults(this.value)" id="identicalSearch">
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页数据
|
||
currentPageData.forEach((result, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
html += `
|
||
<div class="card mb-3 border-success">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>相同记录 #${globalIndex}</strong>
|
||
<span class="badge bg-success ms-2">完全匹配</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
|
||
data-bs-toggle="collapse" data-bs-target="#collapse${globalIndex}">
|
||
<i class="fas fa-eye"></i> 查看详情
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick="showRawData('${escapeForJs(JSON.stringify(result.key))}')">
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(result.key)}</p>
|
||
</div>
|
||
<div class="collapse" id="collapse${globalIndex}">
|
||
<div class="card-body">
|
||
`;
|
||
|
||
// 显示字段对比,左右布局
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allFields = new Set([...Object.keys(proFields), ...Object.keys(testFields)]);
|
||
|
||
html += '<div class="row"><div class="col-12"><h6 class="mb-3">字段对比 (生产环境 vs 测试环境):</h6></div></div>';
|
||
|
||
allFields.forEach(fieldName => {
|
||
const proValue = proFields[fieldName] || '';
|
||
const testValue = testFields[fieldName] || '';
|
||
|
||
// 更安全的JSON检测方法
|
||
let isJson = false;
|
||
try {
|
||
if (typeof proValue === 'string' && (proValue.startsWith('{') || proValue.startsWith('['))) {
|
||
JSON.parse(proValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e1) {
|
||
try {
|
||
if (typeof testValue === 'string' && (testValue.startsWith('{') || testValue.startsWith('['))) {
|
||
JSON.parse(testValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e2) {
|
||
// 如果都不是有效的JSON,保持isJson为false
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<div class="mb-4">
|
||
<div class="field-header mb-2">
|
||
<i class="fas fa-tag text-primary"></i>
|
||
<strong>${fieldName}</strong>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON</span>' : ''}
|
||
</div>
|
||
|
||
<!-- 环境对比数据行 -->
|
||
<div class="row mb-2">
|
||
<div class="col-12">
|
||
<div class="field-container position-relative">
|
||
<pre class="field-value bg-light p-2 rounded mb-0" style="max-height: 400px; overflow-y: auto; margin: 0;">${escapeHtml(String(proValue))}
|
||
${escapeHtml(String(testValue))}</pre>
|
||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||
onclick="copyToClipboard('${escapeForJs(String(proValue))}\\n${escapeForJs(String(testValue))}', this)"
|
||
title="复制全部内容">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
identicalContainer.innerHTML = html;
|
||
}
|
||
|
||
// 生成分页导航
|
||
function generatePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定页面
|
||
function goToIdenticalPage(page) {
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentIdenticalPage = page;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 改变每页显示数量
|
||
function changePageSize(newSize) {
|
||
if (newSize === 'custom') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
identicalPageSize = parseInt(newSize);
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 设置自定义页面大小
|
||
function setCustomPageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
identicalPageSize = pageSize;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayIdenticalResults();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义页面大小输入框回车事件
|
||
function handleCustomPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomPageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索相同结果
|
||
function searchIdenticalResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
if (!searchTerm.trim()) {
|
||
filteredIdenticalResults = currentResults.identical_results;
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
filteredIdenticalResults = currentResults.identical_results.filter(result => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(result.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段内容
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allValues = [...Object.values(proFields), ...Object.values(testFields)];
|
||
|
||
return allValues.some(value =>
|
||
String(value).toLowerCase().includes(term)
|
||
);
|
||
});
|
||
}
|
||
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 生成差异分页导航
|
||
function generateDifferencePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定差异页面
|
||
function goToDifferencePage(page) {
|
||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentDifferencePage = page;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 改变差异每页显示数量
|
||
function changeDifferencePageSize(newSize) {
|
||
if (newSize === 'custom_diff') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
differencePageSize = parseInt(newSize);
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 设置自定义差异页面大小
|
||
function setCustomDifferencePageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
differencePageSize = pageSize;
|
||
currentDifferencePage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom_diff';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayDifferences();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义差异页面大小输入框回车事件
|
||
function handleCustomDiffPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomDifferencePageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索差异结果
|
||
function searchDifferenceResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
if (!searchTerm.trim()) {
|
||
filteredDifferenceResults = currentResults.differences;
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
filteredDifferenceResults = currentResults.differences.filter(diff => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(diff.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段名
|
||
if (diff.field && diff.field.toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索字段值
|
||
if (diff.pro_value && String(diff.pro_value).toLowerCase().includes(term)) return true;
|
||
if (diff.test_value && String(diff.test_value).toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索消息
|
||
if (diff.message && diff.message.toLowerCase().includes(term)) return true;
|
||
|
||
return false;
|
||
});
|
||
}
|
||
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 复制到剪贴板
|
||
function copyToClipboard(text, button) {
|
||
navigator.clipboard.writeText(text).then(function() {
|
||
// 显示复制成功反馈
|
||
const originalIcon = button.innerHTML;
|
||
button.innerHTML = '<i class="fas fa-check text-success"></i>';
|
||
button.classList.add('btn-success');
|
||
button.classList.remove('btn-outline-secondary');
|
||
|
||
setTimeout(() => {
|
||
button.innerHTML = originalIcon;
|
||
button.classList.remove('btn-success');
|
||
button.classList.add('btn-outline-secondary');
|
||
}, 1000);
|
||
}).catch(function(err) {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动复制');
|
||
});
|
||
}
|
||
|
||
// JS字符串转义
|
||
function escapeForJs(str) {
|
||
if (str === null || str === undefined) {
|
||
return '';
|
||
}
|
||
return String(str)
|
||
.replace(/\\/g, '\\\\') // 反斜杠必须先处理
|
||
.replace(/'/g, "\\'") // 单引号
|
||
.replace(/"/g, '\\"') // 双引号
|
||
.replace(/\n/g, '\\n') // 换行符
|
||
.replace(/\r/g, '\\r') // 回车符
|
||
.replace(/\t/g, '\\t') // 制表符
|
||
.replace(/\f/g, '\\f') // 换页符
|
||
.replace(/\b/g, '\\b') // 退格符
|
||
.replace(/\0/g, '\\0'); // 空字符
|
||
}
|
||
|
||
// 显示原生数据
|
||
function showRawData(keyStr) {
|
||
console.log('showRawData 调用,参数:', keyStr);
|
||
|
||
if (!currentResults) {
|
||
console.error('没有查询结果数据');
|
||
showAlert('warning', '请先执行查询操作');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析主键
|
||
const key = JSON.parse(keyStr);
|
||
const keyValue = Object.values(key)[0];
|
||
|
||
console.log('解析主键成功:', key, '键值:', keyValue);
|
||
|
||
// 查找对应的原生数据记录
|
||
let proRecord = null;
|
||
let testRecord = null;
|
||
|
||
if (currentResults.raw_pro_data && currentResults.raw_pro_data.length > 0) {
|
||
proRecord = currentResults.raw_pro_data.find(record =>
|
||
Object.values(record).includes(keyValue)
|
||
);
|
||
}
|
||
|
||
if (currentResults.raw_test_data && currentResults.raw_test_data.length > 0) {
|
||
testRecord = currentResults.raw_test_data.find(record =>
|
||
Object.values(record).includes(keyValue)
|
||
);
|
||
}
|
||
|
||
console.log('查找记录结果 - 生产:', !!proRecord, '测试:', !!testRecord);
|
||
|
||
// 安全处理数据
|
||
const proData = proRecord ? JSON.stringify(proRecord, null, 2) : '{}';
|
||
const testData = testRecord ? JSON.stringify(testRecord, null, 2) : '{}';
|
||
|
||
// 创建重构后的简化模态框
|
||
const modalHtml = `
|
||
<div class="modal fade" id="rawDataModal" tabindex="-1" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-database"></i> 原生数据查看 - ${escapeHtml(String(keyValue))}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="row mb-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<span class="badge bg-primary me-2"><i class="fas fa-server"></i> 生产环境: ${proRecord ? Object.keys(proRecord).length : 0} 字段</span>
|
||
<span class="badge bg-info"><i class="fas fa-flask"></i> 测试环境: ${testRecord ? Object.keys(testRecord).length : 0} 字段</span>
|
||
</div>
|
||
<button class="btn btn-sm btn-outline-primary" onclick="copyRawData()" title="复制全部数据">
|
||
<i class="fas fa-copy"></i> 复制数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="card">
|
||
<div class="card-header bg-light">
|
||
<h6 class="mb-0"><i class="fas fa-code"></i> 原生数据内容</h6>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div class="border-0 bg-light" style="max-height: 500px; overflow-y: auto;">
|
||
<pre id="rawDataContent" class="mb-0 p-3" style="white-space: pre-wrap; font-size: 0.9em; line-height: 1.4;"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||
<i class="fas fa-times"></i> 关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有模态框
|
||
const existingModal = document.getElementById('rawDataModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新模态框
|
||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||
|
||
// 设置预格式化内容,避免模板字符串中的转义问题
|
||
const rawDataContent = document.getElementById('rawDataContent');
|
||
if (rawDataContent) {
|
||
rawDataContent.textContent = proData + '\n\n' + testData;
|
||
}
|
||
|
||
// 存储数据用于复制功能
|
||
window.currentRawData = {
|
||
pro: proData,
|
||
test: testData,
|
||
combined: proData + '\n\n' + testData
|
||
};
|
||
|
||
// 显示模态框
|
||
const modal = new bootstrap.Modal(document.getElementById('rawDataModal'));
|
||
modal.show();
|
||
|
||
console.log('重构后的原生数据模态框已显示');
|
||
|
||
} catch (error) {
|
||
console.error('显示原生数据失败:', error);
|
||
showAlert('danger', '显示原生数据失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 复制原生数据
|
||
function copyRawData() {
|
||
if (window.currentRawData) {
|
||
const combinedData = window.currentRawData.combined ||
|
||
(window.currentRawData.pro + '\n\n' + window.currentRawData.test);
|
||
|
||
navigator.clipboard.writeText(combinedData).then(() => {
|
||
showAlert('success', '原生数据已复制到剪贴板');
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动选择复制');
|
||
});
|
||
} else {
|
||
showAlert('warning', '无可复制的数据');
|
||
}
|
||
}
|
||
|
||
// 显示差异数据的原生数据
|
||
function showDifferenceRawData(keyStr) {
|
||
showRawData(keyStr); // 复用相同的原生数据显示逻辑
|
||
}
|
||
|
||
// 显示比较总结
|
||
function displayComparisonSummary(summary) {
|
||
const summaryContainer = document.getElementById('comparison-summary');
|
||
|
||
if (!summary) {
|
||
summaryContainer.innerHTML = '<p class="text-muted">无总结数据</p>';
|
||
return;
|
||
}
|
||
|
||
const qualityLevel = summary.data_quality.quality_level;
|
||
|
||
const html = `
|
||
<!-- 概览统计 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 数据概览</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-4">
|
||
<h4 class="text-primary">${summary.overview.total_keys_queried}</h4>
|
||
<small>查询Key总数</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-success">${summary.overview.identical_records}</h4>
|
||
<small>相同记录</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-warning">${summary.overview.different_records}</h4>
|
||
<small>差异记录</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-award"></i> 数据质量评估</h6>
|
||
</div>
|
||
<div class="card-body text-center">
|
||
<h3 class="text-${qualityLevel.color}">${qualityLevel.level}</h3>
|
||
<p class="mb-2">${qualityLevel.description}</p>
|
||
<div class="progress mb-2">
|
||
<div class="progress-bar bg-${qualityLevel.color}" role="progressbar"
|
||
style="width: ${summary.data_quality.consistency_score}%">
|
||
${summary.data_quality.consistency_score}%
|
||
</div>
|
||
</div>
|
||
<small>数据一致性评分</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细分析 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-percentage"></i> 百分比统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据一致性:</span>
|
||
<strong class="text-success">${summary.percentages.data_consistency}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-success" style="width: ${summary.percentages.data_consistency}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据差异率:</span>
|
||
<strong class="text-warning">${summary.percentages.data_differences}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-warning" style="width: ${summary.percentages.data_differences}%"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据缺失率:</span>
|
||
<strong class="text-danger">${summary.percentages.missing_rate}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-danger" style="width: ${summary.percentages.missing_rate}%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-list-alt"></i> 字段差异TOP5</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
${summary.field_analysis.most_different_fields.length > 0 ?
|
||
summary.field_analysis.most_different_fields.map(([field, count]) => `
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<code>${field}</code>
|
||
<span class="badge bg-danger">${count}次</span>
|
||
</div>
|
||
`).join('') :
|
||
'<p class="text-muted">无字段差异统计</p>'
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-lightbulb"></i> 改进建议</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(recommendation => `
|
||
<li class="mb-2">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${recommendation}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
summaryContainer.innerHTML = html;
|
||
}
|
||
|
||
// 显示字段差异统计
|
||
function displayFieldStats(fieldStats) {
|
||
const fieldStatsContainer = document.getElementById('field_stats');
|
||
|
||
if (!Object.keys(fieldStats).length) {
|
||
fieldStatsContainer.innerHTML = '<p class="text-muted">无字段差异统计</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="table-responsive"><table class="table table-striped">';
|
||
html += '<thead><tr><th>字段名</th><th>差异次数</th><th>占比</th></tr></thead><tbody>';
|
||
|
||
const totalDiffs = Object.values(fieldStats).reduce((sum, count) => sum + count, 0);
|
||
|
||
Object.entries(fieldStats)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
const percentage = ((count / totalDiffs) * 100).toFixed(1);
|
||
html += `
|
||
<tr>
|
||
<td><code>${field}</code></td>
|
||
<td><span class="badge bg-danger">${count}</span></td>
|
||
<td>${percentage}%</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
fieldStatsContainer.innerHTML = html;
|
||
}
|
||
|
||
// 清空结果
|
||
function clearResults() {
|
||
document.getElementById('results').style.display = 'none';
|
||
document.getElementById('query_values').value = '';
|
||
currentResults = null;
|
||
showAlert('info', '结果已清空');
|
||
}
|
||
|
||
// 显示查询历史对话框
|
||
async function showQueryHistoryDialog() {
|
||
try {
|
||
const response = await fetch('/api/query-history');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
let historyList = '';
|
||
|
||
if (result.data.length === 0) {
|
||
historyList = '<p class="text-muted text-center">暂无查询历史记录</p>';
|
||
} else {
|
||
result.data.forEach(history => {
|
||
const createdDate = new Date(history.created_at).toLocaleString();
|
||
const consistencyRate = history.total_keys > 0 ?
|
||
Math.round((history.identical_count / history.total_keys) * 100) : 0;
|
||
|
||
historyList += `
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="flex-grow-1">
|
||
<h6 class="card-title mb-1">${history.name}</h6>
|
||
<p class="card-text text-muted small mb-2">${history.description || '无描述'}</p>
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-clock"></i> ${createdDate}<br>
|
||
<i class="fas fa-key"></i> 查询: ${history.total_keys}个Key
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-chart-line"></i> 一致性: ${consistencyRate}%<br>
|
||
<i class="fas fa-exclamation-triangle"></i> 差异: ${history.differences_count}处
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ms-3">
|
||
<button class="btn btn-sm btn-primary me-1" onclick="loadHistoryRecord(${history.id})" title="加载配置">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-info me-1" onclick="viewHistoryDetail(${history.id})" title="查看详情">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteHistoryRecord(${history.id}, '${history.name}')" title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="queryHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-history"></i> 查询历史记录
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 600px; overflow-y: auto;">
|
||
${historyList}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('queryHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('queryHistoryModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载查询历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载查询历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载历史记录配置
|
||
async function loadHistoryRecord(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = history.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = history.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (history.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = history.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = history.pro_config.username || '';
|
||
document.getElementById('pro_password').value = history.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = history.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = history.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = history.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = history.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (history.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = history.test_config.port || 9042;
|
||
document.getElementById('test_username').value = history.test_config.username || '';
|
||
document.getElementById('test_password').value = history.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = history.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = history.test_config.table || '';
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = (history.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (history.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (history.query_config.exclude_fields || []).join(',');
|
||
|
||
// 填充查询Key值
|
||
document.getElementById('query_values').value = (history.query_keys || []).join('\n');
|
||
|
||
// 关闭历史记录modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', `历史记录 "${history.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 查看历史记录详情
|
||
async function viewHistoryDetail(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
const summary = history.results_summary;
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="historyDetailModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-eye"></i> 查询历史详情 - ${history.name}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 基本信息 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-info-circle"></i> 基本信息</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>名称:</strong> ${history.name}</p>
|
||
<p><strong>描述:</strong> ${history.description || '无描述'}</p>
|
||
<p><strong>创建时间:</strong> ${new Date(history.created_at).toLocaleString()}</p>
|
||
<p><strong>执行时间:</strong> ${history.execution_time.toFixed(2)}秒</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 查询统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>查询Key数:</strong> ${history.total_keys}</p>
|
||
<p><strong>相同记录:</strong> ${history.identical_count}</p>
|
||
<p><strong>差异记录:</strong> ${history.differences_count}</p>
|
||
<p><strong>一致性:</strong> ${history.total_keys > 0 ? Math.round((history.identical_count / history.total_keys) * 100) : 0}%</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置信息 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-cogs"></i> 查询配置</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<p><strong>主键字段:</strong> ${(history.query_config.keys || []).join(', ')}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>比较字段:</strong> ${(history.query_config.fields_to_compare || []).join(', ') || '全部字段'}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>排除字段:</strong> ${(history.query_config.exclude_fields || []).join(', ') || '无'}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<p><strong>查询Key值:</strong></p>
|
||
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${(history.query_keys || []).join('\n')}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 结果总结 -->
|
||
${summary ? `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-pie"></i> 结果总结</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- 数据质量评估 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<h6>数据质量评估</h6>
|
||
<div class="d-flex align-items-center">
|
||
<span class="badge bg-${summary.data_quality?.quality_level?.color || 'secondary'} me-2">
|
||
${summary.data_quality?.quality_level?.level || 'N/A'}
|
||
</span>
|
||
<span class="text-muted">${summary.data_quality?.quality_level?.description || ''}</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6>一致性评分</h6>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-${summary.data_quality?.quality_level?.color || 'secondary'}"
|
||
style="width: ${summary.data_quality?.consistency_score || 0}%">
|
||
${summary.data_quality?.consistency_score || 0}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
${summary.recommendations?.length > 0 ? `
|
||
<div>
|
||
<h6>改进建议</h6>
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(rec => `
|
||
<li class="mb-1">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${rec}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" onclick="loadHistoryRecord(${history.id}); bootstrap.Modal.getInstance(document.getElementById('historyDetailModal')).hide();">
|
||
<i class="fas fa-download"></i> 加载配置
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('historyDetailModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('historyDetailModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载历史记录详情失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录详情失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除历史记录
|
||
async function deleteHistoryRecord(historyId, historyName) {
|
||
if (!confirm(`确定要删除历史记录 "${historyName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新显示历史记录对话框
|
||
setTimeout(() => {
|
||
showQueryHistoryDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示保存历史记录对话框
|
||
function showSaveHistoryDialog() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '请先执行查询操作,然后再保存历史记录');
|
||
return;
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="saveHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-bookmark"></i> 保存查询历史记录
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">历史记录名称 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="historyRecordName" placeholder="请输入历史记录名称">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="historyRecordDesc" rows="3" placeholder="请输入历史记录描述(可选)"></textarea>
|
||
</div>
|
||
<div class="alert alert-info">
|
||
<small>
|
||
<strong>当前查询统计:</strong><br>
|
||
• 查询Key数: ${currentResults.total_keys}<br>
|
||
• 相同记录: ${currentResults.identical_results.length}<br>
|
||
• 差异记录: ${currentResults.differences.length}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveHistoryRecord()">
|
||
<i class="fas fa-check"></i> 保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('saveHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('saveHistoryModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 保存历史记录
|
||
async function saveHistoryRecord() {
|
||
const name = document.getElementById('historyRecordName').value.trim();
|
||
const description = document.getElementById('historyRecordDesc').value.trim();
|
||
|
||
if (!name) {
|
||
showAlert('warning', '请输入历史记录名称');
|
||
return;
|
||
}
|
||
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可保存的查询结果');
|
||
return;
|
||
}
|
||
|
||
const config = getCurrentConfig();
|
||
|
||
try {
|
||
const response = await fetch('/api/query-history', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
description: description,
|
||
pro_config: config.pro_config,
|
||
test_config: config.test_config,
|
||
query_config: {
|
||
keys: config.keys,
|
||
fields_to_compare: config.fields_to_compare,
|
||
exclude_fields: config.exclude_fields
|
||
},
|
||
query_keys: config.values,
|
||
results_summary: currentResults.summary || {},
|
||
execution_time: 0.0,
|
||
total_keys: currentResults.total_keys,
|
||
differences_count: currentResults.differences.length,
|
||
identical_count: currentResults.identical_results.length
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('saveHistoryModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', result.message);
|
||
} else {
|
||
showAlert('danger', result.error || '保存历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '保存历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 导出配置
|
||
function exportConfig() {
|
||
const config = getCurrentConfig();
|
||
const dataStr = JSON.stringify(config, null, 2);
|
||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = 'db_compare_config.json';
|
||
link.click();
|
||
}
|
||
|
||
// 导出结果
|
||
function exportResults() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可导出的结果');
|
||
return;
|
||
}
|
||
|
||
let output = `数据库查询比对结果\n`;
|
||
output += `生成时间: ${new Date().toLocaleString()}\n`;
|
||
output += `=`.repeat(50) + '\n\n';
|
||
|
||
output += `统计信息:\n`;
|
||
output += `- 总Key数量: ${currentResults.total_keys}\n`;
|
||
output += `- 生产表记录: ${currentResults.pro_count}\n`;
|
||
output += `- 测试表记录: ${currentResults.test_count}\n`;
|
||
output += `- 发现差异: ${currentResults.differences.length}\n\n`;
|
||
|
||
if (currentResults.differences.length > 0) {
|
||
output += `差异详情:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
|
||
currentResults.differences.forEach((diff, index) => {
|
||
output += `差异 #${index + 1}:\n`;
|
||
output += `主键: ${JSON.stringify(diff.key)}\n`;
|
||
|
||
if (diff.message) {
|
||
output += `消息: ${diff.message}\n`;
|
||
} else {
|
||
output += `字段: ${diff.field}\n`;
|
||
output += `生产表值: ${diff.pro_value}\n`;
|
||
output += `测试表值: ${diff.test_value}\n`;
|
||
}
|
||
output += '\n';
|
||
});
|
||
|
||
// 字段差异统计
|
||
if (Object.keys(currentResults.field_diff_count).length > 0) {
|
||
output += `字段差异统计:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
Object.entries(currentResults.field_diff_count)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
output += `${field}: ${count}次\n`;
|
||
});
|
||
}
|
||
}
|
||
|
||
const dataBlob = new Blob([output], {type: 'text/plain;charset=utf-8'});
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = `db_compare_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
|
||
link.click();
|
||
}
|
||
|
||
// 显示一键导入对话框
|
||
function showImportDialog(env) {
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="importModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-download"></i> 一键导入${envName}配置
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="text-muted mb-3">请粘贴配置数据,支持以下格式:</p>
|
||
<div class="mb-3">
|
||
<small class="text-muted">示例格式:</small>
|
||
<pre class="bg-light p-2 rounded small">clusterName: "Hot Cluster"
|
||
clusterNodes: "10.20.2.22,10.20.2.23"
|
||
port: 9042
|
||
datacenter: "cs01"
|
||
username: "cbase"
|
||
password: "antducbaseadmin@2022"
|
||
keyspace: "yuqing_skinny"
|
||
table: "status_test"</pre>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">配置数据:</label>
|
||
<textarea class="form-control" id="importConfigData" rows="8"
|
||
placeholder="请粘贴配置数据...">
|
||
</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="importConfig('${env}')">
|
||
<i class="fas fa-check"></i> 导入配置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('importModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('importModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 导入配置数据
|
||
function importConfig(env) {
|
||
const configData = document.getElementById('importConfigData').value.trim();
|
||
|
||
if (!configData) {
|
||
showAlert('warning', '请输入配置数据');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析配置数据
|
||
const config = parseConfigData(configData);
|
||
|
||
// 根据环境类型填充对应的表单字段
|
||
const prefix = env === 'pro' ? 'pro' : 'test';
|
||
|
||
if (config.clusterName) {
|
||
document.getElementById(`${prefix}_cluster_name`).value = config.clusterName;
|
||
}
|
||
if (config.datacenter) {
|
||
document.getElementById(`${prefix}_datacenter`).value = config.datacenter;
|
||
}
|
||
if (config.clusterNodes) {
|
||
document.getElementById(`${prefix}_hosts`).value = config.clusterNodes;
|
||
}
|
||
if (config.port) {
|
||
document.getElementById(`${prefix}_port`).value = config.port;
|
||
}
|
||
if (config.username) {
|
||
document.getElementById(`${prefix}_username`).value = config.username;
|
||
}
|
||
if (config.password) {
|
||
document.getElementById(`${prefix}_password`).value = config.password;
|
||
}
|
||
if (config.keyspace) {
|
||
document.getElementById(`${prefix}_keyspace`).value = config.keyspace;
|
||
}
|
||
if (config.table) {
|
||
document.getElementById(`${prefix}_table`).value = config.table;
|
||
}
|
||
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('importModal'));
|
||
modal.hide();
|
||
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
showAlert('success', `${envName}配置导入成功!`);
|
||
|
||
} catch (error) {
|
||
showAlert('danger', '配置解析失败:' + error.message);
|
||
}
|
||
}
|
||
|
||
// 解析配置数据
|
||
function parseConfigData(configText) {
|
||
const config = {};
|
||
|
||
try {
|
||
// 尝试按JSON格式解析
|
||
const jsonConfig = JSON.parse(configText);
|
||
return jsonConfig;
|
||
} catch (e) {
|
||
// 如果不是JSON,按YAML格式解析
|
||
const lines = configText.split('\n');
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||
continue; // 跳过空行和注释
|
||
}
|
||
|
||
const colonIndex = trimmedLine.indexOf(':');
|
||
if (colonIndex === -1) {
|
||
continue; // 跳过无效行
|
||
}
|
||
|
||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||
let value = trimmedLine.substring(colonIndex + 1).trim();
|
||
|
||
// 处理带引号的值
|
||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
|
||
// 尝试转换数字
|
||
if (!isNaN(value) && value !== '') {
|
||
config[key] = parseInt(value);
|
||
} else {
|
||
config[key] = value;
|
||
}
|
||
}
|
||
|
||
return config;
|
||
}
|
||
}
|
||
|
||
// 显示提示信息
|
||
function showAlert(type, message) {
|
||
// 移除已存在的提示
|
||
const existingAlert = document.querySelector('.alert');
|
||
if (existingAlert) {
|
||
existingAlert.remove();
|
||
}
|
||
|
||
const alertHtml = `
|
||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||
${message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
`;
|
||
|
||
// 插入到页面顶部
|
||
const container = document.querySelector('.container-fluid');
|
||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||
|
||
// 自动隐藏
|
||
setTimeout(() => {
|
||
const alert = document.querySelector('.alert');
|
||
if (alert) {
|
||
alert.remove();
|
||
}
|
||
}, 5000);
|
||
} |