Files
BigDataTool/static/js/app.js
2025-07-31 21:17:00 +08:00

1815 lines
76 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-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() {
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" 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>
</select>
<span class="ms-3 text-muted">共 ${filteredDifferenceResults.length} 条差异记录</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end align-items-center">
${generateDifferencePagination(currentDifferencePage, totalPages)}
</div>
</div>
</div>
<!-- 搜索框 -->
<div class="mb-3">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
onkeyup="searchDifferenceResults(this.value)" id="differenceSearch">
</div>
</div>
`;
// 显示当前页数据
currentPageData.forEach((diff, index) => {
const globalIndex = startIndex + index + 1;
if (diff.message) {
// 记录不存在的情况
html += `
<div class="difference-item card mb-3 border-warning">
<div class="card-header bg-light">
<div class="row align-items-center">
<div class="col">
<strong>差异 #${globalIndex}</strong>
<span class="badge bg-warning ms-2">记录缺失</span>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
<i class="fas fa-code"></i> 原生数据
</button>
</div>
</div>
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
</div>
<div class="card-body">
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
</div>
</div>
`;
} else {
// 字段值差异的情况
const isJson = diff.is_json;
const isArray = diff.is_array;
const jsonClass = isJson ? 'json-field' : '';
html += `
<div class="difference-item card mb-3 border-danger">
<div class="card-header bg-light">
<div class="row align-items-center">
<div class="col">
<strong>差异 #${globalIndex}</strong>
<span class="badge bg-danger ms-2">字段差异</span>
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
data-bs-toggle="collapse" data-bs-target="#diffCollapse${globalIndex}">
<i class="fas fa-eye"></i> 查看详情
</button>
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
<i class="fas fa-code"></i> 原生数据
</button>
</div>
</div>
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
<p class="mb-0"><strong>差异字段:</strong> ${diff.field}</p>
</div>
<div class="collapse" id="diffCollapse${globalIndex}">
<div class="card-body">
<div class="mb-4">
<div class="field-header mb-2">
<i class="fas fa-tag text-primary"></i>
<strong>${diff.field}</strong>
</div>
<!-- 生产环境数据行 -->
<div class="row 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>
</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" 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] || '';
// 更安全的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-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(String(proValue))}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${escapeForJs(String(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(String(testValue))}</pre>
<button class="btn btn-sm btn-outline-secondary copy-btn"
onclick="copyToClipboard('${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) {
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 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) {
differencePageSize = parseInt(newSize);
currentDifferencePage = 1;
displayDifferences();
}
// 搜索差异结果
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) 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 mb-3">
<div class="col-12">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="viewMode" id="viewMode1" value="formatted" checked onchange="switchViewMode(this.value)">
<label class="btn btn-outline-primary" for="viewMode1">
<i class="fas fa-code"></i> 格式化显示
</label>
<input type="radio" class="btn-check" name="viewMode" id="viewMode2" value="raw" onchange="switchViewMode(this.value)">
<label class="btn btn-outline-primary" for="viewMode2">
<i class="fas fa-align-left"></i> 原始显示
</label>
<input type="radio" class="btn-check" name="viewMode" id="viewMode3" value="diff" onchange="switchViewMode(this.value)">
<label class="btn btn-outline-primary" for="viewMode3">
<i class="fas fa-not-equal"></i> 差异对比
</label>
</div>
<div class="btn-group ms-3" role="group">
<button class="btn btn-outline-secondary" onclick="copyAllRawData()">
<i class="fas fa-copy"></i> 复制全部
</button>
<button class="btn btn-outline-info" onclick="downloadRawData()">
<i class="fas fa-download"></i> 下载数据
</button>
</div>
</div>
</div>
<!-- 数据显示区域 -->
<div id="rawDataContent">
<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 raw-data-container" style="max-height: 500px; overflow-y: auto;" id="proRawData">${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 raw-data-container" style="max-height: 500px; overflow-y: auto;" id="testRawData">${escapeHtml(JSON.stringify(testRecord, null, 2))}</pre>
</div>
</div>
</div>
<!-- 隐藏的数据存储 -->
<div style="display: none;">
<div id="hiddenProData">${escapeHtml(JSON.stringify(proRecord, null, 2))}</div>
<div id="hiddenTestData">${escapeHtml(JSON.stringify(testRecord, null, 2))}</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 switchViewMode(mode) {
const proData = document.getElementById('hiddenProData').textContent;
const testData = document.getElementById('hiddenTestData').textContent;
const proContainer = document.getElementById('proRawData');
const testContainer = document.getElementById('testRawData');
switch(mode) {
case 'formatted':
// 格式化JSON显示
try {
const proObj = JSON.parse(proData);
const testObj = JSON.parse(testData);
proContainer.innerHTML = escapeHtml(JSON.stringify(proObj, null, 2));
testContainer.innerHTML = escapeHtml(JSON.stringify(testObj, null, 2));
proContainer.className = 'bg-light p-3 rounded raw-data-container';
testContainer.className = 'bg-light p-3 rounded raw-data-container';
} catch (e) {
proContainer.innerHTML = escapeHtml(proData);
testContainer.innerHTML = escapeHtml(testData);
}
break;
case 'raw':
// 原始单行显示
try {
const proObj = JSON.parse(proData);
const testObj = JSON.parse(testData);
proContainer.innerHTML = escapeHtml(JSON.stringify(proObj));
testContainer.innerHTML = escapeHtml(JSON.stringify(testObj));
proContainer.className = 'bg-light p-3 rounded raw-data-container';
testContainer.className = 'bg-light p-3 rounded raw-data-container';
} catch (e) {
proContainer.innerHTML = escapeHtml(proData);
testContainer.innerHTML = escapeHtml(testData);
}
break;
case 'diff':
// 差异对比显示
try {
const proObj = JSON.parse(proData);
const testObj = JSON.parse(testData);
const diffResult = generateFieldDiff(proObj, testObj);
document.getElementById('rawDataContent').innerHTML = `
<div class="row">
<div class="col-12">
<h6 class="mb-3"><i class="fas fa-not-equal"></i> 字段差异对比</h6>
${diffResult}
</div>
</div>
`;
} catch (e) {
showAlert('warning', '无法生成差异对比:' + e.message);
}
break;
}
}
// 生成字段差异对比HTML
function generateFieldDiff(proObj, testObj) {
const allFields = new Set([...Object.keys(proObj), ...Object.keys(testObj)]);
let html = '';
allFields.forEach(field => {
const proValue = proObj[field];
const testValue = testObj[field];
const isEqual = JSON.stringify(proValue) === JSON.stringify(testValue);
const isProMissing = !(field in proObj);
const isTestMissing = !(field in testObj);
let statusBadge = '';
let cardClass = '';
if (isProMissing) {
statusBadge = '<span class="badge bg-warning">生产缺失</span>';
cardClass = 'border-warning';
} else if (isTestMissing) {
statusBadge = '<span class="badge bg-info">测试缺失</span>';
cardClass = 'border-info';
} else if (isEqual) {
statusBadge = '<span class="badge bg-success">相同</span>';
cardClass = 'border-success';
} else {
statusBadge = '<span class="badge bg-danger">不同</span>';
cardClass = 'border-danger';
}
html += `
<div class="card mb-3 ${cardClass}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<strong>${field}</strong>
${statusBadge}
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-success">
<i class="fas fa-server"></i> 生产环境
</h6>
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${isProMissing ? '字段不存在' : escapeHtml(JSON.stringify(proValue, null, 2))}</pre>
</div>
<div class="col-md-6">
<h6 class="text-info">
<i class="fas fa-flask"></i> 测试环境
</h6>
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${isTestMissing ? '字段不存在' : escapeHtml(JSON.stringify(testValue, null, 2))}</pre>
</div>
</div>
</div>
</div>
`;
});
return html || '<p class="text-muted">无字段数据可对比</p>';
}
// 复制全部原生数据
function copyAllRawData() {
const proData = document.getElementById('hiddenProData').textContent;
const testData = document.getElementById('hiddenTestData').textContent;
const allData = `生产环境数据:\n${proData}\n\n测试环境数据:\n${testData}`;
navigator.clipboard.writeText(allData).then(() => {
showAlert('success', '已复制全部原生数据到剪贴板');
}).catch(err => {
showAlert('danger', '复制失败: ' + err.message);
});
}
// 下载原生数据
function downloadRawData() {
const proData = document.getElementById('hiddenProData').textContent;
const testData = document.getElementById('hiddenTestData').textContent;
const exportData = {
timestamp: new Date().toISOString(),
production_data: JSON.parse(proData),
test_data: JSON.parse(testData)
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const link = document.createElement('a');
link.href = URL.createObjectURL(dataBlob);
link.download = `raw_data_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
link.click();
}
// 显示差异数据的原生数据
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);
}