Files
BigDataTool/static/js/app.js
2025-07-31 23:45:15 +08:00

2725 lines
116 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 全局变量
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(${JSON.stringify(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(${JSON.stringify(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 == 10 ? 'selected' : ''}>10条</option>
<option value="10" ${identicalPageSize == 50 ? 'selected' : ''}>50条</option>
<option value="20" ${identicalPageSize == 100 ? 'selected' : ''}>100条</option>
<option value="50" ${identicalPageSize == 200 ? 'selected' : ''}>200条</option>
<option value="100" ${identicalPageSize == 500 ? 'selected' : ''}>500条</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(${JSON.stringify(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) {
if (!currentResults) {
showAlert('warning', '请先执行查询操作');
return;
}
try {
// 解析key
const key = JSON.parse(keyStr);
// 在原生数据中查找对应的记录
let proData = null;
let testData = null;
// 查找生产环境数据
if (currentResults.raw_pro_data) {
proData = currentResults.raw_pro_data.find(item => {
// 比较主键
return JSON.stringify(item[Object.keys(key)[0]]) === JSON.stringify(key[Object.keys(key)[0]]);
});
}
// 查找测试环境数据
if (currentResults.raw_test_data) {
testData = currentResults.raw_test_data.find(item => {
// 比较主键
return JSON.stringify(item[Object.keys(key)[0]]) === JSON.stringify(key[Object.keys(key)[0]]);
});
}
// 创建模态框内容
const modalContent = `
<div class="modal fade" id="rawDataModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-code"></i> 原生数据 - ${JSON.stringify(key)}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs" id="rawDataTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="formatted-tab" data-bs-toggle="tab"
data-bs-target="#formatted" type="button" role="tab">
<i class="fas fa-code"></i> 格式化视图
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="raw-tab" data-bs-toggle="tab"
data-bs-target="#raw" type="button" role="tab">
<i class="fas fa-file-code"></i> 原始视图
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="diff-tab" data-bs-toggle="tab"
data-bs-target="#diff" type="button" role="tab">
<i class="fas fa-exchange-alt"></i> 对比视图
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tree-tab" data-bs-toggle="tab"
data-bs-target="#tree" type="button" role="tab">
<i class="fas fa-tree"></i> 树形视图
</button>
</li>
<li class="nav-item ms-auto">
<span class="nav-link text-muted">
<i class="fas fa-sync-alt"></i> 同步滚动已启用
</span>
</li>
</ul>
<div class="tab-content mt-3" id="rawDataTabsContent">
<!-- 格式化视图 -->
<div class="tab-pane fade show active" id="formatted" role="tabpanel">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData, null, 2)) : '无数据'}</pre>
</div>
<div class="col-md-6">
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData, null, 2)) : '无数据'}</pre>
</div>
</div>
</div>
<!-- 原始视图 -->
<div class="tab-pane fade" id="raw" role="tabpanel">
<div class="mb-3">
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境原始数据</h6>
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData)) : '无数据'}</pre>
</div>
<div>
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境原始数据</h6>
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData)) : '无数据'}</pre>
</div>
</div>
<!-- 对比视图 -->
<div class="tab-pane fade" id="diff" role="tabpanel">
<div id="diffView"></div>
</div>
<!-- 树形视图 -->
<div class="tab-pane fade" id="tree" role="tabpanel">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
<div id="proTreeView" class="tree-view"></div>
</div>
<div class="col-md-6">
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
<div id="testTreeView" class="tree-view"></div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="copyRawData()">
<i class="fas fa-copy"></i> 复制数据
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
</div>
</div>
</div>
</div>
`;
// 移除现有modal
const existingModal = document.getElementById('rawDataModal');
if (existingModal) {
existingModal.remove();
}
document.body.insertAdjacentHTML('beforeend', modalContent);
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('rawDataModal'));
modal.show();
// 保存当前数据供复制使用
window.currentRawData = {
pro: proData ? JSON.stringify(proData, null, 2) : '无数据',
test: testData ? JSON.stringify(testData, null, 2) : '无数据',
combined: `生产环境数据:\n${proData ? JSON.stringify(proData, null, 2) : '无数据'}\n\n测试环境数据:\n${testData ? JSON.stringify(testData, null, 2) : '无数据'}`
};
// 延迟渲染对比视图和树形视图
setTimeout(() => {
renderDiffView(proData, testData);
renderTreeView('proTreeView', proData);
renderTreeView('testTreeView', testData);
// 为格式化视图添加同步滚动
setupSyncScroll('formatted');
// 为树形视图添加同步滚动
setupSyncScroll('tree');
}, 100);
} 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 renderDiffView(proData, testData) {
const diffViewContainer = document.getElementById('diffView');
if (!proData && !testData) {
diffViewContainer.innerHTML = '<p class="text-muted text-center">无数据可对比</p>';
return;
}
// 获取所有字段
const allFields = new Set([
...(proData ? Object.keys(proData) : []),
...(testData ? Object.keys(testData) : [])
]);
// 分离相同和不同的字段
const differentFields = [];
const identicalFields = [];
allFields.forEach(field => {
const proValue = proData ? proData[field] : undefined;
const testValue = testData ? testData[field] : undefined;
// 特殊处理:区分 null 和 undefined
// undefined 表示字段不存在null 表示字段存在但值为 null
let isEqual = false;
// 如果一个是 undefined另一个不是包括 null则不相等
if (proValue === undefined && testValue !== undefined) {
isEqual = false;
} else if (proValue !== undefined && testValue === undefined) {
isEqual = false;
} else if (proValue === testValue) {
// 两个都是 undefined 或者值完全相同
isEqual = true;
} else if (proValue !== undefined && testValue !== undefined) {
// 两个都有值,尝试规范化比较
try {
const proNormalized = normalizeValue(proValue);
const testNormalized = normalizeValue(testValue);
isEqual = JSON.stringify(proNormalized) === JSON.stringify(testNormalized);
} catch (e) {
isEqual = JSON.stringify(proValue) === JSON.stringify(testValue);
}
}
if (isEqual) {
identicalFields.push(field);
} else {
differentFields.push(field);
}
});
let html = '';
// 如果有差异字段,先显示差异统计
if (differentFields.length > 0) {
html += `
<div class="alert alert-warning mb-3">
<i class="fas fa-exclamation-triangle"></i>
发现 <strong>${differentFields.length}</strong> 个字段存在差异
</div>
`;
}
html += '<div class="table-responsive"><table class="table table-bordered table-hover">';
html += `
<thead class="table-dark">
<tr>
<th style="width: 15%;">字段名</th>
<th style="width: 40%;">生产环境</th>
<th style="width: 40%;">测试环境</th>
<th style="width: 5%;" class="text-center">状态</th>
</tr>
</thead>
<tbody>
`;
// 先显示差异字段(重点突出)
if (differentFields.length > 0) {
html += '<tr class="table-danger"><td colspan="4" class="text-center fw-bold">差异字段</td></tr>';
differentFields.forEach(field => {
const proValue = proData ? proData[field] : undefined;
const testValue = testData ? testData[field] : undefined;
// 判断值的类型和差异类型
let diffType = '';
let proDisplay = '';
let testDisplay = '';
if (proValue === undefined && testValue !== undefined) {
diffType = 'missing-in-pro';
proDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
testDisplay = formatValue(testValue);
} else if (proValue !== undefined && testValue === undefined) {
diffType = 'missing-in-test';
proDisplay = formatValue(proValue);
testDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
} else {
diffType = 'value-diff';
proDisplay = formatValue(proValue);
testDisplay = formatValue(testValue);
}
html += `
<tr class="table-warning">
<td>
<code class="text-danger fw-bold">${field}</code>
${getFieldTypeIcon(proValue || testValue)}
</td>
<td class="font-monospace small ${diffType === 'missing-in-pro' ? 'bg-light' : ''}" style="vertical-align: top;">${proDisplay}</td>
<td class="font-monospace small ${diffType === 'missing-in-test' ? 'bg-light' : ''}" style="vertical-align: top;">${testDisplay}</td>
<td class="text-center" style="vertical-align: middle;">
<span class="badge bg-danger">
<i class="fas fa-times"></i> 不同
</span>
</td>
</tr>
`;
});
}
// 再显示相同字段
if (identicalFields.length > 0) {
html += '<tr class="table-success"><td colspan="4" class="text-center fw-bold">相同字段</td></tr>';
identicalFields.forEach(field => {
const value = proData ? proData[field] : testData[field];
const displayValue = formatValue(value);
html += `
<tr>
<td>
<code class="text-muted">${field}</code>
${getFieldTypeIcon(value)}
</td>
<td class="font-monospace small text-muted">${displayValue}</td>
<td class="font-monospace small text-muted">${displayValue}</td>
<td class="text-center">
<span class="badge bg-success">
<i class="fas fa-check"></i> 相同
</span>
</td>
</tr>
`;
});
}
html += '</tbody></table></div>';
// 添加快速跳转按钮
if (differentFields.length > 0 && identicalFields.length > 0) {
html = `
<div class="d-flex gap-2 mb-3">
<button class="btn btn-sm btn-warning" onclick="scrollToTableSection('danger')">
<i class="fas fa-exclamation-triangle"></i> 跳转到差异字段
</button>
<button class="btn btn-sm btn-success" onclick="scrollToTableSection('success')">
<i class="fas fa-check"></i> 跳转到相同字段
</button>
</div>
` + html;
}
diffViewContainer.innerHTML = html;
}
// 格式化值显示
function formatValue(value) {
if (value === null) return '<span class="text-muted">null</span>';
if (value === undefined) return '<span class="text-muted">undefined</span>';
if (value === '') return '<span class="text-muted">(空字符串)</span>';
const type = typeof value;
if (type === 'object') {
// 特殊处理数组
if (Array.isArray(value)) {
// 检查是否是JSON字符串数组
let isJsonStringArray = value.length > 0 && value.every(item => {
if (typeof item === 'string') {
try {
if ((item.startsWith('{') && item.endsWith('}')) ||
(item.startsWith('[') && item.endsWith(']'))) {
JSON.parse(item);
return true;
}
} catch (e) {}
}
return false;
});
if (isJsonStringArray) {
// 解析并格式化JSON字符串数组
try {
const parsedArray = value.map(item => JSON.parse(item));
// 如果都有key字段按key排序
if (parsedArray.every(item => 'key' in item)) {
parsedArray.sort((a, b) => a.key - b.key);
}
const formatted = JSON.stringify(parsedArray, null, 2);
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
} catch (e) {
// 如果解析失败,按原样显示
}
}
}
// 对象或数组,格式化显示
try {
const formatted = JSON.stringify(value, null, 2);
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
} catch (e) {
return `<pre class="mb-0 p-2 bg-light rounded">${escapeHtml(String(value))}</pre>`;
}
}
if (type === 'string') {
// 检查是否是JSON字符串
try {
if ((value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))) {
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
}
} catch (e) {
// 不是有效的JSON作为普通字符串处理
}
// 长文本处理
if (value.length > 200) {
return `<div class="text-wrap" style="max-width: 400px; max-height: 200px; overflow-y: auto;">${escapeHtml(value)}</div>`;
}
}
return escapeHtml(String(value));
}
// 获取字段类型图标
function getFieldTypeIcon(value) {
if (value === undefined) {
return '<span class="badge bg-danger ms-2" title="字段缺失">❌</span>';
}
if (value === null) {
return '<span class="badge bg-secondary ms-2" title="空值">NULL</span>';
}
const type = typeof value;
if (Array.isArray(value)) {
return '<span class="badge bg-info ms-2" title="数组">[]</span>';
}
if (type === 'object') {
return '<span class="badge bg-primary ms-2" title="对象">{}</span>';
}
if (type === 'string') {
// 检查是否是JSON字符串
try {
if (value.startsWith('{') || value.startsWith('[')) {
JSON.parse(value);
return '<span class="badge bg-warning ms-2" title="JSON字符串">JSON</span>';
}
} catch (e) {
// 不是JSON
}
// 检查是否是日期字符串
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
return '<span class="badge bg-secondary ms-2" title="日期">📅</span>';
}
}
if (type === 'number') {
return '<span class="badge bg-success ms-2" title="数字">#</span>';
}
if (type === 'boolean') {
return '<span class="badge bg-dark ms-2" title="布尔值">⚡</span>';
}
return '';
}
// 滚动到表格指定部分
function scrollToTableSection(sectionType) {
const targetRow = document.querySelector(`#diffView .table-${sectionType}`);
if (targetRow) {
targetRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// 规范化值用于比较处理JSON键顺序问题
function normalizeValue(value) {
if (typeof value === 'string') {
// 尝试解析JSON字符串
try {
if ((value.startsWith('{') && value.endsWith('}')) ||
(value.startsWith('[') && value.endsWith(']'))) {
value = JSON.parse(value);
} else {
return value;
}
} catch (e) {
return value;
}
}
if (value === null || typeof value !== 'object') {
return value;
}
if (Array.isArray(value)) {
// 对于数组,需要特殊处理
// 1. 先尝试解析每个元素可能是JSON字符串
const parsedArray = value.map(item => {
if (typeof item === 'string') {
try {
if ((item.startsWith('{') && item.endsWith('}')) ||
(item.startsWith('[') && item.endsWith(']'))) {
return JSON.parse(item);
}
} catch (e) {
// 保持原始字符串
}
}
return item;
});
// 2. 检查是否是对象数组,如果是,可能需要按某个键排序
if (parsedArray.length > 0 && typeof parsedArray[0] === 'object' && !Array.isArray(parsedArray[0])) {
// 检查是否所有元素都有相同的键
const firstKeys = Object.keys(parsedArray[0]).sort().join(',');
const allSameStructure = parsedArray.every(item =>
typeof item === 'object' &&
!Array.isArray(item) &&
Object.keys(item).sort().join(',') === firstKeys
);
if (allSameStructure) {
// 如果有 key 字段,按 key 排序
if ('key' in parsedArray[0]) {
return parsedArray
.map(item => normalizeValue(item))
.sort((a, b) => {
const aKey = a.key;
const bKey = b.key;
if (aKey < bKey) return -1;
if (aKey > bKey) return 1;
return 0;
});
}
// 否则按整个对象的字符串表示排序
return parsedArray
.map(item => normalizeValue(item))
.sort((a, b) => {
const aStr = JSON.stringify(a);
const bStr = JSON.stringify(b);
if (aStr < bStr) return -1;
if (aStr > bStr) return 1;
return 0;
});
}
}
// 递归处理每个元素
return parsedArray.map(item => normalizeValue(item));
}
// 对象:按键排序
const sortedObj = {};
Object.keys(value).sort().forEach(key => {
sortedObj[key] = normalizeValue(value[key]);
});
return sortedObj;
}
// 渲染树形视图
function renderTreeView(containerId, data) {
const container = document.getElementById(containerId);
if (!data) {
container.innerHTML = '<p class="text-muted">无数据</p>';
return;
}
container.innerHTML = createTreeNode(data, '');
}
// 创建树形节点
function createTreeNode(obj, indent = '') {
if (obj === null) return '<span class="text-muted">null</span>';
if (obj === undefined) return '<span class="text-muted">undefined</span>';
const type = typeof obj;
if (type !== 'object') {
return `<span class="tree-value">${escapeHtml(JSON.stringify(obj))}</span>`;
}
if (Array.isArray(obj)) {
if (obj.length === 0) return '<span class="text-muted">[]</span>';
let html = '<div class="tree-array">[<br>';
obj.forEach((item, index) => {
html += `${indent} <span class="tree-index">${index}:</span> ${createTreeNode(item, indent + ' ')}`;
if (index < obj.length - 1) html += ',';
html += '<br>';
});
html += `${indent}]</div>`;
return html;
}
const keys = Object.keys(obj);
if (keys.length === 0) return '<span class="text-muted">{}</span>';
let html = '<div class="tree-object">{<br>';
keys.forEach((key, index) => {
html += `${indent} <span class="tree-key">"${key}"</span>: ${createTreeNode(obj[key], indent + ' ')}`;
if (index < keys.length - 1) html += ',';
html += '<br>';
});
html += `${indent}}</div>`;
return html;
}
// 设置同步滚动
function setupSyncScroll(tabType) {
if (tabType === 'formatted') {
// 格式化视图的两个pre元素
const proPre = document.querySelector('#formatted .col-md-6:first-child pre');
const testPre = document.querySelector('#formatted .col-md-6:last-child pre');
if (proPre && testPre) {
let syncing = false;
proPre.addEventListener('scroll', () => {
if (!syncing) {
syncing = true;
testPre.scrollTop = proPre.scrollTop;
testPre.scrollLeft = proPre.scrollLeft;
setTimeout(() => syncing = false, 10);
}
});
testPre.addEventListener('scroll', () => {
if (!syncing) {
syncing = true;
proPre.scrollTop = testPre.scrollTop;
proPre.scrollLeft = testPre.scrollLeft;
setTimeout(() => syncing = false, 10);
}
});
}
} else if (tabType === 'tree') {
// 树形视图的两个div元素
const proTree = document.getElementById('proTreeView');
const testTree = document.getElementById('testTreeView');
if (proTree && testTree) {
let syncing = false;
proTree.addEventListener('scroll', () => {
if (!syncing) {
syncing = true;
testTree.scrollTop = proTree.scrollTop;
testTree.scrollLeft = proTree.scrollLeft;
setTimeout(() => syncing = false, 10);
}
});
testTree.addEventListener('scroll', () => {
if (!syncing) {
syncing = true;
proTree.scrollTop = testTree.scrollTop;
proTree.scrollLeft = testTree.scrollLeft;
setTimeout(() => syncing = false, 10);
}
});
}
}
}
// 显示比较总结
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);
}