Files
BigDataTool/static/js/app.js
2025-08-12 16:27:00 +08:00

4594 lines
194 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 = [];
let isShardingMode = false; // 分表模式标志
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
loadDefaultConfig();
loadConfigGroups(); // 加载配置组列表
bindShardingEvents(); // 绑定分表相关事件
});
// 绑定分表相关事件
function bindShardingEvents() {
// 分表模式切换事件
const enableShardingCheckbox = document.getElementById('enableSharding');
if (enableShardingCheckbox) {
enableShardingCheckbox.addEventListener('change', toggleShardingMode);
}
// 生产环境分表开关变化事件
const useShardingProCheckbox = document.getElementById('use_sharding_for_pro');
if (useShardingProCheckbox) {
useShardingProCheckbox.addEventListener('change', function() {
updateTableNameHints();
});
}
// 测试环境分表开关变化事件
const useShardingTestCheckbox = document.getElementById('use_sharding_for_test');
if (useShardingTestCheckbox) {
useShardingTestCheckbox.addEventListener('change', function() {
updateTableNameHints();
});
}
}
// 切换分表模式
function toggleShardingMode() {
isShardingMode = document.getElementById('enableSharding').checked;
const shardingConfig = document.getElementById('shardingConfig');
const executeButton = document.getElementById('executeButtonText');
const keyInputHint = document.getElementById('key_input_hint');
const keysField = document.getElementById('keys');
if (isShardingMode) {
// 启用分表模式
shardingConfig.style.display = 'block';
executeButton.textContent = '执行分表查询比对';
keyInputHint.textContent = '分表模式Key值应包含时间戳用于计算分表索引支持单主键和复合主键逗号分隔';
keysField.placeholder = '单主键wmid 或 复合主键docid,id (推荐使用包含时间戳的字段)';
keysField.value = '';
// 更新查询Key输入框的占位符
const queryValues = document.getElementById('query_values');
queryValues.placeholder = '请输入查询的Key值一行一个\n单主键示例包含时间戳\nwmid_1609459200\nwmid_1610064000\nwmid_1610668800\n\n复合主键示例逗号分隔\ndocid_1609459200,id1\ndocid_1610064000,id2\ndocid_1610668800,id3';
} else {
// 禁用分表模式
shardingConfig.style.display = 'none';
executeButton.textContent = '执行查询比对';
keyInputHint.textContent = '单表模式:支持单主键和复合主键查询,复合主键用逗号分隔';
keysField.placeholder = '单主键docid 或 复合主键docid,id';
keysField.value = '';
// 更新查询Key输入框的占位符
const queryValues = document.getElementById('query_values');
queryValues.placeholder = '请输入查询的Key值一行一个\n单主键示例\nkey1\nkey2\nkey3\n\n复合主键示例逗号分隔\ndocid1,id1\ndocid2,id2\ndocid3,id3';
}
updateTableNameHints();
}
// 更新表名字段的提示文本
function updateTableNameHints() {
const proTableHint = document.getElementById('pro_table_hint');
const testTableHint = document.getElementById('test_table_hint');
const useShardingPro = document.getElementById('use_sharding_for_pro');
const useShardingTest = document.getElementById('use_sharding_for_test');
if (isShardingMode) {
proTableHint.textContent = (useShardingPro && useShardingPro.checked) ?
'基础表名(自动添加索引后缀)' : '完整表名';
testTableHint.textContent = (useShardingTest && useShardingTest.checked) ?
'基础表名(自动添加索引后缀)' : '完整表名';
} else {
proTableHint.textContent = '完整表名';
testTableHint.textContent = '完整表名';
}
}
// 加载配置组列表
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;
// 调试输出
console.log('加载配置组数据:', config);
console.log('查询配置数据:', config.query_config);
// 填充生产环境配置
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 || '';
// 填充查询配置 - 增加调试输出和保护性检查
if (config.query_config) {
console.log('设置keys字段:', config.query_config.keys);
const keysElement = document.getElementById('keys');
console.log('keys元素:', keysElement);
console.log('keys元素当前值:', keysElement ? keysElement.value : 'element not found');
if (keysElement) {
keysElement.value = (config.query_config.keys || []).join(',');
console.log('keys字段设置后的值:', keysElement.value);
// 强制触发多种事件以确保UI更新
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
// 添加延迟更新,确保模态框关闭后再次设置
setTimeout(() => {
keysElement.value = (config.query_config.keys || []).join(',');
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
console.log('延迟设置后的值:', keysElement.value);
}, 100);
} else {
console.error('未找到keys输入元素!');
}
const fieldsToCompareElement = document.getElementById('fields_to_compare');
const excludeFieldsElement = document.getElementById('exclude_fields');
if (fieldsToCompareElement) {
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
if (excludeFieldsElement) {
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
} else {
console.warn('查询配置为空或未定义');
document.getElementById('keys').value = '';
document.getElementById('fields_to_compare').value = '';
document.getElementById('exclude_fields').value = '';
}
// 加载分表配置
if (config.sharding_config) {
// 设置分表启用状态
document.getElementById('enableSharding').checked = config.sharding_config.enabled || false;
toggleShardingMode();
// 填充分表配置
document.getElementById('use_sharding_for_pro').checked = config.sharding_config.use_sharding_for_pro || false;
document.getElementById('use_sharding_for_test').checked = config.sharding_config.use_sharding_for_test || false;
document.getElementById('pro_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
document.getElementById('pro_table_count').value = config.sharding_config.table_count || 14;
document.getElementById('test_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
document.getElementById('test_table_count').value = config.sharding_config.table_count || 14;
} else {
// 禁用分表模式
document.getElementById('enableSharding').checked = false;
toggleShardingMode();
}
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();
// 获取分表配置
const shardingConfig = getShardingConfig().sharding_config;
try {
const response = await fetch('/api/config-groups', {
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: config.query_config,
sharding_config: shardingConfig
})
});
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 || '';
// 填充查询配置 - 增加调试输出和保护性检查
if (config.query_config) {
console.log('loadConfigGroupById - 设置keys字段:', config.query_config.keys);
const keysElement = document.getElementById('keys');
console.log('loadConfigGroupById - keys元素:', keysElement);
if (keysElement) {
keysElement.value = (config.query_config.keys || []).join(',');
console.log('loadConfigGroupById - keys字段设置后的值:', keysElement.value);
// 强制触发多种事件以确保UI更新
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
// 添加延迟更新,确保模态框关闭后再次设置
setTimeout(() => {
keysElement.value = (config.query_config.keys || []).join(',');
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
console.log('loadConfigGroupById - 延迟设置后的值:', keysElement.value);
}, 100);
} else {
console.error('loadConfigGroupById - 未找到keys输入元素!');
}
const fieldsToCompareElement = document.getElementById('fields_to_compare');
const excludeFieldsElement = document.getElementById('exclude_fields');
if (fieldsToCompareElement) {
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
if (excludeFieldsElement) {
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
} else {
console.warn('loadConfigGroupById - 查询配置为空或未定义');
document.getElementById('keys').value = '';
document.getElementById('fields_to_compare').value = '';
document.getElementById('exclude_fields').value = '';
}
// 更新下拉框选中状态
document.getElementById('configGroupSelect').value = groupId;
// 加载分表配置
if (config.sharding_config) {
// 设置分表启用状态
document.getElementById('enableSharding').checked = config.sharding_config.enabled || false;
toggleShardingMode();
// 填充分表配置
document.getElementById('use_sharding_for_pro').checked = config.sharding_config.use_sharding_for_pro || false;
document.getElementById('use_sharding_for_test').checked = config.sharding_config.use_sharding_for_test || false;
document.getElementById('pro_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
document.getElementById('pro_table_count').value = config.sharding_config.table_count || 14;
document.getElementById('test_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
document.getElementById('test_table_count').value = config.sharding_config.table_count || 14;
} else {
// 禁用分表模式
document.getElementById('enableSharding').checked = false;
toggleShardingMode();
}
// 关闭管理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
},
query_config: {
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.query_config.keys.length) {
showAlert('warning', '请输入主键字段');
return;
}
// 显示加载动画
document.getElementById('loading').style.display = 'block';
document.getElementById('results').style.display = 'none';
// 更新加载文本
const loadingText = document.getElementById('loadingText');
if (isShardingMode) {
loadingText.textContent = '正在执行分表查询比对...';
} else {
loadingText.textContent = '正在执行查询比对...';
}
try {
let apiEndpoint = '/api/query';
let requestConfig = config;
// 如果启用了分表模式使用分表查询API和配置
if (isShardingMode) {
apiEndpoint = '/api/sharding-query';
requestConfig = getShardingConfig();
}
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestConfig)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || '查询失败');
}
const results = await response.json();
currentResults = results;
displayResults(results);
// 自动刷新查询日志
autoRefreshLogsAfterQuery();
} catch (error) {
showAlert('danger', '查询失败: ' + error.message);
} finally {
document.getElementById('loading').style.display = 'none';
}
}
// 获取分表查询配置
function getShardingConfig() {
const baseConfig = getCurrentConfig();
return {
...baseConfig,
sharding_config: {
enabled: document.getElementById('enableSharding').checked,
use_sharding_for_pro: document.getElementById('use_sharding_for_pro').checked,
use_sharding_for_test: document.getElementById('use_sharding_for_test').checked,
// 生产环境分表参数
pro_interval_seconds: parseInt(document.getElementById('pro_interval_seconds').value) || 604800,
pro_table_count: parseInt(document.getElementById('pro_table_count').value) || 14,
// 测试环境分表参数
test_interval_seconds: parseInt(document.getElementById('test_interval_seconds').value) || 604800,
test_table_count: parseInt(document.getElementById('test_table_count').value) || 14,
// 保持向后兼容的通用参数
interval_seconds: parseInt(document.getElementById('pro_interval_seconds').value) || 604800,
table_count: parseInt(document.getElementById('pro_table_count').value) || 14
}
};
}
// 显示查询结果
function displayResults(results) {
// 显示统计信息
displayStats(results);
// 显示分表查询信息(如果有)
displayShardingInfo(results);
// 更新选项卡计数
document.getElementById('diff-count').textContent = results.differences.length;
document.getElementById('identical-count').textContent = results.identical_results.length;
// 计算原始数据总数
const totalRawData = (results.raw_pro_data ? results.raw_pro_data.length : 0) +
(results.raw_test_data ? results.raw_test_data.length : 0);
document.getElementById('rawdata-count').textContent = totalRawData;
// 初始化相同结果分页数据
filteredIdenticalResults = results.identical_results;
currentIdenticalPage = 1;
// 初始化差异结果分页数据
filteredDifferenceResults = results.differences;
currentDifferencePage = 1;
// 显示各个面板内容
displayDifferences();
displayIdenticalResults();
displayRawData(results);
displayComparisonSummary(results.summary);
// 显示结果区域
document.getElementById('results').style.display = 'block';
// 根据查询类型显示不同的成功消息
const queryType = isShardingMode ? '分表查询' : '单表查询';
showAlert('success', `${queryType}完成!共处理${results.total_keys}个Key发现${results.differences.length}处差异,${results.identical_results.length}条记录完全相同`);
}
// 显示分表查询信息
function displayShardingInfo(results) {
const shardingInfoContainer = document.getElementById('shardingInfoContainer');
if (!isShardingMode || !results.sharding_info) {
shardingInfoContainer.style.display = 'none';
return;
}
const shardingInfo = results.sharding_info;
shardingInfoContainer.style.display = 'block';
let html = '<div class="row">';
// 生产环境分表信息
if (shardingInfo.pro_shards) {
html += `
<div class="col-md-6">
<h6 class="text-primary"><i class="fas fa-server"></i> 生产环境分表信息</h6>
<div class="mb-3">
<small class="text-muted">配置:${shardingInfo.pro_shards.interval_seconds}秒间隔,${shardingInfo.pro_shards.table_count}张分表</small>
</div>
<div class="shard-tables-info">
`;
if (shardingInfo.pro_shards.queried_tables) {
shardingInfo.pro_shards.queried_tables.forEach(table => {
const hasError = shardingInfo.pro_shards.error_tables &&
shardingInfo.pro_shards.error_tables.includes(table);
const cssClass = hasError ? 'shard-error-info' : 'shard-table-info';
html += `<span class="${cssClass}">${table}</span>`;
});
}
html += '</div></div>';
}
// 测试环境分表信息
if (shardingInfo.test_shards) {
html += `
<div class="col-md-6">
<h6 class="text-success"><i class="fas fa-flask"></i> 测试环境分表信息</h6>
<div class="mb-3">
<small class="text-muted">配置:${shardingInfo.test_shards.interval_seconds}秒间隔,${shardingInfo.test_shards.table_count}张分表</small>
</div>
<div class="shard-tables-info">
`;
if (shardingInfo.test_shards.queried_tables) {
shardingInfo.test_shards.queried_tables.forEach(table => {
const hasError = shardingInfo.test_shards.error_tables &&
shardingInfo.test_shards.error_tables.includes(table);
const cssClass = hasError ? 'shard-error-info' : 'shard-table-info';
html += `<span class="${cssClass}">${table}</span>`;
});
}
html += '</div></div>';
}
html += '</div>';
// 添加分表计算统计信息
if (shardingInfo.calculation_stats) {
html += `
<div class="row mt-3">
<div class="col-12">
<h6><i class="fas fa-calculator"></i> 分表计算统计</h6>
<div class="row">
<div class="col-md-3">
<small class="text-muted">处理Key数<strong>${shardingInfo.calculation_stats.total_keys || 0}</strong></small>
</div>
<div class="col-md-3">
<small class="text-muted">成功解析时间戳:<strong>${shardingInfo.calculation_stats.successful_extractions || 0}</strong></small>
</div>
<div class="col-md-3">
<small class="text-muted">计算出分表数:<strong>${shardingInfo.calculation_stats.unique_shards || 0}</strong></small>
</div>
<div class="col-md-3">
<small class="text-muted">解析失败:<strong class="text-danger">${shardingInfo.calculation_stats.failed_extractions || 0}</strong></small>
</div>
</div>
</div>
</div>
`;
}
document.getElementById('shardingInfo').innerHTML = html;
}
// 显示统计信息
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');
// 保存当前搜索框的值
const currentSearchValue = document.getElementById('differenceSearch')?.value || '';
if (!filteredDifferenceResults || !filteredDifferenceResults.length) {
differencesContainer.innerHTML = '<p class="text-success"><i class="fas fa-check"></i> 未发现差异</p>';
return;
}
// 调试日志:检查差异数据
console.log('差异数据:', filteredDifferenceResults);
// 按主键分组差异
const groupedDifferences = groupDifferencesByKey(filteredDifferenceResults);
const totalGroups = Object.keys(groupedDifferences).length;
// 计算分页(基于主键组)
const groupKeys = Object.keys(groupedDifferences);
const totalPages = Math.ceil(groupKeys.length / differencePageSize);
const startIndex = (currentDifferencePage - 1) * differencePageSize;
const endIndex = startIndex + differencePageSize;
const currentPageKeys = groupKeys.slice(startIndex, endIndex);
// 统计字段差异类型
const fieldStats = {};
filteredDifferenceResults.forEach(diff => {
if (diff.field) {
fieldStats[diff.field] = (fieldStats[diff.field] || 0) + 1;
}
});
let html = `
<!-- 字段筛选按钮 -->
<div class="mb-3">
<div class="btn-group btn-group-sm flex-wrap" role="group">
<button type="button" class="btn btn-outline-primary active" onclick="filterByField('')">
全部 (${filteredDifferenceResults.length})
</button>
${Object.entries(fieldStats).map(([field, count]) => `
<button type="button" class="btn btn-outline-secondary" onclick="filterByField('${field}')">
${field} (${count})
</button>
`).join('')}
</div>
</div>
<!-- 分页控制 -->
<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">共 ${totalGroups} 个主键组,${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="搜索主键、字段名或值内容..."
onkeypress="if(event.key === 'Enter') searchDifferenceResults(this.value)"
id="differenceSearch"
value="${currentSearchValue}">
<button class="btn btn-outline-secondary" type="button" onclick="searchDifferenceResults(document.getElementById('differenceSearch').value)">
<i class="fas fa-search"></i> 搜索
</button>
<button class="btn btn-outline-secondary" type="button" onclick="clearDifferenceSearch()">
<i class="fas fa-times"></i> 清除
</button>
</div>
</div>
`;
// 显示当前页的主键组
currentPageKeys.forEach((keyStr, groupIndex) => {
const diffs = groupedDifferences[keyStr];
const globalIndex = startIndex + groupIndex + 1;
// 将字符串化的key解析回对象
let keyObj;
try {
keyObj = JSON.parse(keyStr);
} catch (e) {
// 如果解析失败,假设它本身就是一个简单值
keyObj = keyStr;
}
html += `
<div class="card mb-3 border-primary">
<div class="card-header bg-primary bg-opacity-10">
<div class="row align-items-center">
<div class="col">
<strong>主键组 #${globalIndex}</strong>
<span class="badge bg-primary ms-2">${diffs.length} 个差异字段</span>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-outline-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#keyGroup${globalIndex}">
<i class="fas fa-chevron-down"></i> 展开/收起
</button>
<button class="btn btn-sm btn-outline-info ms-2" onclick='showDifferenceRawData(${JSON.stringify(JSON.stringify(keyObj))})'>
<i class="fas fa-code"></i> 原生数据
</button>
</div>
</div>
<p class="mb-0 mt-2"><strong>主键:</strong> ${formatCompositeKey(keyObj)}</p>
</div>
<div class="collapse" id="keyGroup${globalIndex}">
<div class="card-body">
`;
// 显示该主键下的所有差异字段
diffs.forEach((diff, diffIndex) => {
if (diff.message) {
// 记录不存在的情况
html += `
<div class="alert alert-warning mb-2">
<i class="fas fa-exclamation-triangle"></i> ${diff.message}
</div>
`;
} else {
// 字段值差异的情况
const isJson = diff.is_json;
const isArray = diff.is_array;
const jsonClass = isJson ? 'json-field' : '';
const fieldId = `field_${globalIndex}_${diffIndex}`;
// 调试:打印差异数据
console.log(`差异 ${diff.field}:`, {
pro_value: diff.pro_value,
test_value: diff.test_value,
is_json: diff.is_json,
is_array: diff.is_array
});
// 确保值存在
const proValue = diff.pro_value !== undefined && diff.pro_value !== null ? diff.pro_value : '无数据';
const testValue = diff.test_value !== undefined && diff.test_value !== null ? diff.test_value : '无数据';
html += `
<div class="mb-3 border-start border-3 border-danger ps-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<strong class="text-danger">${diff.field}</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-secondary" type="button"
data-bs-toggle="collapse" data-bs-target="#${fieldId}">
<i class="fas fa-eye"></i> 查看
</button>
</div>
<div class="collapse" id="${fieldId}">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header bg-success bg-opacity-10">
<small class="text-success fw-bold">生产环境</small>
</div>
<div class="card-body p-2">
<pre class="field-value mb-0 ${jsonClass}" style="max-height: 200px; overflow-y: auto;">${escapeHtml(proValue)}</pre>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-info bg-opacity-10">
<small class="text-info fw-bold">测试环境</small>
</div>
<div class="card-body p-2">
<pre class="field-value mb-0 ${jsonClass}" style="max-height: 200px; overflow-y: auto;">${escapeHtml(testValue)}</pre>
</div>
</div>
</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">
${generateDifferencePagination(currentDifferencePage, totalPages)}
</div>
</div>
</div>
`;
}
differencesContainer.innerHTML = html;
// 恢复搜索框的焦点和光标位置
if (currentSearchValue) {
const searchInput = document.getElementById('differenceSearch');
if (searchInput) {
searchInput.focus();
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
}
}
}
// HTML转义函数防止XSS
function escapeHtml(text) {
// 处理undefined和null值
if (text === undefined || text === null) {
return '<span class="text-muted">无数据</span>';
}
// 确保是字符串
const str = String(text);
// 如果是空字符串
if (str === '') {
return '<span class="text-muted">空值</span>';
}
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 显示相同结果
function displayIdenticalResults() {
const identicalContainer = document.getElementById('identical-results');
// 保存当前搜索框的值
const currentSearchValue = document.getElementById('identicalSearch')?.value || '';
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="搜索主键或字段内容..."
onkeypress="if(event.key === 'Enter') searchIdenticalResults(this.value)"
id="identicalSearch"
value="${currentIdenticalSearchTerm || ''}">
<button class="btn btn-outline-secondary" type="button" onclick="searchIdenticalResults(document.getElementById('identicalSearch').value)">
<i class="fas fa-search"></i> 搜索
</button>
<button class="btn btn-outline-secondary" type="button" onclick="clearIdenticalSearch()">
<i class="fas fa-times"></i> 清除
</button>
</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> ${formatCompositeKey(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;
// 恢复搜索框的焦点和光标位置
if (currentSearchValue) {
const searchInput = document.getElementById('identicalSearch');
if (searchInput) {
searchInput.focus();
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
}
}
}
// 生成分页导航
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);
}
}
// 搜索相同结果
let currentIdenticalSearchTerm = '';
function searchIdenticalResults(searchTerm) {
if (!currentResults) return;
// 保存搜索词
currentIdenticalSearchTerm = searchTerm;
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 clearIdenticalSearch() {
currentIdenticalSearchTerm = '';
document.getElementById('identicalSearch').value = '';
filteredIdenticalResults = currentResults.identical_results;
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);
}
}
// 搜索差异结果
let currentSearchTerm = '';
function searchDifferenceResults(searchTerm) {
if (!currentResults) return;
// 保存搜索词
currentSearchTerm = searchTerm;
if (!searchTerm.trim()) {
// 如果有字段筛选,应用字段筛选;否则显示全部
if (currentFieldFilter) {
filteredDifferenceResults = currentResults.differences.filter(diff => diff.field === currentFieldFilter);
} else {
filteredDifferenceResults = currentResults.differences;
}
} else {
const term = searchTerm.toLowerCase();
let baseResults = currentFieldFilter ?
currentResults.differences.filter(diff => diff.field === currentFieldFilter) :
currentResults.differences;
filteredDifferenceResults = baseResults.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 clearDifferenceSearch() {
currentSearchTerm = '';
document.getElementById('differenceSearch').value = '';
// 重新应用字段筛选
if (currentFieldFilter) {
filteredDifferenceResults = currentResults.differences.filter(diff => diff.field === currentFieldFilter);
} else {
filteredDifferenceResults = currentResults.differences;
}
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);
console.log('查找原始数据key:', key);
// 在原生数据中查找对应的记录
let proData = null;
let testData = null;
// 查找生产环境数据
if (currentResults.raw_pro_data) {
proData = currentResults.raw_pro_data.find(item => {
// 支持复合主键比较
if (typeof key === 'object' && !Array.isArray(key)) {
// 复合主键情况:比较所有主键字段
const matches = Object.keys(key).every(keyField => {
const itemValue = item[keyField];
const keyValue = key[keyField];
// 如果都是undefined或null认为匹配
if (itemValue == null && keyValue == null) {
return true;
}
// 转换为字符串进行比较
const itemStr = String(itemValue);
const keyStr = String(keyValue);
// 直接比较字符串
return itemStr === keyStr;
});
if (matches) {
console.log('找到生产环境数据:', item);
}
return matches;
} else {
// 单主键情况(兼容旧代码)
return false;
}
});
// 如果没找到,输出调试信息
if (!proData) {
console.log('未找到生产数据查找的key:', key);
console.log('可用的数据:', currentResults.raw_pro_data.map(item => ({
statusid: item.statusid
})));
}
}
// 查找测试环境数据
if (currentResults.raw_test_data) {
testData = currentResults.raw_test_data.find(item => {
// 支持复合主键比较
if (typeof key === 'object' && !Array.isArray(key)) {
// 复合主键情况:比较所有主键字段
const matches = Object.keys(key).every(keyField => {
const itemValue = item[keyField];
const keyValue = key[keyField];
// 如果都是undefined或null认为匹配
if (itemValue == null && keyValue == null) {
return true;
}
// 转换为字符串进行比较
const itemStr = String(itemValue);
const keyStr = String(keyValue);
// 直接比较字符串
return itemStr === keyStr;
});
if (matches) {
console.log('找到测试环境数据:', item);
}
return matches;
} else {
// 单主键情况(兼容旧代码)
return false;
}
});
// 如果没找到,输出调试信息
if (!testData) {
console.log('未找到测试数据查找的key:', key);
console.log('可用的数据:', currentResults.raw_test_data.map(item => ({
statusid: item.statusid
})));
}
}
console.log('查找结果 - 生产数据:', proData, '测试数据:', testData);
// 创建模态框内容
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(() => {
console.log('开始渲染视图,生产数据:', proData, '测试数据:', testData);
// 渲染对比视图
try {
renderDiffView(proData, testData);
console.log('对比视图渲染完成');
} catch (e) {
console.error('对比视图渲染失败:', e);
}
// 渲染树形视图
try {
renderTreeView('proTreeView', proData);
renderTreeView('testTreeView', testData);
console.log('树形视图渲染完成');
} catch (e) {
console.error('树形视图渲染失败:', e);
}
// 为格式化视图添加同步滚动
setupSyncScroll('formatted');
// 为树形视图添加同步滚动
setupSyncScroll('tree');
// 添加标签页切换事件监听器,确保切换时重新渲染
const tabButtons = document.querySelectorAll('#rawDataTabs button[data-bs-toggle="tab"]');
tabButtons.forEach(button => {
button.addEventListener('shown.bs.tab', (event) => {
const targetId = event.target.getAttribute('data-bs-target');
console.log('切换到标签页:', targetId);
if (targetId === '#diff') {
// 重新渲染对比视图
renderDiffView(proData, testData);
} else if (targetId === '#tree') {
// 重新渲染树形视图
renderTreeView('proTreeView', proData);
renderTreeView('testTreeView', testData);
}
});
});
}, 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 (!diffViewContainer) {
console.error('对比视图容器不存在');
return;
}
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 (!container) {
console.error('树形视图容器不存在:', containerId);
return;
}
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 {
// 添加批量操作控制栏
historyList += `
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllQueryHistory" onchange="toggleAllQueryHistorySelection()">
<label class="form-check-label" for="selectAllQueryHistory">
全选
</label>
</div>
<div>
<button class="btn btn-danger btn-sm" id="batchDeleteQueryHistoryBtn" onclick="batchDeleteQueryHistory()" disabled>
<i class="fas fa-trash"></i> 批量删除 (<span id="selectedQueryHistoryCount">0</span>)
</button>
</div>
</div>
`;
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;
const safeName = (history.name || '').replace(/'/g, "\\'");
historyList += `
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div class="form-check me-3 mt-1">
<input class="form-check-input query-history-checkbox" type="checkbox" value="${history.id}" onchange="updateQueryHistorySelectionCount()">
</div>
<div class="flex-grow-1">
<h6 class="card-title mb-1">
${history.name}
<span class="badge ${history.query_type === 'sharding' ? 'bg-primary' : 'bg-secondary'} ms-2">
${history.query_type === 'sharding' ? '分表查询' : '单表查询'}
</span>
</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-success me-1" onclick="loadHistoryResults(${history.id})" title="查看结果">
<i class="fas fa-chart-bar"></i>
</button>
<button class="btn btn-sm btn-danger" onclick="deleteHistoryRecord(${history.id}, '${safeName}')" 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 || '';
// 填充查询配置 - 使用与配置组加载相同的逻辑
if (history.query_config) {
console.log('loadHistoryRecord - 设置keys字段:', history.query_config.keys);
const keysElement = document.getElementById('keys');
console.log('loadHistoryRecord - keys元素:', keysElement);
if (keysElement) {
keysElement.value = (history.query_config.keys || []).join(',');
console.log('loadHistoryRecord - keys字段设置后的值:', keysElement.value);
// 强制触发多种事件以确保UI更新
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
// 添加延迟更新,确保模态框关闭后再次设置
setTimeout(() => {
keysElement.value = (history.query_config.keys || []).join(',');
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
console.log('loadHistoryRecord - 延迟设置后的值:', keysElement.value);
}, 100);
} else {
console.error('loadHistoryRecord - 未找到keys输入元素!');
}
const fieldsToCompareElement = document.getElementById('fields_to_compare');
const excludeFieldsElement = document.getElementById('exclude_fields');
if (fieldsToCompareElement) {
fieldsToCompareElement.value = (history.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
fieldsToCompareElement.value = (history.query_config.fields_to_compare || []).join(',');
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
if (excludeFieldsElement) {
excludeFieldsElement.value = (history.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => {
excludeFieldsElement.value = (history.query_config.exclude_fields || []).join(',');
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
}, 100);
}
} else {
console.warn('loadHistoryRecord - 查询配置为空或未定义');
document.getElementById('keys').value = '';
document.getElementById('fields_to_compare').value = '';
document.getElementById('exclude_fields').value = '';
}
// 填充查询Key值
document.getElementById('query_values').value = (history.query_keys || []).join('\n');
// 处理分表配置(如果存在)
if (history.query_type === 'sharding' && history.sharding_config) {
// 启用分表模式
document.getElementById('enableSharding').checked = true;
toggleShardingMode();
// 填充分表配置
document.getElementById('use_sharding_for_pro').checked = history.sharding_config.use_sharding_for_pro || false;
document.getElementById('use_sharding_for_test').checked = history.sharding_config.use_sharding_for_test || false;
document.getElementById('pro_interval_seconds').value = history.sharding_config.pro_interval_seconds || 604800;
document.getElementById('pro_table_count').value = history.sharding_config.pro_table_count || 14;
document.getElementById('test_interval_seconds').value = history.sharding_config.test_interval_seconds || 604800;
document.getElementById('test_table_count').value = history.sharding_config.test_table_count || 14;
} else {
// 禁用分表模式
document.getElementById('enableSharding').checked = false;
toggleShardingMode();
}
// 关闭历史记录modal
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
modal.hide();
const queryTypeDesc = history.query_type === 'sharding' ? '分表查询' : '单表查询';
showAlert('success', `${queryTypeDesc}历史记录 "${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 loadHistoryResults(historyId) {
try {
const response = await fetch(`/api/query-history/${historyId}/results`);
const result = await response.json();
if (result.success) {
// 设置当前结果数据
currentResults = result.data;
// 确保必要的数组字段存在
if (!currentResults.differences) currentResults.differences = [];
if (!currentResults.identical_results) currentResults.identical_results = [];
if (!currentResults.raw_pro_data) currentResults.raw_pro_data = [];
if (!currentResults.raw_test_data) currentResults.raw_test_data = [];
if (!currentResults.field_diff_count) currentResults.field_diff_count = {};
if (!currentResults.summary) currentResults.summary = {};
// 根据查询类型设置分表模式
if (result.data.history_info && result.data.history_info.query_type === 'sharding') {
isShardingMode = true;
document.getElementById('enableSharding').checked = true;
toggleShardingMode();
} else {
isShardingMode = false;
document.getElementById('enableSharding').checked = false;
toggleShardingMode();
}
// 显示结果
displayResults(result.data);
// 关闭历史记录modal
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
if (modal) {
modal.hide();
}
const queryTypeDesc = (result.data.history_info && result.data.history_info.query_type === 'sharding') ? '分表查询' : '单表查询';
const historyName = (result.data.history_info && result.data.history_info.name) || '未知';
showAlert('success', `${queryTypeDesc}历史记录结果 "${historyName}" 加载成功`);
} else {
showAlert('danger', result.error || '加载历史记录结果失败');
}
} catch (error) {
console.error('加载历史记录结果失败:', 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();
// 确定查询类型和获取相应配置
let queryType = 'single';
let shardingConfig = null;
if (isShardingMode) {
queryType = 'sharding';
shardingConfig = getShardingConfig().sharding_config;
}
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: config.query_config,
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,
// 新增分表相关字段
sharding_config: shardingConfig,
query_type: queryType
})
});
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: "Example Cluster"
clusterNodes: "127.0.0.1,127.0.0.2"
port: 9042
datacenter: "dc1"
username: "cassandra"
password: "example_password"
keyspace: "example_keyspace"
table: "example_table"</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);
}
// 查询日志相关功能
let allQueryLogs = []; // 存储所有日志
async function refreshQueryLogs() {
// 这个函数现在不再使用,因为查询日志已经独立到模态框中
// 如果需要刷新查询日志,请使用 refreshModalQueryLogs()
console.warn('refreshQueryLogs() 已弃用,请使用 refreshModalQueryLogs()');
}
// 显示分组查询日志 - 已弃用
function displayGroupedQueryLogs(groupedLogs) {
// 这个函数已弃用,查询日志现在使用独立模态框显示
console.warn('displayGroupedQueryLogs() 已弃用,查询日志现在使用独立模态框显示');
return;
}
// 计算持续时间
function calculateDuration(startTime, endTime) {
try {
const start = new Date(startTime);
const end = new Date(endTime);
const diffMs = end - start;
if (diffMs < 1000) {
return `${diffMs}ms`;
} else if (diffMs < 60000) {
return `${(diffMs / 1000).toFixed(1)}`;
} else {
const minutes = Math.floor(diffMs / 60000);
const seconds = Math.floor((diffMs % 60000) / 1000);
return `${minutes}${seconds}`;
}
} catch (e) {
return '未知';
}
}
// 格式化日志消息
function formatLogMessage(message) {
// HTML转义
let formatted = escapeHtml(message);
// 高亮SQL查询语句
if (formatted.includes('执行查询SQL:')) {
formatted = formatted.replace(/执行查询SQL: (SELECT.*?);/g,
'执行查询SQL: <br><code class="bg-light d-block p-2 text-dark" style="font-size: 0.85em;">$1;</code>');
}
// 高亮重要信息
formatted = formatted.replace(/([\d.]+秒)/g, '<strong class="text-success">$1</strong>');
formatted = formatted.replace(/(返回记录数=\d+)/g, '<strong class="text-info">$1</strong>');
formatted = formatted.replace(/(执行时间=[\d.]+秒)/g, '<strong class="text-success">$1</strong>');
formatted = formatted.replace(/(分表索引=\d+)/g, '<strong class="text-warning">$1</strong>');
formatted = formatted.replace(/(表名=\w+)/g, '<strong class="text-primary">$1</strong>');
return formatted;
}
function filterLogsByLevel() {
// 这个函数已弃用,因为查询日志现在使用独立模态框显示
console.warn('filterLogsByLevel() 已弃用,请使用 filterModalLogsByLevel()');
}
async function clearQueryLogs() {
if (!confirm('确定要清空所有查询日志吗?这将删除内存和数据库中的所有日志记录。')) {
return;
}
try {
const response = await fetch('/api/query-logs?clear_db=true', {
method: 'DELETE'
});
const result = await response.json();
if (result.success) {
// 如果查询日志模态框是打开的,清空显示内容
const modalLogsContainer = document.getElementById('modal-query-logs');
if (modalLogsContainer) {
modalLogsContainer.innerHTML = '<div class="alert alert-info">查询日志已清空</div>';
}
showAlert('success', result.message);
} else {
showAlert('danger', '清空查询日志失败: ' + result.error);
}
} catch (error) {
console.error('清空查询日志失败:', error);
showAlert('danger', '清空查询日志失败');
}
}
// 清理旧日志
async function cleanupOldLogs() {
const days = prompt('请输入要保留的天数默认30天:', '30');
if (days === null) return; // 用户取消
const daysToKeep = parseInt(days) || 30;
if (daysToKeep <= 0) {
showAlert('warning', '保留天数必须大于0');
return;
}
if (!confirm(`确定要清理超过 ${daysToKeep} 天的旧日志吗?`)) {
return;
}
try {
const response = await fetch('/api/query-logs/cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
days_to_keep: daysToKeep
})
});
const result = await response.json();
if (result.success) {
showAlert('success', result.message);
// 刷新模态框中的日志显示
refreshModalQueryLogs();
} else {
showAlert('danger', '清理旧日志失败: ' + result.error);
}
} catch (error) {
console.error('清理旧日志失败:', error);
showAlert('danger', '清理旧日志失败');
}
}
// 显示查询日志对话框
function showQueryLogsDialog() {
// 打开模态框
const modal = new bootstrap.Modal(document.getElementById('queryLogsModal'));
modal.show();
// 加载查询日志
refreshModalQueryLogs();
}
// 刷新模态框中的查询日志
async function refreshModalQueryLogs() {
try {
const response = await fetch('/api/query-logs?grouped=true&from_db=true');
const result = await response.json();
if (result.success && result.data) {
if (result.grouped) {
displayModalGroupedQueryLogs(result.data);
} else {
// 兼容旧版本的平铺显示
displayModalQueryLogs(result.data);
}
} else {
document.getElementById('modal-query-logs').innerHTML = '<div class="alert alert-warning">无法获取查询日志</div>';
}
} catch (error) {
console.error('获取查询日志失败:', error);
document.getElementById('modal-query-logs').innerHTML = '<div class="alert alert-danger">获取查询日志失败</div>';
}
}
// 在模态框中显示分组查询日志
function displayModalGroupedQueryLogs(groupedLogs) {
const container = document.getElementById('modal-query-logs');
if (!groupedLogs || groupedLogs.length === 0) {
container.innerHTML = '<div class="alert alert-info">暂无查询日志</div>';
return;
}
// 获取当前的过滤器状态,如果复选框不存在则默认显示所有
const showInfo = document.getElementById('modal-log-level-info')?.checked ?? true;
const showWarning = document.getElementById('modal-log-level-warning')?.checked ?? true;
const showError = document.getElementById('modal-log-level-error')?.checked ?? true;
let html = '';
// 为每个批次生成折叠面板
groupedLogs.forEach((batchData, index) => {
const [batchId, logs] = batchData;
const isExpanded = index === groupedLogs.length - 1; // 默认展开最新批次
const collapseId = `modal-batch-${batchId}`;
// 过滤日志
const filteredLogs = logs.filter(log => {
return (log.level === 'INFO' && showInfo) ||
(log.level === 'WARNING' && showWarning) ||
(log.level === 'ERROR' && showError);
});
// 如果过滤后没有日志,跳过这个批次
if (filteredLogs.length === 0) {
return;
}
// 统计批次信息(基于原始日志)
const logCounts = {
INFO: logs.filter(log => log.level === 'INFO').length,
WARNING: logs.filter(log => log.level === 'WARNING').length,
ERROR: logs.filter(log => log.level === 'ERROR').length
};
const totalLogs = logs.length;
const filteredCount = filteredLogs.length;
const firstLog = logs[0];
const lastLog = logs[logs.length - 1];
const duration = firstLog && lastLog ? calculateDuration(firstLog.timestamp, lastLog.timestamp) : '0秒';
// 确定批次类型和状态
const hasErrors = logCounts.ERROR > 0;
const hasWarnings = logCounts.WARNING > 0;
const batchStatus = hasErrors ? 'danger' : hasWarnings ? 'warning' : 'success';
const batchIcon = hasErrors ? 'fas fa-times-circle' : hasWarnings ? 'fas fa-exclamation-triangle' : 'fas fa-check-circle';
// 提取查询类型
const batchTypeMatch = firstLog?.message.match(/开始(\w+)查询批次/) || firstLog?.message.match(/🚀 开始执行(\w+)数据比较/);
const batchType = batchTypeMatch ? batchTypeMatch[1] : '未知';
// 提取并格式化查询日期
const queryDate = firstLog ? new Date(firstLog.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}) : '未知时间';
// 检查是否有关联的历史记录ID
const historyId = firstLog?.history_id;
const historyBadge = historyId ?
`<span class="badge bg-secondary ms-1" title="关联历史记录ID: ${historyId}">
ID:${historyId}
<button class="btn btn-sm btn-outline-light ms-1 p-0" style="border: none; font-size: 10px;"
onclick="loadHistoryFromLogs(${historyId})" title="加载此历史记录">
<i class="fas fa-external-link-alt"></i>
</button>
</span>` : '';
html += `
<div class="card mb-3 border-${batchStatus}">
<div class="card-header bg-${batchStatus} bg-opacity-10" data-bs-toggle="collapse" data-bs-target="#${collapseId}" style="cursor: pointer;">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex flex-column">
<div class="d-flex align-items-center mb-1">
<i class="${batchIcon} text-${batchStatus} me-2"></i>
<strong>${batchType}查询批次 ${batchId}</strong>
<span class="badge bg-primary ms-2">${filteredCount}/${totalLogs}条日志</span>
${historyBadge}
</div>
<div class="d-flex align-items-center">
<small class="text-muted">
<i class="fas fa-calendar-alt me-1"></i>
查询时间: ${queryDate}
</small>
</div>
</div>
<div class="d-flex align-items-center">
<small class="text-muted me-3">
<i class="fas fa-clock"></i> 持续时间: ${duration}
</small>
<div>
${logCounts.INFO > 0 ? `<span class="badge bg-info me-1">${logCounts.INFO} INFO</span>` : ''}
${logCounts.WARNING > 0 ? `<span class="badge bg-warning me-1">${logCounts.WARNING} WARN</span>` : ''}
${logCounts.ERROR > 0 ? `<span class="badge bg-danger me-1">${logCounts.ERROR} ERROR</span>` : ''}
</div>
<i class="fas fa-chevron-down ms-2"></i>
</div>
</div>
</div>
<div class="collapse ${isExpanded ? 'show' : ''}" id="${collapseId}">
<div class="card-body p-0">
<div class="log-entries" style="max-height: 400px; overflow-y: auto;">
`;
// 显示过滤后的日志条目
filteredLogs.forEach(log => {
const levelClass = log.level === 'ERROR' ? 'danger' :
log.level === 'WARNING' ? 'warning' : 'info';
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
// 简化时间戳显示
const timeOnly = log.timestamp ? (log.timestamp.split(' ')[1] || log.timestamp) : '未知时间';
html += `
<div class="log-entry p-2 border-bottom" data-level="${log.level}">
<div class="d-flex align-items-start">
<span class="badge bg-${levelClass} me-2" style="min-width: 60px;">
<i class="${levelIcon}"></i> ${log.level}
</span>
<small class="text-muted me-2 flex-shrink-0" style="min-width: 80px;">
${timeOnly}
</small>
<div class="log-message flex-grow-1" style="word-wrap: break-word; white-space: pre-wrap;">
${formatLogMessage(log.message || '无消息内容')}
</div>
</div>
</div>
`;
});
html += `
</div>
</div>
</div>
</div>
`;
});
if (html === '') {
html = '<div class="alert alert-warning">没有符合过滤条件的日志</div>';
}
container.innerHTML = html;
}
// 过滤模态框中的日志级别
function filterModalLogsByLevel() {
// 刷新分组日志显示,应用过滤器
refreshModalQueryLogs();
}
// 显示平铺的查询日志(兼容性)
function displayModalQueryLogs(logs) {
const container = document.getElementById('modal-query-logs');
if (!logs || logs.length === 0) {
container.innerHTML = '<div class="alert alert-info">暂无查询日志</div>';
return;
}
const showInfo = document.getElementById('modal-log-level-info').checked;
const showWarning = document.getElementById('modal-log-level-warning').checked;
const showError = document.getElementById('modal-log-level-error').checked;
let html = '<div class="list-group">';
logs.forEach(log => {
const shouldShow = (log.level === 'INFO' && showInfo) ||
(log.level === 'WARNING' && showWarning) ||
(log.level === 'ERROR' && showError);
if (shouldShow) {
const levelClass = log.level === 'ERROR' ? 'danger' :
log.level === 'WARNING' ? 'warning' : 'info';
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
html += `
<div class="list-group-item">
<div class="d-flex align-items-start">
<span class="badge bg-${levelClass} me-2">
<i class="${levelIcon}"></i> ${log.level}
</span>
<small class="text-muted me-2">${log.timestamp}</small>
<div class="flex-grow-1">${formatLogMessage(log.message)}</div>
</div>
</div>
`;
}
});
html += '</div>';
container.innerHTML = html;
}
// 从查询日志模态框中加载历史记录
function loadHistoryFromLogs(historyId) {
// 先关闭查询日志模态框
const logsModal = bootstrap.Modal.getInstance(document.getElementById('queryLogsModal'));
if (logsModal) {
logsModal.hide();
}
// 延迟一下再加载历史记录,确保模态框已关闭
setTimeout(() => {
loadHistoryResults(historyId);
}, 300);
}
// 根据历史记录ID获取相关查询日志
async function loadQueryLogsByHistory(historyId) {
try {
const response = await fetch(`/api/query-logs/history/${historyId}`);
const result = await response.json();
if (result.success && result.data) {
// 显示关联的查询日志
displayHistoryRelatedLogs(result.data, historyId);
return result.data;
} else {
console.warn('获取历史记录相关日志失败:', result.error);
return [];
}
} catch (error) {
console.error('获取历史记录相关查询日志失败:', error);
return [];
}
}
// 显示历史记录相关的查询日志
function displayHistoryRelatedLogs(groupedLogs, historyId) {
const container = document.getElementById('query-logs');
if (!groupedLogs || groupedLogs.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
历史记录 ID ${historyId} 没有关联的查询日志
</div>
`;
return;
}
let html = `
<div class="alert alert-primary">
<i class="fas fa-link"></i>
显示历史记录 ID ${historyId} 的相关查询日志 (共 ${groupedLogs.length} 个批次)
</div>
`;
// 使用与refreshQueryLogs相同的显示逻辑
groupedLogs.forEach((batchData, index) => {
const [batchId, logs] = batchData;
const isExpanded = true; // 历史记录日志默认展开所有批次
const collapseId = `history-batch-${batchId}`;
// 统计批次信息
const logCounts = {
INFO: logs.filter(log => log.level === 'INFO').length,
WARNING: logs.filter(log => log.level === 'WARNING').length,
ERROR: logs.filter(log => log.level === 'ERROR').length
};
const totalLogs = logs.length;
const firstLog = logs[0];
const lastLog = logs[logs.length - 1];
const duration = firstLog && lastLog ? calculateDuration(firstLog.timestamp, lastLog.timestamp) : '0秒';
// 确定批次类型和状态
const hasErrors = logCounts.ERROR > 0;
const hasWarnings = logCounts.WARNING > 0;
const batchStatus = hasErrors ? 'danger' : hasWarnings ? 'warning' : 'success';
const batchIcon = hasErrors ? 'fas fa-times-circle' : hasWarnings ? 'fas fa-exclamation-triangle' : 'fas fa-check-circle';
// 提取查询类型
const batchTypeMatch = firstLog?.message.match(/开始(\w+)查询批次/);
const batchType = batchTypeMatch ? batchTypeMatch[1] : '未知';
// 提取并格式化查询日期
const queryDate = firstLog ? new Date(firstLog.timestamp).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}) : '未知时间';
html += `
<div class="card mb-3 border-${batchStatus}">
<div class="card-header bg-${batchStatus} bg-opacity-10" data-bs-toggle="collapse" data-bs-target="#${collapseId}" style="cursor: pointer;">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex flex-column">
<div class="d-flex align-items-center mb-1">
<i class="${batchIcon} text-${batchStatus} me-2"></i>
<strong>${batchType}查询批次 ${batchId}</strong>
<span class="badge bg-primary ms-2">${totalLogs}条日志</span>
<span class="badge bg-secondary ms-1">历史记录</span>
</div>
<div class="d-flex align-items-center">
<small class="text-muted">
<i class="fas fa-calendar-alt me-1"></i>
查询时间: ${queryDate}
</small>
</div>
</div>
<div class="d-flex align-items-center">
<small class="text-muted me-3">
<i class="fas fa-clock"></i> 持续时间: ${duration}
</small>
<div>
${logCounts.INFO > 0 ? `<span class="badge bg-info me-1">${logCounts.INFO} INFO</span>` : ''}
${logCounts.WARNING > 0 ? `<span class="badge bg-warning me-1">${logCounts.WARNING} WARN</span>` : ''}
${logCounts.ERROR > 0 ? `<span class="badge bg-danger me-1">${logCounts.ERROR} ERROR</span>` : ''}
</div>
<i class="fas fa-chevron-down ms-2"></i>
</div>
</div>
</div>
<div class="collapse ${isExpanded ? 'show' : ''}" id="${collapseId}">
<div class="card-body p-0">
<div class="log-entries" style="max-height: 400px; overflow-y: auto;">
`;
// 显示该批次的日志条目
logs.forEach(log => {
const shouldShow = true; // 历史记录日志显示所有级别
if (shouldShow) {
const levelClass = log.level === 'ERROR' ? 'danger' :
log.level === 'WARNING' ? 'warning' : 'info';
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
// 简化时间戳显示
const timeOnly = log.timestamp.split(' ')[1] || log.timestamp;
html += `
<div class="log-entry p-2 border-bottom">
<div class="d-flex align-items-start">
<span class="badge bg-${levelClass} me-2">
<i class="${levelIcon}"></i> ${log.level}
</span>
<small class="text-muted me-2 flex-shrink-0" style="min-width: 80px;">
${timeOnly}
</small>
<div class="log-message flex-grow-1">
${formatLogMessage(log.message)}
</div>
</div>
</div>
`;
}
});
html += `
</div>
</div>
</div>
</div>
`;
});
container.innerHTML = html;
}
// 在查询执行后自动刷新日志
function autoRefreshLogsAfterQuery() {
// 延迟一下确保后端日志已经记录
setTimeout(() => {
// 只有当查询日志模态框打开时才刷新
const queryLogsModal = document.getElementById('queryLogsModal');
if (queryLogsModal && queryLogsModal.classList.contains('show')) {
refreshModalQueryLogs();
}
}, 500);
}
// 原始数据相关变量
let currentRawData = null;
let filteredRawData = [];
let currentRawDataPage = 1;
let rawDataPageSize = 20;
// 显示原始数据
function displayRawData(results) {
currentRawData = results;
// 添加调试信息
console.log('displayRawData - results.query_config:', results.query_config);
console.log('displayRawData - 当前数据键:', Object.keys(results));
// 合并生产和测试环境数据
const combinedData = [];
if (results.raw_pro_data) {
results.raw_pro_data.forEach(record => {
combinedData.push({
environment: 'production',
data: record,
displayName: '生产环境'
});
});
}
if (results.raw_test_data) {
results.raw_test_data.forEach(record => {
combinedData.push({
environment: 'test',
data: record,
displayName: '测试环境'
});
});
}
// 按环境排序,生产环境在前
combinedData.sort((a, b) => {
if (a.environment === 'production' && b.environment === 'test') return -1;
if (a.environment === 'test' && b.environment === 'production') return 1;
return 0;
});
filteredRawData = combinedData;
currentRawDataPage = 1;
renderRawDataContent();
}
// 渲染原始数据内容
function renderRawDataContent() {
const container = document.getElementById('raw-data-content');
if (!filteredRawData.length) {
container.innerHTML = '<div class="alert alert-info">没有原始数据可显示</div>';
return;
}
// 分页计算
const totalPages = Math.ceil(filteredRawData.length / rawDataPageSize);
const startIndex = (currentRawDataPage - 1) * rawDataPageSize;
const endIndex = startIndex + rawDataPageSize;
const currentPageData = filteredRawData.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="changeRawDataPageSize(this.value)">
<option value="10" ${rawDataPageSize == 10 ? 'selected' : ''}>10条</option>
<option value="20" ${rawDataPageSize == 20 ? 'selected' : ''}>20条</option>
<option value="50" ${rawDataPageSize == 50 ? 'selected' : ''}>50条</option>
<option value="100" ${rawDataPageSize == 100 ? 'selected' : ''}>100条</option>
</select>
<span class="ms-2 text-muted">共 ${filteredRawData.length} 条记录</span>
</div>
</div>
<div class="col-md-6">
<div class="d-flex justify-content-end align-items-center">
${generateRawDataPagination(currentRawDataPage, totalPages)}
</div>
</div>
</div>
`;
// 显示数据记录
currentPageData.forEach((item, index) => {
const globalIndex = startIndex + index + 1;
const envBadgeClass = item.environment === 'production' ? 'bg-primary' : 'bg-success';
// 获取主键值用于标识 - 修复主键解析问题,支持复合主键
let keyValue = 'N/A';
// 从当前配置获取keys因为query_config可能不在results中
const currentConfig = getCurrentConfig();
console.log('渲染原始数据 - currentConfig.query_config.keys:', currentConfig.query_config.keys);
console.log('渲染原始数据 - item.data keys:', Object.keys(item.data));
if (currentConfig && currentConfig.query_config && currentConfig.query_config.keys && currentConfig.query_config.keys.length > 0) {
const keyFields = currentConfig.query_config.keys;
const keyValues = keyFields.map(key => {
const value = item.data[key];
console.log(`查找主键字段 ${key}: `, value);
return value || 'N/A';
}).filter(val => val !== 'N/A');
if (keyValues.length > 0) {
// 复合主键显示格式docid1,id1 或 单主键显示value1
keyValue = keyValues.join(', ');
}
} else {
// 如果没有配置主键,尝试常见的主键字段名
const commonKeyFields = ['id', 'statusid', 'key', 'uuid', 'docid'];
for (const field of commonKeyFields) {
if (item.data[field] !== undefined) {
keyValue = item.data[field];
break;
}
}
}
// 格式化JSON数据
const jsonData = JSON.stringify(item.data, null, 2);
html += `
<div class="card mb-3 border-${item.environment === 'production' ? 'primary' : 'success'}">
<div class="card-header bg-light">
<div class="row align-items-center">
<div class="col">
<strong>记录 #${globalIndex}</strong>
<span class="badge ${envBadgeClass} ms-2">${item.displayName}</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="#rawCollapse${globalIndex}">
<i class="fas fa-eye"></i> 查看详情
</button>
<button class="btn btn-sm btn-outline-info" onclick='copyRawRecord(${JSON.stringify(JSON.stringify(item.data))})'>
<i class="fas fa-copy"></i> 复制
</button>
</div>
</div>
<p class="mb-0 mt-2"><strong>主键:</strong> ${keyValue}</p>
</div>
<div class="collapse" id="rawCollapse${globalIndex}">
<div class="card-body">
<pre class="bg-light p-3 rounded" style="max-height: 500px; overflow-y: auto; font-size: 0.9em; line-height: 1.4;">${escapeHtml(jsonData)}</pre>
</div>
</div>
</div>
`;
});
// 底部分页
if (totalPages > 1) {
html += `
<div class="row mt-3">
<div class="col-12">
<div class="d-flex justify-content-center">
${generateRawDataPagination(currentRawDataPage, totalPages)}
</div>
</div>
</div>
`;
}
container.innerHTML = html;
}
// 生成原始数据分页导航
function generateRawDataPagination(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="goToRawDataPage(${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="goToRawDataPage(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="goToRawDataPage(${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="goToRawDataPage(${totalPages})">${totalPages}</a></li>`;
}
// 下一页
html += `
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="goToRawDataPage(${currentPage + 1})">
<i class="fas fa-chevron-right"></i>
</a>
</li>
`;
html += '</ul></nav>';
return html;
}
// 跳转到指定原始数据页面
function goToRawDataPage(page) {
const totalPages = Math.ceil(filteredRawData.length / rawDataPageSize);
if (page < 1 || page > totalPages) return;
currentRawDataPage = page;
renderRawDataContent();
}
// 改变原始数据每页显示数量
function changeRawDataPageSize(newSize) {
rawDataPageSize = parseInt(newSize);
currentRawDataPage = 1;
renderRawDataContent();
}
// 过滤原始数据(按环境)
function filterRawData() {
if (!currentRawData) return;
const showPro = document.getElementById('showProData').checked;
const showTest = document.getElementById('showTestData').checked;
let combinedData = [];
if (showPro && currentRawData.raw_pro_data) {
currentRawData.raw_pro_data.forEach(record => {
combinedData.push({
environment: 'production',
data: record,
displayName: '生产环境'
});
});
}
if (showTest && currentRawData.raw_test_data) {
currentRawData.raw_test_data.forEach(record => {
combinedData.push({
environment: 'test',
data: record,
displayName: '测试环境'
});
});
}
// 按环境排序
combinedData.sort((a, b) => {
if (a.environment === 'production' && b.environment === 'test') return -1;
if (a.environment === 'test' && b.environment === 'production') return 1;
return 0;
});
filteredRawData = combinedData;
currentRawDataPage = 1;
renderRawDataContent();
}
// 搜索原始数据
function searchRawData(searchTerm) {
if (!currentRawData) return;
const showPro = document.getElementById('showProData').checked;
const showTest = document.getElementById('showTestData').checked;
let combinedData = [];
if (showPro && currentRawData.raw_pro_data) {
currentRawData.raw_pro_data.forEach(record => {
combinedData.push({
environment: 'production',
data: record,
displayName: '生产环境'
});
});
}
if (showTest && currentRawData.raw_test_data) {
currentRawData.raw_test_data.forEach(record => {
combinedData.push({
environment: 'test',
data: record,
displayName: '测试环境'
});
});
}
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase();
combinedData = combinedData.filter(item => {
// 搜索所有字段值
return Object.values(item.data).some(value =>
String(value).toLowerCase().includes(term)
);
});
}
// 按环境排序
combinedData.sort((a, b) => {
if (a.environment === 'production' && b.environment === 'test') return -1;
if (a.environment === 'test' && b.environment === 'production') return 1;
return 0;
});
filteredRawData = combinedData;
currentRawDataPage = 1;
renderRawDataContent();
}
// 复制原始记录
function copyRawRecord(recordStr) {
try {
const record = JSON.parse(recordStr);
const formattedData = JSON.stringify(record, null, 2);
navigator.clipboard.writeText(formattedData).then(() => {
showAlert('success', '原始记录已复制到剪贴板');
}).catch(err => {
console.error('复制失败:', err);
showAlert('danger', '复制失败,请手动选择复制');
});
} catch (error) {
showAlert('danger', '复制失败: ' + error.message);
}
}
// 格式化复合主键显示
function formatCompositeKey(key) {
if (!key) {
return 'N/A';
}
if (typeof key === 'object' && !Array.isArray(key)) {
// 复合主键情况:显示为 "docid: value1, id: value2" 格式
return Object.entries(key)
.map(([field, value]) => `${field}: ${value}`)
.join(', ');
} else {
// 单主键或其他情况使用JSON格式
return JSON.stringify(key);
}
}
// 切换全选Cassandra查询历史记录
function toggleAllQueryHistorySelection() {
const selectAllCheckbox = document.getElementById('selectAllQueryHistory');
const historyCheckboxes = document.querySelectorAll('.query-history-checkbox');
historyCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
});
updateQueryHistorySelectionCount();
}
// 更新Cassandra查询历史记录选择数量
function updateQueryHistorySelectionCount() {
const selectedCheckboxes = document.querySelectorAll('.query-history-checkbox:checked');
const count = selectedCheckboxes.length;
const totalCheckboxes = document.querySelectorAll('.query-history-checkbox');
// 更新显示计数
const countSpan = document.getElementById('selectedQueryHistoryCount');
if (countSpan) {
countSpan.textContent = count;
}
// 更新批量删除按钮状态
const batchDeleteBtn = document.getElementById('batchDeleteQueryHistoryBtn');
if (batchDeleteBtn) {
batchDeleteBtn.disabled = count === 0;
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById('selectAllQueryHistory');
if (selectAllCheckbox) {
if (count === 0) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = false;
} else if (count === totalCheckboxes.length) {
selectAllCheckbox.indeterminate = false;
selectAllCheckbox.checked = true;
} else {
selectAllCheckbox.indeterminate = true;
}
}
}
// 批量删除Cassandra查询历史记录
async function batchDeleteQueryHistory() {
const selectedCheckboxes = document.querySelectorAll('.query-history-checkbox:checked');
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
if (selectedIds.length === 0) {
showAlert('warning', '请选择要删除的历史记录');
return;
}
if (!confirm(`确定要删除选中的 ${selectedIds.length} 条历史记录吗?此操作不可恢复。`)) {
return;
}
try {
const response = await fetch('/api/query-history/batch-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
history_ids: selectedIds
})
});
const result = await response.json();
if (result.success) {
showAlert('success', `成功删除 ${result.deleted_count} 条历史记录`);
// 重新显示历史记录列表
setTimeout(() => {
showQueryHistoryDialog();
}, 500);
} else {
showAlert('danger', result.error || '批量删除失败');
}
} catch (error) {
console.error('批量删除Cassandra查询历史记录失败:', error);
showAlert('danger', `批量删除失败: ${error.message}`);
}
}
// 按主键分组差异数据
function groupDifferencesByKey(differences) {
const grouped = {};
differences.forEach(diff => {
const keyStr = typeof diff.key === 'object' ? JSON.stringify(diff.key) : diff.key;
if (!grouped[keyStr]) {
grouped[keyStr] = [];
}
grouped[keyStr].push(diff);
});
return grouped;
}
// 按字段筛选差异
let currentFieldFilter = '';
function filterByField(field) {
currentFieldFilter = field;
// 更新按钮状态
const buttons = document.querySelectorAll('.btn-group button');
buttons.forEach(btn => {
btn.classList.remove('active', 'btn-outline-primary');
btn.classList.add('btn-outline-secondary');
});
// 设置当前选中的按钮
if (field === '') {
buttons[0].classList.remove('btn-outline-secondary');
buttons[0].classList.add('btn-outline-primary', 'active');
} else {
buttons.forEach(btn => {
if (btn.textContent.includes(field + ' (')) {
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-outline-primary', 'active');
}
});
}
// 筛选差异数据(考虑搜索条件)
let baseResults = field === '' ?
currentResults.differences :
currentResults.differences.filter(diff => diff.field === field);
// 如果有搜索条件,应用搜索过滤
if (currentSearchTerm && currentSearchTerm.trim()) {
const term = currentSearchTerm.toLowerCase();
filteredDifferenceResults = baseResults.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;
});
} else {
filteredDifferenceResults = baseResults;
}
// 重置页码并重新显示
currentDifferencePage = 1;
displayDifferences();
}