Files
BigDataTool/static/js/app.js
2025-07-31 18:05:10 +08:00

1408 lines
58 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 = [];
// 页面加载完成后初始化
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;
// 显示各个面板内容
displayDifferences(results.differences);
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-3">
<div class="stat-card bg-primary text-white">
<h3>${results.total_keys}</h3>
<p>总Key数量</p>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-success text-white">
<h3>${results.identical_results.length}</h3>
<p>相同记录</p>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-warning text-white">
<h3>${results.differences.length}</h3>
<p>差异记录</p>
</div>
</div>
<div class="col-md-3">
<div class="stat-card bg-info 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(differences) {
const differencesContainer = document.getElementById('differences');
if (!differences.length) {
differencesContainer.innerHTML = '<p class="text-success"><i class="fas fa-check"></i> 未发现差异</p>';
return;
}
let html = '';
differences.forEach((diff, index) => {
if (diff.message) {
// 记录不存在的情况
html += `
<div class="difference-item">
<strong>差异 #${index + 1}</strong>
<p><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
</div>
`;
} else {
// 字段值差异的情况
const isJson = diff.is_json;
const isArray = diff.is_array;
const jsonClass = isJson ? 'json-field' : '';
html += `
<div class="difference-item">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong>差异 #${index + 1}</strong>
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
</div>
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
<i class="fas fa-code"></i> 原生数据
</button>
</div>
<p><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
<p><strong>字段:</strong> ${diff.field}</p>
<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 mb-2">
<div class="col-2">
<h6 class="text-success mb-0 d-flex align-items-center">
<i class="fas fa-server me-2"></i>生产环境
</h6>
</div>
<div class="col-10">
<div class="field-container position-relative">
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.pro_value)}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${escapeForJs(diff.pro_value)}', this)"
title="复制内容">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- 测试环境数据行 -->
<div class="row">
<div class="col-2">
<h6 class="text-info mb-0 d-flex align-items-center">
<i class="fas fa-flask me-2"></i>测试环境
</h6>
</div>
<div class="col-10">
<div class="field-container position-relative">
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.test_value)}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${escapeForJs(diff.test_value)}', this)"
title="复制内容">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</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" 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>
</select>
<span class="ms-3 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] || '';
const isJson = (proValue.includes('{') || proValue.includes('[')) ||
(testValue.includes('{') || testValue.includes('['));
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-2">
<h6 class="text-success mb-0 d-flex align-items-center">
<i class="fas fa-server me-2"></i>生产环境
</h6>
</div>
<div class="col-10">
<div class="field-container position-relative">
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(proValue)}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${escapeForJs(proValue)}', this)"
title="复制内容">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
<!-- 测试环境数据行 -->
<div class="row">
<div class="col-2">
<h6 class="text-info mb-0 d-flex align-items-center">
<i class="fas fa-flask me-2"></i>测试环境
</h6>
</div>
<div class="col-10">
<div class="field-container position-relative">
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(testValue)}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${escapeForJs(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) {
identicalPageSize = parseInt(newSize);
currentIdenticalPage = 1;
displayIdenticalResults();
}
// 搜索相同结果
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 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) {
return str.replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
}
// 显示原生数据
function showRawData(keyStr) {
if (!currentResults) return;
try {
const key = JSON.parse(keyStr);
const keyValue = Object.values(key)[0];
// 在原生数据中查找匹配的记录
const proRecord = currentResults.raw_pro_data.find(record =>
Object.values(record).includes(keyValue)
);
const testRecord = currentResults.raw_test_data.find(record =>
Object.values(record).includes(keyValue)
);
let 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> 原生查询数据
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-success mb-0">
<i class="fas fa-server"></i> 生产环境原生数据
</h6>
<button class="btn btn-sm btn-outline-secondary"
onclick="copyToClipboard('${escapeForJs(JSON.stringify(proRecord, null, 2))}', this)">
<i class="fas fa-copy"></i> 复制
</button>
</div>
<pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;">${escapeHtml(JSON.stringify(proRecord, null, 2))}</pre>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-info mb-0">
<i class="fas fa-flask"></i> 测试环境原生数据
</h6>
<button class="btn btn-sm btn-outline-secondary"
onclick="copyToClipboard('${escapeForJs(JSON.stringify(testRecord, null, 2))}', this)">
<i class="fas fa-copy"></i> 复制
</button>
</div>
<pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;">${escapeHtml(JSON.stringify(testRecord, null, 2))}</pre>
</div>
</div>
</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('rawDataModal');
if (existingModal) {
existingModal.remove();
}
// 添加新modal到body
document.body.insertAdjacentHTML('beforeend', modalContent);
// 显示modal
const modal = new bootstrap.Modal(document.getElementById('rawDataModal'));
modal.show();
} catch (e) {
showAlert('danger', '无法显示原生数据:' + e.message);
}
}
// 显示差异数据的原生数据
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', '结果已清空');
}
// 导出配置
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);
}