初始化项目
This commit is contained in:
132
CLAUDE.md
Normal file
132
CLAUDE.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目架构
|
||||
|
||||
这是一个基于 Flask 的数据库查询比对工具,用于比较 Cassandra 数据库中生产环境和测试环境的数据差异。
|
||||
|
||||
### 核心组件架构
|
||||
|
||||
**后端 (Flask)**
|
||||
- `app.py`: 主应用文件,包含所有API端点和数据处理逻辑
|
||||
- 数据库连接管理(Cassandra + SQLite)
|
||||
- 查询执行和结果比对算法
|
||||
- 配置组管理(CRUD操作)
|
||||
- JSON字段特殊处理和数组比较逻辑
|
||||
- `config_groups.db`: SQLite数据库,存储用户保存的配置组
|
||||
|
||||
**前端 (原生JavaScript + Bootstrap)**
|
||||
- `templates/db_compare.html`: 主界面模板,包含配置表单和结果展示
|
||||
- `templates/index.html`: 工具集合首页
|
||||
- `static/js/app.js`: 核心前端逻辑
|
||||
- 配置管理和表单处理
|
||||
- 差异结果的分页展示系统
|
||||
- 原生数据展示(多种视图模式:格式化、原始、差异对比、树形)
|
||||
- 高级错误处理和用户反馈
|
||||
|
||||
### 关键功能模块
|
||||
|
||||
**数据比对引擎**
|
||||
- 支持复杂JSON字段的深度比较
|
||||
- 数组字段的顺序无关比较
|
||||
- 字段级别的差异统计和分析
|
||||
- 数据质量评估和建议生成
|
||||
|
||||
**用户界面特性**
|
||||
- 分页系统(差异记录和相同记录)
|
||||
- 实时搜索和过滤
|
||||
- 原生数据展示(JSON语法高亮、树形视图)
|
||||
- 配置导入导出和管理
|
||||
- 详细的错误诊断和故障排查指南
|
||||
|
||||
## 开发相关命令
|
||||
|
||||
### 环境设置
|
||||
```bash
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 运行应用(默认端口5000)
|
||||
python app.py
|
||||
|
||||
# 自定义端口运行
|
||||
# 修改app.py最后一行:app.run(debug=True, port=5001)
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
应用默认运行在debug模式,代码修改后自动重启。访问 http://localhost:5000 查看首页,http://localhost:5000/db-compare 使用比对工具。
|
||||
|
||||
## API架构说明
|
||||
|
||||
### 核心API端点
|
||||
- `GET /api/default-config`: 获取默认数据库配置
|
||||
- `POST /api/query`: 执行数据库查询比对(主要功能)
|
||||
- `GET|POST|DELETE /api/config-groups`: 配置组的CRUD操作
|
||||
- `POST /api/init-db`: 初始化SQLite数据库
|
||||
|
||||
### 查询比对流程
|
||||
1. 前端发送配置和Key值列表到 `/api/query`
|
||||
2. 后端创建两个Cassandra连接(生产+测试)
|
||||
3. 并行执行查询,获取原始数据
|
||||
4. 运行比较算法,生成差异报告
|
||||
5. 返回完整结果(差异、统计、原始数据)
|
||||
|
||||
## 数据结构和配置
|
||||
|
||||
### 数据库配置结构
|
||||
```javascript
|
||||
{
|
||||
pro_config: {
|
||||
cluster_name, datacenter, hosts[], port,
|
||||
username, password, keyspace, table
|
||||
},
|
||||
test_config: { /* 同上 */ },
|
||||
keys: ["主键字段名"],
|
||||
fields_to_compare: ["字段1", "字段2"], // 空数组=全部字段
|
||||
exclude_fields: ["排除字段"],
|
||||
values: ["key1", "key2", "key3"] // 要查询的Key值
|
||||
}
|
||||
```
|
||||
|
||||
### 查询结果结构
|
||||
```javascript
|
||||
{
|
||||
total_keys, pro_count, test_count,
|
||||
differences: [{ key, field, pro_value, test_value, message }],
|
||||
identical_results: [{ key, pro_fields, test_fields }],
|
||||
field_diff_count: { "field_name": count },
|
||||
raw_pro_data: [], raw_test_data: [],
|
||||
summary: { overview, percentages, field_analysis, recommendations }
|
||||
}
|
||||
```
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
### Cassandra连接处理
|
||||
- 连接包含详细的错误诊断和重试机制
|
||||
- 使用DCAwareRoundRobinPolicy避免负载均衡警告
|
||||
- 连接超时设置为10秒
|
||||
- 失败时提供网络连通性测试
|
||||
|
||||
### 前端状态管理
|
||||
- `currentResults`: 存储最新查询结果
|
||||
- 分页状态:`currentIdenticalPage`, `currentDifferencePage`
|
||||
- 过滤状态:`filteredIdenticalResults`, `filteredDifferenceResults`
|
||||
|
||||
### JSON和数组字段处理
|
||||
- `normalize_json_string()`: 标准化JSON字符串用于比较
|
||||
- `compare_array_values()`: 数组的顺序无关比较
|
||||
- `is_json_field()`: 智能检测JSON字段
|
||||
- 前端提供专门的JSON语法高亮和树形展示
|
||||
|
||||
### 错误处理策略
|
||||
- 后端:分类错误(connection_error, validation_error, query_error, system_error)
|
||||
- 前端:详细错误展示,包含配置信息、解决建议、连接测试工具
|
||||
- 提供交互式故障排查指南
|
||||
|
||||
### 性能考虑
|
||||
- 大数据集的分页处理
|
||||
- 原生数据的延迟加载
|
||||
- JSON格式化的客户端缓存
|
||||
- 搜索和过滤的防抖处理
|
21
app.py
21
app.py
@@ -36,6 +36,25 @@ def init_database():
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建查询历史表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS query_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
pro_config TEXT NOT NULL,
|
||||
test_config TEXT NOT NULL,
|
||||
query_config TEXT NOT NULL,
|
||||
query_keys TEXT NOT NULL,
|
||||
results_summary TEXT NOT NULL,
|
||||
execution_time REAL NOT NULL,
|
||||
total_keys INTEGER NOT NULL,
|
||||
differences_count INTEGER NOT NULL,
|
||||
identical_count INTEGER NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("数据库初始化完成")
|
||||
@@ -751,4 +770,4 @@ def api_init_database():
|
||||
return jsonify({'success': False, 'error': '数据库初始化失败'}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
app.run(debug=True, port=5001)
|
||||
|
583
static/js/app.js
583
static/js/app.js
@@ -3,6 +3,9 @@ let currentResults = null;
|
||||
let currentIdenticalPage = 1;
|
||||
let identicalPageSize = 10;
|
||||
let filteredIdenticalResults = [];
|
||||
let currentDifferencePage = 1;
|
||||
let differencePageSize = 10;
|
||||
let filteredDifferenceResults = [];
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -472,8 +475,12 @@ function displayResults(results) {
|
||||
filteredIdenticalResults = results.identical_results;
|
||||
currentIdenticalPage = 1;
|
||||
|
||||
// 初始化差异结果分页数据
|
||||
filteredDifferenceResults = results.differences;
|
||||
currentDifferencePage = 1;
|
||||
|
||||
// 显示各个面板内容
|
||||
displayDifferences(results.differences);
|
||||
displayDifferences();
|
||||
displayIdenticalResults();
|
||||
displayComparisonSummary(results.summary);
|
||||
|
||||
@@ -516,23 +523,76 @@ function displayStats(results) {
|
||||
}
|
||||
|
||||
// 显示差异详情
|
||||
function displayDifferences(differences) {
|
||||
function displayDifferences() {
|
||||
const differencesContainer = document.getElementById('differences');
|
||||
|
||||
if (!differences.length) {
|
||||
if (!filteredDifferenceResults.length) {
|
||||
differencesContainer.innerHTML = '<p class="text-success"><i class="fas fa-check"></i> 未发现差异</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
differences.forEach((diff, index) => {
|
||||
// 计算分页
|
||||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||||
const startIndex = (currentDifferencePage - 1) * differencePageSize;
|
||||
const endIndex = startIndex + differencePageSize;
|
||||
const currentPageData = filteredDifferenceResults.slice(startIndex, endIndex);
|
||||
|
||||
let html = `
|
||||
<!-- 分页控制 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="form-label me-2 mb-0">每页显示:</label>
|
||||
<select class="form-select form-select-sm" style="width: auto;" onchange="changeDifferencePageSize(this.value)">
|
||||
<option value="5" ${differencePageSize == 5 ? 'selected' : ''}>5条</option>
|
||||
<option value="10" ${differencePageSize == 10 ? 'selected' : ''}>10条</option>
|
||||
<option value="20" ${differencePageSize == 20 ? 'selected' : ''}>20条</option>
|
||||
<option value="50" ${differencePageSize == 50 ? 'selected' : ''}>50条</option>
|
||||
</select>
|
||||
<span class="ms-3 text-muted">共 ${filteredDifferenceResults.length} 条差异记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-end align-items-center">
|
||||
${generateDifferencePagination(currentDifferencePage, totalPages)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="搜索主键、字段名或值内容..."
|
||||
onkeyup="searchDifferenceResults(this.value)" id="differenceSearch">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 显示当前页数据
|
||||
currentPageData.forEach((diff, index) => {
|
||||
const globalIndex = startIndex + index + 1;
|
||||
if (diff.message) {
|
||||
// 记录不存在的情况
|
||||
html += `
|
||||
<div class="difference-item">
|
||||
<strong>差异 #${index + 1}</strong>
|
||||
<p><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||||
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
|
||||
<div class="difference-item card mb-3 border-warning">
|
||||
<div class="card-header bg-light">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<strong>差异 #${globalIndex}</strong>
|
||||
<span class="badge bg-warning ms-2">记录缺失</span>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
|
||||
<i class="fas fa-code"></i> 原生数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-warning"><i class="fas fa-exclamation-triangle"></i> ${diff.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
@@ -542,60 +602,72 @@ function displayDifferences(differences) {
|
||||
const jsonClass = isJson ? 'json-field' : '';
|
||||
|
||||
html += `
|
||||
<div class="difference-item">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<strong>差异 #${index + 1}</strong>
|
||||
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
|
||||
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
|
||||
<div class="difference-item card mb-3 border-danger">
|
||||
<div class="card-header bg-light">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<strong>差异 #${globalIndex}</strong>
|
||||
<span class="badge bg-danger ms-2">字段差异</span>
|
||||
${isJson ? '<span class="badge bg-info ms-2">JSON字段</span>' : ''}
|
||||
${isArray ? '<span class="badge bg-warning ms-2">数组字段</span>' : ''}
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary me-2" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#diffCollapse${globalIndex}">
|
||||
<i class="fas fa-eye"></i> 查看详情
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
|
||||
<i class="fas fa-code"></i> 原生数据
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-info" onclick="showDifferenceRawData('${escapeForJs(JSON.stringify(diff.key))}')">
|
||||
<i class="fas fa-code"></i> 原生数据
|
||||
</button>
|
||||
<p class="mb-0 mt-2"><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||||
<p class="mb-0"><strong>差异字段:</strong> ${diff.field}</p>
|
||||
</div>
|
||||
<p><strong>主键:</strong> ${JSON.stringify(diff.key)}</p>
|
||||
<p><strong>字段:</strong> ${diff.field}</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="field-header mb-2">
|
||||
<i class="fas fa-tag text-primary"></i>
|
||||
<strong>${diff.field}</strong>
|
||||
</div>
|
||||
|
||||
<!-- 生产环境数据行 -->
|
||||
<div class="row mb-2">
|
||||
<div class="col-2">
|
||||
<h6 class="text-success mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-server me-2"></i>生产环境
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.pro_value)}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(diff.pro_value)}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
<div class="collapse" id="diffCollapse${globalIndex}">
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<div class="field-header mb-2">
|
||||
<i class="fas fa-tag text-primary"></i>
|
||||
<strong>${diff.field}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试环境数据行 -->
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<h6 class="text-info mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-flask me-2"></i>测试环境
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.test_value)}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(diff.test_value)}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
|
||||
<!-- 生产环境数据行 -->
|
||||
<div class="row mb-2">
|
||||
<div class="col-2">
|
||||
<h6 class="text-success mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-server me-2"></i>生产环境
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.pro_value)}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(diff.pro_value)}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 测试环境数据行 -->
|
||||
<div class="row">
|
||||
<div class="col-2">
|
||||
<h6 class="text-info mb-0 d-flex align-items-center">
|
||||
<i class="fas fa-flask me-2"></i>测试环境
|
||||
</h6>
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0 ${jsonClass}">${escapeHtml(diff.test_value)}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(diff.test_value)}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -605,6 +677,19 @@ function displayDifferences(differences) {
|
||||
}
|
||||
});
|
||||
|
||||
// 底部分页
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -699,8 +784,24 @@ function displayIdenticalResults() {
|
||||
allFields.forEach(fieldName => {
|
||||
const proValue = proFields[fieldName] || '';
|
||||
const testValue = testFields[fieldName] || '';
|
||||
const isJson = (proValue.includes('{') || proValue.includes('[')) ||
|
||||
(testValue.includes('{') || testValue.includes('['));
|
||||
|
||||
// 更安全的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">
|
||||
@@ -719,9 +820,9 @@ function displayIdenticalResults() {
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(proValue)}</pre>
|
||||
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(String(proValue))}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(proValue)}', this)"
|
||||
onclick="copyToClipboard('${escapeForJs(String(proValue))}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -738,9 +839,9 @@ function displayIdenticalResults() {
|
||||
</div>
|
||||
<div class="col-10">
|
||||
<div class="field-container position-relative">
|
||||
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(testValue)}</pre>
|
||||
<pre class="field-value bg-light p-2 rounded mb-0">${escapeHtml(String(testValue))}</pre>
|
||||
<button class="btn btn-sm btn-outline-secondary copy-btn"
|
||||
onclick="copyToClipboard('${escapeForJs(testValue)}', this)"
|
||||
onclick="copyToClipboard('${escapeForJs(String(testValue))}', this)"
|
||||
title="复制内容">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
@@ -872,6 +973,107 @@ function searchIdenticalResults(searchTerm) {
|
||||
displayIdenticalResults();
|
||||
}
|
||||
|
||||
// 生成差异分页导航
|
||||
function generateDifferencePagination(currentPage, totalPages) {
|
||||
if (totalPages <= 1) return '';
|
||||
|
||||
let html = '<nav><ul class="pagination pagination-sm mb-0">';
|
||||
|
||||
// 上一页
|
||||
html += `
|
||||
<li class="page-item ${currentPage === 1 ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage - 1})">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// 页码
|
||||
const startPage = Math.max(1, currentPage - 2);
|
||||
const endPage = Math.min(totalPages, currentPage + 2);
|
||||
|
||||
if (startPage > 1) {
|
||||
html += '<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(1)">1</a></li>';
|
||||
if (startPage > 2) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `
|
||||
<li class="page-item ${i === currentPage ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="goToDifferencePage(${i})">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
if (endPage < totalPages) {
|
||||
if (endPage < totalPages - 1) {
|
||||
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
|
||||
}
|
||||
html += `<li class="page-item"><a class="page-link" href="#" onclick="goToDifferencePage(${totalPages})">${totalPages}</a></li>`;
|
||||
}
|
||||
|
||||
// 下一页
|
||||
html += `
|
||||
<li class="page-item ${currentPage === totalPages ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="goToDifferencePage(${currentPage + 1})">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
html += '</ul></nav>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// 跳转到指定差异页面
|
||||
function goToDifferencePage(page) {
|
||||
const totalPages = Math.ceil(filteredDifferenceResults.length / differencePageSize);
|
||||
if (page < 1 || page > totalPages) return;
|
||||
|
||||
currentDifferencePage = page;
|
||||
displayDifferences();
|
||||
}
|
||||
|
||||
// 改变差异每页显示数量
|
||||
function changeDifferencePageSize(newSize) {
|
||||
differencePageSize = parseInt(newSize);
|
||||
currentDifferencePage = 1;
|
||||
displayDifferences();
|
||||
}
|
||||
|
||||
// 搜索差异结果
|
||||
function searchDifferenceResults(searchTerm) {
|
||||
if (!currentResults) return;
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
filteredDifferenceResults = currentResults.differences;
|
||||
} else {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filteredDifferenceResults = currentResults.differences.filter(diff => {
|
||||
// 搜索主键
|
||||
const keyStr = JSON.stringify(diff.key).toLowerCase();
|
||||
if (keyStr.includes(term)) return true;
|
||||
|
||||
// 搜索字段名
|
||||
if (diff.field && diff.field.toLowerCase().includes(term)) return true;
|
||||
|
||||
// 搜索字段值
|
||||
if (diff.pro_value && String(diff.pro_value).toLowerCase().includes(term)) return true;
|
||||
if (diff.test_value && String(diff.test_value).toLowerCase().includes(term)) return true;
|
||||
|
||||
// 搜索消息
|
||||
if (diff.message && diff.message.toLowerCase().includes(term)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
currentDifferencePage = 1;
|
||||
displayDifferences();
|
||||
}
|
||||
|
||||
// 复制到剪贴板
|
||||
function copyToClipboard(text, button) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
@@ -894,7 +1096,19 @@ function copyToClipboard(text, button) {
|
||||
|
||||
// JS字符串转义
|
||||
function escapeForJs(str) {
|
||||
return str.replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
||||
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'); // 空字符
|
||||
}
|
||||
|
||||
// 显示原生数据
|
||||
@@ -919,37 +1133,76 @@ function showRawData(keyStr) {
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-code"></i> 原生查询数据
|
||||
<i class="fas fa-code"></i> 原生查询数据对比
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-success mb-0">
|
||||
<i class="fas fa-server"></i> 生产环境原生数据
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyToClipboard('${escapeForJs(JSON.stringify(proRecord, null, 2))}', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
<!-- 操作工具栏 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="viewMode" id="viewMode1" value="formatted" checked onchange="switchViewMode(this.value)">
|
||||
<label class="btn btn-outline-primary" for="viewMode1">
|
||||
<i class="fas fa-code"></i> 格式化显示
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="viewMode" id="viewMode2" value="raw" onchange="switchViewMode(this.value)">
|
||||
<label class="btn btn-outline-primary" for="viewMode2">
|
||||
<i class="fas fa-align-left"></i> 原始显示
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="viewMode" id="viewMode3" value="diff" onchange="switchViewMode(this.value)">
|
||||
<label class="btn btn-outline-primary" for="viewMode3">
|
||||
<i class="fas fa-not-equal"></i> 差异对比
|
||||
</label>
|
||||
</div>
|
||||
<div class="btn-group ms-3" role="group">
|
||||
<button class="btn btn-outline-secondary" onclick="copyAllRawData()">
|
||||
<i class="fas fa-copy"></i> 复制全部
|
||||
</button>
|
||||
<button class="btn btn-outline-info" onclick="downloadRawData()">
|
||||
<i class="fas fa-download"></i> 下载数据
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;">${escapeHtml(JSON.stringify(proRecord, null, 2))}</pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-info mb-0">
|
||||
<i class="fas fa-flask"></i> 测试环境原生数据
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyToClipboard('${escapeForJs(JSON.stringify(testRecord, null, 2))}', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 数据显示区域 -->
|
||||
<div id="rawDataContent">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-success mb-0">
|
||||
<i class="fas fa-server"></i> 生产环境原生数据
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyToClipboard('${escapeForJs(JSON.stringify(proRecord, null, 2))}', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-light p-3 rounded raw-data-container" style="max-height: 500px; overflow-y: auto;" id="proRawData">${escapeHtml(JSON.stringify(proRecord, null, 2))}</pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-info mb-0">
|
||||
<i class="fas fa-flask"></i> 测试环境原生数据
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
onclick="copyToClipboard('${escapeForJs(JSON.stringify(testRecord, null, 2))}', this)">
|
||||
<i class="fas fa-copy"></i> 复制
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-light p-3 rounded raw-data-container" style="max-height: 500px; overflow-y: auto;" id="testRawData">${escapeHtml(JSON.stringify(testRecord, null, 2))}</pre>
|
||||
</div>
|
||||
<pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto;">${escapeHtml(JSON.stringify(testRecord, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的数据存储 -->
|
||||
<div style="display: none;">
|
||||
<div id="hiddenProData">${escapeHtml(JSON.stringify(proRecord, null, 2))}</div>
|
||||
<div id="hiddenTestData">${escapeHtml(JSON.stringify(testRecord, null, 2))}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
@@ -977,6 +1230,160 @@ function showRawData(keyStr) {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换原生数据显示模式
|
||||
function switchViewMode(mode) {
|
||||
const proData = document.getElementById('hiddenProData').textContent;
|
||||
const testData = document.getElementById('hiddenTestData').textContent;
|
||||
const proContainer = document.getElementById('proRawData');
|
||||
const testContainer = document.getElementById('testRawData');
|
||||
|
||||
switch(mode) {
|
||||
case 'formatted':
|
||||
// 格式化JSON显示
|
||||
try {
|
||||
const proObj = JSON.parse(proData);
|
||||
const testObj = JSON.parse(testData);
|
||||
proContainer.innerHTML = escapeHtml(JSON.stringify(proObj, null, 2));
|
||||
testContainer.innerHTML = escapeHtml(JSON.stringify(testObj, null, 2));
|
||||
proContainer.className = 'bg-light p-3 rounded raw-data-container';
|
||||
testContainer.className = 'bg-light p-3 rounded raw-data-container';
|
||||
} catch (e) {
|
||||
proContainer.innerHTML = escapeHtml(proData);
|
||||
testContainer.innerHTML = escapeHtml(testData);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'raw':
|
||||
// 原始单行显示
|
||||
try {
|
||||
const proObj = JSON.parse(proData);
|
||||
const testObj = JSON.parse(testData);
|
||||
proContainer.innerHTML = escapeHtml(JSON.stringify(proObj));
|
||||
testContainer.innerHTML = escapeHtml(JSON.stringify(testObj));
|
||||
proContainer.className = 'bg-light p-3 rounded raw-data-container';
|
||||
testContainer.className = 'bg-light p-3 rounded raw-data-container';
|
||||
} catch (e) {
|
||||
proContainer.innerHTML = escapeHtml(proData);
|
||||
testContainer.innerHTML = escapeHtml(testData);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'diff':
|
||||
// 差异对比显示
|
||||
try {
|
||||
const proObj = JSON.parse(proData);
|
||||
const testObj = JSON.parse(testData);
|
||||
const diffResult = generateFieldDiff(proObj, testObj);
|
||||
|
||||
document.getElementById('rawDataContent').innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h6 class="mb-3"><i class="fas fa-not-equal"></i> 字段差异对比</h6>
|
||||
${diffResult}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
showAlert('warning', '无法生成差异对比:' + e.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成字段差异对比HTML
|
||||
function generateFieldDiff(proObj, testObj) {
|
||||
const allFields = new Set([...Object.keys(proObj), ...Object.keys(testObj)]);
|
||||
let html = '';
|
||||
|
||||
allFields.forEach(field => {
|
||||
const proValue = proObj[field];
|
||||
const testValue = testObj[field];
|
||||
const isEqual = JSON.stringify(proValue) === JSON.stringify(testValue);
|
||||
const isProMissing = !(field in proObj);
|
||||
const isTestMissing = !(field in testObj);
|
||||
|
||||
let statusBadge = '';
|
||||
let cardClass = '';
|
||||
|
||||
if (isProMissing) {
|
||||
statusBadge = '<span class="badge bg-warning">生产缺失</span>';
|
||||
cardClass = 'border-warning';
|
||||
} else if (isTestMissing) {
|
||||
statusBadge = '<span class="badge bg-info">测试缺失</span>';
|
||||
cardClass = 'border-info';
|
||||
} else if (isEqual) {
|
||||
statusBadge = '<span class="badge bg-success">相同</span>';
|
||||
cardClass = 'border-success';
|
||||
} else {
|
||||
statusBadge = '<span class="badge bg-danger">不同</span>';
|
||||
cardClass = 'border-danger';
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="card mb-3 ${cardClass}">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>${field}</strong>
|
||||
${statusBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-success">
|
||||
<i class="fas fa-server"></i> 生产环境
|
||||
</h6>
|
||||
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${isProMissing ? '字段不存在' : escapeHtml(JSON.stringify(proValue, null, 2))}</pre>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6 class="text-info">
|
||||
<i class="fas fa-flask"></i> 测试环境
|
||||
</h6>
|
||||
<pre class="bg-light p-2 rounded" style="max-height: 200px; overflow-y: auto;">${isTestMissing ? '字段不存在' : escapeHtml(JSON.stringify(testValue, null, 2))}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
return html || '<p class="text-muted">无字段数据可对比</p>';
|
||||
}
|
||||
|
||||
// 复制全部原生数据
|
||||
function copyAllRawData() {
|
||||
const proData = document.getElementById('hiddenProData').textContent;
|
||||
const testData = document.getElementById('hiddenTestData').textContent;
|
||||
|
||||
const allData = `生产环境数据:\n${proData}\n\n测试环境数据:\n${testData}`;
|
||||
|
||||
navigator.clipboard.writeText(allData).then(() => {
|
||||
showAlert('success', '已复制全部原生数据到剪贴板');
|
||||
}).catch(err => {
|
||||
showAlert('danger', '复制失败: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 下载原生数据
|
||||
function downloadRawData() {
|
||||
const proData = document.getElementById('hiddenProData').textContent;
|
||||
const testData = document.getElementById('hiddenTestData').textContent;
|
||||
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
production_data: JSON.parse(proData),
|
||||
test_data: JSON.parse(testData)
|
||||
};
|
||||
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], {type: 'application/json'});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(dataBlob);
|
||||
link.download = `raw_data_${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
// 显示差异数据的原生数据
|
||||
function showDifferenceRawData(keyStr) {
|
||||
showRawData(keyStr); // 复用相同的原生数据显示逻辑
|
||||
|
Reference in New Issue
Block a user