3085 lines
130 KiB
JavaScript
3085 lines
130 KiB
JavaScript
// 全局变量
|
||
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 = ' (推荐使用包含时间戳的字段)';
|
||
keysField.value = '';
|
||
|
||
// 更新查询Key输入框的占位符
|
||
const queryValues = document.getElementById('query_values');
|
||
queryValues.placeholder = '请输入查询的Key值,一行一个\n分表查询示例(包含时间戳):\nwmid_1609459200\nwmid_1610064000\nwmid_1610668800';
|
||
} else {
|
||
// 禁用分表模式
|
||
shardingConfig.style.display = 'none';
|
||
executeButton.textContent = '执行查询比对';
|
||
keyInputHint.textContent = '单表模式:输入普通Key值';
|
||
keysField.placeholder = '';
|
||
keysField.value = '';
|
||
|
||
// 更新查询Key输入框的占位符
|
||
const queryValues = document.getElementById('query_values');
|
||
queryValues.placeholder = '请输入查询的Key值,一行一个\n单表查询示例:\nkey1\nkey2\nkey3';
|
||
}
|
||
|
||
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;
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (config.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = config.pro_config.username || '';
|
||
document.getElementById('pro_password').value = config.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = config.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (config.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = config.test_config.port || 9042;
|
||
document.getElementById('test_username').value = config.test_config.username || '';
|
||
document.getElementById('test_password').value = config.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = config.test_config.table || '';
|
||
|
||
document.getElementById('keys').value = (config.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (config.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (config.query_config.exclude_fields || []).join(',');
|
||
|
||
// 加载分表配置
|
||
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 || '';
|
||
|
||
document.getElementById('keys').value = (config.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (config.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (config.query_config.exclude_fields || []).join(',');
|
||
|
||
// 更新下拉框选中状态
|
||
document.getElementById('configGroupSelect').value = groupId;
|
||
|
||
// 关闭管理modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('manageConfigModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', `配置组 "${config.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除配置组
|
||
async function deleteConfigGroup(groupId, groupName) {
|
||
if (!confirm(`确定要删除配置组 "${groupName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新加载配置组列表
|
||
await loadConfigGroups();
|
||
|
||
// 重新显示管理对话框
|
||
setTimeout(() => {
|
||
showManageConfigDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载默认配置
|
||
async function loadDefaultConfig() {
|
||
try {
|
||
const response = await fetch('/api/default-config');
|
||
const config = await response.json();
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = config.pro_config.hosts.join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port;
|
||
document.getElementById('pro_username').value = config.pro_config.username;
|
||
document.getElementById('pro_password').value = config.pro_config.password;
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace;
|
||
document.getElementById('pro_table').value = config.pro_config.table;
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = config.test_config.hosts.join(',');
|
||
document.getElementById('test_port').value = config.test_config.port;
|
||
document.getElementById('test_username').value = config.test_config.username;
|
||
document.getElementById('test_password').value = config.test_config.password;
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace;
|
||
document.getElementById('test_table').value = config.test_config.table;
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = config.keys.join(',');
|
||
document.getElementById('fields_to_compare').value = config.fields_to_compare.join(',');
|
||
document.getElementById('exclude_fields').value = config.exclude_fields.join(',');
|
||
|
||
showAlert('success', '默认配置加载成功');
|
||
} catch (error) {
|
||
showAlert('danger', '加载默认配置失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 获取当前配置
|
||
function getCurrentConfig() {
|
||
return {
|
||
pro_config: {
|
||
cluster_name: document.getElementById('pro_cluster_name').value,
|
||
datacenter: document.getElementById('pro_datacenter').value,
|
||
hosts: document.getElementById('pro_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('pro_port').value) || 9042,
|
||
username: document.getElementById('pro_username').value,
|
||
password: document.getElementById('pro_password').value,
|
||
keyspace: document.getElementById('pro_keyspace').value,
|
||
table: document.getElementById('pro_table').value
|
||
},
|
||
test_config: {
|
||
cluster_name: document.getElementById('test_cluster_name').value,
|
||
datacenter: document.getElementById('test_datacenter').value,
|
||
hosts: document.getElementById('test_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('test_port').value) || 9042,
|
||
username: document.getElementById('test_username').value,
|
||
password: document.getElementById('test_password').value,
|
||
keyspace: document.getElementById('test_keyspace').value,
|
||
table: document.getElementById('test_table').value
|
||
},
|
||
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,
|
||
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;
|
||
|
||
// 初始化相同结果分页数据
|
||
filteredIdenticalResults = results.identical_results;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 初始化差异结果分页数据
|
||
filteredDifferenceResults = results.differences;
|
||
currentDifferencePage = 1;
|
||
|
||
// 显示各个面板内容
|
||
displayDifferences();
|
||
displayIdenticalResults();
|
||
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');
|
||
|
||
if (!filteredDifferenceResults.length) {
|
||
differencesContainer.innerHTML = '<p class="text-success"><i class="fas fa-check"></i> 未发现差异</p>';
|
||
return;
|
||
}
|
||
|
||
// 计算分页
|
||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||
const startIndex = (currentDifferencePage - 1) * differencePageSize;
|
||
const endIndex = startIndex + differencePageSize;
|
||
const currentPageData = filteredDifferenceResults.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changeDifferencePageSize(this.value)">
|
||
<option value="5" ${differencePageSize == 5 ? 'selected' : ''}>5条</option>
|
||
<option value="10" ${differencePageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="20" ${differencePageSize == 20 ? 'selected' : ''}>20条</option>
|
||
<option value="50" ${differencePageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="100" ${differencePageSize == 100 ? 'selected' : ''}>100条</option>
|
||
<option value="custom_diff">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customDiffPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomDifferencePageSize(this.value)" onkeypress="handleCustomDiffPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${filteredDifferenceResults.length} 条差异记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
|
||
onkeyup="searchDifferenceResults(this.value)" id="differenceSearch">
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页数据
|
||
currentPageData.forEach((diff, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
if (diff.message) {
|
||
// 记录不存在的情况
|
||
html += `
|
||
<div class="difference-item card mb-3 border-warning">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>差异 #${globalIndex}</strong>
|
||
<span class="badge bg-warning ms-2">记录缺失</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-info" onclick='showDifferenceRawData(${JSON.stringify(JSON.stringify(diff.key))})'>
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||
</div>
|
||
<div class="card-body">
|
||
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
// 字段值差异的情况
|
||
const isJson = diff.is_json;
|
||
const isArray = diff.is_array;
|
||
const jsonClass = isJson ? 'json-field' : '';
|
||
|
||
html += `
|
||
<div class="difference-item card mb-3 border-danger">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>差异 #${globalIndex}</strong>
|
||
<span class="badge bg-danger ms-2">字段差异</span>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
|
||
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
|
||
data-bs-toggle="collapse" data-bs-target="#diffCollapse${globalIndex}">
|
||
<i class="fas fa-eye"></i> 查看详情
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick='showDifferenceRawData(${JSON.stringify(JSON.stringify(diff.key))})'>
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||
<p class="mb-0"><strong>差异字段:</strong> ${diff.field}</p>
|
||
</div>
|
||
<div class="collapse" id="diffCollapse${globalIndex}">
|
||
<div class="card-body">
|
||
<div class="mb-4">
|
||
<div class="field-header mb-2">
|
||
<i class="fas fa-tag text-primary"></i>
|
||
<strong>${diff.field}</strong>
|
||
</div>
|
||
|
||
<!-- 环境对比数据行 -->
|
||
<div class="row">
|
||
<div class="col-12">
|
||
<div class="field-container position-relative">
|
||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}" style="max-height: 400px; overflow-y: auto; margin: 0;">${escapeHtml(diff.pro_value)}
|
||
${escapeHtml(diff.test_value)}</pre>
|
||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||
onclick="copyToClipboard('${escapeForJs(diff.pro_value)}\n${escapeForJs(diff.test_value)}', this)"
|
||
title="复制全部内容">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
differencesContainer.innerHTML = html;
|
||
}
|
||
|
||
// HTML转义函数,防止XSS
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 显示相同结果
|
||
function displayIdenticalResults() {
|
||
const identicalContainer = document.getElementById('identical-results');
|
||
|
||
if (!filteredIdenticalResults.length) {
|
||
identicalContainer.innerHTML = '<p class="text-muted"><i class="fas fa-info-circle"></i> 没有完全相同的记录</p>';
|
||
return;
|
||
}
|
||
|
||
// 计算分页
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
const startIndex = (currentIdenticalPage - 1) * identicalPageSize;
|
||
const endIndex = startIndex + identicalPageSize;
|
||
const currentPageData = filteredIdenticalResults.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changePageSize(this.value)">
|
||
<option value="5" ${identicalPageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="10" ${identicalPageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="20" ${identicalPageSize == 100 ? 'selected' : ''}>100条</option>
|
||
<option value="50" ${identicalPageSize == 200 ? 'selected' : ''}>200条</option>
|
||
<option value="100" ${identicalPageSize == 500 ? 'selected' : ''}>500条</option>
|
||
<option value="custom">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomPageSize(this.value)" onkeypress="handleCustomPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${filteredIdenticalResults.length} 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键或字段内容..."
|
||
onkeyup="searchIdenticalResults(this.value)" id="identicalSearch">
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页数据
|
||
currentPageData.forEach((result, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
html += `
|
||
<div class="card mb-3 border-success">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>相同记录 #${globalIndex}</strong>
|
||
<span class="badge bg-success ms-2">完全匹配</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
|
||
data-bs-toggle="collapse" data-bs-target="#collapse${globalIndex}">
|
||
<i class="fas fa-eye"></i> 查看详情
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info" onclick='showRawData(${JSON.stringify(JSON.stringify(result.key))})'>
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(result.key)}</p>
|
||
</div>
|
||
<div class="collapse" id="collapse${globalIndex}">
|
||
<div class="card-body">
|
||
`;
|
||
|
||
// 显示字段对比,左右布局
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allFields = new Set([...Object.keys(proFields), ...Object.keys(testFields)]);
|
||
|
||
html += '<div class="row"><div class="col-12"><h6 class="mb-3">字段对比 (生产环境 vs 测试环境):</h6></div></div>';
|
||
|
||
allFields.forEach(fieldName => {
|
||
const proValue = proFields[fieldName] || '';
|
||
const testValue = testFields[fieldName] || '';
|
||
|
||
// 更安全的JSON检测方法
|
||
let isJson = false;
|
||
try {
|
||
if (typeof proValue === 'string' && (proValue.startsWith('{') || proValue.startsWith('['))) {
|
||
JSON.parse(proValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e1) {
|
||
try {
|
||
if (typeof testValue === 'string' && (testValue.startsWith('{') || testValue.startsWith('['))) {
|
||
JSON.parse(testValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e2) {
|
||
// 如果都不是有效的JSON,保持isJson为false
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<div class="mb-4">
|
||
<div class="field-header mb-2">
|
||
<i class="fas fa-tag text-primary"></i>
|
||
<strong>${fieldName}</strong>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON</span>' : ''}
|
||
</div>
|
||
|
||
<!-- 环境对比数据行 -->
|
||
<div class="row mb-2">
|
||
<div class="col-12">
|
||
<div class="field-container position-relative">
|
||
<pre class="field-value bg-light p-2 rounded mb-0" style="max-height: 400px; overflow-y: auto; margin: 0;">${escapeHtml(String(proValue))}
|
||
${escapeHtml(String(testValue))}</pre>
|
||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||
onclick="copyToClipboard('${escapeForJs(String(proValue))}\n${escapeForJs(String(testValue))}', this)"
|
||
title="复制全部内容">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
identicalContainer.innerHTML = html;
|
||
}
|
||
|
||
// 生成分页导航
|
||
function generatePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定页面
|
||
function goToIdenticalPage(page) {
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentIdenticalPage = page;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 改变每页显示数量
|
||
function changePageSize(newSize) {
|
||
if (newSize === 'custom') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
identicalPageSize = parseInt(newSize);
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 设置自定义页面大小
|
||
function setCustomPageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
identicalPageSize = pageSize;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayIdenticalResults();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义页面大小输入框回车事件
|
||
function handleCustomPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomPageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索相同结果
|
||
function searchIdenticalResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
if (!searchTerm.trim()) {
|
||
filteredIdenticalResults = currentResults.identical_results;
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
filteredIdenticalResults = currentResults.identical_results.filter(result => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(result.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段内容
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allValues = [...Object.values(proFields), ...Object.values(testFields)];
|
||
|
||
return allValues.some(value =>
|
||
String(value).toLowerCase().includes(term)
|
||
);
|
||
});
|
||
}
|
||
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 生成差异分页导航
|
||
function generateDifferencePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定差异页面
|
||
function goToDifferencePage(page) {
|
||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentDifferencePage = page;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 改变差异每页显示数量
|
||
function changeDifferencePageSize(newSize) {
|
||
if (newSize === 'custom_diff') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
differencePageSize = parseInt(newSize);
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 设置自定义差异页面大小
|
||
function setCustomDifferencePageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
differencePageSize = pageSize;
|
||
currentDifferencePage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom_diff';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayDifferences();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义差异页面大小输入框回车事件
|
||
function handleCustomDiffPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomDifferencePageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索差异结果
|
||
function searchDifferenceResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
if (!searchTerm.trim()) {
|
||
filteredDifferenceResults = currentResults.differences;
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
filteredDifferenceResults = currentResults.differences.filter(diff => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(diff.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段名
|
||
if (diff.field && diff.field.toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索字段值
|
||
if (diff.pro_value && String(diff.pro_value).toLowerCase().includes(term)) return true;
|
||
if (diff.test_value && String(diff.test_value).toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索消息
|
||
if (diff.message && diff.message.toLowerCase().includes(term)) return true;
|
||
|
||
return false;
|
||
});
|
||
}
|
||
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 复制到剪贴板
|
||
function copyToClipboard(text, button) {
|
||
navigator.clipboard.writeText(text).then(function() {
|
||
// 显示复制成功反馈
|
||
const originalIcon = button.innerHTML;
|
||
button.innerHTML = '<i class="fas fa-check text-success"></i>';
|
||
button.classList.add('btn-success');
|
||
button.classList.remove('btn-outline-secondary');
|
||
|
||
setTimeout(() => {
|
||
button.innerHTML = originalIcon;
|
||
button.classList.remove('btn-success');
|
||
button.classList.add('btn-outline-secondary');
|
||
}, 1000);
|
||
}).catch(function(err) {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动复制');
|
||
});
|
||
}
|
||
|
||
// JS字符串转义
|
||
function escapeForJs(str) {
|
||
if (str === null || str === undefined) {
|
||
return '';
|
||
}
|
||
return String(str)
|
||
.replace(/\\/g, '\\\\') // 反斜杠必须先处理
|
||
.replace(/'/g, "\\'") // 单引号
|
||
.replace(/"/g, '\\"') // 双引号
|
||
.replace(/\n/g, '\\n') // 换行符
|
||
.replace(/\r/g, '\\r') // 回车符
|
||
.replace(/\t/g, '\\t') // 制表符
|
||
.replace(/\f/g, '\\f') // 换页符
|
||
.replace(/\b/g, '\\b') // 退格符
|
||
.replace(/\0/g, '\\0'); // 空字符
|
||
}
|
||
|
||
// 显示原生数据
|
||
function showRawData(keyStr) {
|
||
if (!currentResults) {
|
||
showAlert('warning', '请先执行查询操作');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析key
|
||
const key = JSON.parse(keyStr);
|
||
|
||
// 在原生数据中查找对应的记录
|
||
let proData = null;
|
||
let testData = null;
|
||
|
||
// 查找生产环境数据
|
||
if (currentResults.raw_pro_data) {
|
||
proData = currentResults.raw_pro_data.find(item => {
|
||
// 比较主键
|
||
return JSON.stringify(item[Object.keys(key)[0]]) === JSON.stringify(key[Object.keys(key)[0]]);
|
||
});
|
||
}
|
||
|
||
// 查找测试环境数据
|
||
if (currentResults.raw_test_data) {
|
||
testData = currentResults.raw_test_data.find(item => {
|
||
// 比较主键
|
||
return JSON.stringify(item[Object.keys(key)[0]]) === JSON.stringify(key[Object.keys(key)[0]]);
|
||
});
|
||
}
|
||
|
||
// 创建模态框内容
|
||
const modalContent = `
|
||
<div class="modal fade" id="rawDataModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-code"></i> 原生数据 - ${JSON.stringify(key)}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<ul class="nav nav-tabs" id="rawDataTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="formatted-tab" data-bs-toggle="tab"
|
||
data-bs-target="#formatted" type="button" role="tab">
|
||
<i class="fas fa-code"></i> 格式化视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="raw-tab" data-bs-toggle="tab"
|
||
data-bs-target="#raw" type="button" role="tab">
|
||
<i class="fas fa-file-code"></i> 原始视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="diff-tab" data-bs-toggle="tab"
|
||
data-bs-target="#diff" type="button" role="tab">
|
||
<i class="fas fa-exchange-alt"></i> 对比视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="tree-tab" data-bs-toggle="tab"
|
||
data-bs-target="#tree" type="button" role="tab">
|
||
<i class="fas fa-tree"></i> 树形视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item ms-auto">
|
||
<span class="nav-link text-muted">
|
||
<i class="fas fa-sync-alt"></i> 同步滚动已启用
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<div class="tab-content mt-3" id="rawDataTabsContent">
|
||
<!-- 格式化视图 -->
|
||
<div class="tab-pane fade show active" id="formatted" role="tabpanel">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
|
||
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData, null, 2)) : '无数据'}</pre>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
|
||
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData, null, 2)) : '无数据'}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 原始视图 -->
|
||
<div class="tab-pane fade" id="raw" role="tabpanel">
|
||
<div class="mb-3">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境原始数据</h6>
|
||
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData)) : '无数据'}</pre>
|
||
</div>
|
||
<div>
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境原始数据</h6>
|
||
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData)) : '无数据'}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 对比视图 -->
|
||
<div class="tab-pane fade" id="diff" role="tabpanel">
|
||
<div id="diffView"></div>
|
||
</div>
|
||
|
||
<!-- 树形视图 -->
|
||
<div class="tab-pane fade" id="tree" role="tabpanel">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
|
||
<div id="proTreeView" class="tree-view"></div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
|
||
<div id="testTreeView" class="tree-view"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" onclick="copyRawData()">
|
||
<i class="fas fa-copy"></i> 复制数据
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('rawDataModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示模态框
|
||
const modal = new bootstrap.Modal(document.getElementById('rawDataModal'));
|
||
modal.show();
|
||
|
||
// 保存当前数据供复制使用
|
||
window.currentRawData = {
|
||
pro: proData ? JSON.stringify(proData, null, 2) : '无数据',
|
||
test: testData ? JSON.stringify(testData, null, 2) : '无数据',
|
||
combined: `生产环境数据:\n${proData ? JSON.stringify(proData, null, 2) : '无数据'}\n\n测试环境数据:\n${testData ? JSON.stringify(testData, null, 2) : '无数据'}`
|
||
};
|
||
|
||
// 延迟渲染对比视图和树形视图
|
||
setTimeout(() => {
|
||
renderDiffView(proData, testData);
|
||
renderTreeView('proTreeView', proData);
|
||
renderTreeView('testTreeView', testData);
|
||
|
||
// 为格式化视图添加同步滚动
|
||
setupSyncScroll('formatted');
|
||
// 为树形视图添加同步滚动
|
||
setupSyncScroll('tree');
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('显示原生数据失败:', error);
|
||
showAlert('danger', '显示原生数据失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 复制原生数据
|
||
function copyRawData() {
|
||
if (window.currentRawData) {
|
||
const combinedData = window.currentRawData.combined ||
|
||
(window.currentRawData.pro + '\n\n' + window.currentRawData.test);
|
||
|
||
navigator.clipboard.writeText(combinedData).then(() => {
|
||
showAlert('success', '原生数据已复制到剪贴板');
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动选择复制');
|
||
});
|
||
} else {
|
||
showAlert('warning', '无可复制的数据');
|
||
}
|
||
}
|
||
|
||
// 显示差异数据的原生数据
|
||
function showDifferenceRawData(keyStr) {
|
||
showRawData(keyStr); // 复用相同的原生数据显示逻辑
|
||
}
|
||
|
||
// 渲染对比视图
|
||
function renderDiffView(proData, testData) {
|
||
const diffViewContainer = document.getElementById('diffView');
|
||
|
||
if (!proData && !testData) {
|
||
diffViewContainer.innerHTML = '<p class="text-muted text-center">无数据可对比</p>';
|
||
return;
|
||
}
|
||
|
||
// 获取所有字段
|
||
const allFields = new Set([
|
||
...(proData ? Object.keys(proData) : []),
|
||
...(testData ? Object.keys(testData) : [])
|
||
]);
|
||
|
||
// 分离相同和不同的字段
|
||
const differentFields = [];
|
||
const identicalFields = [];
|
||
|
||
allFields.forEach(field => {
|
||
const proValue = proData ? proData[field] : undefined;
|
||
const testValue = testData ? testData[field] : undefined;
|
||
|
||
// 特殊处理:区分 null 和 undefined
|
||
// undefined 表示字段不存在,null 表示字段存在但值为 null
|
||
let isEqual = false;
|
||
|
||
// 如果一个是 undefined,另一个不是(包括 null),则不相等
|
||
if (proValue === undefined && testValue !== undefined) {
|
||
isEqual = false;
|
||
} else if (proValue !== undefined && testValue === undefined) {
|
||
isEqual = false;
|
||
} else if (proValue === testValue) {
|
||
// 两个都是 undefined 或者值完全相同
|
||
isEqual = true;
|
||
} else if (proValue !== undefined && testValue !== undefined) {
|
||
// 两个都有值,尝试规范化比较
|
||
try {
|
||
const proNormalized = normalizeValue(proValue);
|
||
const testNormalized = normalizeValue(testValue);
|
||
isEqual = JSON.stringify(proNormalized) === JSON.stringify(testNormalized);
|
||
} catch (e) {
|
||
isEqual = JSON.stringify(proValue) === JSON.stringify(testValue);
|
||
}
|
||
}
|
||
|
||
if (isEqual) {
|
||
identicalFields.push(field);
|
||
} else {
|
||
differentFields.push(field);
|
||
}
|
||
});
|
||
|
||
let html = '';
|
||
|
||
// 如果有差异字段,先显示差异统计
|
||
if (differentFields.length > 0) {
|
||
html += `
|
||
<div class="alert alert-warning mb-3">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
发现 <strong>${differentFields.length}</strong> 个字段存在差异
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '<div class="table-responsive"><table class="table table-bordered table-hover">';
|
||
html += `
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th style="width: 15%;">字段名</th>
|
||
<th style="width: 40%;">生产环境</th>
|
||
<th style="width: 40%;">测试环境</th>
|
||
<th style="width: 5%;" class="text-center">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
// 先显示差异字段(重点突出)
|
||
if (differentFields.length > 0) {
|
||
html += '<tr class="table-danger"><td colspan="4" class="text-center fw-bold">差异字段</td></tr>';
|
||
|
||
differentFields.forEach(field => {
|
||
const proValue = proData ? proData[field] : undefined;
|
||
const testValue = testData ? testData[field] : undefined;
|
||
|
||
// 判断值的类型和差异类型
|
||
let diffType = '';
|
||
let proDisplay = '';
|
||
let testDisplay = '';
|
||
|
||
if (proValue === undefined && testValue !== undefined) {
|
||
diffType = 'missing-in-pro';
|
||
proDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
|
||
testDisplay = formatValue(testValue);
|
||
} else if (proValue !== undefined && testValue === undefined) {
|
||
diffType = 'missing-in-test';
|
||
proDisplay = formatValue(proValue);
|
||
testDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
|
||
} else {
|
||
diffType = 'value-diff';
|
||
proDisplay = formatValue(proValue);
|
||
testDisplay = formatValue(testValue);
|
||
}
|
||
|
||
html += `
|
||
<tr class="table-warning">
|
||
<td>
|
||
<code class="text-danger fw-bold">${field}</code>
|
||
${getFieldTypeIcon(proValue || testValue)}
|
||
</td>
|
||
<td class="font-monospace small ${diffType === 'missing-in-pro' ? 'bg-light' : ''}" style="vertical-align: top;">${proDisplay}</td>
|
||
<td class="font-monospace small ${diffType === 'missing-in-test' ? 'bg-light' : ''}" style="vertical-align: top;">${testDisplay}</td>
|
||
<td class="text-center" style="vertical-align: middle;">
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-times"></i> 不同
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// 再显示相同字段
|
||
if (identicalFields.length > 0) {
|
||
html += '<tr class="table-success"><td colspan="4" class="text-center fw-bold">相同字段</td></tr>';
|
||
|
||
identicalFields.forEach(field => {
|
||
const value = proData ? proData[field] : testData[field];
|
||
const displayValue = formatValue(value);
|
||
|
||
html += `
|
||
<tr>
|
||
<td>
|
||
<code class="text-muted">${field}</code>
|
||
${getFieldTypeIcon(value)}
|
||
</td>
|
||
<td class="font-monospace small text-muted">${displayValue}</td>
|
||
<td class="font-monospace small text-muted">${displayValue}</td>
|
||
<td class="text-center">
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check"></i> 相同
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
}
|
||
|
||
html += '</tbody></table></div>';
|
||
|
||
// 添加快速跳转按钮
|
||
if (differentFields.length > 0 && identicalFields.length > 0) {
|
||
html = `
|
||
<div class="d-flex gap-2 mb-3">
|
||
<button class="btn btn-sm btn-warning" onclick="scrollToTableSection('danger')">
|
||
<i class="fas fa-exclamation-triangle"></i> 跳转到差异字段
|
||
</button>
|
||
<button class="btn btn-sm btn-success" onclick="scrollToTableSection('success')">
|
||
<i class="fas fa-check"></i> 跳转到相同字段
|
||
</button>
|
||
</div>
|
||
` + html;
|
||
}
|
||
|
||
diffViewContainer.innerHTML = html;
|
||
}
|
||
|
||
// 格式化值显示
|
||
function formatValue(value) {
|
||
if (value === null) return '<span class="text-muted">null</span>';
|
||
if (value === undefined) return '<span class="text-muted">undefined</span>';
|
||
if (value === '') return '<span class="text-muted">(空字符串)</span>';
|
||
|
||
const type = typeof value;
|
||
|
||
if (type === 'object') {
|
||
// 特殊处理数组
|
||
if (Array.isArray(value)) {
|
||
// 检查是否是JSON字符串数组
|
||
let isJsonStringArray = value.length > 0 && value.every(item => {
|
||
if (typeof item === 'string') {
|
||
try {
|
||
if ((item.startsWith('{') && item.endsWith('}')) ||
|
||
(item.startsWith('[') && item.endsWith(']'))) {
|
||
JSON.parse(item);
|
||
return true;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return false;
|
||
});
|
||
|
||
if (isJsonStringArray) {
|
||
// 解析并格式化JSON字符串数组
|
||
try {
|
||
const parsedArray = value.map(item => JSON.parse(item));
|
||
// 如果都有key字段,按key排序
|
||
if (parsedArray.every(item => 'key' in item)) {
|
||
parsedArray.sort((a, b) => a.key - b.key);
|
||
}
|
||
const formatted = JSON.stringify(parsedArray, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
} catch (e) {
|
||
// 如果解析失败,按原样显示
|
||
}
|
||
}
|
||
}
|
||
|
||
// 对象或数组,格式化显示
|
||
try {
|
||
const formatted = JSON.stringify(value, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
} catch (e) {
|
||
return `<pre class="mb-0 p-2 bg-light rounded">${escapeHtml(String(value))}</pre>`;
|
||
}
|
||
}
|
||
|
||
if (type === 'string') {
|
||
// 检查是否是JSON字符串
|
||
try {
|
||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||
(value.startsWith('[') && value.endsWith(']'))) {
|
||
const parsed = JSON.parse(value);
|
||
const formatted = JSON.stringify(parsed, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
}
|
||
} catch (e) {
|
||
// 不是有效的JSON,作为普通字符串处理
|
||
}
|
||
|
||
// 长文本处理
|
||
if (value.length > 200) {
|
||
return `<div class="text-wrap" style="max-width: 400px; max-height: 200px; overflow-y: auto;">${escapeHtml(value)}</div>`;
|
||
}
|
||
}
|
||
|
||
return escapeHtml(String(value));
|
||
}
|
||
|
||
// 获取字段类型图标
|
||
function getFieldTypeIcon(value) {
|
||
if (value === undefined) {
|
||
return '<span class="badge bg-danger ms-2" title="字段缺失">❌</span>';
|
||
}
|
||
|
||
if (value === null) {
|
||
return '<span class="badge bg-secondary ms-2" title="空值">NULL</span>';
|
||
}
|
||
|
||
const type = typeof value;
|
||
|
||
if (Array.isArray(value)) {
|
||
return '<span class="badge bg-info ms-2" title="数组">[]</span>';
|
||
}
|
||
|
||
if (type === 'object') {
|
||
return '<span class="badge bg-primary ms-2" title="对象">{}</span>';
|
||
}
|
||
|
||
if (type === 'string') {
|
||
// 检查是否是JSON字符串
|
||
try {
|
||
if (value.startsWith('{') || value.startsWith('[')) {
|
||
JSON.parse(value);
|
||
return '<span class="badge bg-warning ms-2" title="JSON字符串">JSON</span>';
|
||
}
|
||
} catch (e) {
|
||
// 不是JSON
|
||
}
|
||
|
||
// 检查是否是日期字符串
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||
return '<span class="badge bg-secondary ms-2" title="日期">📅</span>';
|
||
}
|
||
}
|
||
|
||
if (type === 'number') {
|
||
return '<span class="badge bg-success ms-2" title="数字">#</span>';
|
||
}
|
||
|
||
if (type === 'boolean') {
|
||
return '<span class="badge bg-dark ms-2" title="布尔值">⚡</span>';
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
// 滚动到表格指定部分
|
||
function scrollToTableSection(sectionType) {
|
||
const targetRow = document.querySelector(`#diffView .table-${sectionType}`);
|
||
if (targetRow) {
|
||
targetRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
// 规范化值用于比较(处理JSON键顺序问题)
|
||
function normalizeValue(value) {
|
||
if (typeof value === 'string') {
|
||
// 尝试解析JSON字符串
|
||
try {
|
||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||
(value.startsWith('[') && value.endsWith(']'))) {
|
||
value = JSON.parse(value);
|
||
} else {
|
||
return value;
|
||
}
|
||
} catch (e) {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
if (value === null || typeof value !== 'object') {
|
||
return value;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
// 对于数组,需要特殊处理
|
||
// 1. 先尝试解析每个元素(可能是JSON字符串)
|
||
const parsedArray = value.map(item => {
|
||
if (typeof item === 'string') {
|
||
try {
|
||
if ((item.startsWith('{') && item.endsWith('}')) ||
|
||
(item.startsWith('[') && item.endsWith(']'))) {
|
||
return JSON.parse(item);
|
||
}
|
||
} catch (e) {
|
||
// 保持原始字符串
|
||
}
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 2. 检查是否是对象数组,如果是,可能需要按某个键排序
|
||
if (parsedArray.length > 0 && typeof parsedArray[0] === 'object' && !Array.isArray(parsedArray[0])) {
|
||
// 检查是否所有元素都有相同的键
|
||
const firstKeys = Object.keys(parsedArray[0]).sort().join(',');
|
||
const allSameStructure = parsedArray.every(item =>
|
||
typeof item === 'object' &&
|
||
!Array.isArray(item) &&
|
||
Object.keys(item).sort().join(',') === firstKeys
|
||
);
|
||
|
||
if (allSameStructure) {
|
||
// 如果有 key 字段,按 key 排序
|
||
if ('key' in parsedArray[0]) {
|
||
return parsedArray
|
||
.map(item => normalizeValue(item))
|
||
.sort((a, b) => {
|
||
const aKey = a.key;
|
||
const bKey = b.key;
|
||
if (aKey < bKey) return -1;
|
||
if (aKey > bKey) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
// 否则按整个对象的字符串表示排序
|
||
return parsedArray
|
||
.map(item => normalizeValue(item))
|
||
.sort((a, b) => {
|
||
const aStr = JSON.stringify(a);
|
||
const bStr = JSON.stringify(b);
|
||
if (aStr < bStr) return -1;
|
||
if (aStr > bStr) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 递归处理每个元素
|
||
return parsedArray.map(item => normalizeValue(item));
|
||
}
|
||
|
||
// 对象:按键排序
|
||
const sortedObj = {};
|
||
Object.keys(value).sort().forEach(key => {
|
||
sortedObj[key] = normalizeValue(value[key]);
|
||
});
|
||
return sortedObj;
|
||
}
|
||
|
||
// 渲染树形视图
|
||
function renderTreeView(containerId, data) {
|
||
const container = document.getElementById(containerId);
|
||
|
||
if (!data) {
|
||
container.innerHTML = '<p class="text-muted">无数据</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = createTreeNode(data, '');
|
||
}
|
||
|
||
// 创建树形节点
|
||
function createTreeNode(obj, indent = '') {
|
||
if (obj === null) return '<span class="text-muted">null</span>';
|
||
if (obj === undefined) return '<span class="text-muted">undefined</span>';
|
||
|
||
const type = typeof obj;
|
||
|
||
if (type !== 'object') {
|
||
return `<span class="tree-value">${escapeHtml(JSON.stringify(obj))}</span>`;
|
||
}
|
||
|
||
if (Array.isArray(obj)) {
|
||
if (obj.length === 0) return '<span class="text-muted">[]</span>';
|
||
|
||
let html = '<div class="tree-array">[<br>';
|
||
obj.forEach((item, index) => {
|
||
html += `${indent} <span class="tree-index">${index}:</span> ${createTreeNode(item, indent + ' ')}`;
|
||
if (index < obj.length - 1) html += ',';
|
||
html += '<br>';
|
||
});
|
||
html += `${indent}]</div>`;
|
||
return html;
|
||
}
|
||
|
||
const keys = Object.keys(obj);
|
||
if (keys.length === 0) return '<span class="text-muted">{}</span>';
|
||
|
||
let html = '<div class="tree-object">{<br>';
|
||
keys.forEach((key, index) => {
|
||
html += `${indent} <span class="tree-key">"${key}"</span>: ${createTreeNode(obj[key], indent + ' ')}`;
|
||
if (index < keys.length - 1) html += ',';
|
||
html += '<br>';
|
||
});
|
||
html += `${indent}}</div>`;
|
||
return html;
|
||
}
|
||
|
||
// 设置同步滚动
|
||
function setupSyncScroll(tabType) {
|
||
if (tabType === 'formatted') {
|
||
// 格式化视图的两个pre元素
|
||
const proPre = document.querySelector('#formatted .col-md-6:first-child pre');
|
||
const testPre = document.querySelector('#formatted .col-md-6:last-child pre');
|
||
|
||
if (proPre && testPre) {
|
||
let syncing = false;
|
||
|
||
proPre.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
testPre.scrollTop = proPre.scrollTop;
|
||
testPre.scrollLeft = proPre.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
|
||
testPre.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
proPre.scrollTop = testPre.scrollTop;
|
||
proPre.scrollLeft = testPre.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
}
|
||
} else if (tabType === 'tree') {
|
||
// 树形视图的两个div元素
|
||
const proTree = document.getElementById('proTreeView');
|
||
const testTree = document.getElementById('testTreeView');
|
||
|
||
if (proTree && testTree) {
|
||
let syncing = false;
|
||
|
||
proTree.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
testTree.scrollTop = proTree.scrollTop;
|
||
testTree.scrollLeft = proTree.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
|
||
testTree.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
proTree.scrollTop = testTree.scrollTop;
|
||
proTree.scrollLeft = testTree.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 显示比较总结
|
||
function displayComparisonSummary(summary) {
|
||
const summaryContainer = document.getElementById('comparison-summary');
|
||
|
||
if (!summary) {
|
||
summaryContainer.innerHTML = '<p class="text-muted">无总结数据</p>';
|
||
return;
|
||
}
|
||
|
||
const qualityLevel = summary.data_quality.quality_level;
|
||
|
||
const html = `
|
||
<!-- 概览统计 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 数据概览</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-4">
|
||
<h4 class="text-primary">${summary.overview.total_keys_queried}</h4>
|
||
<small>查询Key总数</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-success">${summary.overview.identical_records}</h4>
|
||
<small>相同记录</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-warning">${summary.overview.different_records}</h4>
|
||
<small>差异记录</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-award"></i> 数据质量评估</h6>
|
||
</div>
|
||
<div class="card-body text-center">
|
||
<h3 class="text-${qualityLevel.color}">${qualityLevel.level}</h3>
|
||
<p class="mb-2">${qualityLevel.description}</p>
|
||
<div class="progress mb-2">
|
||
<div class="progress-bar bg-${qualityLevel.color}" role="progressbar"
|
||
style="width: ${summary.data_quality.consistency_score}%">
|
||
${summary.data_quality.consistency_score}%
|
||
</div>
|
||
</div>
|
||
<small>数据一致性评分</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细分析 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-percentage"></i> 百分比统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据一致性:</span>
|
||
<strong class="text-success">${summary.percentages.data_consistency}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-success" style="width: ${summary.percentages.data_consistency}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据差异率:</span>
|
||
<strong class="text-warning">${summary.percentages.data_differences}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-warning" style="width: ${summary.percentages.data_differences}%"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据缺失率:</span>
|
||
<strong class="text-danger">${summary.percentages.missing_rate}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-danger" style="width: ${summary.percentages.missing_rate}%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-list-alt"></i> 字段差异TOP5</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
${summary.field_analysis.most_different_fields.length > 0 ?
|
||
summary.field_analysis.most_different_fields.map(([field, count]) => `
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<code>${field}</code>
|
||
<span class="badge bg-danger">${count}次</span>
|
||
</div>
|
||
`).join('') :
|
||
'<p class="text-muted">无字段差异统计</p>'
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-lightbulb"></i> 改进建议</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(recommendation => `
|
||
<li class="mb-2">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${recommendation}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
summaryContainer.innerHTML = html;
|
||
}
|
||
|
||
// 显示字段差异统计
|
||
function displayFieldStats(fieldStats) {
|
||
const fieldStatsContainer = document.getElementById('field_stats');
|
||
|
||
if (!Object.keys(fieldStats).length) {
|
||
fieldStatsContainer.innerHTML = '<p class="text-muted">无字段差异统计</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="table-responsive"><table class="table table-striped">';
|
||
html += '<thead><tr><th>字段名</th><th>差异次数</th><th>占比</th></tr></thead><tbody>';
|
||
|
||
const totalDiffs = Object.values(fieldStats).reduce((sum, count) => sum + count, 0);
|
||
|
||
Object.entries(fieldStats)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
const percentage = ((count / totalDiffs) * 100).toFixed(1);
|
||
html += `
|
||
<tr>
|
||
<td><code>${field}</code></td>
|
||
<td><span class="badge bg-danger">${count}</span></td>
|
||
<td>${percentage}%</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
fieldStatsContainer.innerHTML = html;
|
||
}
|
||
|
||
// 清空结果
|
||
function clearResults() {
|
||
document.getElementById('results').style.display = 'none';
|
||
document.getElementById('query_values').value = '';
|
||
currentResults = null;
|
||
showAlert('info', '结果已清空');
|
||
}
|
||
|
||
// 显示查询历史对话框
|
||
async function showQueryHistoryDialog() {
|
||
try {
|
||
const response = await fetch('/api/query-history');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
let historyList = '';
|
||
|
||
if (result.data.length === 0) {
|
||
historyList = '<p class="text-muted text-center">暂无查询历史记录</p>';
|
||
} else {
|
||
result.data.forEach(history => {
|
||
const createdDate = new Date(history.created_at).toLocaleString();
|
||
const consistencyRate = history.total_keys > 0 ?
|
||
Math.round((history.identical_count / history.total_keys) * 100) : 0;
|
||
|
||
historyList += `
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="flex-grow-1">
|
||
<h6 class="card-title mb-1">${history.name}</h6>
|
||
<p class="card-text text-muted small mb-2">${history.description || '无描述'}</p>
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-clock"></i> ${createdDate}<br>
|
||
<i class="fas fa-key"></i> 查询: ${history.total_keys}个Key
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-chart-line"></i> 一致性: ${consistencyRate}%<br>
|
||
<i class="fas fa-exclamation-triangle"></i> 差异: ${history.differences_count}处
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ms-3">
|
||
<button class="btn btn-sm btn-primary me-1" onclick="loadHistoryRecord(${history.id})" title="加载配置">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-info me-1" onclick="viewHistoryDetail(${history.id})" title="查看详情">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteHistoryRecord(${history.id}, '${history.name}')" title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="queryHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-history"></i> 查询历史记录
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 600px; overflow-y: auto;">
|
||
${historyList}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('queryHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('queryHistoryModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载查询历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载查询历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载历史记录配置
|
||
async function loadHistoryRecord(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = history.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = history.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (history.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = history.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = history.pro_config.username || '';
|
||
document.getElementById('pro_password').value = history.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = history.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = history.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = history.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = history.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (history.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = history.test_config.port || 9042;
|
||
document.getElementById('test_username').value = history.test_config.username || '';
|
||
document.getElementById('test_password').value = history.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = history.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = history.test_config.table || '';
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = (history.query_config.keys || []).join(',');
|
||
document.getElementById('fields_to_compare').value = (history.query_config.fields_to_compare || []).join(',');
|
||
document.getElementById('exclude_fields').value = (history.query_config.exclude_fields || []).join(',');
|
||
|
||
// 填充查询Key值
|
||
document.getElementById('query_values').value = (history.query_keys || []).join('\n');
|
||
|
||
// 关闭历史记录modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', `历史记录 "${history.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 查看历史记录详情
|
||
async function viewHistoryDetail(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
const summary = history.results_summary;
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="historyDetailModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-eye"></i> 查询历史详情 - ${history.name}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 基本信息 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-info-circle"></i> 基本信息</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>名称:</strong> ${history.name}</p>
|
||
<p><strong>描述:</strong> ${history.description || '无描述'}</p>
|
||
<p><strong>创建时间:</strong> ${new Date(history.created_at).toLocaleString()}</p>
|
||
<p><strong>执行时间:</strong> ${history.execution_time.toFixed(2)}秒</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 查询统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>查询Key数:</strong> ${history.total_keys}</p>
|
||
<p><strong>相同记录:</strong> ${history.identical_count}</p>
|
||
<p><strong>差异记录:</strong> ${history.differences_count}</p>
|
||
<p><strong>一致性:</strong> ${history.total_keys > 0 ? Math.round((history.identical_count / history.total_keys) * 100) : 0}%</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置信息 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-cogs"></i> 查询配置</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<p><strong>主键字段:</strong> ${(history.query_config.keys || []).join(', ')}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>比较字段:</strong> ${(history.query_config.fields_to_compare || []).join(', ') || '全部字段'}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>排除字段:</strong> ${(history.query_config.exclude_fields || []).join(', ') || '无'}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<p><strong>查询Key值:</strong></p>
|
||
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${(history.query_keys || []).join('\n')}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 结果总结 -->
|
||
${summary ? `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-pie"></i> 结果总结</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- 数据质量评估 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<h6>数据质量评估</h6>
|
||
<div class="d-flex align-items-center">
|
||
<span class="badge bg-${summary.data_quality?.quality_level?.color || 'secondary'} me-2">
|
||
${summary.data_quality?.quality_level?.level || 'N/A'}
|
||
</span>
|
||
<span class="text-muted">${summary.data_quality?.quality_level?.description || ''}</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6>一致性评分</h6>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-${summary.data_quality?.quality_level?.color || 'secondary'}"
|
||
style="width: ${summary.data_quality?.consistency_score || 0}%">
|
||
${summary.data_quality?.consistency_score || 0}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
${summary.recommendations?.length > 0 ? `
|
||
<div>
|
||
<h6>改进建议</h6>
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(rec => `
|
||
<li class="mb-1">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${rec}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" onclick="loadHistoryRecord(${history.id}); bootstrap.Modal.getInstance(document.getElementById('historyDetailModal')).hide();">
|
||
<i class="fas fa-download"></i> 加载配置
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('historyDetailModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('historyDetailModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载历史记录详情失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录详情失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除历史记录
|
||
async function deleteHistoryRecord(historyId, historyName) {
|
||
if (!confirm(`确定要删除历史记录 "${historyName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新显示历史记录对话框
|
||
setTimeout(() => {
|
||
showQueryHistoryDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示保存历史记录对话框
|
||
function showSaveHistoryDialog() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '请先执行查询操作,然后再保存历史记录');
|
||
return;
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="saveHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-bookmark"></i> 保存查询历史记录
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">历史记录名称 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="historyRecordName" placeholder="请输入历史记录名称">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="historyRecordDesc" rows="3" placeholder="请输入历史记录描述(可选)"></textarea>
|
||
</div>
|
||
<div class="alert alert-info">
|
||
<small>
|
||
<strong>当前查询统计:</strong><br>
|
||
• 查询Key数: ${currentResults.total_keys}<br>
|
||
• 相同记录: ${currentResults.identical_results.length}<br>
|
||
• 差异记录: ${currentResults.differences.length}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveHistoryRecord()">
|
||
<i class="fas fa-check"></i> 保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('saveHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('saveHistoryModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 保存历史记录
|
||
async function saveHistoryRecord() {
|
||
const name = document.getElementById('historyRecordName').value.trim();
|
||
const description = document.getElementById('historyRecordDesc').value.trim();
|
||
|
||
if (!name) {
|
||
showAlert('warning', '请输入历史记录名称');
|
||
return;
|
||
}
|
||
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可保存的查询结果');
|
||
return;
|
||
}
|
||
|
||
const config = getCurrentConfig();
|
||
|
||
try {
|
||
const response = await fetch('/api/query-history', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
description: description,
|
||
pro_config: config.pro_config,
|
||
test_config: config.test_config,
|
||
query_config: 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
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('saveHistoryModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', result.message);
|
||
} else {
|
||
showAlert('danger', result.error || '保存历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '保存历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 导出配置
|
||
function exportConfig() {
|
||
const config = getCurrentConfig();
|
||
const dataStr = JSON.stringify(config, null, 2);
|
||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = 'db_compare_config.json';
|
||
link.click();
|
||
}
|
||
|
||
// 导出结果
|
||
function exportResults() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可导出的结果');
|
||
return;
|
||
}
|
||
|
||
let output = `数据库查询比对结果\n`;
|
||
output += `生成时间: ${new Date().toLocaleString()}\n`;
|
||
output += `=`.repeat(50) + '\n\n';
|
||
|
||
output += `统计信息:\n`;
|
||
output += `- 总Key数量: ${currentResults.total_keys}\n`;
|
||
output += `- 生产表记录: ${currentResults.pro_count}\n`;
|
||
output += `- 测试表记录: ${currentResults.test_count}\n`;
|
||
output += `- 发现差异: ${currentResults.differences.length}\n\n`;
|
||
|
||
if (currentResults.differences.length > 0) {
|
||
output += `差异详情:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
|
||
currentResults.differences.forEach((diff, index) => {
|
||
output += `差异 #${index + 1}:\n`;
|
||
output += `主键: ${JSON.stringify(diff.key)}\n`;
|
||
|
||
if (diff.message) {
|
||
output += `消息: ${diff.message}\n`;
|
||
} else {
|
||
output += `字段: ${diff.field}\n`;
|
||
output += `生产表值: ${diff.pro_value}\n`;
|
||
output += `测试表值: ${diff.test_value}\n`;
|
||
}
|
||
output += '\n';
|
||
});
|
||
|
||
// 字段差异统计
|
||
if (Object.keys(currentResults.field_diff_count).length > 0) {
|
||
output += `字段差异统计:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
Object.entries(currentResults.field_diff_count)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
output += `${field}: ${count}次\n`;
|
||
});
|
||
}
|
||
}
|
||
|
||
const dataBlob = new Blob([output], {type: 'text/plain;charset=utf-8'});
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = `db_compare_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
|
||
link.click();
|
||
}
|
||
|
||
// 显示一键导入对话框
|
||
function showImportDialog(env) {
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="importModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-download"></i> 一键导入${envName}配置
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="text-muted mb-3">请粘贴配置数据,支持以下格式:</p>
|
||
<div class="mb-3">
|
||
<small class="text-muted">示例格式:</small>
|
||
<pre class="bg-light p-2 rounded small">clusterName: "Hot Cluster"
|
||
clusterNodes: "10.20.2.22,10.20.2.23"
|
||
port: 9042
|
||
datacenter: "cs01"
|
||
username: "cbase"
|
||
password: "antducbaseadmin@2022"
|
||
keyspace: "yuqing_skinny"
|
||
table: "status_test"</pre>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">配置数据:</label>
|
||
<textarea class="form-control" id="importConfigData" rows="8"
|
||
placeholder="请粘贴配置数据...">
|
||
</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="importConfig('${env}')">
|
||
<i class="fas fa-check"></i> 导入配置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('importModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('importModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 导入配置数据
|
||
function importConfig(env) {
|
||
const configData = document.getElementById('importConfigData').value.trim();
|
||
|
||
if (!configData) {
|
||
showAlert('warning', '请输入配置数据');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析配置数据
|
||
const config = parseConfigData(configData);
|
||
|
||
// 根据环境类型填充对应的表单字段
|
||
const prefix = env === 'pro' ? 'pro' : 'test';
|
||
|
||
if (config.clusterName) {
|
||
document.getElementById(`${prefix}_cluster_name`).value = config.clusterName;
|
||
}
|
||
if (config.datacenter) {
|
||
document.getElementById(`${prefix}_datacenter`).value = config.datacenter;
|
||
}
|
||
if (config.clusterNodes) {
|
||
document.getElementById(`${prefix}_hosts`).value = config.clusterNodes;
|
||
}
|
||
if (config.port) {
|
||
document.getElementById(`${prefix}_port`).value = config.port;
|
||
}
|
||
if (config.username) {
|
||
document.getElementById(`${prefix}_username`).value = config.username;
|
||
}
|
||
if (config.password) {
|
||
document.getElementById(`${prefix}_password`).value = config.password;
|
||
}
|
||
if (config.keyspace) {
|
||
document.getElementById(`${prefix}_keyspace`).value = config.keyspace;
|
||
}
|
||
if (config.table) {
|
||
document.getElementById(`${prefix}_table`).value = config.table;
|
||
}
|
||
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('importModal'));
|
||
modal.hide();
|
||
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
showAlert('success', `${envName}配置导入成功!`);
|
||
|
||
} catch (error) {
|
||
showAlert('danger', '配置解析失败:' + error.message);
|
||
}
|
||
}
|
||
|
||
// 解析配置数据
|
||
function parseConfigData(configText) {
|
||
const config = {};
|
||
|
||
try {
|
||
// 尝试按JSON格式解析
|
||
const jsonConfig = JSON.parse(configText);
|
||
return jsonConfig;
|
||
} catch (e) {
|
||
// 如果不是JSON,按YAML格式解析
|
||
const lines = configText.split('\n');
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||
continue; // 跳过空行和注释
|
||
}
|
||
|
||
const colonIndex = trimmedLine.indexOf(':');
|
||
if (colonIndex === -1) {
|
||
continue; // 跳过无效行
|
||
}
|
||
|
||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||
let value = trimmedLine.substring(colonIndex + 1).trim();
|
||
|
||
// 处理带引号的值
|
||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
|
||
// 尝试转换数字
|
||
if (!isNaN(value) && value !== '') {
|
||
config[key] = parseInt(value);
|
||
} else {
|
||
config[key] = value;
|
||
}
|
||
}
|
||
|
||
return config;
|
||
}
|
||
}
|
||
|
||
// 显示提示信息
|
||
function showAlert(type, message) {
|
||
// 移除已存在的提示
|
||
const existingAlert = document.querySelector('.alert');
|
||
if (existingAlert) {
|
||
existingAlert.remove();
|
||
}
|
||
|
||
const alertHtml = `
|
||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||
${message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
`;
|
||
|
||
// 插入到页面顶部
|
||
const container = document.querySelector('.container-fluid');
|
||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||
|
||
// 自动隐藏
|
||
setTimeout(() => {
|
||
const alert = document.querySelector('.alert');
|
||
if (alert) {
|
||
alert.remove();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
// 查询日志相关功能
|
||
let allQueryLogs = []; // 存储所有日志
|
||
|
||
async function refreshQueryLogs() {
|
||
try {
|
||
const response = await fetch('/api/query-logs');
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
allQueryLogs = result.data;
|
||
filterLogsByLevel();
|
||
} else {
|
||
document.getElementById('query-logs').innerHTML = '<div class="alert alert-warning">无法获取查询日志</div>';
|
||
}
|
||
} catch (error) {
|
||
console.error('获取查询日志失败:', error);
|
||
document.getElementById('query-logs').innerHTML = '<div class="alert alert-danger">获取查询日志失败</div>';
|
||
}
|
||
}
|
||
|
||
function filterLogsByLevel() {
|
||
const showInfo = document.getElementById('log-level-info').checked;
|
||
const showWarning = document.getElementById('log-level-warning').checked;
|
||
const showError = document.getElementById('log-level-error').checked;
|
||
|
||
const filteredLogs = allQueryLogs.filter(log => {
|
||
switch(log.level) {
|
||
case 'INFO': return showInfo;
|
||
case 'WARNING': return showWarning;
|
||
case 'ERROR': return showError;
|
||
default: return true;
|
||
}
|
||
});
|
||
|
||
displayQueryLogs(filteredLogs);
|
||
}
|
||
|
||
async function clearQueryLogs() {
|
||
if (!confirm('确定要清空所有查询日志吗?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/query-logs', {
|
||
method: 'DELETE'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
document.getElementById('query-logs').innerHTML = '<div class="alert alert-info">查询日志已清空</div>';
|
||
showAlert('success', '查询日志已清空');
|
||
} else {
|
||
showAlert('danger', '清空查询日志失败: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('清空查询日志失败:', error);
|
||
showAlert('danger', '清空查询日志失败');
|
||
}
|
||
}
|
||
|
||
function displayQueryLogs(logs) {
|
||
const container = document.getElementById('query-logs');
|
||
|
||
if (!logs || logs.length === 0) {
|
||
container.innerHTML = '<div class="alert alert-info">暂无查询日志</div>';
|
||
return;
|
||
}
|
||
|
||
const logHtml = logs.map(log => {
|
||
const levelClass = {
|
||
'INFO': 'text-primary',
|
||
'WARNING': 'text-warning',
|
||
'ERROR': 'text-danger',
|
||
'DEBUG': 'text-secondary'
|
||
}[log.level] || 'text-dark';
|
||
|
||
const levelIcon = {
|
||
'INFO': 'fas fa-info-circle',
|
||
'WARNING': 'fas fa-exclamation-triangle',
|
||
'ERROR': 'fas fa-times-circle',
|
||
'DEBUG': 'fas fa-bug'
|
||
}[log.level] || 'fas fa-circle';
|
||
|
||
// 改进SQL高亮显示
|
||
let message = escapeHtml(log.message);
|
||
|
||
// 高亮SQL查询语句
|
||
if (message.includes('执行查询SQL:')) {
|
||
message = message.replace(/执行查询SQL: (SELECT.*?);/g,
|
||
'执行查询SQL: <br><code class="bg-light d-block p-2 text-dark" style="font-size: 0.9em;">$1;</code>');
|
||
}
|
||
|
||
// 高亮重要信息
|
||
message = message.replace(/(\d+\.\d{3}秒)/g, '<strong class="text-success">$1</strong>');
|
||
message = message.replace(/(返回记录数=\d+)/g, '<strong class="text-info">$1</strong>');
|
||
message = message.replace(/(执行时间=[\d.]+秒)/g, '<strong class="text-success">$1</strong>');
|
||
|
||
return `
|
||
<div class="border-bottom py-2 log-entry" data-level="${log.level}">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="flex-grow-1">
|
||
<span class="${levelClass}">
|
||
<i class="${levelIcon}"></i>
|
||
<strong>[${log.level}]</strong>
|
||
</span>
|
||
<div class="ms-4 mt-1">${message}</div>
|
||
</div>
|
||
<small class="text-muted ms-2 flex-shrink-0" style="min-width: 140px;">${log.timestamp}</small>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = logHtml;
|
||
|
||
// 自动滚动到底部
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
// 在查询执行后自动刷新日志
|
||
function autoRefreshLogsAfterQuery() {
|
||
// 延迟一下确保后端日志已经记录
|
||
setTimeout(() => {
|
||
refreshQueryLogs();
|
||
}, 500);
|
||
} |