4815 lines
204 KiB
JavaScript
4815 lines
204 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 = '单主键:wmid 或 复合主键:docid,id (推荐使用包含时间戳的字段)';
|
||
keysField.value = '';
|
||
|
||
// 更新查询Key输入框的占位符
|
||
const queryValues = document.getElementById('query_values');
|
||
queryValues.placeholder = '请输入查询的Key值,一行一个\n单主键示例(包含时间戳):\nwmid_1609459200\nwmid_1610064000\nwmid_1610668800\n\n复合主键示例(逗号分隔):\ndocid_1609459200,id1\ndocid_1610064000,id2\ndocid_1610668800,id3';
|
||
} else {
|
||
// 禁用分表模式
|
||
shardingConfig.style.display = 'none';
|
||
executeButton.textContent = '执行查询比对';
|
||
keyInputHint.textContent = '单表模式:支持单主键和复合主键查询,复合主键用逗号分隔';
|
||
keysField.placeholder = '单主键:docid 或 复合主键:docid,id';
|
||
keysField.value = '';
|
||
|
||
// 更新查询Key输入框的占位符
|
||
const queryValues = document.getElementById('query_values');
|
||
queryValues.placeholder = '请输入查询的Key值,一行一个\n单主键示例:\nkey1\nkey2\nkey3\n\n复合主键示例(逗号分隔):\ndocid1,id1\ndocid2,id2\ndocid3,id3';
|
||
}
|
||
|
||
updateTableNameHints();
|
||
}
|
||
|
||
// 更新表名字段的提示文本
|
||
function updateTableNameHints() {
|
||
const proTableHint = document.getElementById('pro_table_hint');
|
||
const testTableHint = document.getElementById('test_table_hint');
|
||
const useShardingPro = document.getElementById('use_sharding_for_pro');
|
||
const useShardingTest = document.getElementById('use_sharding_for_test');
|
||
|
||
if (isShardingMode) {
|
||
proTableHint.textContent = (useShardingPro && useShardingPro.checked) ?
|
||
'基础表名(自动添加索引后缀)' : '完整表名';
|
||
testTableHint.textContent = (useShardingTest && useShardingTest.checked) ?
|
||
'基础表名(自动添加索引后缀)' : '完整表名';
|
||
} else {
|
||
proTableHint.textContent = '完整表名';
|
||
testTableHint.textContent = '完整表名';
|
||
}
|
||
}
|
||
|
||
// 加载配置组列表
|
||
async function loadConfigGroups() {
|
||
try {
|
||
const response = await fetch('/api/config-groups');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const select = document.getElementById('configGroupSelect');
|
||
select.innerHTML = '<option value="">选择配置组...</option>';
|
||
|
||
result.data.forEach(group => {
|
||
const option = document.createElement('option');
|
||
option.value = group.id;
|
||
option.textContent = `${group.name} (${group.description || '无描述'})`;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
if (result.data.length === 0) {
|
||
console.log('暂无配置组,数据库已就绪');
|
||
}
|
||
} else {
|
||
console.error('加载配置组失败:', result.error);
|
||
// 如果是数据库问题,显示友好提示
|
||
if (result.error && result.error.includes('table')) {
|
||
showAlert('info', '正在初始化数据库,请稍后再试');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('加载配置组失败:', error);
|
||
showAlert('warning', '配置组功能暂时不可用,但不影响基本查询功能');
|
||
}
|
||
}
|
||
|
||
// 加载选中的配置组
|
||
async function loadSelectedConfigGroup() {
|
||
const groupId = document.getElementById('configGroupSelect').value;
|
||
|
||
if (!groupId) {
|
||
showAlert('warning', '请先选择一个配置组');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const config = result.data;
|
||
|
||
// 调试输出
|
||
console.log('加载配置组数据:', config);
|
||
console.log('查询配置数据:', config.query_config);
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (config.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = config.pro_config.username || '';
|
||
document.getElementById('pro_password').value = config.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = config.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (config.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = config.test_config.port || 9042;
|
||
document.getElementById('test_username').value = config.test_config.username || '';
|
||
document.getElementById('test_password').value = config.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = config.test_config.table || '';
|
||
|
||
// 填充查询配置 - 增加调试输出和保护性检查
|
||
if (config.query_config) {
|
||
console.log('设置keys字段:', config.query_config.keys);
|
||
const keysElement = document.getElementById('keys');
|
||
console.log('keys元素:', keysElement);
|
||
console.log('keys元素当前值:', keysElement ? keysElement.value : 'element not found');
|
||
|
||
if (keysElement) {
|
||
keysElement.value = (config.query_config.keys || []).join(',');
|
||
console.log('keys字段设置后的值:', keysElement.value);
|
||
|
||
// 强制触发多种事件以确保UI更新
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
||
// 添加延迟更新,确保模态框关闭后再次设置
|
||
setTimeout(() => {
|
||
keysElement.value = (config.query_config.keys || []).join(',');
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
console.log('延迟设置后的值:', keysElement.value);
|
||
}, 100);
|
||
} else {
|
||
console.error('未找到keys输入元素!');
|
||
}
|
||
|
||
const fieldsToCompareElement = document.getElementById('fields_to_compare');
|
||
const excludeFieldsElement = document.getElementById('exclude_fields');
|
||
|
||
if (fieldsToCompareElement) {
|
||
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
|
||
if (excludeFieldsElement) {
|
||
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
} else {
|
||
console.warn('查询配置为空或未定义');
|
||
document.getElementById('keys').value = '';
|
||
document.getElementById('fields_to_compare').value = '';
|
||
document.getElementById('exclude_fields').value = '';
|
||
}
|
||
|
||
// 加载分表配置
|
||
if (config.sharding_config) {
|
||
// 设置分表启用状态
|
||
document.getElementById('enableSharding').checked = config.sharding_config.enabled || false;
|
||
toggleShardingMode();
|
||
|
||
// 填充分表配置
|
||
document.getElementById('use_sharding_for_pro').checked = config.sharding_config.use_sharding_for_pro || false;
|
||
document.getElementById('use_sharding_for_test').checked = config.sharding_config.use_sharding_for_test || false;
|
||
document.getElementById('pro_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
|
||
document.getElementById('pro_table_count').value = config.sharding_config.table_count || 14;
|
||
document.getElementById('test_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
|
||
document.getElementById('test_table_count').value = config.sharding_config.table_count || 14;
|
||
} else {
|
||
// 禁用分表模式
|
||
document.getElementById('enableSharding').checked = false;
|
||
toggleShardingMode();
|
||
}
|
||
|
||
showAlert('success', `配置组 "${config.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示保存配置组对话框
|
||
function showSaveConfigDialog() {
|
||
const modalContent = `
|
||
<div class="modal fade" id="saveConfigModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-save"></i> 保存配置组
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">配置组名称 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="configGroupName" placeholder="请输入配置组名称">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="configGroupDesc" rows="3" placeholder="请输入配置组描述(可选)"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveConfigGroup()">
|
||
<i class="fas fa-check"></i> 保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('saveConfigModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('saveConfigModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 保存配置组
|
||
async function saveConfigGroup() {
|
||
const name = document.getElementById('configGroupName').value.trim();
|
||
const description = document.getElementById('configGroupDesc').value.trim();
|
||
|
||
if (!name) {
|
||
showAlert('warning', '请输入配置组名称');
|
||
return;
|
||
}
|
||
|
||
const config = getCurrentConfig();
|
||
|
||
// 获取分表配置
|
||
const shardingConfig = getShardingConfig().sharding_config;
|
||
|
||
try {
|
||
const response = await fetch('/api/config-groups', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
description: description,
|
||
pro_config: config.pro_config,
|
||
test_config: config.test_config,
|
||
query_config: config.query_config,
|
||
sharding_config: shardingConfig
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('saveConfigModal'));
|
||
modal.hide();
|
||
|
||
// 重新加载配置组列表
|
||
await loadConfigGroups();
|
||
|
||
showAlert('success', result.message + '(包含分表配置)');
|
||
} else {
|
||
showAlert('danger', result.error || '保存配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '保存配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示管理配置组对话框
|
||
async function showManageConfigDialog() {
|
||
try {
|
||
const response = await fetch('/api/config-groups');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
let configGroupsList = '';
|
||
|
||
if (result.data.length === 0) {
|
||
configGroupsList = '<p class="text-muted text-center">暂无配置组</p>';
|
||
} else {
|
||
result.data.forEach(group => {
|
||
const createdDate = new Date(group.created_at).toLocaleString();
|
||
const updatedDate = new Date(group.updated_at).toLocaleString();
|
||
|
||
configGroupsList += `
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<h6 class="card-title mb-1">${group.name}</h6>
|
||
<p class="card-text text-muted small mb-1">${group.description || '无描述'}</p>
|
||
<small class="text-muted">创建: ${createdDate} | 更新: ${updatedDate}</small>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-sm btn-primary me-1" onclick="loadConfigGroupById(${group.id})">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteConfigGroup(${group.id}, '${group.name}')">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="manageConfigModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-cog"></i> 配置组管理
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 500px; overflow-y: auto;">
|
||
${configGroupsList}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('manageConfigModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('manageConfigModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载配置组列表失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组列表失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 通过ID加载配置组
|
||
async function loadConfigGroupById(groupId) {
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const config = result.data;
|
||
|
||
// 填充表单数据(与loadSelectedConfigGroup相同逻辑)
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (config.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = config.pro_config.username || '';
|
||
document.getElementById('pro_password').value = config.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = config.pro_config.table || '';
|
||
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (config.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = config.test_config.port || 9042;
|
||
document.getElementById('test_username').value = config.test_config.username || '';
|
||
document.getElementById('test_password').value = config.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = config.test_config.table || '';
|
||
|
||
// 填充查询配置 - 增加调试输出和保护性检查
|
||
if (config.query_config) {
|
||
console.log('loadConfigGroupById - 设置keys字段:', config.query_config.keys);
|
||
const keysElement = document.getElementById('keys');
|
||
console.log('loadConfigGroupById - keys元素:', keysElement);
|
||
|
||
if (keysElement) {
|
||
keysElement.value = (config.query_config.keys || []).join(',');
|
||
console.log('loadConfigGroupById - keys字段设置后的值:', keysElement.value);
|
||
|
||
// 强制触发多种事件以确保UI更新
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
||
// 添加延迟更新,确保模态框关闭后再次设置
|
||
setTimeout(() => {
|
||
keysElement.value = (config.query_config.keys || []).join(',');
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
console.log('loadConfigGroupById - 延迟设置后的值:', keysElement.value);
|
||
}, 100);
|
||
} else {
|
||
console.error('loadConfigGroupById - 未找到keys输入元素!');
|
||
}
|
||
|
||
const fieldsToCompareElement = document.getElementById('fields_to_compare');
|
||
const excludeFieldsElement = document.getElementById('exclude_fields');
|
||
|
||
if (fieldsToCompareElement) {
|
||
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
fieldsToCompareElement.value = (config.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
|
||
if (excludeFieldsElement) {
|
||
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
excludeFieldsElement.value = (config.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
} else {
|
||
console.warn('loadConfigGroupById - 查询配置为空或未定义');
|
||
document.getElementById('keys').value = '';
|
||
document.getElementById('fields_to_compare').value = '';
|
||
document.getElementById('exclude_fields').value = '';
|
||
}
|
||
|
||
// 更新下拉框选中状态
|
||
document.getElementById('configGroupSelect').value = groupId;
|
||
// 加载分表配置
|
||
if (config.sharding_config) {
|
||
// 设置分表启用状态
|
||
document.getElementById('enableSharding').checked = config.sharding_config.enabled || false;
|
||
toggleShardingMode();
|
||
|
||
// 填充分表配置
|
||
document.getElementById('use_sharding_for_pro').checked = config.sharding_config.use_sharding_for_pro || false;
|
||
document.getElementById('use_sharding_for_test').checked = config.sharding_config.use_sharding_for_test || false;
|
||
document.getElementById('pro_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
|
||
document.getElementById('pro_table_count').value = config.sharding_config.table_count || 14;
|
||
document.getElementById('test_interval_seconds').value = config.sharding_config.interval_seconds || 604800;
|
||
document.getElementById('test_table_count').value = config.sharding_config.table_count || 14;
|
||
} else {
|
||
// 禁用分表模式
|
||
document.getElementById('enableSharding').checked = false;
|
||
toggleShardingMode();
|
||
}
|
||
// 关闭管理modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('manageConfigModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', `配置组 "${config.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除配置组
|
||
async function deleteConfigGroup(groupId, groupName) {
|
||
if (!confirm(`确定要删除配置组 "${groupName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/config-groups/${groupId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新加载配置组列表
|
||
await loadConfigGroups();
|
||
|
||
// 重新显示管理对话框
|
||
setTimeout(() => {
|
||
showManageConfigDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除配置组失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除配置组失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载默认配置
|
||
async function loadDefaultConfig() {
|
||
try {
|
||
const response = await fetch('/api/default-config');
|
||
const config = await response.json();
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = config.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = config.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = config.pro_config.hosts.join(',');
|
||
document.getElementById('pro_port').value = config.pro_config.port;
|
||
document.getElementById('pro_username').value = config.pro_config.username;
|
||
document.getElementById('pro_password').value = config.pro_config.password;
|
||
document.getElementById('pro_keyspace').value = config.pro_config.keyspace;
|
||
document.getElementById('pro_table').value = config.pro_config.table;
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = config.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = config.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = config.test_config.hosts.join(',');
|
||
document.getElementById('test_port').value = config.test_config.port;
|
||
document.getElementById('test_username').value = config.test_config.username;
|
||
document.getElementById('test_password').value = config.test_config.password;
|
||
document.getElementById('test_keyspace').value = config.test_config.keyspace;
|
||
document.getElementById('test_table').value = config.test_config.table;
|
||
|
||
// 填充查询配置
|
||
document.getElementById('keys').value = config.keys.join(',');
|
||
document.getElementById('fields_to_compare').value = config.fields_to_compare.join(',');
|
||
document.getElementById('exclude_fields').value = config.exclude_fields.join(',');
|
||
|
||
showAlert('success', '默认配置加载成功');
|
||
} catch (error) {
|
||
showAlert('danger', '加载默认配置失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 获取当前配置
|
||
function getCurrentConfig() {
|
||
return {
|
||
pro_config: {
|
||
cluster_name: document.getElementById('pro_cluster_name').value,
|
||
datacenter: document.getElementById('pro_datacenter').value,
|
||
hosts: document.getElementById('pro_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('pro_port').value) || 9042,
|
||
username: document.getElementById('pro_username').value,
|
||
password: document.getElementById('pro_password').value,
|
||
keyspace: document.getElementById('pro_keyspace').value,
|
||
table: document.getElementById('pro_table').value
|
||
},
|
||
test_config: {
|
||
cluster_name: document.getElementById('test_cluster_name').value,
|
||
datacenter: document.getElementById('test_datacenter').value,
|
||
hosts: document.getElementById('test_hosts').value.split(',').map(h => h.trim()).filter(h => h),
|
||
port: parseInt(document.getElementById('test_port').value) || 9042,
|
||
username: document.getElementById('test_username').value,
|
||
password: document.getElementById('test_password').value,
|
||
keyspace: document.getElementById('test_keyspace').value,
|
||
table: document.getElementById('test_table').value
|
||
},
|
||
query_config: {
|
||
keys: document.getElementById('keys').value.split(',').map(k => k.trim()).filter(k => k),
|
||
fields_to_compare: document.getElementById('fields_to_compare').value
|
||
.split(',').map(f => f.trim()).filter(f => f),
|
||
exclude_fields: document.getElementById('exclude_fields').value
|
||
.split(',').map(f => f.trim()).filter(f => f)
|
||
},
|
||
values: document.getElementById('query_values').value
|
||
.split('\n').map(v => v.trim()).filter(v => v)
|
||
};
|
||
}
|
||
|
||
// 执行查询比对
|
||
async function executeQuery() {
|
||
const config = getCurrentConfig();
|
||
|
||
// 验证配置
|
||
if (!config.values.length) {
|
||
showAlert('warning', '请输入查询Key值');
|
||
return;
|
||
}
|
||
|
||
if (!config.query_config.keys.length) {
|
||
showAlert('warning', '请输入主键字段');
|
||
return;
|
||
}
|
||
|
||
// 显示加载动画
|
||
document.getElementById('loading').style.display = 'block';
|
||
document.getElementById('results').style.display = 'none';
|
||
|
||
// 更新加载文本
|
||
const loadingText = document.getElementById('loadingText');
|
||
if (isShardingMode) {
|
||
loadingText.textContent = '正在执行分表查询比对...';
|
||
} else {
|
||
loadingText.textContent = '正在执行查询比对...';
|
||
}
|
||
|
||
try {
|
||
let apiEndpoint = '/api/query';
|
||
let requestConfig = config;
|
||
|
||
// 如果启用了分表模式,使用分表查询API和配置
|
||
if (isShardingMode) {
|
||
apiEndpoint = '/api/sharding-query';
|
||
requestConfig = getShardingConfig();
|
||
}
|
||
|
||
const response = await fetch(apiEndpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(requestConfig)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.error || '查询失败');
|
||
}
|
||
|
||
const results = await response.json();
|
||
currentResults = results;
|
||
displayResults(results);
|
||
|
||
// 自动刷新查询日志
|
||
autoRefreshLogsAfterQuery();
|
||
|
||
} catch (error) {
|
||
showAlert('danger', '查询失败: ' + error.message);
|
||
} finally {
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 获取分表查询配置
|
||
function getShardingConfig() {
|
||
const baseConfig = getCurrentConfig();
|
||
|
||
return {
|
||
...baseConfig,
|
||
sharding_config: {
|
||
enabled: document.getElementById('enableSharding').checked,
|
||
use_sharding_for_pro: document.getElementById('use_sharding_for_pro').checked,
|
||
use_sharding_for_test: document.getElementById('use_sharding_for_test').checked,
|
||
// 生产环境分表参数
|
||
pro_interval_seconds: parseInt(document.getElementById('pro_interval_seconds').value) || 604800,
|
||
pro_table_count: parseInt(document.getElementById('pro_table_count').value) || 14,
|
||
// 测试环境分表参数
|
||
test_interval_seconds: parseInt(document.getElementById('test_interval_seconds').value) || 604800,
|
||
test_table_count: parseInt(document.getElementById('test_table_count').value) || 14,
|
||
// 保持向后兼容的通用参数
|
||
interval_seconds: parseInt(document.getElementById('pro_interval_seconds').value) || 604800,
|
||
table_count: parseInt(document.getElementById('pro_table_count').value) || 14
|
||
}
|
||
};
|
||
}
|
||
|
||
// 显示查询结果
|
||
function displayResults(results) {
|
||
// 显示统计信息
|
||
displayStats(results);
|
||
|
||
// 显示分表查询信息(如果有)
|
||
displayShardingInfo(results);
|
||
|
||
// 更新选项卡计数
|
||
document.getElementById('diff-count').textContent = results.differences.length;
|
||
document.getElementById('identical-count').textContent = results.identical_results.length;
|
||
|
||
// 计算原始数据总数
|
||
const totalRawData = (results.raw_pro_data ? results.raw_pro_data.length : 0) +
|
||
(results.raw_test_data ? results.raw_test_data.length : 0);
|
||
document.getElementById('rawdata-count').textContent = totalRawData;
|
||
|
||
// 初始化相同结果分页数据
|
||
filteredIdenticalResults = results.identical_results;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 初始化差异结果分页数据
|
||
filteredDifferenceResults = results.differences;
|
||
currentDifferencePage = 1;
|
||
|
||
// 显示各个面板内容
|
||
displayDifferences();
|
||
displayIdenticalResults();
|
||
displayRawData(results);
|
||
displayComparisonSummary(results.summary);
|
||
|
||
// 显示结果区域
|
||
document.getElementById('results').style.display = 'block';
|
||
|
||
// 根据查询类型显示不同的成功消息
|
||
const queryType = isShardingMode ? '分表查询' : '单表查询';
|
||
showAlert('success', `${queryType}完成!共处理${results.total_keys}个Key,发现${results.differences.length}处差异,${results.identical_results.length}条记录完全相同`);
|
||
}
|
||
|
||
// 显示分表查询信息
|
||
function displayShardingInfo(results) {
|
||
const shardingInfoContainer = document.getElementById('shardingInfoContainer');
|
||
|
||
if (!isShardingMode || !results.sharding_info) {
|
||
shardingInfoContainer.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const shardingInfo = results.sharding_info;
|
||
shardingInfoContainer.style.display = 'block';
|
||
|
||
let html = '<div class="row">';
|
||
|
||
// 生产环境分表信息
|
||
if (shardingInfo.pro_shards) {
|
||
html += `
|
||
<div class="col-md-6">
|
||
<h6 class="text-primary"><i class="fas fa-server"></i> 生产环境分表信息</h6>
|
||
<div class="mb-3">
|
||
<small class="text-muted">配置:${shardingInfo.pro_shards.interval_seconds}秒间隔,${shardingInfo.pro_shards.table_count}张分表</small>
|
||
</div>
|
||
<div class="shard-tables-info">
|
||
`;
|
||
|
||
if (shardingInfo.pro_shards.queried_tables) {
|
||
shardingInfo.pro_shards.queried_tables.forEach(table => {
|
||
const hasError = shardingInfo.pro_shards.error_tables &&
|
||
shardingInfo.pro_shards.error_tables.includes(table);
|
||
const cssClass = hasError ? 'shard-error-info' : 'shard-table-info';
|
||
html += `<span class="${cssClass}">${table}</span>`;
|
||
});
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
// 测试环境分表信息
|
||
if (shardingInfo.test_shards) {
|
||
html += `
|
||
<div class="col-md-6">
|
||
<h6 class="text-success"><i class="fas fa-flask"></i> 测试环境分表信息</h6>
|
||
<div class="mb-3">
|
||
<small class="text-muted">配置:${shardingInfo.test_shards.interval_seconds}秒间隔,${shardingInfo.test_shards.table_count}张分表</small>
|
||
</div>
|
||
<div class="shard-tables-info">
|
||
`;
|
||
|
||
if (shardingInfo.test_shards.queried_tables) {
|
||
shardingInfo.test_shards.queried_tables.forEach(table => {
|
||
const hasError = shardingInfo.test_shards.error_tables &&
|
||
shardingInfo.test_shards.error_tables.includes(table);
|
||
const cssClass = hasError ? 'shard-error-info' : 'shard-table-info';
|
||
html += `<span class="${cssClass}">${table}</span>`;
|
||
});
|
||
}
|
||
|
||
html += '</div></div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// 添加分表计算统计信息
|
||
if (shardingInfo.calculation_stats) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<h6><i class="fas fa-calculator"></i> 分表计算统计</h6>
|
||
<div class="row">
|
||
<div class="col-md-3">
|
||
<small class="text-muted">处理Key数:<strong>${shardingInfo.calculation_stats.total_keys || 0}</strong></small>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<small class="text-muted">成功解析时间戳:<strong>${shardingInfo.calculation_stats.successful_extractions || 0}</strong></small>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<small class="text-muted">计算出分表数:<strong>${shardingInfo.calculation_stats.unique_shards || 0}</strong></small>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<small class="text-muted">解析失败:<strong class="text-danger">${shardingInfo.calculation_stats.failed_extractions || 0}</strong></small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
document.getElementById('shardingInfo').innerHTML = html;
|
||
}
|
||
|
||
// 显示统计信息
|
||
function displayStats(results) {
|
||
const statsHtml = `
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-primary text-white">
|
||
<h3>${results.total_keys}</h3>
|
||
<p>总Key数量</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-info text-white">
|
||
<h3>${results.pro_count}</h3>
|
||
<p>生产表记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-secondary text-white">
|
||
<h3>${results.test_count}</h3>
|
||
<p>测试表记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-success text-white">
|
||
<h3>${results.identical_results.length}</h3>
|
||
<p>相同记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-warning text-white">
|
||
<h3>${results.differences.length}</h3>
|
||
<p>差异记录</p>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<div class="stat-card bg-danger text-white">
|
||
<h3>${Math.round((results.identical_results.length / results.total_keys) * 100)}%</h3>
|
||
<p>一致性比例</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('stats').innerHTML = statsHtml;
|
||
}
|
||
|
||
// 显示差异详情
|
||
function displayDifferences() {
|
||
const differencesContainer = document.getElementById('differences');
|
||
|
||
// 保存当前搜索框的值
|
||
const currentSearchValue = document.getElementById('differenceSearch')?.value || '';
|
||
|
||
if (!filteredDifferenceResults || !filteredDifferenceResults.length) {
|
||
// 显示搜索和控制界面,即使没有结果
|
||
let html = `
|
||
<!-- 字段筛选按钮和操作按钮 -->
|
||
<div class="mb-3">
|
||
<div class="row">
|
||
<div class="col-md-8">
|
||
<div class="btn-group btn-group-sm flex-wrap" role="group">
|
||
<button type="button" class="btn btn-outline-primary active" onclick="filterByField('')">
|
||
全部 (0)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="d-flex justify-content-end">
|
||
<button type="button" class="btn btn-outline-primary btn-sm me-2" onclick="toggleAllDifferenceCollapse()">
|
||
<i class="fas fa-expand-alt"></i> <span id="toggleAllText">全部收起</span>
|
||
</button>
|
||
<button type="button" class="btn btn-success btn-sm" onclick="copyDifferenceKeys()">
|
||
<i class="fas fa-copy"></i> 复制差异主键
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
|
||
onkeypress="if(event.key === 'Enter') searchDifferenceResults(this.value)"
|
||
id="differenceSearch"
|
||
value="${currentSearchValue}">
|
||
<button class="btn btn-outline-secondary" type="button" onclick="searchDifferenceResults(document.getElementById('differenceSearch').value)">
|
||
<i class="fas fa-search"></i> 搜索
|
||
</button>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="clearDifferenceSearch()">
|
||
<i class="fas fa-times"></i> 清除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无结果提示 -->
|
||
<div class="alert alert-info text-center">
|
||
<i class="fas fa-search"></i>
|
||
${currentSearchValue ? `没有找到包含"${escapeHtml(currentSearchValue)}"的差异记录` : '未发现差异'}
|
||
${currentSearchValue ? '<br><small class="text-muted">请尝试其他搜索条件或点击"清除"按钮查看所有结果</small>' : ''}
|
||
</div>
|
||
`;
|
||
|
||
differencesContainer.innerHTML = html;
|
||
|
||
// 恢复搜索框的焦点和光标位置
|
||
if (currentSearchValue) {
|
||
const searchInput = document.getElementById('differenceSearch');
|
||
if (searchInput) {
|
||
searchInput.focus();
|
||
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 调试日志:检查差异数据
|
||
console.log('差异数据:', filteredDifferenceResults);
|
||
|
||
// 按主键分组差异
|
||
const groupedDifferences = groupDifferencesByKey(filteredDifferenceResults);
|
||
const totalGroups = Object.keys(groupedDifferences).length;
|
||
|
||
// 计算分页(基于主键组)
|
||
const groupKeys = Object.keys(groupedDifferences);
|
||
const totalPages = Math.ceil(groupKeys.length / differencePageSize);
|
||
const startIndex = (currentDifferencePage - 1) * differencePageSize;
|
||
const endIndex = startIndex + differencePageSize;
|
||
const currentPageKeys = groupKeys.slice(startIndex, endIndex);
|
||
|
||
// 统计字段差异类型
|
||
const fieldStats = {};
|
||
filteredDifferenceResults.forEach(diff => {
|
||
if (diff.field) {
|
||
fieldStats[diff.field] = (fieldStats[diff.field] || 0) + 1;
|
||
}
|
||
});
|
||
|
||
let html = `
|
||
<!-- 字段筛选按钮和操作按钮 -->
|
||
<div class="mb-3">
|
||
<div class="row">
|
||
<div class="col-md-8">
|
||
<div class="btn-group btn-group-sm flex-wrap" role="group">
|
||
<button type="button" class="btn btn-outline-primary active" onclick="filterByField('')">
|
||
全部 (${filteredDifferenceResults.length})
|
||
</button>
|
||
${Object.entries(fieldStats).map(([field, count]) => `
|
||
<button type="button" class="btn btn-outline-secondary" onclick="filterByField('${field}')">
|
||
${field} (${count})
|
||
</button>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="d-flex justify-content-end">
|
||
<button type="button" class="btn btn-outline-primary btn-sm me-2" onclick="toggleAllDifferenceCollapse()">
|
||
<i class="fas fa-expand-alt"></i> <span id="toggleAllText">全部收起</span>
|
||
</button>
|
||
<button type="button" class="btn btn-success btn-sm" onclick="copyDifferenceKeys()">
|
||
<i class="fas fa-copy"></i> 复制差异主键
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changeDifferencePageSize(this.value)">
|
||
<option value="5" ${differencePageSize == 5 ? 'selected' : ''}>5组</option>
|
||
<option value="10" ${differencePageSize == 10 ? 'selected' : ''}>10组</option>
|
||
<option value="20" ${differencePageSize == 20 ? 'selected' : ''}>20组</option>
|
||
<option value="50" ${differencePageSize == 50 ? 'selected' : ''}>50组</option>
|
||
<option value="100" ${differencePageSize == 100 ? 'selected' : ''}>100组</option>
|
||
<option value="custom_diff">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customDiffPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomDifferencePageSize(this.value)" onkeypress="handleCustomDiffPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${totalGroups} 个主键组,${filteredDifferenceResults.length} 条差异记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
|
||
onkeypress="if(event.key === 'Enter') searchDifferenceResults(this.value)"
|
||
id="differenceSearch"
|
||
value="${currentSearchValue}">
|
||
<button class="btn btn-outline-secondary" type="button" onclick="searchDifferenceResults(document.getElementById('differenceSearch').value)">
|
||
<i class="fas fa-search"></i> 搜索
|
||
</button>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="clearDifferenceSearch()">
|
||
<i class="fas fa-times"></i> 清除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页的主键组
|
||
currentPageKeys.forEach((keyStr, groupIndex) => {
|
||
const diffs = groupedDifferences[keyStr];
|
||
const globalIndex = startIndex + groupIndex + 1;
|
||
|
||
// 将字符串化的key解析回对象
|
||
let keyObj;
|
||
try {
|
||
keyObj = JSON.parse(keyStr);
|
||
} catch (e) {
|
||
// 如果解析失败,假设它本身就是一个简单值
|
||
keyObj = keyStr;
|
||
}
|
||
|
||
html += `
|
||
<div class="card mb-3 border-primary">
|
||
<div class="card-header bg-primary bg-opacity-10">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>主键组 #${globalIndex}</strong>
|
||
<span class="badge bg-primary ms-2">${diffs.length} 个差异字段</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-info" onclick='showDifferenceRawData(${JSON.stringify(JSON.stringify(keyObj))})'>
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${formatCompositeKey(keyObj)}</p>
|
||
</div>
|
||
<div class="collapse show" id="keyGroup${globalIndex}">
|
||
<div class="card-body">
|
||
`;
|
||
|
||
// 显示该主键下的所有差异字段
|
||
diffs.forEach((diff, diffIndex) => {
|
||
if (diff.message) {
|
||
// 记录不存在的情况
|
||
html += `
|
||
<div class="alert alert-warning mb-2">
|
||
<i class="fas fa-exclamation-triangle"></i> ${diff.message}
|
||
</div>
|
||
`;
|
||
} else {
|
||
// 字段值差异的情况
|
||
const isJson = diff.is_json;
|
||
const isArray = diff.is_array;
|
||
const jsonClass = isJson ? 'json-field' : '';
|
||
const fieldId = `field_${globalIndex}_${diffIndex}`;
|
||
|
||
// 调试:打印差异数据
|
||
console.log(`差异 ${diff.field}:`, {
|
||
pro_value: diff.pro_value,
|
||
test_value: diff.test_value,
|
||
is_json: diff.is_json,
|
||
is_array: diff.is_array
|
||
});
|
||
|
||
// 确保值存在
|
||
const proValue = diff.pro_value !== undefined && diff.pro_value !== null ? diff.pro_value : '无数据';
|
||
const testValue = diff.test_value !== undefined && diff.test_value !== null ? diff.test_value : '无数据';
|
||
|
||
html += `
|
||
<div class="mb-3 border-start border-3 border-danger ps-3">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div>
|
||
<strong class="text-danger">${diff.field}</strong>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON</span>' : ''}
|
||
${isArray ? '<span class="badge bg-warning ms-2">数组</span>' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="collapse show" id="${fieldId}">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header bg-success bg-opacity-10">
|
||
<small class="text-success fw-bold">生产环境</small>
|
||
</div>
|
||
<div class="card-body p-2">
|
||
<pre class="field-value mb-0 ${jsonClass}" style="max-height: 200px; overflow-y: auto;">${escapeHtml(proValue)}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header bg-info bg-opacity-10">
|
||
<small class="text-info fw-bold">测试环境</small>
|
||
</div>
|
||
<div class="card-body p-2">
|
||
<pre class="field-value mb-0 ${jsonClass}" style="max-height: 200px; overflow-y: auto;">${escapeHtml(testValue)}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
differencesContainer.innerHTML = html;
|
||
|
||
// 初始化全部展开/收起按钮的状态
|
||
const toggleButton = document.getElementById('toggleAllText');
|
||
if (toggleButton) {
|
||
// 由于默认展开,按钮文本应该是"全部收起"
|
||
toggleButton.innerHTML = '全部收起';
|
||
}
|
||
|
||
// 恢复搜索框的焦点和光标位置
|
||
if (currentSearchValue) {
|
||
const searchInput = document.getElementById('differenceSearch');
|
||
if (searchInput) {
|
||
searchInput.focus();
|
||
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
|
||
}
|
||
}
|
||
}
|
||
|
||
// HTML转义函数,防止XSS
|
||
function escapeHtml(text) {
|
||
// 处理undefined和null值
|
||
if (text === undefined || text === null) {
|
||
return '<span class="text-muted">无数据</span>';
|
||
}
|
||
|
||
// 确保是字符串
|
||
const str = String(text);
|
||
|
||
// 如果是空字符串
|
||
if (str === '') {
|
||
return '<span class="text-muted">空值</span>';
|
||
}
|
||
|
||
const div = document.createElement('div');
|
||
div.textContent = str;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 显示相同结果
|
||
function displayIdenticalResults() {
|
||
const identicalContainer = document.getElementById('identical-results');
|
||
|
||
// 保存当前搜索框的值
|
||
const currentSearchValue = document.getElementById('identicalSearch')?.value || '';
|
||
|
||
if (!filteredIdenticalResults.length) {
|
||
// 显示搜索和控制界面,即使没有结果
|
||
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">共 0 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
<!-- 无分页控制 -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键或字段内容..."
|
||
onkeypress="if(event.key === 'Enter') searchIdenticalResults(this.value)"
|
||
id="identicalSearch"
|
||
value="${currentIdenticalSearchTerm || ''}">
|
||
<button class="btn btn-outline-secondary" type="button" onclick="searchIdenticalResults(document.getElementById('identicalSearch').value)">
|
||
<i class="fas fa-search"></i> 搜索
|
||
</button>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="clearIdenticalSearch()">
|
||
<i class="fas fa-times"></i> 清除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无结果提示 -->
|
||
<div class="alert alert-info text-center">
|
||
<i class="fas fa-search"></i>
|
||
${(currentIdenticalSearchTerm || currentSearchValue) ? `没有找到包含"${escapeHtml(currentIdenticalSearchTerm || currentSearchValue)}"的相同记录` : '没有完全相同的记录'}
|
||
${(currentIdenticalSearchTerm || currentSearchValue) ? '<br><small class="text-muted">请尝试其他搜索条件或点击"清除"按钮查看所有结果</small>' : ''}
|
||
</div>
|
||
`;
|
||
|
||
identicalContainer.innerHTML = html;
|
||
|
||
// 恢复搜索框的焦点和光标位置
|
||
if (currentSearchValue) {
|
||
const searchInput = document.getElementById('identicalSearch');
|
||
if (searchInput) {
|
||
searchInput.focus();
|
||
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 计算分页
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
const startIndex = (currentIdenticalPage - 1) * identicalPageSize;
|
||
const endIndex = startIndex + identicalPageSize;
|
||
const currentPageData = filteredIdenticalResults.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changePageSize(this.value)">
|
||
<option value="5" ${identicalPageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="10" ${identicalPageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="20" ${identicalPageSize == 100 ? 'selected' : ''}>100条</option>
|
||
<option value="50" ${identicalPageSize == 200 ? 'selected' : ''}>200条</option>
|
||
<option value="100" ${identicalPageSize == 500 ? 'selected' : ''}>500条</option>
|
||
<option value="custom">自定义</option>
|
||
</select>
|
||
<input type="number" class="form-control form-control-sm me-2" style="width: 80px; display: none;"
|
||
id="customPageSize" placeholder="数量" min="1" max="1000"
|
||
onchange="setCustomPageSize(this.value)" onkeypress="handleCustomPageSizeEnter(event)">
|
||
<span class="ms-2 text-muted">共 ${filteredIdenticalResults.length} 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 搜索框 -->
|
||
<div class="mb-3">
|
||
<div class="input-group">
|
||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||
<input type="text" class="form-control" placeholder="搜索主键或字段内容..."
|
||
onkeypress="if(event.key === 'Enter') searchIdenticalResults(this.value)"
|
||
id="identicalSearch"
|
||
value="${currentIdenticalSearchTerm || ''}">
|
||
<button class="btn btn-outline-secondary" type="button" onclick="searchIdenticalResults(document.getElementById('identicalSearch').value)">
|
||
<i class="fas fa-search"></i> 搜索
|
||
</button>
|
||
<button class="btn btn-outline-secondary" type="button" onclick="clearIdenticalSearch()">
|
||
<i class="fas fa-times"></i> 清除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示当前页数据
|
||
currentPageData.forEach((result, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
html += `
|
||
<div class="card mb-3 border-success">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>相同记录 #${globalIndex}</strong>
|
||
<span class="badge bg-success ms-2">完全匹配</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-info" onclick='showRawData(${JSON.stringify(JSON.stringify(result.key))})'>
|
||
<i class="fas fa-code"></i> 原生数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${formatCompositeKey(result.key)}</p>
|
||
</div>
|
||
<div class="collapse show" id="collapse${globalIndex}">
|
||
<div class="card-body">
|
||
`;
|
||
|
||
// 显示字段对比,左右布局
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allFields = new Set([...Object.keys(proFields), ...Object.keys(testFields)]);
|
||
|
||
html += '<div class="row"><div class="col-12"><h6 class="mb-3">字段对比 (生产环境 vs 测试环境):</h6></div></div>';
|
||
|
||
allFields.forEach(fieldName => {
|
||
const proValue = proFields[fieldName] || '';
|
||
const testValue = testFields[fieldName] || '';
|
||
|
||
// 更安全的JSON检测方法
|
||
let isJson = false;
|
||
try {
|
||
if (typeof proValue === 'string' && (proValue.startsWith('{') || proValue.startsWith('['))) {
|
||
JSON.parse(proValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e1) {
|
||
try {
|
||
if (typeof testValue === 'string' && (testValue.startsWith('{') || testValue.startsWith('['))) {
|
||
JSON.parse(testValue);
|
||
isJson = true;
|
||
}
|
||
} catch (e2) {
|
||
// 如果都不是有效的JSON,保持isJson为false
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<div class="mb-4">
|
||
<div class="field-header mb-2">
|
||
<i class="fas fa-tag text-primary"></i>
|
||
<strong>${fieldName}</strong>
|
||
${isJson ? '<span class="badge bg-info ms-2">JSON</span>' : ''}
|
||
</div>
|
||
|
||
<!-- 环境对比数据行 -->
|
||
<div class="row mb-2">
|
||
<div class="col-12">
|
||
<div class="field-container position-relative">
|
||
<pre class="field-value bg-light p-2 rounded mb-0" style="max-height: 400px; overflow-y: auto; margin: 0;">${escapeHtml(String(proValue))}
|
||
${escapeHtml(String(testValue))}</pre>
|
||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||
onclick="copyToClipboard('${escapeForJs(String(proValue))}\n${escapeForJs(String(testValue))}', this)"
|
||
title="复制全部内容">
|
||
<i class="fas fa-copy"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generatePagination(currentIdenticalPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
identicalContainer.innerHTML = html;
|
||
|
||
// 恢复搜索框的焦点和光标位置
|
||
if (currentSearchValue) {
|
||
const searchInput = document.getElementById('identicalSearch');
|
||
if (searchInput) {
|
||
searchInput.focus();
|
||
searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 生成分页导航
|
||
function generatePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToIdenticalPage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToIdenticalPage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定页面
|
||
function goToIdenticalPage(page) {
|
||
const totalPages = Math.ceil(filteredIdenticalResults.length / identicalPageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentIdenticalPage = page;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 改变每页显示数量
|
||
function changePageSize(newSize) {
|
||
if (newSize === 'custom') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
identicalPageSize = parseInt(newSize);
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 设置自定义页面大小
|
||
function setCustomPageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
identicalPageSize = pageSize;
|
||
currentIdenticalPage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customPageSize');
|
||
const select = document.querySelector('select[onchange="changePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayIdenticalResults();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义页面大小输入框回车事件
|
||
function handleCustomPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomPageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索相同结果
|
||
let currentIdenticalSearchTerm = '';
|
||
|
||
function searchIdenticalResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
// 保存搜索词
|
||
currentIdenticalSearchTerm = searchTerm;
|
||
|
||
if (!searchTerm.trim()) {
|
||
filteredIdenticalResults = currentResults.identical_results;
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
filteredIdenticalResults = currentResults.identical_results.filter(result => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(result.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段内容
|
||
const proFields = result.pro_fields || {};
|
||
const testFields = result.test_fields || {};
|
||
const allValues = [...Object.values(proFields), ...Object.values(testFields)];
|
||
|
||
return allValues.some(value =>
|
||
String(value).toLowerCase().includes(term)
|
||
);
|
||
});
|
||
}
|
||
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 清除相同记录搜索
|
||
function clearIdenticalSearch() {
|
||
currentIdenticalSearchTerm = '';
|
||
document.getElementById('identicalSearch').value = '';
|
||
filteredIdenticalResults = currentResults.identical_results;
|
||
currentIdenticalPage = 1;
|
||
displayIdenticalResults();
|
||
}
|
||
|
||
// 生成差异分页导航
|
||
function generateDifferencePagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定差异页面
|
||
function goToDifferencePage(page) {
|
||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentDifferencePage = page;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 改变差异每页显示数量
|
||
function changeDifferencePageSize(newSize) {
|
||
if (newSize === 'custom_diff') {
|
||
// 显示自定义输入框
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'inline-block';
|
||
customInput.focus();
|
||
return;
|
||
}
|
||
|
||
differencePageSize = parseInt(newSize);
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 设置自定义差异页面大小
|
||
function setCustomDifferencePageSize(size) {
|
||
const pageSize = parseInt(size);
|
||
if (pageSize && pageSize > 0 && pageSize <= 1000) {
|
||
differencePageSize = pageSize;
|
||
currentDifferencePage = 1;
|
||
|
||
// 隐藏输入框,更新下拉框显示
|
||
const customInput = document.getElementById('customDiffPageSize');
|
||
const select = document.querySelector('select[onchange="changeDifferencePageSize(this.value)"]');
|
||
customInput.style.display = 'none';
|
||
|
||
// 如果不是预设值,保持custom选中状态
|
||
const presetValues = [5, 10, 20, 50, 100];
|
||
if (!presetValues.includes(pageSize)) {
|
||
select.value = 'custom_diff';
|
||
} else {
|
||
select.value = pageSize.toString();
|
||
}
|
||
|
||
displayDifferences();
|
||
} else {
|
||
showAlert('warning', '请输入1-1000之间的有效数字');
|
||
}
|
||
}
|
||
|
||
// 处理自定义差异页面大小输入框回车事件
|
||
function handleCustomDiffPageSizeEnter(event) {
|
||
if (event.key === 'Enter') {
|
||
setCustomDifferencePageSize(event.target.value);
|
||
}
|
||
}
|
||
|
||
// 搜索差异结果
|
||
let currentSearchTerm = '';
|
||
|
||
function searchDifferenceResults(searchTerm) {
|
||
if (!currentResults) return;
|
||
|
||
// 保存搜索词
|
||
currentSearchTerm = searchTerm;
|
||
|
||
if (!searchTerm.trim()) {
|
||
// 如果有字段筛选,应用字段筛选;否则显示全部
|
||
if (currentFieldFilter) {
|
||
filteredDifferenceResults = currentResults.differences.filter(diff => diff.field === currentFieldFilter);
|
||
} else {
|
||
filteredDifferenceResults = currentResults.differences;
|
||
}
|
||
} else {
|
||
const term = searchTerm.toLowerCase();
|
||
let baseResults = currentFieldFilter ?
|
||
currentResults.differences.filter(diff => diff.field === currentFieldFilter) :
|
||
currentResults.differences;
|
||
|
||
filteredDifferenceResults = baseResults.filter(diff => {
|
||
// 搜索主键
|
||
const keyStr = JSON.stringify(diff.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
|
||
// 搜索字段名
|
||
if (diff.field && diff.field.toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索字段值
|
||
if (diff.pro_value && String(diff.pro_value).toLowerCase().includes(term)) return true;
|
||
if (diff.test_value && String(diff.test_value).toLowerCase().includes(term)) return true;
|
||
|
||
// 搜索消息
|
||
if (diff.message && diff.message.toLowerCase().includes(term)) return true;
|
||
|
||
return false;
|
||
});
|
||
}
|
||
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 清除差异搜索
|
||
function clearDifferenceSearch() {
|
||
currentSearchTerm = '';
|
||
document.getElementById('differenceSearch').value = '';
|
||
|
||
// 重新应用字段筛选
|
||
if (currentFieldFilter) {
|
||
filteredDifferenceResults = currentResults.differences.filter(diff => diff.field === currentFieldFilter);
|
||
} else {
|
||
filteredDifferenceResults = currentResults.differences;
|
||
}
|
||
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
}
|
||
|
||
// 复制到剪贴板
|
||
function copyToClipboard(text, button) {
|
||
navigator.clipboard.writeText(text).then(function() {
|
||
// 显示复制成功反馈
|
||
const originalIcon = button.innerHTML;
|
||
button.innerHTML = '<i class="fas fa-check text-success"></i>';
|
||
button.classList.add('btn-success');
|
||
button.classList.remove('btn-outline-secondary');
|
||
|
||
setTimeout(() => {
|
||
button.innerHTML = originalIcon;
|
||
button.classList.remove('btn-success');
|
||
button.classList.add('btn-outline-secondary');
|
||
}, 1000);
|
||
}).catch(function(err) {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动复制');
|
||
});
|
||
}
|
||
|
||
// JS字符串转义
|
||
function escapeForJs(str) {
|
||
if (str === null || str === undefined) {
|
||
return '';
|
||
}
|
||
return String(str)
|
||
.replace(/\\/g, '\\\\') // 反斜杠必须先处理
|
||
.replace(/'/g, "\\'") // 单引号
|
||
.replace(/"/g, '\\"') // 双引号
|
||
.replace(/\n/g, '\\n') // 换行符
|
||
.replace(/\r/g, '\\r') // 回车符
|
||
.replace(/\t/g, '\\t') // 制表符
|
||
.replace(/\f/g, '\\f') // 换页符
|
||
.replace(/\b/g, '\\b') // 退格符
|
||
.replace(/\0/g, '\\0'); // 空字符
|
||
}
|
||
|
||
// 显示原生数据
|
||
function showRawData(keyStr) {
|
||
if (!currentResults) {
|
||
showAlert('warning', '请先执行查询操作');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析key
|
||
const key = JSON.parse(keyStr);
|
||
console.log('查找原始数据,key:', key);
|
||
|
||
// 在原生数据中查找对应的记录
|
||
let proData = null;
|
||
let testData = null;
|
||
|
||
// 查找生产环境数据
|
||
if (currentResults.raw_pro_data) {
|
||
proData = currentResults.raw_pro_data.find(item => {
|
||
// 支持复合主键比较
|
||
if (typeof key === 'object' && !Array.isArray(key)) {
|
||
// 复合主键情况:比较所有主键字段
|
||
const matches = Object.keys(key).every(keyField => {
|
||
const itemValue = item[keyField];
|
||
const keyValue = key[keyField];
|
||
|
||
// 如果都是undefined或null,认为匹配
|
||
if (itemValue == null && keyValue == null) {
|
||
return true;
|
||
}
|
||
|
||
// 转换为字符串进行比较
|
||
const itemStr = String(itemValue);
|
||
const keyStr = String(keyValue);
|
||
|
||
// 直接比较字符串
|
||
return itemStr === keyStr;
|
||
});
|
||
|
||
if (matches) {
|
||
console.log('找到生产环境数据:', item);
|
||
}
|
||
return matches;
|
||
} else {
|
||
// 单主键情况(兼容旧代码)
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// 如果没找到,输出调试信息
|
||
if (!proData) {
|
||
console.log('未找到生产数据,查找的key:', key);
|
||
console.log('可用的数据:', currentResults.raw_pro_data.map(item => ({
|
||
statusid: item.statusid
|
||
})));
|
||
}
|
||
}
|
||
|
||
// 查找测试环境数据
|
||
if (currentResults.raw_test_data) {
|
||
testData = currentResults.raw_test_data.find(item => {
|
||
// 支持复合主键比较
|
||
if (typeof key === 'object' && !Array.isArray(key)) {
|
||
// 复合主键情况:比较所有主键字段
|
||
const matches = Object.keys(key).every(keyField => {
|
||
const itemValue = item[keyField];
|
||
const keyValue = key[keyField];
|
||
|
||
// 如果都是undefined或null,认为匹配
|
||
if (itemValue == null && keyValue == null) {
|
||
return true;
|
||
}
|
||
|
||
// 转换为字符串进行比较
|
||
const itemStr = String(itemValue);
|
||
const keyStr = String(keyValue);
|
||
|
||
// 直接比较字符串
|
||
return itemStr === keyStr;
|
||
});
|
||
|
||
if (matches) {
|
||
console.log('找到测试环境数据:', item);
|
||
}
|
||
return matches;
|
||
} else {
|
||
// 单主键情况(兼容旧代码)
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// 如果没找到,输出调试信息
|
||
if (!testData) {
|
||
console.log('未找到测试数据,查找的key:', key);
|
||
console.log('可用的数据:', currentResults.raw_test_data.map(item => ({
|
||
statusid: item.statusid
|
||
})));
|
||
}
|
||
}
|
||
|
||
console.log('查找结果 - 生产数据:', proData, '测试数据:', testData);
|
||
|
||
// 创建模态框内容
|
||
const modalContent = `
|
||
<div class="modal fade" id="rawDataModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-code"></i> 原生数据 - ${JSON.stringify(key)}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<ul class="nav nav-tabs" id="rawDataTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="formatted-tab" data-bs-toggle="tab"
|
||
data-bs-target="#formatted" type="button" role="tab">
|
||
<i class="fas fa-code"></i> 格式化视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="raw-tab" data-bs-toggle="tab"
|
||
data-bs-target="#raw" type="button" role="tab">
|
||
<i class="fas fa-file-code"></i> 原始视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="diff-tab" data-bs-toggle="tab"
|
||
data-bs-target="#diff" type="button" role="tab">
|
||
<i class="fas fa-exchange-alt"></i> 对比视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="tree-tab" data-bs-toggle="tab"
|
||
data-bs-target="#tree" type="button" role="tab">
|
||
<i class="fas fa-tree"></i> 树形视图
|
||
</button>
|
||
</li>
|
||
<li class="nav-item ms-auto">
|
||
<span class="nav-link text-muted">
|
||
<i class="fas fa-sync-alt"></i> 同步滚动已启用
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<div class="tab-content mt-3" id="rawDataTabsContent">
|
||
<!-- 格式化视图 -->
|
||
<div class="tab-pane fade show active" id="formatted" role="tabpanel">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
|
||
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData, null, 2)) : '无数据'}</pre>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
|
||
<pre class="bg-light p-3 rounded sync-scroll" style="max-height: 500px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData, null, 2)) : '无数据'}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 原始视图 -->
|
||
<div class="tab-pane fade" id="raw" role="tabpanel">
|
||
<div class="mb-3">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境原始数据</h6>
|
||
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${proData ? escapeHtml(JSON.stringify(proData)) : '无数据'}</pre>
|
||
</div>
|
||
<div>
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境原始数据</h6>
|
||
<pre class="bg-light p-3 rounded" style="max-height: 300px; overflow-y: auto;">${testData ? escapeHtml(JSON.stringify(testData)) : '无数据'}</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 对比视图 -->
|
||
<div class="tab-pane fade" id="diff" role="tabpanel">
|
||
<div id="diffView"></div>
|
||
</div>
|
||
|
||
<!-- 树形视图 -->
|
||
<div class="tab-pane fade" id="tree" role="tabpanel">
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<h6 class="text-primary"><i class="fas fa-database"></i> 生产环境数据</h6>
|
||
<div id="proTreeView" class="tree-view"></div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6 class="text-secondary"><i class="fas fa-database"></i> 测试环境数据</h6>
|
||
<div id="testTreeView" class="tree-view"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" onclick="copyRawData()">
|
||
<i class="fas fa-copy"></i> 复制数据
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('rawDataModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示模态框
|
||
const modal = new bootstrap.Modal(document.getElementById('rawDataModal'));
|
||
modal.show();
|
||
|
||
// 保存当前数据供复制使用
|
||
window.currentRawData = {
|
||
pro: proData ? JSON.stringify(proData, null, 2) : '无数据',
|
||
test: testData ? JSON.stringify(testData, null, 2) : '无数据',
|
||
combined: `生产环境数据:\n${proData ? JSON.stringify(proData, null, 2) : '无数据'}\n\n测试环境数据:\n${testData ? JSON.stringify(testData, null, 2) : '无数据'}`
|
||
};
|
||
|
||
// 延迟渲染对比视图和树形视图
|
||
setTimeout(() => {
|
||
console.log('开始渲染视图,生产数据:', proData, '测试数据:', testData);
|
||
|
||
// 渲染对比视图
|
||
try {
|
||
renderDiffView(proData, testData);
|
||
console.log('对比视图渲染完成');
|
||
} catch (e) {
|
||
console.error('对比视图渲染失败:', e);
|
||
}
|
||
|
||
// 渲染树形视图
|
||
try {
|
||
renderTreeView('proTreeView', proData);
|
||
renderTreeView('testTreeView', testData);
|
||
console.log('树形视图渲染完成');
|
||
} catch (e) {
|
||
console.error('树形视图渲染失败:', e);
|
||
}
|
||
|
||
// 为格式化视图添加同步滚动
|
||
setupSyncScroll('formatted');
|
||
// 为树形视图添加同步滚动
|
||
setupSyncScroll('tree');
|
||
|
||
// 添加标签页切换事件监听器,确保切换时重新渲染
|
||
const tabButtons = document.querySelectorAll('#rawDataTabs button[data-bs-toggle="tab"]');
|
||
tabButtons.forEach(button => {
|
||
button.addEventListener('shown.bs.tab', (event) => {
|
||
const targetId = event.target.getAttribute('data-bs-target');
|
||
console.log('切换到标签页:', targetId);
|
||
|
||
if (targetId === '#diff') {
|
||
// 重新渲染对比视图
|
||
renderDiffView(proData, testData);
|
||
} else if (targetId === '#tree') {
|
||
// 重新渲染树形视图
|
||
renderTreeView('proTreeView', proData);
|
||
renderTreeView('testTreeView', testData);
|
||
}
|
||
});
|
||
});
|
||
}, 100);
|
||
|
||
} catch (error) {
|
||
console.error('显示原生数据失败:', error);
|
||
showAlert('danger', '显示原生数据失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 复制原生数据
|
||
function copyRawData() {
|
||
if (window.currentRawData) {
|
||
const combinedData = window.currentRawData.combined ||
|
||
(window.currentRawData.pro + '\n\n' + window.currentRawData.test);
|
||
|
||
navigator.clipboard.writeText(combinedData).then(() => {
|
||
showAlert('success', '原生数据已复制到剪贴板');
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动选择复制');
|
||
});
|
||
} else {
|
||
showAlert('warning', '无可复制的数据');
|
||
}
|
||
}
|
||
|
||
// 复制差异主键
|
||
function copyDifferenceKeys() {
|
||
if (!filteredDifferenceResults || filteredDifferenceResults.length === 0) {
|
||
showAlert('warning', '无差异数据可复制');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 收集所有差异记录的主键
|
||
const differenceKeys = [];
|
||
const uniqueKeys = new Set();
|
||
|
||
filteredDifferenceResults.forEach(diff => {
|
||
if (diff.key) {
|
||
let keyText = '';
|
||
|
||
if (typeof diff.key === 'object' && !Array.isArray(diff.key)) {
|
||
// 复合主键:转换为逗号分隔格式
|
||
keyText = Object.values(diff.key).join(',');
|
||
} else {
|
||
// 单主键或其他格式
|
||
keyText = String(diff.key);
|
||
}
|
||
|
||
// 避免重复主键
|
||
if (!uniqueKeys.has(keyText)) {
|
||
uniqueKeys.add(keyText);
|
||
differenceKeys.push(keyText);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (differenceKeys.length === 0) {
|
||
showAlert('warning', '未找到有效的主键数据');
|
||
return;
|
||
}
|
||
|
||
// 将主键列表转换为文本格式(每行一个)
|
||
const keyText = differenceKeys.join('\n');
|
||
|
||
// 复制到剪贴板
|
||
navigator.clipboard.writeText(keyText).then(() => {
|
||
showAlert('success', `已复制 ${differenceKeys.length} 个差异主键到剪贴板`);
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动选择复制');
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('处理差异主键失败:', error);
|
||
showAlert('danger', '处理差异主键失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 切换所有差异详情的展开/收起状态
|
||
function toggleAllDifferenceCollapse() {
|
||
// 获取当前页面的所有差异展开区域
|
||
const collapseElements = document.querySelectorAll('#differences [id^="keyGroup"]');
|
||
const toggleButton = document.getElementById('toggleAllText');
|
||
|
||
if (collapseElements.length === 0) {
|
||
showAlert('warning', '没有找到差异详情');
|
||
return;
|
||
}
|
||
|
||
// 检查当前状态 - 如果大部分是展开的,就全部收起;否则全部展开
|
||
let expandedCount = 0;
|
||
collapseElements.forEach(element => {
|
||
if (element.classList.contains('show')) {
|
||
expandedCount++;
|
||
}
|
||
});
|
||
|
||
const shouldExpand = expandedCount < collapseElements.length / 2;
|
||
|
||
collapseElements.forEach(element => {
|
||
if (shouldExpand) {
|
||
// 展开
|
||
element.classList.add('show');
|
||
} else {
|
||
// 收起
|
||
element.classList.remove('show');
|
||
}
|
||
});
|
||
|
||
// 更新按钮文本
|
||
if (shouldExpand) {
|
||
toggleButton.innerHTML = '全部收起';
|
||
showAlert('success', `已展开 ${collapseElements.length} 个差异详情`);
|
||
} else {
|
||
toggleButton.innerHTML = '全部展开';
|
||
showAlert('success', `已收起 ${collapseElements.length} 个差异详情`);
|
||
}
|
||
}
|
||
|
||
// 显示差异数据的原生数据
|
||
function showDifferenceRawData(keyStr) {
|
||
showRawData(keyStr); // 复用相同的原生数据显示逻辑
|
||
}
|
||
|
||
// 渲染对比视图
|
||
function renderDiffView(proData, testData) {
|
||
const diffViewContainer = document.getElementById('diffView');
|
||
|
||
// 确保容器存在
|
||
if (!diffViewContainer) {
|
||
console.error('对比视图容器不存在');
|
||
return;
|
||
}
|
||
|
||
if (!proData && !testData) {
|
||
diffViewContainer.innerHTML = '<p class="text-muted text-center">无数据可对比</p>';
|
||
return;
|
||
}
|
||
|
||
// 获取所有字段
|
||
const allFields = new Set([
|
||
...(proData ? Object.keys(proData) : []),
|
||
...(testData ? Object.keys(testData) : [])
|
||
]);
|
||
|
||
// 分离相同和不同的字段
|
||
const differentFields = [];
|
||
const identicalFields = [];
|
||
|
||
allFields.forEach(field => {
|
||
const proValue = proData ? proData[field] : undefined;
|
||
const testValue = testData ? testData[field] : undefined;
|
||
|
||
// 特殊处理:区分 null 和 undefined
|
||
// undefined 表示字段不存在,null 表示字段存在但值为 null
|
||
let isEqual = false;
|
||
|
||
// 如果一个是 undefined,另一个不是(包括 null),则不相等
|
||
if (proValue === undefined && testValue !== undefined) {
|
||
isEqual = false;
|
||
} else if (proValue !== undefined && testValue === undefined) {
|
||
isEqual = false;
|
||
} else if (proValue === testValue) {
|
||
// 两个都是 undefined 或者值完全相同
|
||
isEqual = true;
|
||
} else if (proValue !== undefined && testValue !== undefined) {
|
||
// 两个都有值,尝试规范化比较
|
||
try {
|
||
const proNormalized = normalizeValue(proValue);
|
||
const testNormalized = normalizeValue(testValue);
|
||
isEqual = JSON.stringify(proNormalized) === JSON.stringify(testNormalized);
|
||
} catch (e) {
|
||
isEqual = JSON.stringify(proValue) === JSON.stringify(testValue);
|
||
}
|
||
}
|
||
|
||
if (isEqual) {
|
||
identicalFields.push(field);
|
||
} else {
|
||
differentFields.push(field);
|
||
}
|
||
});
|
||
|
||
let html = '';
|
||
|
||
// 如果有差异字段,先显示差异统计
|
||
if (differentFields.length > 0) {
|
||
html += `
|
||
<div class="alert alert-warning mb-3">
|
||
<i class="fas fa-exclamation-triangle"></i>
|
||
发现 <strong>${differentFields.length}</strong> 个字段存在差异
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
html += '<div class="table-responsive"><table class="table table-bordered table-hover">';
|
||
html += `
|
||
<thead class="table-dark">
|
||
<tr>
|
||
<th style="width: 15%;">字段名</th>
|
||
<th style="width: 40%;">生产环境</th>
|
||
<th style="width: 40%;">测试环境</th>
|
||
<th style="width: 5%;" class="text-center">状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
// 先显示差异字段(重点突出)
|
||
if (differentFields.length > 0) {
|
||
html += '<tr class="table-danger"><td colspan="4" class="text-center fw-bold">差异字段</td></tr>';
|
||
|
||
differentFields.forEach(field => {
|
||
const proValue = proData ? proData[field] : undefined;
|
||
const testValue = testData ? testData[field] : undefined;
|
||
|
||
// 判断值的类型和差异类型
|
||
let diffType = '';
|
||
let proDisplay = '';
|
||
let testDisplay = '';
|
||
|
||
if (proValue === undefined && testValue !== undefined) {
|
||
diffType = 'missing-in-pro';
|
||
proDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
|
||
testDisplay = formatValue(testValue);
|
||
} else if (proValue !== undefined && testValue === undefined) {
|
||
diffType = 'missing-in-test';
|
||
proDisplay = formatValue(proValue);
|
||
testDisplay = '<span class="text-danger"><i class="fas fa-minus-circle"></i> 缺失</span>';
|
||
} else {
|
||
diffType = 'value-diff';
|
||
proDisplay = formatValue(proValue);
|
||
testDisplay = formatValue(testValue);
|
||
}
|
||
|
||
html += `
|
||
<tr class="table-warning">
|
||
<td>
|
||
<code class="text-danger fw-bold">${field}</code>
|
||
${getFieldTypeIcon(proValue || testValue)}
|
||
</td>
|
||
<td class="font-monospace small ${diffType === 'missing-in-pro' ? 'bg-light' : ''}" style="vertical-align: top;">${proDisplay}</td>
|
||
<td class="font-monospace small ${diffType === 'missing-in-test' ? 'bg-light' : ''}" style="vertical-align: top;">${testDisplay}</td>
|
||
<td class="text-center" style="vertical-align: middle;">
|
||
<span class="badge bg-danger">
|
||
<i class="fas fa-times"></i> 不同
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
}
|
||
|
||
// 再显示相同字段
|
||
if (identicalFields.length > 0) {
|
||
html += '<tr class="table-success"><td colspan="4" class="text-center fw-bold">相同字段</td></tr>';
|
||
|
||
identicalFields.forEach(field => {
|
||
const value = proData ? proData[field] : testData[field];
|
||
const displayValue = formatValue(value);
|
||
|
||
html += `
|
||
<tr>
|
||
<td>
|
||
<code class="text-muted">${field}</code>
|
||
${getFieldTypeIcon(value)}
|
||
</td>
|
||
<td class="font-monospace small text-muted">${displayValue}</td>
|
||
<td class="font-monospace small text-muted">${displayValue}</td>
|
||
<td class="text-center">
|
||
<span class="badge bg-success">
|
||
<i class="fas fa-check"></i> 相同
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
}
|
||
|
||
html += '</tbody></table></div>';
|
||
|
||
// 添加快速跳转按钮
|
||
if (differentFields.length > 0 && identicalFields.length > 0) {
|
||
html = `
|
||
<div class="d-flex gap-2 mb-3">
|
||
<button class="btn btn-sm btn-warning" onclick="scrollToTableSection('danger')">
|
||
<i class="fas fa-exclamation-triangle"></i> 跳转到差异字段
|
||
</button>
|
||
<button class="btn btn-sm btn-success" onclick="scrollToTableSection('success')">
|
||
<i class="fas fa-check"></i> 跳转到相同字段
|
||
</button>
|
||
</div>
|
||
` + html;
|
||
}
|
||
|
||
diffViewContainer.innerHTML = html;
|
||
}
|
||
|
||
// 格式化值显示
|
||
function formatValue(value) {
|
||
if (value === null) return '<span class="text-muted">null</span>';
|
||
if (value === undefined) return '<span class="text-muted">undefined</span>';
|
||
if (value === '') return '<span class="text-muted">(空字符串)</span>';
|
||
|
||
const type = typeof value;
|
||
|
||
if (type === 'object') {
|
||
// 特殊处理数组
|
||
if (Array.isArray(value)) {
|
||
// 检查是否是JSON字符串数组
|
||
let isJsonStringArray = value.length > 0 && value.every(item => {
|
||
if (typeof item === 'string') {
|
||
try {
|
||
if ((item.startsWith('{') && item.endsWith('}')) ||
|
||
(item.startsWith('[') && item.endsWith(']'))) {
|
||
JSON.parse(item);
|
||
return true;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
return false;
|
||
});
|
||
|
||
if (isJsonStringArray) {
|
||
// 解析并格式化JSON字符串数组
|
||
try {
|
||
const parsedArray = value.map(item => JSON.parse(item));
|
||
// 如果都有key字段,按key排序
|
||
if (parsedArray.every(item => 'key' in item)) {
|
||
parsedArray.sort((a, b) => a.key - b.key);
|
||
}
|
||
const formatted = JSON.stringify(parsedArray, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
} catch (e) {
|
||
// 如果解析失败,按原样显示
|
||
}
|
||
}
|
||
}
|
||
|
||
// 对象或数组,格式化显示
|
||
try {
|
||
const formatted = JSON.stringify(value, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
} catch (e) {
|
||
return `<pre class="mb-0 p-2 bg-light rounded">${escapeHtml(String(value))}</pre>`;
|
||
}
|
||
}
|
||
|
||
if (type === 'string') {
|
||
// 检查是否是JSON字符串
|
||
try {
|
||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||
(value.startsWith('[') && value.endsWith(']'))) {
|
||
const parsed = JSON.parse(value);
|
||
const formatted = JSON.stringify(parsed, null, 2);
|
||
return `<pre class="mb-0 p-2 bg-light rounded" style="max-height: 300px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(formatted)}</pre>`;
|
||
}
|
||
} catch (e) {
|
||
// 不是有效的JSON,作为普通字符串处理
|
||
}
|
||
|
||
// 长文本处理
|
||
if (value.length > 200) {
|
||
return `<div class="text-wrap" style="max-width: 400px; max-height: 200px; overflow-y: auto;">${escapeHtml(value)}</div>`;
|
||
}
|
||
}
|
||
|
||
return escapeHtml(String(value));
|
||
}
|
||
|
||
// 获取字段类型图标
|
||
function getFieldTypeIcon(value) {
|
||
if (value === undefined) {
|
||
return '<span class="badge bg-danger ms-2" title="字段缺失">❌</span>';
|
||
}
|
||
|
||
if (value === null) {
|
||
return '<span class="badge bg-secondary ms-2" title="空值">NULL</span>';
|
||
}
|
||
|
||
const type = typeof value;
|
||
|
||
if (Array.isArray(value)) {
|
||
return '<span class="badge bg-info ms-2" title="数组">[]</span>';
|
||
}
|
||
|
||
if (type === 'object') {
|
||
return '<span class="badge bg-primary ms-2" title="对象">{}</span>';
|
||
}
|
||
|
||
if (type === 'string') {
|
||
// 检查是否是JSON字符串
|
||
try {
|
||
if (value.startsWith('{') || value.startsWith('[')) {
|
||
JSON.parse(value);
|
||
return '<span class="badge bg-warning ms-2" title="JSON字符串">JSON</span>';
|
||
}
|
||
} catch (e) {
|
||
// 不是JSON
|
||
}
|
||
|
||
// 检查是否是日期字符串
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||
return '<span class="badge bg-secondary ms-2" title="日期">📅</span>';
|
||
}
|
||
}
|
||
|
||
if (type === 'number') {
|
||
return '<span class="badge bg-success ms-2" title="数字">#</span>';
|
||
}
|
||
|
||
if (type === 'boolean') {
|
||
return '<span class="badge bg-dark ms-2" title="布尔值">⚡</span>';
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
// 滚动到表格指定部分
|
||
function scrollToTableSection(sectionType) {
|
||
const targetRow = document.querySelector(`#diffView .table-${sectionType}`);
|
||
if (targetRow) {
|
||
targetRow.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
// 规范化值用于比较(处理JSON键顺序问题)
|
||
function normalizeValue(value) {
|
||
if (typeof value === 'string') {
|
||
// 尝试解析JSON字符串
|
||
try {
|
||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||
(value.startsWith('[') && value.endsWith(']'))) {
|
||
value = JSON.parse(value);
|
||
} else {
|
||
return value;
|
||
}
|
||
} catch (e) {
|
||
return value;
|
||
}
|
||
}
|
||
|
||
if (value === null || typeof value !== 'object') {
|
||
return value;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
// 对于数组,需要特殊处理
|
||
// 1. 先尝试解析每个元素(可能是JSON字符串)
|
||
const parsedArray = value.map(item => {
|
||
if (typeof item === 'string') {
|
||
try {
|
||
if ((item.startsWith('{') && item.endsWith('}')) ||
|
||
(item.startsWith('[') && item.endsWith(']'))) {
|
||
return JSON.parse(item);
|
||
}
|
||
} catch (e) {
|
||
// 保持原始字符串
|
||
}
|
||
}
|
||
return item;
|
||
});
|
||
|
||
// 2. 检查是否是对象数组,如果是,可能需要按某个键排序
|
||
if (parsedArray.length > 0 && typeof parsedArray[0] === 'object' && !Array.isArray(parsedArray[0])) {
|
||
// 检查是否所有元素都有相同的键
|
||
const firstKeys = Object.keys(parsedArray[0]).sort().join(',');
|
||
const allSameStructure = parsedArray.every(item =>
|
||
typeof item === 'object' &&
|
||
!Array.isArray(item) &&
|
||
Object.keys(item).sort().join(',') === firstKeys
|
||
);
|
||
|
||
if (allSameStructure) {
|
||
// 如果有 key 字段,按 key 排序
|
||
if ('key' in parsedArray[0]) {
|
||
return parsedArray
|
||
.map(item => normalizeValue(item))
|
||
.sort((a, b) => {
|
||
const aKey = a.key;
|
||
const bKey = b.key;
|
||
if (aKey < bKey) return -1;
|
||
if (aKey > bKey) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
// 否则按整个对象的字符串表示排序
|
||
return parsedArray
|
||
.map(item => normalizeValue(item))
|
||
.sort((a, b) => {
|
||
const aStr = JSON.stringify(a);
|
||
const bStr = JSON.stringify(b);
|
||
if (aStr < bStr) return -1;
|
||
if (aStr > bStr) return 1;
|
||
return 0;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 递归处理每个元素
|
||
return parsedArray.map(item => normalizeValue(item));
|
||
}
|
||
|
||
// 对象:按键排序
|
||
const sortedObj = {};
|
||
Object.keys(value).sort().forEach(key => {
|
||
sortedObj[key] = normalizeValue(value[key]);
|
||
});
|
||
return sortedObj;
|
||
}
|
||
|
||
// 渲染树形视图
|
||
function renderTreeView(containerId, data) {
|
||
const container = document.getElementById(containerId);
|
||
|
||
// 确保容器存在
|
||
if (!container) {
|
||
console.error('树形视图容器不存在:', containerId);
|
||
return;
|
||
}
|
||
|
||
if (!data) {
|
||
container.innerHTML = '<p class="text-muted">无数据</p>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = createTreeNode(data, '');
|
||
}
|
||
|
||
// 创建树形节点
|
||
function createTreeNode(obj, indent = '') {
|
||
if (obj === null) return '<span class="text-muted">null</span>';
|
||
if (obj === undefined) return '<span class="text-muted">undefined</span>';
|
||
|
||
const type = typeof obj;
|
||
|
||
if (type !== 'object') {
|
||
return `<span class="tree-value">${escapeHtml(JSON.stringify(obj))}</span>`;
|
||
}
|
||
|
||
if (Array.isArray(obj)) {
|
||
if (obj.length === 0) return '<span class="text-muted">[]</span>';
|
||
|
||
let html = '<div class="tree-array">[<br>';
|
||
obj.forEach((item, index) => {
|
||
html += `${indent} <span class="tree-index">${index}:</span> ${createTreeNode(item, indent + ' ')}`;
|
||
if (index < obj.length - 1) html += ',';
|
||
html += '<br>';
|
||
});
|
||
html += `${indent}]</div>`;
|
||
return html;
|
||
}
|
||
|
||
const keys = Object.keys(obj);
|
||
if (keys.length === 0) return '<span class="text-muted">{}</span>';
|
||
|
||
let html = '<div class="tree-object">{<br>';
|
||
keys.forEach((key, index) => {
|
||
html += `${indent} <span class="tree-key">"${key}"</span>: ${createTreeNode(obj[key], indent + ' ')}`;
|
||
if (index < keys.length - 1) html += ',';
|
||
html += '<br>';
|
||
});
|
||
html += `${indent}}</div>`;
|
||
return html;
|
||
}
|
||
|
||
// 设置同步滚动
|
||
function setupSyncScroll(tabType) {
|
||
if (tabType === 'formatted') {
|
||
// 格式化视图的两个pre元素
|
||
const proPre = document.querySelector('#formatted .col-md-6:first-child pre');
|
||
const testPre = document.querySelector('#formatted .col-md-6:last-child pre');
|
||
|
||
if (proPre && testPre) {
|
||
let syncing = false;
|
||
|
||
proPre.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
testPre.scrollTop = proPre.scrollTop;
|
||
testPre.scrollLeft = proPre.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
|
||
testPre.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
proPre.scrollTop = testPre.scrollTop;
|
||
proPre.scrollLeft = testPre.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
}
|
||
} else if (tabType === 'tree') {
|
||
// 树形视图的两个div元素
|
||
const proTree = document.getElementById('proTreeView');
|
||
const testTree = document.getElementById('testTreeView');
|
||
|
||
if (proTree && testTree) {
|
||
let syncing = false;
|
||
|
||
proTree.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
testTree.scrollTop = proTree.scrollTop;
|
||
testTree.scrollLeft = proTree.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
|
||
testTree.addEventListener('scroll', () => {
|
||
if (!syncing) {
|
||
syncing = true;
|
||
proTree.scrollTop = testTree.scrollTop;
|
||
proTree.scrollLeft = testTree.scrollLeft;
|
||
setTimeout(() => syncing = false, 10);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// 显示比较总结
|
||
function displayComparisonSummary(summary) {
|
||
const summaryContainer = document.getElementById('comparison-summary');
|
||
|
||
if (!summary) {
|
||
summaryContainer.innerHTML = '<p class="text-muted">无总结数据</p>';
|
||
return;
|
||
}
|
||
|
||
const qualityLevel = summary.data_quality.quality_level;
|
||
|
||
const html = `
|
||
<!-- 概览统计 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 数据概览</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row text-center">
|
||
<div class="col-4">
|
||
<h4 class="text-primary">${summary.overview.total_keys_queried}</h4>
|
||
<small>查询Key总数</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-success">${summary.overview.identical_records}</h4>
|
||
<small>相同记录</small>
|
||
</div>
|
||
<div class="col-4">
|
||
<h4 class="text-warning">${summary.overview.different_records}</h4>
|
||
<small>差异记录</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-award"></i> 数据质量评估</h6>
|
||
</div>
|
||
<div class="card-body text-center">
|
||
<h3 class="text-${qualityLevel.color}">${qualityLevel.level}</h3>
|
||
<p class="mb-2">${qualityLevel.description}</p>
|
||
<div class="progress mb-2">
|
||
<div class="progress-bar bg-${qualityLevel.color}" role="progressbar"
|
||
style="width: ${summary.data_quality.consistency_score}%">
|
||
${summary.data_quality.consistency_score}%
|
||
</div>
|
||
</div>
|
||
<small>数据一致性评分</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细分析 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-percentage"></i> 百分比统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据一致性:</span>
|
||
<strong class="text-success">${summary.percentages.data_consistency}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-success" style="width: ${summary.percentages.data_consistency}%"></div>
|
||
</div>
|
||
</div>
|
||
<div class="mb-3">
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据差异率:</span>
|
||
<strong class="text-warning">${summary.percentages.data_differences}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-warning" style="width: ${summary.percentages.data_differences}%"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div class="d-flex justify-content-between">
|
||
<span>数据缺失率:</span>
|
||
<strong class="text-danger">${summary.percentages.missing_rate}%</strong>
|
||
</div>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-danger" style="width: ${summary.percentages.missing_rate}%"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-list-alt"></i> 字段差异TOP5</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
${summary.field_analysis.most_different_fields.length > 0 ?
|
||
summary.field_analysis.most_different_fields.map(([field, count]) => `
|
||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||
<code>${field}</code>
|
||
<span class="badge bg-danger">${count}次</span>
|
||
</div>
|
||
`).join('') :
|
||
'<p class="text-muted">无字段差异统计</p>'
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-lightbulb"></i> 改进建议</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(recommendation => `
|
||
<li class="mb-2">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${recommendation}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
summaryContainer.innerHTML = html;
|
||
}
|
||
|
||
// 显示字段差异统计
|
||
function displayFieldStats(fieldStats) {
|
||
const fieldStatsContainer = document.getElementById('field_stats');
|
||
|
||
if (!Object.keys(fieldStats).length) {
|
||
fieldStatsContainer.innerHTML = '<p class="text-muted">无字段差异统计</p>';
|
||
return;
|
||
}
|
||
|
||
let html = '<div class="table-responsive"><table class="table table-striped">';
|
||
html += '<thead><tr><th>字段名</th><th>差异次数</th><th>占比</th></tr></thead><tbody>';
|
||
|
||
const totalDiffs = Object.values(fieldStats).reduce((sum, count) => sum + count, 0);
|
||
|
||
Object.entries(fieldStats)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
const percentage = ((count / totalDiffs) * 100).toFixed(1);
|
||
html += `
|
||
<tr>
|
||
<td><code>${field}</code></td>
|
||
<td><span class="badge bg-danger">${count}</span></td>
|
||
<td>${percentage}%</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
fieldStatsContainer.innerHTML = html;
|
||
}
|
||
|
||
// 清空结果
|
||
function clearResults() {
|
||
document.getElementById('results').style.display = 'none';
|
||
document.getElementById('query_values').value = '';
|
||
currentResults = null;
|
||
showAlert('info', '结果已清空');
|
||
}
|
||
|
||
// 显示查询历史对话框
|
||
async function showQueryHistoryDialog() {
|
||
try {
|
||
const response = await fetch('/api/query-history');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
let historyList = '';
|
||
|
||
if (result.data.length === 0) {
|
||
historyList = '<p class="text-muted text-center">暂无查询历史记录</p>';
|
||
} else {
|
||
// 添加批量操作控制栏
|
||
historyList += `
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="selectAllQueryHistory" onchange="toggleAllQueryHistorySelection()">
|
||
<label class="form-check-label" for="selectAllQueryHistory">
|
||
全选
|
||
</label>
|
||
</div>
|
||
<div>
|
||
<button class="btn btn-danger btn-sm" id="batchDeleteQueryHistoryBtn" onclick="batchDeleteQueryHistory()" disabled>
|
||
<i class="fas fa-trash"></i> 批量删除 (<span id="selectedQueryHistoryCount">0</span>)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
result.data.forEach(history => {
|
||
const createdDate = new Date(history.created_at).toLocaleString();
|
||
const consistencyRate = history.total_keys > 0 ?
|
||
Math.round((history.identical_count / history.total_keys) * 100) : 0;
|
||
const safeName = (history.name || '').replace(/'/g, "\\'");
|
||
|
||
historyList += `
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div class="form-check me-3 mt-1">
|
||
<input class="form-check-input query-history-checkbox" type="checkbox" value="${history.id}" onchange="updateQueryHistorySelectionCount()">
|
||
</div>
|
||
<div class="flex-grow-1">
|
||
<h6 class="card-title mb-1">
|
||
${history.name}
|
||
<span class="badge ${history.query_type === 'sharding' ? 'bg-primary' : 'bg-secondary'} ms-2">
|
||
${history.query_type === 'sharding' ? '分表查询' : '单表查询'}
|
||
</span>
|
||
</h6>
|
||
<p class="card-text text-muted small mb-2">${history.description || '无描述'}</p>
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-clock"></i> ${createdDate}<br>
|
||
<i class="fas fa-key"></i> 查询: ${history.total_keys}个Key
|
||
</small>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<small class="text-muted">
|
||
<i class="fas fa-chart-line"></i> 一致性: ${consistencyRate}%<br>
|
||
<i class="fas fa-exclamation-triangle"></i> 差异: ${history.differences_count}处
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ms-3">
|
||
<button class="btn btn-sm btn-primary me-1" onclick="loadHistoryRecord(${history.id})" title="加载配置">
|
||
<i class="fas fa-download"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-info me-1" onclick="viewHistoryDetail(${history.id})" title="查看详情">
|
||
<i class="fas fa-eye"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-success me-1" onclick="loadHistoryResults(${history.id})" title="查看结果">
|
||
<i class="fas fa-chart-bar"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-danger" onclick="deleteHistoryRecord(${history.id}, '${safeName}')" title="删除">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="queryHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-history"></i> 查询历史记录管理
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body" style="max-height: 600px; overflow-y: auto;">
|
||
${historyList}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('queryHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('queryHistoryModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载查询历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载查询历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载历史记录配置
|
||
async function loadHistoryRecord(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
|
||
// 填充生产环境配置
|
||
document.getElementById('pro_cluster_name').value = history.pro_config.cluster_name || '';
|
||
document.getElementById('pro_datacenter').value = history.pro_config.datacenter || '';
|
||
document.getElementById('pro_hosts').value = (history.pro_config.hosts || []).join(',');
|
||
document.getElementById('pro_port').value = history.pro_config.port || 9042;
|
||
document.getElementById('pro_username').value = history.pro_config.username || '';
|
||
document.getElementById('pro_password').value = history.pro_config.password || '';
|
||
document.getElementById('pro_keyspace').value = history.pro_config.keyspace || '';
|
||
document.getElementById('pro_table').value = history.pro_config.table || '';
|
||
|
||
// 填充测试环境配置
|
||
document.getElementById('test_cluster_name').value = history.test_config.cluster_name || '';
|
||
document.getElementById('test_datacenter').value = history.test_config.datacenter || '';
|
||
document.getElementById('test_hosts').value = (history.test_config.hosts || []).join(',');
|
||
document.getElementById('test_port').value = history.test_config.port || 9042;
|
||
document.getElementById('test_username').value = history.test_config.username || '';
|
||
document.getElementById('test_password').value = history.test_config.password || '';
|
||
document.getElementById('test_keyspace').value = history.test_config.keyspace || '';
|
||
document.getElementById('test_table').value = history.test_config.table || '';
|
||
|
||
// 填充查询配置 - 使用与配置组加载相同的逻辑
|
||
if (history.query_config) {
|
||
console.log('loadHistoryRecord - 设置keys字段:', history.query_config.keys);
|
||
const keysElement = document.getElementById('keys');
|
||
console.log('loadHistoryRecord - keys元素:', keysElement);
|
||
|
||
if (keysElement) {
|
||
keysElement.value = (history.query_config.keys || []).join(',');
|
||
console.log('loadHistoryRecord - keys字段设置后的值:', keysElement.value);
|
||
|
||
// 强制触发多种事件以确保UI更新
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
keysElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
|
||
// 添加延迟更新,确保模态框关闭后再次设置
|
||
setTimeout(() => {
|
||
keysElement.value = (history.query_config.keys || []).join(',');
|
||
keysElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
console.log('loadHistoryRecord - 延迟设置后的值:', keysElement.value);
|
||
}, 100);
|
||
} else {
|
||
console.error('loadHistoryRecord - 未找到keys输入元素!');
|
||
}
|
||
|
||
const fieldsToCompareElement = document.getElementById('fields_to_compare');
|
||
const excludeFieldsElement = document.getElementById('exclude_fields');
|
||
|
||
if (fieldsToCompareElement) {
|
||
fieldsToCompareElement.value = (history.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
fieldsToCompareElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
fieldsToCompareElement.value = (history.query_config.fields_to_compare || []).join(',');
|
||
fieldsToCompareElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
|
||
if (excludeFieldsElement) {
|
||
excludeFieldsElement.value = (history.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
excludeFieldsElement.dispatchEvent(new Event('change', { bubbles: true }));
|
||
setTimeout(() => {
|
||
excludeFieldsElement.value = (history.query_config.exclude_fields || []).join(',');
|
||
excludeFieldsElement.dispatchEvent(new Event('input', { bubbles: true }));
|
||
}, 100);
|
||
}
|
||
} else {
|
||
console.warn('loadHistoryRecord - 查询配置为空或未定义');
|
||
document.getElementById('keys').value = '';
|
||
document.getElementById('fields_to_compare').value = '';
|
||
document.getElementById('exclude_fields').value = '';
|
||
}
|
||
|
||
// 填充查询Key值
|
||
document.getElementById('query_values').value = (history.query_keys || []).join('\n');
|
||
|
||
// 处理分表配置(如果存在)
|
||
if (history.query_type === 'sharding' && history.sharding_config) {
|
||
// 启用分表模式
|
||
document.getElementById('enableSharding').checked = true;
|
||
toggleShardingMode();
|
||
|
||
// 填充分表配置
|
||
document.getElementById('use_sharding_for_pro').checked = history.sharding_config.use_sharding_for_pro || false;
|
||
document.getElementById('use_sharding_for_test').checked = history.sharding_config.use_sharding_for_test || false;
|
||
document.getElementById('pro_interval_seconds').value = history.sharding_config.pro_interval_seconds || 604800;
|
||
document.getElementById('pro_table_count').value = history.sharding_config.pro_table_count || 14;
|
||
document.getElementById('test_interval_seconds').value = history.sharding_config.test_interval_seconds || 604800;
|
||
document.getElementById('test_table_count').value = history.sharding_config.test_table_count || 14;
|
||
} else {
|
||
// 禁用分表模式
|
||
document.getElementById('enableSharding').checked = false;
|
||
toggleShardingMode();
|
||
}
|
||
|
||
// 关闭历史记录modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
|
||
modal.hide();
|
||
|
||
const queryTypeDesc = history.query_type === 'sharding' ? '分表查询' : '单表查询';
|
||
showAlert('success', `${queryTypeDesc}历史记录 "${history.name}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 查看历史记录详情
|
||
async function viewHistoryDetail(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
const history = result.data;
|
||
const summary = history.results_summary;
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="historyDetailModal" tabindex="-1">
|
||
<div class="modal-dialog modal-xl">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-eye"></i> 查询历史详情 - ${history.name}
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- 基本信息 -->
|
||
<div class="row mb-4">
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-info-circle"></i> 基本信息</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>名称:</strong> ${history.name}</p>
|
||
<p><strong>描述:</strong> ${history.description || '无描述'}</p>
|
||
<p><strong>创建时间:</strong> ${new Date(history.created_at).toLocaleString()}</p>
|
||
<p><strong>执行时间:</strong> ${history.execution_time.toFixed(2)}秒</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-bar"></i> 查询统计</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<p><strong>查询Key数:</strong> ${history.total_keys}</p>
|
||
<p><strong>相同记录:</strong> ${history.identical_count}</p>
|
||
<p><strong>差异记录:</strong> ${history.differences_count}</p>
|
||
<p><strong>一致性:</strong> ${history.total_keys > 0 ? Math.round((history.identical_count / history.total_keys) * 100) : 0}%</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置信息 -->
|
||
<div class="card mb-4">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-cogs"></i> 查询配置</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="row">
|
||
<div class="col-md-4">
|
||
<p><strong>主键字段:</strong> ${(history.query_config.keys || []).join(', ')}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>比较字段:</strong> ${(history.query_config.fields_to_compare || []).join(', ') || '全部字段'}</p>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<p><strong>排除字段:</strong> ${(history.query_config.exclude_fields || []).join(', ') || '无'}</p>
|
||
</div>
|
||
</div>
|
||
<div class="mt-3">
|
||
<p><strong>查询Key值:</strong></p>
|
||
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${(history.query_keys || []).join('\n')}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 结果总结 -->
|
||
${summary ? `
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h6><i class="fas fa-chart-pie"></i> 结果总结</h6>
|
||
</div>
|
||
<div class="card-body">
|
||
<!-- 数据质量评估 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<h6>数据质量评估</h6>
|
||
<div class="d-flex align-items-center">
|
||
<span class="badge bg-${summary.data_quality?.quality_level?.color || 'secondary'} me-2">
|
||
${summary.data_quality?.quality_level?.level || 'N/A'}
|
||
</span>
|
||
<span class="text-muted">${summary.data_quality?.quality_level?.description || ''}</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<h6>一致性评分</h6>
|
||
<div class="progress">
|
||
<div class="progress-bar bg-${summary.data_quality?.quality_level?.color || 'secondary'}"
|
||
style="width: ${summary.data_quality?.consistency_score || 0}%">
|
||
${summary.data_quality?.consistency_score || 0}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 改进建议 -->
|
||
${summary.recommendations?.length > 0 ? `
|
||
<div>
|
||
<h6>改进建议</h6>
|
||
<ul class="list-unstyled">
|
||
${summary.recommendations.map(rec => `
|
||
<li class="mb-1">
|
||
<i class="fas fa-arrow-right text-primary me-2"></i>
|
||
${rec}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-primary" onclick="loadHistoryRecord(${history.id}); bootstrap.Modal.getInstance(document.getElementById('historyDetailModal')).hide();">
|
||
<i class="fas fa-download"></i> 加载配置
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('historyDetailModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('historyDetailModal'));
|
||
modal.show();
|
||
|
||
} else {
|
||
showAlert('danger', '加载历史记录详情失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '加载历史记录详情失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 加载历史记录结果
|
||
async function loadHistoryResults(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}/results`);
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 设置当前结果数据
|
||
currentResults = result.data;
|
||
|
||
// 确保必要的数组字段存在
|
||
if (!currentResults.differences) currentResults.differences = [];
|
||
if (!currentResults.identical_results) currentResults.identical_results = [];
|
||
if (!currentResults.raw_pro_data) currentResults.raw_pro_data = [];
|
||
if (!currentResults.raw_test_data) currentResults.raw_test_data = [];
|
||
if (!currentResults.field_diff_count) currentResults.field_diff_count = {};
|
||
if (!currentResults.summary) currentResults.summary = {};
|
||
|
||
// 根据查询类型设置分表模式
|
||
if (result.data.history_info && result.data.history_info.query_type === 'sharding') {
|
||
isShardingMode = true;
|
||
document.getElementById('enableSharding').checked = true;
|
||
toggleShardingMode();
|
||
} else {
|
||
isShardingMode = false;
|
||
document.getElementById('enableSharding').checked = false;
|
||
toggleShardingMode();
|
||
}
|
||
|
||
// 显示结果
|
||
displayResults(result.data);
|
||
|
||
// 关闭历史记录modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('queryHistoryModal'));
|
||
if (modal) {
|
||
modal.hide();
|
||
}
|
||
|
||
const queryTypeDesc = (result.data.history_info && result.data.history_info.query_type === 'sharding') ? '分表查询' : '单表查询';
|
||
const historyName = (result.data.history_info && result.data.history_info.name) || '未知';
|
||
showAlert('success', `${queryTypeDesc}历史记录结果 "${historyName}" 加载成功`);
|
||
} else {
|
||
showAlert('danger', result.error || '加载历史记录结果失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('加载历史记录结果失败:', error);
|
||
showAlert('danger', '加载历史记录结果失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 删除历史记录
|
||
async function deleteHistoryRecord(historyId, historyName) {
|
||
if (!confirm(`确定要删除历史记录 "${historyName}" 吗?此操作不可撤销。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/query-history/${historyId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
|
||
// 重新显示历史记录对话框
|
||
setTimeout(() => {
|
||
showQueryHistoryDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '删除历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '删除历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 显示保存历史记录对话框
|
||
function showSaveHistoryDialog() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '请先执行查询操作,然后再保存历史记录');
|
||
return;
|
||
}
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="saveHistoryModal" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-bookmark"></i> 保存查询历史记录
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="mb-3">
|
||
<label class="form-label">历史记录名称 <span class="text-danger">*</span></label>
|
||
<input type="text" class="form-control" id="historyRecordName" placeholder="请输入历史记录名称">
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">描述</label>
|
||
<textarea class="form-control" id="historyRecordDesc" rows="3" placeholder="请输入历史记录描述(可选)"></textarea>
|
||
</div>
|
||
<div class="alert alert-info">
|
||
<small>
|
||
<strong>当前查询统计:</strong><br>
|
||
• 查询Key数: ${currentResults.total_keys}<br>
|
||
• 相同记录: ${currentResults.identical_results.length}<br>
|
||
• 差异记录: ${currentResults.differences.length}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="saveHistoryRecord()">
|
||
<i class="fas fa-check"></i> 保存
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('saveHistoryModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('saveHistoryModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 保存历史记录
|
||
async function saveHistoryRecord() {
|
||
const name = document.getElementById('historyRecordName').value.trim();
|
||
const description = document.getElementById('historyRecordDesc').value.trim();
|
||
|
||
if (!name) {
|
||
showAlert('warning', '请输入历史记录名称');
|
||
return;
|
||
}
|
||
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可保存的查询结果');
|
||
return;
|
||
}
|
||
|
||
const config = getCurrentConfig();
|
||
|
||
// 确定查询类型和获取相应配置
|
||
let queryType = 'single';
|
||
let shardingConfig = null;
|
||
|
||
if (isShardingMode) {
|
||
queryType = 'sharding';
|
||
shardingConfig = getShardingConfig().sharding_config;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/query-history', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
description: description,
|
||
pro_config: config.pro_config,
|
||
test_config: config.test_config,
|
||
query_config: config.query_config,
|
||
query_keys: config.values,
|
||
results_summary: currentResults.summary || {},
|
||
execution_time: 0.0,
|
||
total_keys: currentResults.total_keys,
|
||
differences_count: currentResults.differences.length,
|
||
identical_count: currentResults.identical_results.length,
|
||
// 新增分表相关字段
|
||
sharding_config: shardingConfig,
|
||
query_type: queryType
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('saveHistoryModal'));
|
||
modal.hide();
|
||
|
||
showAlert('success', result.message);
|
||
} else {
|
||
showAlert('danger', result.error || '保存历史记录失败');
|
||
}
|
||
} catch (error) {
|
||
showAlert('danger', '保存历史记录失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 导出配置
|
||
function exportConfig() {
|
||
const config = getCurrentConfig();
|
||
const dataStr = JSON.stringify(config, null, 2);
|
||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = 'db_compare_config.json';
|
||
link.click();
|
||
}
|
||
|
||
// 导出结果
|
||
function exportResults() {
|
||
if (!currentResults) {
|
||
showAlert('warning', '没有可导出的结果');
|
||
return;
|
||
}
|
||
|
||
let output = `数据库查询比对结果\n`;
|
||
output += `生成时间: ${new Date().toLocaleString()}\n`;
|
||
output += `=`.repeat(50) + '\n\n';
|
||
|
||
output += `统计信息:\n`;
|
||
output += `- 总Key数量: ${currentResults.total_keys}\n`;
|
||
output += `- 生产表记录: ${currentResults.pro_count}\n`;
|
||
output += `- 测试表记录: ${currentResults.test_count}\n`;
|
||
output += `- 发现差异: ${currentResults.differences.length}\n\n`;
|
||
|
||
if (currentResults.differences.length > 0) {
|
||
output += `差异详情:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
|
||
currentResults.differences.forEach((diff, index) => {
|
||
output += `差异 #${index + 1}:\n`;
|
||
output += `主键: ${JSON.stringify(diff.key)}\n`;
|
||
|
||
if (diff.message) {
|
||
output += `消息: ${diff.message}\n`;
|
||
} else {
|
||
output += `字段: ${diff.field}\n`;
|
||
output += `生产表值: ${diff.pro_value}\n`;
|
||
output += `测试表值: ${diff.test_value}\n`;
|
||
}
|
||
output += '\n';
|
||
});
|
||
|
||
// 字段差异统计
|
||
if (Object.keys(currentResults.field_diff_count).length > 0) {
|
||
output += `字段差异统计:\n`;
|
||
output += `-`.repeat(30) + '\n';
|
||
Object.entries(currentResults.field_diff_count)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.forEach(([field, count]) => {
|
||
output += `${field}: ${count}次\n`;
|
||
});
|
||
}
|
||
}
|
||
|
||
const dataBlob = new Blob([output], {type: 'text/plain;charset=utf-8'});
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(dataBlob);
|
||
link.download = `db_compare_result_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.txt`;
|
||
link.click();
|
||
}
|
||
|
||
// 显示一键导入对话框
|
||
function showImportDialog(env) {
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
|
||
const modalContent = `
|
||
<div class="modal fade" id="importModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title">
|
||
<i class="fas fa-download"></i> 一键导入${envName}配置
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p class="text-muted mb-3">请粘贴配置数据,支持以下格式:</p>
|
||
<div class="mb-3">
|
||
<small class="text-muted">示例格式:</small>
|
||
<pre class="bg-light p-2 rounded small">clusterName: "Example Cluster"
|
||
clusterNodes: "127.0.0.1,127.0.0.2"
|
||
port: 9042
|
||
datacenter: "dc1"
|
||
username: "cassandra"
|
||
password: "example_password"
|
||
keyspace: "example_keyspace"
|
||
table: "example_table"</pre>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label">配置数据:</label>
|
||
<textarea class="form-control" id="importConfigData" rows="8"
|
||
placeholder="请粘贴配置数据...">
|
||
</textarea>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||
<button type="button" class="btn btn-primary" onclick="importConfig('${env}')">
|
||
<i class="fas fa-check"></i> 导入配置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 移除现有modal
|
||
const existingModal = document.getElementById('importModal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
// 添加新modal到body
|
||
document.body.insertAdjacentHTML('beforeend', modalContent);
|
||
|
||
// 显示modal
|
||
const modal = new bootstrap.Modal(document.getElementById('importModal'));
|
||
modal.show();
|
||
}
|
||
|
||
// 导入配置数据
|
||
function importConfig(env) {
|
||
const configData = document.getElementById('importConfigData').value.trim();
|
||
|
||
if (!configData) {
|
||
showAlert('warning', '请输入配置数据');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 解析配置数据
|
||
const config = parseConfigData(configData);
|
||
|
||
// 根据环境类型填充对应的表单字段
|
||
const prefix = env === 'pro' ? 'pro' : 'test';
|
||
|
||
if (config.clusterName) {
|
||
document.getElementById(`${prefix}_cluster_name`).value = config.clusterName;
|
||
}
|
||
if (config.datacenter) {
|
||
document.getElementById(`${prefix}_datacenter`).value = config.datacenter;
|
||
}
|
||
if (config.clusterNodes) {
|
||
document.getElementById(`${prefix}_hosts`).value = config.clusterNodes;
|
||
}
|
||
if (config.port) {
|
||
document.getElementById(`${prefix}_port`).value = config.port;
|
||
}
|
||
if (config.username) {
|
||
document.getElementById(`${prefix}_username`).value = config.username;
|
||
}
|
||
if (config.password) {
|
||
document.getElementById(`${prefix}_password`).value = config.password;
|
||
}
|
||
if (config.keyspace) {
|
||
document.getElementById(`${prefix}_keyspace`).value = config.keyspace;
|
||
}
|
||
if (config.table) {
|
||
document.getElementById(`${prefix}_table`).value = config.table;
|
||
}
|
||
|
||
// 关闭modal
|
||
const modal = bootstrap.Modal.getInstance(document.getElementById('importModal'));
|
||
modal.hide();
|
||
|
||
const envName = env === 'pro' ? '生产环境' : '测试环境';
|
||
showAlert('success', `${envName}配置导入成功!`);
|
||
|
||
} catch (error) {
|
||
showAlert('danger', '配置解析失败:' + error.message);
|
||
}
|
||
}
|
||
|
||
// 解析配置数据
|
||
function parseConfigData(configText) {
|
||
const config = {};
|
||
|
||
try {
|
||
// 尝试按JSON格式解析
|
||
const jsonConfig = JSON.parse(configText);
|
||
return jsonConfig;
|
||
} catch (e) {
|
||
// 如果不是JSON,按YAML格式解析
|
||
const lines = configText.split('\n');
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
||
continue; // 跳过空行和注释
|
||
}
|
||
|
||
const colonIndex = trimmedLine.indexOf(':');
|
||
if (colonIndex === -1) {
|
||
continue; // 跳过无效行
|
||
}
|
||
|
||
const key = trimmedLine.substring(0, colonIndex).trim();
|
||
let value = trimmedLine.substring(colonIndex + 1).trim();
|
||
|
||
// 处理带引号的值
|
||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
|
||
// 尝试转换数字
|
||
if (!isNaN(value) && value !== '') {
|
||
config[key] = parseInt(value);
|
||
} else {
|
||
config[key] = value;
|
||
}
|
||
}
|
||
|
||
return config;
|
||
}
|
||
}
|
||
|
||
// 显示提示信息
|
||
function showAlert(type, message) {
|
||
// 移除已存在的提示
|
||
const existingAlert = document.querySelector('.alert');
|
||
if (existingAlert) {
|
||
existingAlert.remove();
|
||
}
|
||
|
||
const alertHtml = `
|
||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||
${message}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
`;
|
||
|
||
// 插入到页面顶部
|
||
const container = document.querySelector('.container-fluid');
|
||
container.insertAdjacentHTML('afterbegin', alertHtml);
|
||
|
||
// 自动隐藏
|
||
setTimeout(() => {
|
||
const alert = document.querySelector('.alert');
|
||
if (alert) {
|
||
alert.remove();
|
||
}
|
||
}, 5000);
|
||
}
|
||
|
||
// 查询日志相关功能
|
||
let allQueryLogs = []; // 存储所有日志
|
||
|
||
async function refreshQueryLogs() {
|
||
// 这个函数现在不再使用,因为查询日志已经独立到模态框中
|
||
// 如果需要刷新查询日志,请使用 refreshModalQueryLogs()
|
||
console.warn('refreshQueryLogs() 已弃用,请使用 refreshModalQueryLogs()');
|
||
}
|
||
|
||
// 显示分组查询日志 - 已弃用
|
||
function displayGroupedQueryLogs(groupedLogs) {
|
||
// 这个函数已弃用,查询日志现在使用独立模态框显示
|
||
console.warn('displayGroupedQueryLogs() 已弃用,查询日志现在使用独立模态框显示');
|
||
return;
|
||
}
|
||
|
||
// 计算持续时间
|
||
function calculateDuration(startTime, endTime) {
|
||
try {
|
||
const start = new Date(startTime);
|
||
const end = new Date(endTime);
|
||
const diffMs = end - start;
|
||
|
||
if (diffMs < 1000) {
|
||
return `${diffMs}ms`;
|
||
} else if (diffMs < 60000) {
|
||
return `${(diffMs / 1000).toFixed(1)}秒`;
|
||
} else {
|
||
const minutes = Math.floor(diffMs / 60000);
|
||
const seconds = Math.floor((diffMs % 60000) / 1000);
|
||
return `${minutes}分${seconds}秒`;
|
||
}
|
||
} catch (e) {
|
||
return '未知';
|
||
}
|
||
}
|
||
|
||
// 格式化日志消息
|
||
function formatLogMessage(message) {
|
||
// HTML转义
|
||
let formatted = escapeHtml(message);
|
||
|
||
// 高亮SQL查询语句
|
||
if (formatted.includes('执行查询SQL:')) {
|
||
formatted = formatted.replace(/执行查询SQL: (SELECT.*?);/g,
|
||
'执行查询SQL: <br><code class="bg-light d-block p-2 text-dark" style="font-size: 0.85em;">$1;</code>');
|
||
}
|
||
|
||
// 高亮重要信息
|
||
formatted = formatted.replace(/([\d.]+秒)/g, '<strong class="text-success">$1</strong>');
|
||
formatted = formatted.replace(/(返回记录数=\d+)/g, '<strong class="text-info">$1</strong>');
|
||
formatted = formatted.replace(/(执行时间=[\d.]+秒)/g, '<strong class="text-success">$1</strong>');
|
||
formatted = formatted.replace(/(分表索引=\d+)/g, '<strong class="text-warning">$1</strong>');
|
||
formatted = formatted.replace(/(表名=\w+)/g, '<strong class="text-primary">$1</strong>');
|
||
|
||
return formatted;
|
||
}
|
||
|
||
function filterLogsByLevel() {
|
||
// 这个函数已弃用,因为查询日志现在使用独立模态框显示
|
||
console.warn('filterLogsByLevel() 已弃用,请使用 filterModalLogsByLevel()');
|
||
}
|
||
|
||
async function clearQueryLogs() {
|
||
if (!confirm('确定要清空所有查询日志吗?这将删除内存和数据库中的所有日志记录。')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/query-logs?clear_db=true', {
|
||
method: 'DELETE'
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
// 如果查询日志模态框是打开的,清空显示内容
|
||
const modalLogsContainer = document.getElementById('modal-query-logs');
|
||
if (modalLogsContainer) {
|
||
modalLogsContainer.innerHTML = '<div class="alert alert-info">查询日志已清空</div>';
|
||
}
|
||
showAlert('success', result.message);
|
||
} else {
|
||
showAlert('danger', '清空查询日志失败: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('清空查询日志失败:', error);
|
||
showAlert('danger', '清空查询日志失败');
|
||
}
|
||
}
|
||
|
||
// 清理旧日志
|
||
async function cleanupOldLogs() {
|
||
const days = prompt('请输入要保留的天数(默认30天):', '30');
|
||
if (days === null) return; // 用户取消
|
||
|
||
const daysToKeep = parseInt(days) || 30;
|
||
if (daysToKeep <= 0) {
|
||
showAlert('warning', '保留天数必须大于0');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`确定要清理超过 ${daysToKeep} 天的旧日志吗?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/query-logs/cleanup', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
days_to_keep: daysToKeep
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', result.message);
|
||
// 刷新模态框中的日志显示
|
||
refreshModalQueryLogs();
|
||
} else {
|
||
showAlert('danger', '清理旧日志失败: ' + result.error);
|
||
}
|
||
} catch (error) {
|
||
console.error('清理旧日志失败:', error);
|
||
showAlert('danger', '清理旧日志失败');
|
||
}
|
||
}
|
||
|
||
// 显示查询日志对话框
|
||
function showQueryLogsDialog() {
|
||
// 打开模态框
|
||
const modal = new bootstrap.Modal(document.getElementById('queryLogsModal'));
|
||
modal.show();
|
||
|
||
// 加载查询日志
|
||
refreshModalQueryLogs();
|
||
}
|
||
|
||
// 刷新模态框中的查询日志
|
||
async function refreshModalQueryLogs() {
|
||
try {
|
||
const response = await fetch('/api/query-logs?grouped=true&from_db=true');
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
if (result.grouped) {
|
||
displayModalGroupedQueryLogs(result.data);
|
||
} else {
|
||
// 兼容旧版本的平铺显示
|
||
displayModalQueryLogs(result.data);
|
||
}
|
||
} else {
|
||
document.getElementById('modal-query-logs').innerHTML = '<div class="alert alert-warning">无法获取查询日志</div>';
|
||
}
|
||
} catch (error) {
|
||
console.error('获取查询日志失败:', error);
|
||
document.getElementById('modal-query-logs').innerHTML = '<div class="alert alert-danger">获取查询日志失败</div>';
|
||
}
|
||
}
|
||
|
||
// 在模态框中显示分组查询日志
|
||
function displayModalGroupedQueryLogs(groupedLogs) {
|
||
const container = document.getElementById('modal-query-logs');
|
||
|
||
if (!groupedLogs || groupedLogs.length === 0) {
|
||
container.innerHTML = '<div class="alert alert-info">暂无查询日志</div>';
|
||
return;
|
||
}
|
||
|
||
// 获取当前的过滤器状态,如果复选框不存在则默认显示所有
|
||
const showInfo = document.getElementById('modal-log-level-info')?.checked ?? true;
|
||
const showWarning = document.getElementById('modal-log-level-warning')?.checked ?? true;
|
||
const showError = document.getElementById('modal-log-level-error')?.checked ?? true;
|
||
|
||
let html = '';
|
||
|
||
// 为每个批次生成折叠面板
|
||
groupedLogs.forEach((batchData, index) => {
|
||
const [batchId, logs] = batchData;
|
||
const isExpanded = index === groupedLogs.length - 1; // 默认展开最新批次
|
||
const collapseId = `modal-batch-${batchId}`;
|
||
|
||
// 过滤日志
|
||
const filteredLogs = logs.filter(log => {
|
||
return (log.level === 'INFO' && showInfo) ||
|
||
(log.level === 'WARNING' && showWarning) ||
|
||
(log.level === 'ERROR' && showError);
|
||
});
|
||
|
||
// 如果过滤后没有日志,跳过这个批次
|
||
if (filteredLogs.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 统计批次信息(基于原始日志)
|
||
const logCounts = {
|
||
INFO: logs.filter(log => log.level === 'INFO').length,
|
||
WARNING: logs.filter(log => log.level === 'WARNING').length,
|
||
ERROR: logs.filter(log => log.level === 'ERROR').length
|
||
};
|
||
|
||
const totalLogs = logs.length;
|
||
const filteredCount = filteredLogs.length;
|
||
const firstLog = logs[0];
|
||
const lastLog = logs[logs.length - 1];
|
||
const duration = firstLog && lastLog ? calculateDuration(firstLog.timestamp, lastLog.timestamp) : '0秒';
|
||
|
||
// 确定批次类型和状态
|
||
const hasErrors = logCounts.ERROR > 0;
|
||
const hasWarnings = logCounts.WARNING > 0;
|
||
const batchStatus = hasErrors ? 'danger' : hasWarnings ? 'warning' : 'success';
|
||
const batchIcon = hasErrors ? 'fas fa-times-circle' : hasWarnings ? 'fas fa-exclamation-triangle' : 'fas fa-check-circle';
|
||
|
||
// 提取查询类型
|
||
const batchTypeMatch = firstLog?.message.match(/开始(\w+)查询批次/) || firstLog?.message.match(/🚀 开始执行(\w+)数据比较/);
|
||
const batchType = batchTypeMatch ? batchTypeMatch[1] : '未知';
|
||
|
||
// 提取并格式化查询日期
|
||
const queryDate = firstLog ? new Date(firstLog.timestamp).toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
}) : '未知时间';
|
||
|
||
// 检查是否有关联的历史记录ID
|
||
const historyId = firstLog?.history_id;
|
||
const historyBadge = historyId ?
|
||
`<span class="badge bg-secondary ms-1" title="关联历史记录ID: ${historyId}">
|
||
ID:${historyId}
|
||
<button class="btn btn-sm btn-outline-light ms-1 p-0" style="border: none; font-size: 10px;"
|
||
onclick="loadHistoryFromLogs(${historyId})" title="加载此历史记录">
|
||
<i class="fas fa-external-link-alt"></i>
|
||
</button>
|
||
</span>` : '';
|
||
|
||
html += `
|
||
<div class="card mb-3 border-${batchStatus}">
|
||
<div class="card-header bg-${batchStatus} bg-opacity-10" data-bs-toggle="collapse" data-bs-target="#${collapseId}" style="cursor: pointer;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div class="d-flex flex-column">
|
||
<div class="d-flex align-items-center mb-1">
|
||
<i class="${batchIcon} text-${batchStatus} me-2"></i>
|
||
<strong>${batchType}查询批次 ${batchId}</strong>
|
||
<span class="badge bg-primary ms-2">${filteredCount}/${totalLogs}条日志</span>
|
||
${historyBadge}
|
||
</div>
|
||
<div class="d-flex align-items-center">
|
||
<small class="text-muted">
|
||
<i class="fas fa-calendar-alt me-1"></i>
|
||
查询时间: ${queryDate}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex align-items-center">
|
||
<small class="text-muted me-3">
|
||
<i class="fas fa-clock"></i> 持续时间: ${duration}
|
||
</small>
|
||
<div>
|
||
${logCounts.INFO > 0 ? `<span class="badge bg-info me-1">${logCounts.INFO} INFO</span>` : ''}
|
||
${logCounts.WARNING > 0 ? `<span class="badge bg-warning me-1">${logCounts.WARNING} WARN</span>` : ''}
|
||
${logCounts.ERROR > 0 ? `<span class="badge bg-danger me-1">${logCounts.ERROR} ERROR</span>` : ''}
|
||
</div>
|
||
<i class="fas fa-chevron-down ms-2"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="collapse ${isExpanded ? 'show' : ''}" id="${collapseId}">
|
||
<div class="card-body p-0">
|
||
<div class="log-entries" style="max-height: 400px; overflow-y: auto;">
|
||
`;
|
||
|
||
// 显示过滤后的日志条目
|
||
filteredLogs.forEach(log => {
|
||
const levelClass = log.level === 'ERROR' ? 'danger' :
|
||
log.level === 'WARNING' ? 'warning' : 'info';
|
||
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
|
||
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
|
||
|
||
// 简化时间戳显示
|
||
const timeOnly = log.timestamp ? (log.timestamp.split(' ')[1] || log.timestamp) : '未知时间';
|
||
|
||
html += `
|
||
<div class="log-entry p-2 border-bottom" data-level="${log.level}">
|
||
<div class="d-flex align-items-start">
|
||
<span class="badge bg-${levelClass} me-2" style="min-width: 60px;">
|
||
<i class="${levelIcon}"></i> ${log.level}
|
||
</span>
|
||
<small class="text-muted me-2 flex-shrink-0" style="min-width: 80px;">
|
||
${timeOnly}
|
||
</small>
|
||
<div class="log-message flex-grow-1" style="word-wrap: break-word; white-space: pre-wrap;">
|
||
${formatLogMessage(log.message || '无消息内容')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
if (html === '') {
|
||
html = '<div class="alert alert-warning">没有符合过滤条件的日志</div>';
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 过滤模态框中的日志级别
|
||
function filterModalLogsByLevel() {
|
||
// 刷新分组日志显示,应用过滤器
|
||
refreshModalQueryLogs();
|
||
}
|
||
|
||
// 显示平铺的查询日志(兼容性)
|
||
function displayModalQueryLogs(logs) {
|
||
const container = document.getElementById('modal-query-logs');
|
||
|
||
if (!logs || logs.length === 0) {
|
||
container.innerHTML = '<div class="alert alert-info">暂无查询日志</div>';
|
||
return;
|
||
}
|
||
|
||
const showInfo = document.getElementById('modal-log-level-info').checked;
|
||
const showWarning = document.getElementById('modal-log-level-warning').checked;
|
||
const showError = document.getElementById('modal-log-level-error').checked;
|
||
|
||
let html = '<div class="list-group">';
|
||
|
||
logs.forEach(log => {
|
||
const shouldShow = (log.level === 'INFO' && showInfo) ||
|
||
(log.level === 'WARNING' && showWarning) ||
|
||
(log.level === 'ERROR' && showError);
|
||
|
||
if (shouldShow) {
|
||
const levelClass = log.level === 'ERROR' ? 'danger' :
|
||
log.level === 'WARNING' ? 'warning' : 'info';
|
||
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
|
||
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
|
||
|
||
html += `
|
||
<div class="list-group-item">
|
||
<div class="d-flex align-items-start">
|
||
<span class="badge bg-${levelClass} me-2">
|
||
<i class="${levelIcon}"></i> ${log.level}
|
||
</span>
|
||
<small class="text-muted me-2">${log.timestamp}</small>
|
||
<div class="flex-grow-1">${formatLogMessage(log.message)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
html += '</div>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 从查询日志模态框中加载历史记录
|
||
function loadHistoryFromLogs(historyId) {
|
||
// 先关闭查询日志模态框
|
||
const logsModal = bootstrap.Modal.getInstance(document.getElementById('queryLogsModal'));
|
||
if (logsModal) {
|
||
logsModal.hide();
|
||
}
|
||
|
||
// 延迟一下再加载历史记录,确保模态框已关闭
|
||
setTimeout(() => {
|
||
loadHistoryResults(historyId);
|
||
}, 300);
|
||
}
|
||
|
||
// 根据历史记录ID获取相关查询日志
|
||
async function loadQueryLogsByHistory(historyId) {
|
||
try {
|
||
const response = await fetch(`/api/query-logs/history/${historyId}`);
|
||
const result = await response.json();
|
||
|
||
if (result.success && result.data) {
|
||
// 显示关联的查询日志
|
||
displayHistoryRelatedLogs(result.data, historyId);
|
||
return result.data;
|
||
} else {
|
||
console.warn('获取历史记录相关日志失败:', result.error);
|
||
return [];
|
||
}
|
||
} catch (error) {
|
||
console.error('获取历史记录相关查询日志失败:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// 显示历史记录相关的查询日志
|
||
function displayHistoryRelatedLogs(groupedLogs, historyId) {
|
||
const container = document.getElementById('query-logs');
|
||
|
||
if (!groupedLogs || groupedLogs.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="alert alert-info">
|
||
<i class="fas fa-info-circle"></i>
|
||
历史记录 ID ${historyId} 没有关联的查询日志
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
let html = `
|
||
<div class="alert alert-primary">
|
||
<i class="fas fa-link"></i>
|
||
显示历史记录 ID ${historyId} 的相关查询日志 (共 ${groupedLogs.length} 个批次)
|
||
</div>
|
||
`;
|
||
|
||
// 使用与refreshQueryLogs相同的显示逻辑
|
||
groupedLogs.forEach((batchData, index) => {
|
||
const [batchId, logs] = batchData;
|
||
const isExpanded = true; // 历史记录日志默认展开所有批次
|
||
const collapseId = `history-batch-${batchId}`;
|
||
|
||
// 统计批次信息
|
||
const logCounts = {
|
||
INFO: logs.filter(log => log.level === 'INFO').length,
|
||
WARNING: logs.filter(log => log.level === 'WARNING').length,
|
||
ERROR: logs.filter(log => log.level === 'ERROR').length
|
||
};
|
||
|
||
const totalLogs = logs.length;
|
||
const firstLog = logs[0];
|
||
const lastLog = logs[logs.length - 1];
|
||
const duration = firstLog && lastLog ? calculateDuration(firstLog.timestamp, lastLog.timestamp) : '0秒';
|
||
|
||
// 确定批次类型和状态
|
||
const hasErrors = logCounts.ERROR > 0;
|
||
const hasWarnings = logCounts.WARNING > 0;
|
||
const batchStatus = hasErrors ? 'danger' : hasWarnings ? 'warning' : 'success';
|
||
const batchIcon = hasErrors ? 'fas fa-times-circle' : hasWarnings ? 'fas fa-exclamation-triangle' : 'fas fa-check-circle';
|
||
|
||
// 提取查询类型
|
||
const batchTypeMatch = firstLog?.message.match(/开始(\w+)查询批次/);
|
||
const batchType = batchTypeMatch ? batchTypeMatch[1] : '未知';
|
||
|
||
// 提取并格式化查询日期
|
||
const queryDate = firstLog ? new Date(firstLog.timestamp).toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
}) : '未知时间';
|
||
|
||
html += `
|
||
<div class="card mb-3 border-${batchStatus}">
|
||
<div class="card-header bg-${batchStatus} bg-opacity-10" data-bs-toggle="collapse" data-bs-target="#${collapseId}" style="cursor: pointer;">
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div class="d-flex flex-column">
|
||
<div class="d-flex align-items-center mb-1">
|
||
<i class="${batchIcon} text-${batchStatus} me-2"></i>
|
||
<strong>${batchType}查询批次 ${batchId}</strong>
|
||
<span class="badge bg-primary ms-2">${totalLogs}条日志</span>
|
||
<span class="badge bg-secondary ms-1">历史记录</span>
|
||
</div>
|
||
<div class="d-flex align-items-center">
|
||
<small class="text-muted">
|
||
<i class="fas fa-calendar-alt me-1"></i>
|
||
查询时间: ${queryDate}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex align-items-center">
|
||
<small class="text-muted me-3">
|
||
<i class="fas fa-clock"></i> 持续时间: ${duration}
|
||
</small>
|
||
<div>
|
||
${logCounts.INFO > 0 ? `<span class="badge bg-info me-1">${logCounts.INFO} INFO</span>` : ''}
|
||
${logCounts.WARNING > 0 ? `<span class="badge bg-warning me-1">${logCounts.WARNING} WARN</span>` : ''}
|
||
${logCounts.ERROR > 0 ? `<span class="badge bg-danger me-1">${logCounts.ERROR} ERROR</span>` : ''}
|
||
</div>
|
||
<i class="fas fa-chevron-down ms-2"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="collapse ${isExpanded ? 'show' : ''}" id="${collapseId}">
|
||
<div class="card-body p-0">
|
||
<div class="log-entries" style="max-height: 400px; overflow-y: auto;">
|
||
`;
|
||
|
||
// 显示该批次的日志条目
|
||
logs.forEach(log => {
|
||
const shouldShow = true; // 历史记录日志显示所有级别
|
||
|
||
if (shouldShow) {
|
||
const levelClass = log.level === 'ERROR' ? 'danger' :
|
||
log.level === 'WARNING' ? 'warning' : 'info';
|
||
const levelIcon = log.level === 'ERROR' ? 'fas fa-times-circle' :
|
||
log.level === 'WARNING' ? 'fas fa-exclamation-triangle' : 'fas fa-info-circle';
|
||
|
||
// 简化时间戳显示
|
||
const timeOnly = log.timestamp.split(' ')[1] || log.timestamp;
|
||
|
||
html += `
|
||
<div class="log-entry p-2 border-bottom">
|
||
<div class="d-flex align-items-start">
|
||
<span class="badge bg-${levelClass} me-2">
|
||
<i class="${levelIcon}"></i> ${log.level}
|
||
</span>
|
||
<small class="text-muted me-2 flex-shrink-0" style="min-width: 80px;">
|
||
${timeOnly}
|
||
</small>
|
||
<div class="log-message flex-grow-1">
|
||
${formatLogMessage(log.message)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
|
||
html += `
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 在查询执行后自动刷新日志
|
||
function autoRefreshLogsAfterQuery() {
|
||
// 延迟一下确保后端日志已经记录
|
||
setTimeout(() => {
|
||
// 只有当查询日志模态框打开时才刷新
|
||
const queryLogsModal = document.getElementById('queryLogsModal');
|
||
if (queryLogsModal && queryLogsModal.classList.contains('show')) {
|
||
refreshModalQueryLogs();
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
// 原始数据相关变量
|
||
let currentRawData = null;
|
||
let filteredRawData = [];
|
||
let currentRawDataPage = 1;
|
||
let rawDataPageSize = 20;
|
||
|
||
// 显示原始数据
|
||
function displayRawData(results) {
|
||
currentRawData = results;
|
||
|
||
// 添加调试信息
|
||
console.log('displayRawData - results.query_config:', results.query_config);
|
||
console.log('displayRawData - 当前数据键:', Object.keys(results));
|
||
|
||
// 合并生产和测试环境数据
|
||
const combinedData = [];
|
||
|
||
if (results.raw_pro_data) {
|
||
results.raw_pro_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'production',
|
||
data: record,
|
||
displayName: '生产环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
if (results.raw_test_data) {
|
||
results.raw_test_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'test',
|
||
data: record,
|
||
displayName: '测试环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
// 按环境排序,生产环境在前
|
||
combinedData.sort((a, b) => {
|
||
if (a.environment === 'production' && b.environment === 'test') return -1;
|
||
if (a.environment === 'test' && b.environment === 'production') return 1;
|
||
return 0;
|
||
});
|
||
|
||
filteredRawData = combinedData;
|
||
currentRawDataPage = 1;
|
||
renderRawDataContent();
|
||
}
|
||
|
||
// 渲染原始数据内容
|
||
function renderRawDataContent() {
|
||
const container = document.getElementById('raw-data-content');
|
||
|
||
if (!filteredRawData.length) {
|
||
container.innerHTML = '<div class="alert alert-info">没有原始数据可显示</div>';
|
||
return;
|
||
}
|
||
|
||
// 分页计算
|
||
const totalPages = Math.ceil(filteredRawData.length / rawDataPageSize);
|
||
const startIndex = (currentRawDataPage - 1) * rawDataPageSize;
|
||
const endIndex = startIndex + rawDataPageSize;
|
||
const currentPageData = filteredRawData.slice(startIndex, endIndex);
|
||
|
||
let html = `
|
||
<!-- 分页控制 -->
|
||
<div class="row mb-3">
|
||
<div class="col-md-6">
|
||
<div class="d-flex align-items-center">
|
||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||
<select class="form-select form-select-sm me-2" style="width: auto;" onchange="changeRawDataPageSize(this.value)">
|
||
<option value="10" ${rawDataPageSize == 10 ? 'selected' : ''}>10条</option>
|
||
<option value="20" ${rawDataPageSize == 20 ? 'selected' : ''}>20条</option>
|
||
<option value="50" ${rawDataPageSize == 50 ? 'selected' : ''}>50条</option>
|
||
<option value="100" ${rawDataPageSize == 100 ? 'selected' : ''}>100条</option>
|
||
</select>
|
||
<span class="ms-2 text-muted">共 ${filteredRawData.length} 条记录</span>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="d-flex justify-content-end align-items-center">
|
||
${generateRawDataPagination(currentRawDataPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// 显示数据记录
|
||
currentPageData.forEach((item, index) => {
|
||
const globalIndex = startIndex + index + 1;
|
||
const envBadgeClass = item.environment === 'production' ? 'bg-primary' : 'bg-success';
|
||
|
||
// 获取主键值用于标识 - 修复主键解析问题,支持复合主键
|
||
let keyValue = 'N/A';
|
||
|
||
// 从当前配置获取keys,因为query_config可能不在results中
|
||
const currentConfig = getCurrentConfig();
|
||
console.log('渲染原始数据 - currentConfig.query_config.keys:', currentConfig.query_config.keys);
|
||
console.log('渲染原始数据 - item.data keys:', Object.keys(item.data));
|
||
|
||
if (currentConfig && currentConfig.query_config && currentConfig.query_config.keys && currentConfig.query_config.keys.length > 0) {
|
||
const keyFields = currentConfig.query_config.keys;
|
||
const keyValues = keyFields.map(key => {
|
||
const value = item.data[key];
|
||
console.log(`查找主键字段 ${key}: `, value);
|
||
return value || 'N/A';
|
||
}).filter(val => val !== 'N/A');
|
||
|
||
if (keyValues.length > 0) {
|
||
// 复合主键显示格式:docid1,id1 或 单主键显示:value1
|
||
keyValue = keyValues.join(', ');
|
||
}
|
||
} else {
|
||
// 如果没有配置主键,尝试常见的主键字段名
|
||
const commonKeyFields = ['id', 'statusid', 'key', 'uuid', 'docid'];
|
||
for (const field of commonKeyFields) {
|
||
if (item.data[field] !== undefined) {
|
||
keyValue = item.data[field];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 格式化JSON数据
|
||
const jsonData = JSON.stringify(item.data, null, 2);
|
||
|
||
html += `
|
||
<div class="card mb-3 border-${item.environment === 'production' ? 'primary' : 'success'}">
|
||
<div class="card-header bg-light">
|
||
<div class="row align-items-center">
|
||
<div class="col">
|
||
<strong>记录 #${globalIndex}</strong>
|
||
<span class="badge ${envBadgeClass} ms-2">${item.displayName}</span>
|
||
</div>
|
||
<div class="col-auto">
|
||
<button class="btn btn-sm btn-outline-info" onclick='copyRawRecord(${JSON.stringify(JSON.stringify(item.data))})'>
|
||
<i class="fas fa-copy"></i> 复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p class="mb-0 mt-2"><strong>主键:</strong> ${keyValue}</p>
|
||
</div>
|
||
<div class="collapse show" id="rawCollapse${globalIndex}">
|
||
<div class="card-body">
|
||
<pre class="bg-light p-3 rounded" style="max-height: 500px; overflow-y: auto; font-size: 0.9em; line-height: 1.4;">${escapeHtml(jsonData)}</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
// 底部分页
|
||
if (totalPages > 1) {
|
||
html += `
|
||
<div class="row mt-3">
|
||
<div class="col-12">
|
||
<div class="d-flex justify-content-center">
|
||
${generateRawDataPagination(currentRawDataPage, totalPages)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// 生成原始数据分页导航
|
||
function generateRawDataPagination(currentPage, totalPages) {
|
||
if (totalPages <= 1) return '';
|
||
|
||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||
|
||
// 上一页
|
||
html += `
|
||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToRawDataPage(${currentPage - 1})">
|
||
<i class="fas fa-chevron-left"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
// 页码
|
||
const startPage = Math.max(1, currentPage - 2);
|
||
const endPage = Math.min(totalPages, currentPage + 2);
|
||
|
||
if (startPage > 1) {
|
||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToRawDataPage(1)">1</a></li>';
|
||
if (startPage > 2) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
html += `
|
||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||
<a class="page-link" href="#" onclick="goToRawDataPage(${i})">${i}</a>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
if (endPage < totalPages) {
|
||
if (endPage < totalPages - 1) {
|
||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||
}
|
||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToRawDataPage(${totalPages})">${totalPages}</a></li>`;
|
||
}
|
||
|
||
// 下一页
|
||
html += `
|
||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||
<a class="page-link" href="#" onclick="goToRawDataPage(${currentPage + 1})">
|
||
<i class="fas fa-chevron-right"></i>
|
||
</a>
|
||
</li>
|
||
`;
|
||
|
||
html += '</ul></nav>';
|
||
return html;
|
||
}
|
||
|
||
// 跳转到指定原始数据页面
|
||
function goToRawDataPage(page) {
|
||
const totalPages = Math.ceil(filteredRawData.length / rawDataPageSize);
|
||
if (page < 1 || page > totalPages) return;
|
||
|
||
currentRawDataPage = page;
|
||
renderRawDataContent();
|
||
}
|
||
|
||
// 改变原始数据每页显示数量
|
||
function changeRawDataPageSize(newSize) {
|
||
rawDataPageSize = parseInt(newSize);
|
||
currentRawDataPage = 1;
|
||
renderRawDataContent();
|
||
}
|
||
|
||
// 过滤原始数据(按环境)
|
||
function filterRawData() {
|
||
if (!currentRawData) return;
|
||
|
||
const showPro = document.getElementById('showProData').checked;
|
||
const showTest = document.getElementById('showTestData').checked;
|
||
|
||
let combinedData = [];
|
||
|
||
if (showPro && currentRawData.raw_pro_data) {
|
||
currentRawData.raw_pro_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'production',
|
||
data: record,
|
||
displayName: '生产环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
if (showTest && currentRawData.raw_test_data) {
|
||
currentRawData.raw_test_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'test',
|
||
data: record,
|
||
displayName: '测试环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
// 按环境排序
|
||
combinedData.sort((a, b) => {
|
||
if (a.environment === 'production' && b.environment === 'test') return -1;
|
||
if (a.environment === 'test' && b.environment === 'production') return 1;
|
||
return 0;
|
||
});
|
||
|
||
filteredRawData = combinedData;
|
||
currentRawDataPage = 1;
|
||
renderRawDataContent();
|
||
}
|
||
|
||
// 搜索原始数据
|
||
function searchRawData(searchTerm) {
|
||
if (!currentRawData) return;
|
||
|
||
const showPro = document.getElementById('showProData').checked;
|
||
const showTest = document.getElementById('showTestData').checked;
|
||
|
||
let combinedData = [];
|
||
|
||
if (showPro && currentRawData.raw_pro_data) {
|
||
currentRawData.raw_pro_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'production',
|
||
data: record,
|
||
displayName: '生产环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
if (showTest && currentRawData.raw_test_data) {
|
||
currentRawData.raw_test_data.forEach(record => {
|
||
combinedData.push({
|
||
environment: 'test',
|
||
data: record,
|
||
displayName: '测试环境'
|
||
});
|
||
});
|
||
}
|
||
|
||
if (searchTerm.trim()) {
|
||
const term = searchTerm.toLowerCase();
|
||
combinedData = combinedData.filter(item => {
|
||
// 搜索所有字段值
|
||
return Object.values(item.data).some(value =>
|
||
String(value).toLowerCase().includes(term)
|
||
);
|
||
});
|
||
}
|
||
|
||
// 按环境排序
|
||
combinedData.sort((a, b) => {
|
||
if (a.environment === 'production' && b.environment === 'test') return -1;
|
||
if (a.environment === 'test' && b.environment === 'production') return 1;
|
||
return 0;
|
||
});
|
||
|
||
filteredRawData = combinedData;
|
||
currentRawDataPage = 1;
|
||
renderRawDataContent();
|
||
}
|
||
|
||
// 复制原始记录
|
||
function copyRawRecord(recordStr) {
|
||
try {
|
||
const record = JSON.parse(recordStr);
|
||
const formattedData = JSON.stringify(record, null, 2);
|
||
|
||
navigator.clipboard.writeText(formattedData).then(() => {
|
||
showAlert('success', '原始记录已复制到剪贴板');
|
||
}).catch(err => {
|
||
console.error('复制失败:', err);
|
||
showAlert('danger', '复制失败,请手动选择复制');
|
||
});
|
||
} catch (error) {
|
||
showAlert('danger', '复制失败: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 格式化复合主键显示
|
||
function formatCompositeKey(key) {
|
||
if (!key) {
|
||
return 'N/A';
|
||
}
|
||
|
||
if (typeof key === 'object' && !Array.isArray(key)) {
|
||
// 复合主键情况:显示为 "docid: value1, id: value2" 格式
|
||
return Object.entries(key)
|
||
.map(([field, value]) => `${field}: ${value}`)
|
||
.join(', ');
|
||
} else {
|
||
// 单主键或其他情况:使用JSON格式
|
||
return JSON.stringify(key);
|
||
}
|
||
}
|
||
|
||
// 切换全选Cassandra查询历史记录
|
||
function toggleAllQueryHistorySelection() {
|
||
const selectAllCheckbox = document.getElementById('selectAllQueryHistory');
|
||
const historyCheckboxes = document.querySelectorAll('.query-history-checkbox');
|
||
|
||
historyCheckboxes.forEach(checkbox => {
|
||
checkbox.checked = selectAllCheckbox.checked;
|
||
});
|
||
|
||
updateQueryHistorySelectionCount();
|
||
}
|
||
|
||
// 更新Cassandra查询历史记录选择数量
|
||
function updateQueryHistorySelectionCount() {
|
||
const selectedCheckboxes = document.querySelectorAll('.query-history-checkbox:checked');
|
||
const count = selectedCheckboxes.length;
|
||
const totalCheckboxes = document.querySelectorAll('.query-history-checkbox');
|
||
|
||
// 更新显示计数
|
||
const countSpan = document.getElementById('selectedQueryHistoryCount');
|
||
if (countSpan) {
|
||
countSpan.textContent = count;
|
||
}
|
||
|
||
// 更新批量删除按钮状态
|
||
const batchDeleteBtn = document.getElementById('batchDeleteQueryHistoryBtn');
|
||
if (batchDeleteBtn) {
|
||
batchDeleteBtn.disabled = count === 0;
|
||
}
|
||
|
||
// 更新全选复选框状态
|
||
const selectAllCheckbox = document.getElementById('selectAllQueryHistory');
|
||
if (selectAllCheckbox) {
|
||
if (count === 0) {
|
||
selectAllCheckbox.indeterminate = false;
|
||
selectAllCheckbox.checked = false;
|
||
} else if (count === totalCheckboxes.length) {
|
||
selectAllCheckbox.indeterminate = false;
|
||
selectAllCheckbox.checked = true;
|
||
} else {
|
||
selectAllCheckbox.indeterminate = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 批量删除Cassandra查询历史记录
|
||
async function batchDeleteQueryHistory() {
|
||
const selectedCheckboxes = document.querySelectorAll('.query-history-checkbox:checked');
|
||
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||
|
||
if (selectedIds.length === 0) {
|
||
showAlert('warning', '请选择要删除的历史记录');
|
||
return;
|
||
}
|
||
|
||
if (!confirm(`确定要删除选中的 ${selectedIds.length} 条历史记录吗?此操作不可恢复。`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/query-history/batch-delete', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
history_ids: selectedIds
|
||
})
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('success', `成功删除 ${result.deleted_count} 条历史记录`);
|
||
// 重新显示历史记录列表
|
||
setTimeout(() => {
|
||
showQueryHistoryDialog();
|
||
}, 500);
|
||
} else {
|
||
showAlert('danger', result.error || '批量删除失败');
|
||
}
|
||
} catch (error) {
|
||
console.error('批量删除Cassandra查询历史记录失败:', error);
|
||
showAlert('danger', `批量删除失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// 按主键分组差异数据
|
||
function groupDifferencesByKey(differences) {
|
||
const grouped = {};
|
||
differences.forEach(diff => {
|
||
const keyStr = typeof diff.key === 'object' ? JSON.stringify(diff.key) : diff.key;
|
||
if (!grouped[keyStr]) {
|
||
grouped[keyStr] = [];
|
||
}
|
||
grouped[keyStr].push(diff);
|
||
});
|
||
return grouped;
|
||
}
|
||
|
||
// 按字段筛选差异
|
||
let currentFieldFilter = '';
|
||
|
||
function filterByField(field) {
|
||
currentFieldFilter = field;
|
||
|
||
// 更新按钮状态
|
||
const buttons = document.querySelectorAll('.btn-group button');
|
||
buttons.forEach(btn => {
|
||
btn.classList.remove('active', 'btn-outline-primary');
|
||
btn.classList.add('btn-outline-secondary');
|
||
});
|
||
|
||
// 设置当前选中的按钮
|
||
if (field === '') {
|
||
buttons[0].classList.remove('btn-outline-secondary');
|
||
buttons[0].classList.add('btn-outline-primary', 'active');
|
||
} else {
|
||
buttons.forEach(btn => {
|
||
if (btn.textContent.includes(field + ' (')) {
|
||
btn.classList.remove('btn-outline-secondary');
|
||
btn.classList.add('btn-outline-primary', 'active');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 筛选差异数据(考虑搜索条件)
|
||
let baseResults = field === '' ?
|
||
currentResults.differences :
|
||
currentResults.differences.filter(diff => diff.field === field);
|
||
|
||
// 如果有搜索条件,应用搜索过滤
|
||
if (currentSearchTerm && currentSearchTerm.trim()) {
|
||
const term = currentSearchTerm.toLowerCase();
|
||
filteredDifferenceResults = baseResults.filter(diff => {
|
||
const keyStr = JSON.stringify(diff.key).toLowerCase();
|
||
if (keyStr.includes(term)) return true;
|
||
if (diff.field && diff.field.toLowerCase().includes(term)) return true;
|
||
if (diff.pro_value && String(diff.pro_value).toLowerCase().includes(term)) return true;
|
||
if (diff.test_value && String(diff.test_value).toLowerCase().includes(term)) return true;
|
||
if (diff.message && diff.message.toLowerCase().includes(term)) return true;
|
||
return false;
|
||
});
|
||
} else {
|
||
filteredDifferenceResults = baseResults;
|
||
}
|
||
|
||
// 重置页码并重新显示
|
||
currentDifferencePage = 1;
|
||
displayDifferences();
|
||
} |