<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>字符级差异对比工具</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
:root {
--primary-color: #4361ee;
--secondary-color: #3f37c9;
--light-color: #f8f9fa;
--dark-color: #212529;
--delete-color: #f94144;
--add-color: #4cc9f0;
--modify-color: #f8961e;
--border-color: #dee2e6;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
--success-color: #2ecc71;
--warning-color: #f39c12;
--char-delete: #ff4757;
--char-add: #2ed573;
--char-modify: #ffa502;
}
body {
background-color: #f5f7fb;
color: var(--dark-color);
line-height: 1.6;
}
.container {
max-width: 1800px;
margin: 0 auto;
padding: 20px;
}
header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
h1 {
color: var(--primary-color);
margin-bottom: 10px;
font-size: 2.5rem;
font-weight: 700;
}
.subtitle {
color: #6c757d;
font-size: 1.1rem;
max-width: 800px;
margin: 0 auto;
}
.controls-panel {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 30px;
}
.mode-selector {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
.mode-btn {
padding: 12px 24px;
background-color: white;
border: 2px solid var(--border-color);
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.mode-btn.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.mode-btn:hover:not(.active) {
background-color: #f8f9fa;
border-color: var(--primary-color);
}
.diff-level-selector {
display: flex;
justify-content: center;
gap: 10px;
margin: 15px 0;
}
.level-btn {
padding: 10px 20px;
background-color: white;
border: 2px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.level-btn.active {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.level-btn:hover:not(.active) {
background-color: #f8f9fa;
border-color: var(--primary-color);
}
.input-panel {
display: flex;
flex-direction: column;
gap: 30px;
}
@media (min-width: 992px) {
.input-panel {
flex-direction: row;
}
}
.input-section {
flex: 1;
display: flex;
flex-direction: column;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background-color: white;
border-radius: 12px 12px 0 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-bottom: 1px solid var(--border-color);
}
.section-title {
font-size: 1.3rem;
font-weight: 600;
color: var(--primary-color);
}
.section-actions {
display: flex;
gap: 15px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.btn-success {
background-color: var(--success-color);
color: white;
}
.btn-success:hover {
background-color: #27ae60;
transform: translateY(-2px);
}
.btn-outline {
background-color: transparent;
color: var(--primary-color);
border: 2px solid var(--primary-color);
}
.btn-outline:hover {
background-color: rgba(67, 97, 238, 0.1);
transform: translateY(-2px);
}
.input-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.input-action-btn {
background: none;
border: none;
color: #6c757d;
cursor: pointer;
font-size: 1.1rem;
transition: color 0.2s;
padding: 5px;
border-radius: 4px;
}
.input-action-btn:hover {
color: var(--primary-color);
background-color: #f8f9fa;
}
.textarea-container {
flex: 1;
position: relative;
background-color: white;
border-radius: 0 0 12px 12px;
overflow: hidden;
box-shadow: var(--shadow);
}
.input-textarea {
width: 100%;
height: 300px;
padding: 20px;
border: none;
resize: vertical;
font-size: 1.05rem;
line-height: 1.8;
font-family: 'Courier New', monospace;
outline: none;
}
.textarea-info {
position: absolute;
bottom: 10px;
right: 15px;
color: #adb5bd;
font-size: 0.9rem;
background-color: rgba(255, 255, 255, 0.8);
padding: 2px 8px;
border-radius: 4px;
}
.stats-legend-panel {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: var(--shadow);
margin-bottom: 30px;
gap: 15px;
}
.stats {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.stat-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 20px;
border-radius: 8px;
background-color: var(--light-color);
min-width: 120px;
}
.stat-label {
font-size: 0.9rem;
color: #6c757d;
margin-bottom: 5px;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
}
.deletions .stat-value {
color: var(--delete-color);
}
.additions .stat-value {
color: var(--add-color);
}
.modifications .stat-value {
color: var(--modify-color);
}
.legend {
display: flex;
gap: 15px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.color-box {
width: 20px;
height: 20px;
border-radius: 4px;
}
.color-delete {
background-color: var(--delete-color);
}
.color-add {
background-color: var(--add-color);
}
.color-modify {
background-color: var(--modify-color);
}
.char-legend {
display: flex;
gap: 15px;
margin-top: 10px;
flex-wrap: wrap;
}
.char-legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.char-color-box {
width: 16px;
height: 16px;
border-radius: 3px;
}
.char-delete {
background-color: var(--char-delete);
}
.char-add {
background-color: var(--char-add);
}
.char-modify {
background-color: var(--char-modify);
}
.comparison-area {
display: flex;
flex-direction: column;
gap: 30px;
}
@media (min-width: 992px) {
.comparison-area {
flex-direction: row;
}
}
.comparison-section {
flex: 1;
display: flex;
flex-direction: column;
}
.content-box {
flex: 1;
background-color: white;
border-radius: 0 0 12px 12px;
overflow: hidden;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
}
.content-area {
flex: 1;
padding: 25px;
overflow-y: auto;
max-height: 500px;
line-height: 1.8;
font-size: 1.05rem;
}
.content-area.left {
border-right: 1px solid #f0f0f0;
}
.line-number {
display: inline-block;
width: 40px;
text-align: right;
margin-right: 20px;
color: #adb5bd;
user-select: none;
flex-shrink: 0;
}
.line-content {
flex: 1;
}
.line {
padding: 4px 8px;
border-radius: 4px;
margin-bottom: 2px;
transition: background-color 0.2s;
white-space: pre-wrap;
word-break: break-word;
display: flex;
}
.line:hover {
background-color: #f8f9fa;
}
.deleted {
background-color: rgba(249, 65, 68, 0.1);
}
.added {
background-color: rgba(76, 201, 240, 0.1);
}
.modified {
background-color: rgba(248, 150, 30, 0.1);
}
/* 优化的字符级别差异样式 - 更显眼 */
.char-deleted {
background-color: var(--char-delete);
text-decoration: line-through;
padding: 0 2px;
border-radius: 3px;
margin: 0 1px;
font-weight: bold;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.char-added {
background-color: var(--char-add);
padding: 0 2px;
border-radius: 3px;
margin: 0 1px;
font-weight: bold;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.char-modified {
background-color: var(--char-modify);
padding: 0 2px;
border-radius: 3px;
margin: 0 1px;
font-weight: bold;
color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.sync-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background-color: white;
border-radius: 12px;
box-shadow: var(--shadow);
margin: 20px auto;
width: fit-content;
cursor: pointer;
transition: all 0.2s;
}
.sync-toggle:hover {
background-color: #f8f9fa;
}
.sync-toggle.active {
background-color: rgba(67, 97, 238, 0.1);
color: var(--primary-color);
}
.toggle-switch {
width: 50px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
position: relative;
transition: background-color 0.3s;
}
.toggle-switch::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
top: 2px;
left: 2px;
transition: transform 0.3s;
}
.sync-toggle.active .toggle-switch {
background-color: var(--primary-color);
}
.sync-toggle.active .toggle-switch::after {
transform: translateX(26px);
}
.actions {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 30px;
flex-wrap: wrap;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--secondary-color);
transform: translateY(-2px);
}
.btn-warning {
background-color: var(--warning-color);
color: white;
}
.btn-warning:hover {
background-color: #e67e22;
transform: translateY(-2px);
}
footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
color: #6c757d;
border-top: 1px solid var(--border-color);
font-size: 0.9rem;
}
.notification {
position: fixed;
top: 20px;
right: 20px;
padding: 15px 20px;
border-radius: 8px;
background-color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
gap: 10px;
z-index: 1000;
transform: translateX(150%);
transition: transform 0.3s ease-in-out;
}
.notification.show {
transform: translateX(0);
}
.notification.success {
border-left: 4px solid var(--success-color);
}
.notification.warning {
border-left: 4px solid var(--warning-color);
}
.notification.info {
border-left: 4px solid var(--primary-color);
}
.notification i {
font-size: 1.2rem;
}
.notification.success i {
color: var(--success-color);
}
.notification.warning i {
color: var(--warning-color);
}
.notification.info i {
color: var(--primary-color);
}
.active-diff {
box-shadow: 0 0 0 2px var(--primary-color);
position: relative;
z-index: 10;
}
.active-diff::before {
content: '▶';
color: var(--primary-color);
position: absolute;
left: -25px;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
/* 新增差异行数导航表格样式 */
.diff-nav-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.diff-nav-header {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-color);
display: flex;
align-items: center;
gap: 8px;
}
.diff-nav-table {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 120px;
overflow-y: auto;
padding: 10px;
background-color: white;
border-radius: 8px;
box-shadow: var(--shadow);
}
.diff-nav-item {
padding: 6px 12px;
background-color: #f8f9fa;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 5px;
font-size: 0.9rem;
}
.diff-nav-item:hover {
background-color: #e9ecef;
transform: translateY(-2px);
}
.diff-nav-item.active {
background-color: var(--primary-color);
color: white;
font-weight: 600;
}
.diff-nav-item.deleted {
background-color: rgba(249, 65, 68, 0.15);
color: var(--delete-color);
}
.diff-nav-item.deleted.active {
background-color: var(--delete-color);
color: white;
}
.diff-nav-item.added {
background-color: rgba(76, 201, 240, 0.15);
color: var(--add-color);
}
.diff-nav-item.added.active {
background-color: var(--add-color);
color: white;
}
.diff-nav-item.modified {
background-color: rgba(248, 150, 30, 0.15);
color: var(--modify-color);
}
.diff-nav-item.modified.active {
background-color: var(--modify-color);
color: white;
}
.diff-type-icon {
font-size: 0.8rem;
}
@media (max-width: 768px) {
.stats-legend-panel {
flex-direction: column;
align-items: flex-start;
}
.legend, .char-legend {
margin-top: 10px;
}
.content-area {
max-height: 400px;
padding: 15px;
}
h1 {
font-size: 2rem;
}
.stat-box {
min-width: 100px;
padding: 10px 15px;
}
.mode-selector, .diff-level-selector {
flex-direction: column;
align-items: center;
}
.mode-btn, .level-btn {
width: 100%;
max-width: 300px;
justify-content: center;
}
.section-actions {
flex-direction: column;
gap: 8px;
}
.section-actions .btn {
padding: 8px 12px;
font-size: 0.9rem;
}
.diff-nav-table {
max-height: 100px;
}
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-state i {
font-size: 3rem;
margin-bottom: 15px;
color: #adb5bd;
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<header>
<h1><i class="fas fa-code-compare"></i> 字符级差异对比工具</h1>
<p class="subtitle">输入或粘贴需要对比的内容,系统将自动检测并高亮显示行级和字符级的差异。</p>
</header>
<div class="controls-panel">
<div class="mode-selector">
<button class="mode-btn active" id="edit-mode-btn">
<i class="fas fa-edit"></i> 编辑模式
</button>
<button class="mode-btn" id="view-mode-btn">
<i class="fas fa-eye"></i> 查看模式
</button>
</div>
<div class="diff-level-selector">
<button class="level-btn active" data-level="line">
行级对比
</button>
<button class="level-btn" data-level="char">
字符级对比
</button>
<button class="level-btn" data-level="both">
双重对比
</button>
</div>
</div>
<div class="input-panel" id="input-panel">
<div class="input-section">
<div class="section-header">
<h2 class="section-title">原文内容</h2>
<div class="section-actions">
<button class="btn btn-success" id="compare-btn">
<i class="fas fa-code-compare"></i> 对比内容
</button>
<button class="btn btn-outline" id="reset-view">
<i class="fas fa-redo"></i> 重置所有
</button>
</div>
</div>
<div class="textarea-container">
<textarea class="input-textarea" id="left-input" placeholder="在此输入或粘贴原文内容..."></textarea>
<div class="textarea-info">
<span id="left-count">0</span> 字
</div>
</div>
<div class="input-actions">
<button class="input-action-btn" id="clear-left" title="清空内容">
<i class="fas fa-trash-alt"></i> 清空
</button>
<button class="input-action-btn" id="paste-left" title="粘贴内容">
<i class="fas fa-paste"></i> 粘贴
</button>
</div>
</div>
<div class="input-section">
<div class="section-header">
<h2 class="section-title">修改后内容</h2>
<div style="min-width: 120px;"></div> <!-- 占位,保持对齐 -->
</div>
<div class="textarea-container">
<textarea class="input-textarea" id="right-input" placeholder="在此输入或粘贴修改后的内容..."></textarea>
<div class="textarea-info">
<span id="right-count">0</span> 字
</div>
</div>
<div class="input-actions">
<button class="input-action-btn" id="clear-right" title="清空内容">
<i class="fas fa-trash-alt"></i> 清空
</button>
<button class="input-action-btn" id="paste-right" title="粘贴内容">
<i class="fas fa-paste"></i> 粘贴
</button>
</div>
</div>
</div>
<div class="stats-legend-panel">
<div class="stats">
<div class="stat-box deletions">
<div class="stat-label">删除内容</div>
<div class="stat-value" id="delete-count">0</div>
</div>
<div class="stat-box additions">
<div class="stat-label">新增内容</div>
<div class="stat-value" id="add-count">0</div>
</div>
<div class="stat-box modifications">
<div class="stat-label">修改内容</div>
<div class="stat-value" id="modify-count">0</div>
</div>
</div>
<div class="legend-section">
<div class="legend">
<div class="legend-item">
<div class="color-box color-delete"></div>
<span>删除的行</span>
</div>
<div class="legend-item">
<div class="color-box color-add"></div>
<span>新增的行</span>
</div>
<div class="legend-item">
<div class="color-box color-modify"></div>
<span>修改的行</span>
</div>
</div>
<div class="char-legend" id="char-legend" style="display: none;">
<div class="char-legend-item">
<div class="char-color-box char-delete"></div>
<span>删除的字符</span>
</div>
<div class="char-legend-item">
<div class="char-color-box char-add"></div>
<span>新增的字符</span>
</div>
<div class="char-legend-item">
<div class="char-color-box char-modify"></div>
<span>修改的字符</span>
</div>
</div>
</div>
</div>
<div class="comparison-area" id="comparison-area" style="display: none;">
<div class="comparison-section">
<div class="section-header">
<h2 class="section-title">原文内容</h2>
<span id="left-line-count">0 行</span>
</div>
<!-- 左侧差异行数导航表格 -->
<div class="diff-nav-container" id="left-diff-nav" style="display: none;">
<div class="diff-nav-header">
<i class="fas fa-list-ol"></i>
<span>差异行号导航</span>
</div>
<div class="diff-nav-table" id="left-diff-table">
<!-- 差异行号将通过JavaScript动态生成 -->
</div>
</div>
<div class="content-box">
<div class="content-area left" id="left-content">
<div class="empty-state">
<i class="fas fa-code-compare"></i>
<p>点击"对比内容"按钮开始对比</p>
</div>
</div>
</div>
</div>
<div class="comparison-section">
<div class="section-header">
<h2 class="section-title">修改后内容</h2>
<span id="right-line-count">0 行</span>
</div>
<!-- 右侧差异行数导航表格 -->
<div class="diff-nav-container" id="right-diff-nav" style="display: none;">
<div class="diff-nav-header">
<i class="fas fa-list-ol"></i>
<span>差异行号导航</span>
</div>
<div class="diff-nav-table" id="right-diff-table">
<!-- 差异行号将通过JavaScript动态生成 -->
</div>
</div>
<div class="content-box">
<div class="content-area" id="right-content">
<div class="empty-state">
<i class="fas fa-code-compare"></i>
<p>点击"对比内容"按钮开始对比</p>
</div>
</div>
</div>
</div>
</div>
<div class="sync-toggle active" id="sync-toggle" style="display: none;">
<div class="toggle-switch"></div>
<span>同步滚动</span>
</div>
<div class="actions">
<button class="btn btn-primary" id="copy-diff">
<i class="fas fa-copy"></i> 复制差异报告
</button>
<button class="btn btn-warning" id="export-diff">
<i class="fas fa-file-export"></i> 导出差异
</button>
</div>
<footer>
<p>字符级差异对比工具 © 2023 | 支持行级和字符级双重差异对比,实时输入和智能检测</p>
</footer>
</div>
<div class="notification" id="notification">
<i class="fas fa-check-circle"></i>
<span id="notification-text">操作成功</span>
</div>
<script>
// 应用状态
const appState = {
diffMode: 'both', // 'line', 'char', 'both'
isComparing: false,
diffIndices: [],
currentDiffIndex: 0,
isSyncing: true,
diffData: null, // 保存差异数据
activeDiffItem: null // 当前激活的差异项
};
// 字符级差异算法
function charLevelDiff(str1, str2) {
// 如果字符串完全相同,直接返回
if (str1 === str2) {
return {
left: [{ type: 'same', text: str1 }],
right: [{ type: 'same', text: str2 }]
};
}
// 使用动态规划算法计算最小编辑距离
const m = str1.length;
const n = str2.length;
// 创建DP表
const dp = Array(m + 1).fill().map(() => Array(n + 1).fill(0));
// 初始化第一行和第一列
for (let i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (let j = 0; j <= n; j++) {
dp[0][j] = j;
}
// 填充DP表
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (str1[i - 1] === str2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // 删除
dp[i][j - 1] + 1, // 插入
dp[i - 1][j - 1] + 1 // 替换
);
}
}
}
// 回溯构建差异结果
let i = m, j = n;
const leftResult = [];
const rightResult = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && str1[i - 1] === str2[j - 1]) {
// 字符相同
leftResult.unshift({ type: 'same', char: str1[i - 1] });
rightResult.unshift({ type: 'same', char: str2[j - 1] });
i--;
j--;
} else if (i > 0 && (j === 0 || dp[i][j] === dp[i - 1][j] + 1)) {
// 删除字符
leftResult.unshift({ type: 'delete', char: str1[i - 1] });
i--;
} else if (j > 0 && (i === 0 || dp[i][j] === dp[i][j - 1] + 1)) {
// 插入字符
rightResult.unshift({ type: 'add', char: str2[j - 1] });
j--;
} else {
// 替换字符
leftResult.unshift({ type: 'modify', char: str1[i - 1] });
rightResult.unshift({ type: 'modify', char: str2[j - 1] });
i--;
j--;
}
}
// 将连续的相同类型字符合并为片段,提高渲染性能
return {
left: mergeCharSegments(leftResult),
right: mergeCharSegments(rightResult)
};
}
// 合并连续的相同类型字符
function mergeCharSegments(chars) {
if (chars.length === 0) return [];
const segments = [];
let currentSegment = {
type: chars[0].type,
text: chars[0].char
};
for (let i = 1; i < chars.length; i++) {
if (chars[i].type === currentSegment.type) {
currentSegment.text += chars[i].char;
} else {
segments.push(currentSegment);
currentSegment = {
type: chars[i].type,
text: chars[i].char
};
}
}
segments.push(currentSegment);
return segments;
}
// 行级差异检测算法
function findLineDifferences(text1, text2) {
const lines1 = text1.split('\n');
const lines2 = text2.split('\n');
// 创建差异数组
const diff = [];
let i = 0, j = 0;
while (i < lines1.length || j < lines2.length) {
if (i < lines1.length && j < lines2.length && lines1[i] === lines2[j]) {
// 行相同
diff.push({
type: 'same',
left: lines1[i],
right: lines2[j],
charDiff: null,
lineNumber: i + 1 // 记录行号(原文行号)
});
i++;
j++;
} else if (j < lines2.length && (i >= lines1.length || lines1[i] !== lines2[j])) {
// 检查是否是修改(下一行是否匹配)
if (i + 1 < lines1.length && j + 1 < lines2.length &&
lines1[i + 1] === lines2[j + 1]) {
// 可能是修改
diff.push({
type: 'modified',
left: lines1[i],
right: lines2[j],
charDiff: null,
lineNumber: i + 1 // 记录行号(原文行号)
});
i++;
j++;
} else {
// 新增行
diff.push({
type: 'added',
left: null,
right: lines2[j],
charDiff: null,
lineNumber: j + 1 // 记录行号(修改后行号)
});
j++;
}
} else if (i < lines1.length && (j >= lines2.length || lines1[i] !== lines2[j])) {
// 检查是否是修改
if (i + 1 < lines1.length && j + 1 < lines2.length &&
lines1[i + 1] === lines2[j + 1]) {
// 可能是修改
diff.push({
type: 'modified',
left: lines1[i],
right: lines2[j],
charDiff: null,
lineNumber: i + 1 // 记录行号(原文行号)
});
i++;
j++;
} else {
// 删除行
diff.push({
type: 'deleted',
left: lines1[i],
right: null,
charDiff: null,
lineNumber: i + 1 // 记录行号(原文行号)
});
i++;
}
}
}
return diff;
}
// 增强的差异检测,包含字符级差异
function findEnhancedDifferences(text1, text2, diffLevel = 'both') {
const lineDiff = findLineDifferences(text1, text2);
// 如果不需要字符级差异,直接返回
if (diffLevel === 'line') {
return lineDiff;
}
// 为修改的行计算字符级差异
return lineDiff.map(item => {
if (item.type === 'modified' && item.left && item.right) {
item.charDiff = charLevelDiff(item.left, item.right);
}
return item;
});
}
// 渲染字符级差异内容
function renderCharDiff(segments) {
const fragment = document.createDocumentFragment();
segments.forEach(segment => {
const span = document.createElement('span');
span.textContent = segment.text;
switch(segment.type) {
case 'same':
// 不添加特殊类名
break;
case 'delete':
span.className = 'char-deleted';
break;
case 'add':
span.className = 'char-added';
break;
case 'modify':
span.className = 'char-modified';
break;
}
fragment.appendChild(span);
});
return fragment;
}
// 渲染内容
function renderContent(diff) {
const leftContent = document.getElementById('left-content');
const rightContent = document.getElementById('right-content');
leftContent.innerHTML = '';
rightContent.innerHTML = '';
if (diff.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'empty-state';
emptyState.innerHTML = '<i class="fas fa-exclamation-circle"></i><p>没有内容可以对比</p>';
leftContent.appendChild(emptyState);
rightContent.appendChild(emptyState.cloneNode(true));
return { diffIndices: [], leftDiffRows: [], rightDiffRows: [] };
}
let deleteCount = 0;
let addCount = 0;
let modifyCount = 0;
// 用于差异导航
const diffIndices = [];
// 左侧差异行号(删除和修改)
const leftDiffRows = [];
// 右侧差异行号(新增和修改)
const rightDiffRows = [];
diff.forEach((item, index) => {
const leftLine = document.createElement('div');
const rightLine = document.createElement('div');
leftLine.className = 'line';
rightLine.className = 'line';
// 添加行号
const leftLineNum = document.createElement('span');
leftLineNum.className = 'line-number';
leftLineNum.textContent = index + 1;
const rightLineNum = document.createElement('span');
rightLineNum.className = 'line-number';
rightLineNum.textContent = index + 1;
// 创建内容容器
const leftContentDiv = document.createElement('div');
leftContentDiv.className = 'line-content';
const rightContentDiv = document.createElement('div');
rightContentDiv.className = 'line-content';
// 根据类型设置样式和内容
switch(item.type) {
case 'same':
leftLine.appendChild(leftLineNum);
leftContentDiv.textContent = item.left || '';
leftLine.appendChild(leftContentDiv);
rightLine.appendChild(rightLineNum);
rightContentDiv.textContent = item.right || '';
rightLine.appendChild(rightContentDiv);
break;
case 'deleted':
leftLine.className += ' deleted';
leftLine.appendChild(leftLineNum);
leftContentDiv.textContent = item.left || '';
leftLine.appendChild(leftContentDiv);
rightLine.appendChild(rightLineNum);
rightContentDiv.textContent = '';
rightLine.appendChild(rightContentDiv);
deleteCount++;
diffIndices.push({index, side: 'left', type: 'deleted'});
leftDiffRows.push({index, type: 'deleted', lineNumber: item.lineNumber || index + 1});
break;
case 'added':
rightLine.className += ' added';
leftLine.appendChild(leftLineNum);
leftContentDiv.textContent = '';
leftLine.appendChild(leftContentDiv);
rightLine.appendChild(rightLineNum);
rightContentDiv.textContent = item.right || '';
rightLine.appendChild(rightContentDiv);
addCount++;
diffIndices.push({index, side: 'right', type: 'added'});
rightDiffRows.push({index, type: 'added', lineNumber: item.lineNumber || index + 1});
break;
case 'modified':
leftLine.className += ' modified';
rightLine.className += ' modified';
leftLine.appendChild(leftLineNum);
rightLine.appendChild(rightLineNum);
// 根据diffLevel决定渲染方式
if (appState.diffMode !== 'line' && item.charDiff) {
// 渲染字符级差异
leftContentDiv.appendChild(renderCharDiff(item.charDiff.left));
rightContentDiv.appendChild(renderCharDiff(item.charDiff.right));
} else {
// 渲染整行差异
leftContentDiv.textContent = item.left || '';
rightContentDiv.textContent = item.right || '';
}
leftLine.appendChild(leftContentDiv);
rightLine.appendChild(rightContentDiv);
modifyCount++;
diffIndices.push({index, side: 'both', type: 'modified'});
leftDiffRows.push({index, type: 'modified', lineNumber: item.lineNumber || index + 1});
rightDiffRows.push({index, type: 'modified', lineNumber: item.lineNumber || index + 1});
break;
}
leftContent.appendChild(leftLine);
rightContent.appendChild(rightLine);
});
// 更新统计
document.getElementById('delete-count').textContent = deleteCount;
document.getElementById('add-count').textContent = addCount;
document.getElementById('modify-count').textContent = modifyCount;
document.getElementById('left-line-count').textContent = diff.length + ' 行';
document.getElementById('right-line-count').textContent = diff.length + ' 行';
return { diffIndices, leftDiffRows, rightDiffRows };
}
// 创建差异行号导航表格
function createDiffNavigation(diffRows, side) {
const container = document.getElementById(`${side}-diff-nav`);
const table = document.getElementById(`${side}-diff-table`);
if (!diffRows || diffRows.length === 0) {
container.style.display = 'none';
return;
}
container.style.display = 'flex';
table.innerHTML = '';
diffRows.forEach(diffRow => {
const item = document.createElement('div');
item.className = `diff-nav-item ${diffRow.type}`;
item.dataset.index = diffRow.index;
item.dataset.side = side;
// 添加类型图标
let icon = '';
if (diffRow.type === 'deleted') {
icon = '<i class="fas fa-minus diff-type-icon"></i>';
} else if (diffRow.type === 'added') {
icon = '<i class="fas fa-plus diff-type-icon"></i>';
} else if (diffRow.type === 'modified') {
icon = '<i class="fas fa-pen diff-type-icon"></i>';
}
item.innerHTML = `${icon}第 ${diffRow.lineNumber} 行`;
// 点击事件
item.addEventListener('click', () => {
navigateToDiff(diffRow.index, side);
});
table.appendChild(item);
});
}
// 导航到指定差异行
function navigateToDiff(index, side) {
const leftLines = document.querySelectorAll('#left-content .line');
const rightLines = document.querySelectorAll('#right-content .line');
// 移除之前的高亮
leftLines.forEach(line => line.classList.remove('active-diff'));
rightLines.forEach(line => line.classList.remove('active-diff'));
// 移除之前激活的导航项
document.querySelectorAll('.diff-nav-item.active').forEach(item => {
item.classList.remove('active');
});
// 添加新的高亮
if (side === 'left' && leftLines[index]) {
leftLines[index].classList.add('active-diff');
leftLines[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
// 高亮对应的导航项
const navItem = document.querySelector(`.diff-nav-item[data-index="${index}"][data-side="left"]`);
if (navItem) {
navItem.classList.add('active');
}
}
if (side === 'right' && rightLines[index]) {
rightLines[index].classList.add('active-diff');
rightLines[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
// 高亮对应的导航项
const navItem = document.querySelector(`.diff-nav-item[data-index="${index}"][data-side="right"]`);
if (navItem) {
navItem.classList.add('active');
}
}
// 如果是修改的行,同时高亮两侧
const leftNavItem = document.querySelector(`.diff-nav-item[data-index="${index}"][data-side="left"]`);
const rightNavItem = document.querySelector(`.diff-nav-item[data-index="${index}"][data-side="right"]`);
if (leftNavItem && rightNavItem) {
// 说明是修改的行,两个导航项同时高亮
leftNavItem.classList.add('active');
rightNavItem.classList.add('active');
// 同时滚动两侧到该行
if (leftLines[index] && rightLines[index]) {
leftLines[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
// 同步滚动功能
function setupSyncScroll() {
const leftContent = document.getElementById('left-content');
const rightContent = document.getElementById('right-content');
const syncToggle = document.getElementById('sync-toggle');
let isSyncing = false;
function syncScroll(source, target) {
if (!appState.isSyncing || isSyncing) return;
isSyncing = true;
const percent = source.scrollTop / (source.scrollHeight - source.clientHeight);
target.scrollTop = percent * (target.scrollHeight - target.clientHeight);
setTimeout(() => {
isSyncing = false;
}, 50);
}
leftContent.addEventListener('scroll', () => {
syncScroll(leftContent, rightContent);
});
rightContent.addEventListener('scroll', () => {
syncScroll(rightContent, leftContent);
});
// 切换同步滚动
syncToggle.addEventListener('click', () => {
syncToggle.classList.toggle('active');
appState.isSyncing = syncToggle.classList.contains('active');
});
}
// 显示通知
function showNotification(message, type = 'success') {
const notification = document.getElementById('notification');
const textElement = document.getElementById('notification-text');
textElement.textContent = message;
notification.className = `notification ${type}`;
notification.classList.add('show');
setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// 更新字数统计
function updateWordCount() {
const leftInput = document.getElementById('left-input');
const rightInput = document.getElementById('right-input');
const leftCount = document.getElementById('left-count');
const rightCount = document.getElementById('right-count');
leftCount.textContent = leftInput.value.length;
rightCount.textContent = rightInput.value.length;
}
// 对比内容
function compareContent() {
if (appState.isComparing) return;
const leftInput = document.getElementById('left-input');
const rightInput = document.getElementById('right-input');
if (!leftInput.value.trim() && !rightInput.value.trim()) {
showNotification('请输入要对比的内容', 'warning');
return;
}
appState.isComparing = true;
const compareBtn = document.getElementById('compare-btn');
const originalText = compareBtn.innerHTML;
compareBtn.innerHTML = '<div class="loading"></div> 对比中...';
compareBtn.disabled = true;
// 模拟处理时间,让用户看到加载状态
setTimeout(() => {
// 计算差异
appState.diffData = findEnhancedDifferences(leftInput.value, rightInput.value, appState.diffMode);
const { diffIndices, leftDiffRows, rightDiffRows } = renderContent(appState.diffData);
// 显示对比区域
document.getElementById('comparison-area').style.display = 'flex';
document.getElementById('sync-toggle').style.display = 'flex';
// 创建差异导航表格
createDiffNavigation(leftDiffRows, 'left');
createDiffNavigation(rightDiffRows, 'right');
// 更新字符级差异图例显示
const charLegend = document.getElementById('char-legend');
if (appState.diffMode !== 'line' && diffIndices.length > 0) {
charLegend.style.display = 'flex';
} else {
charLegend.style.display = 'none';
}
// 恢复按钮状态
compareBtn.innerHTML = originalText;
compareBtn.disabled = false;
appState.isComparing = false;
// 显示结果通知
const totalChanges = diffIndices.length;
if (totalChanges > 0) {
showNotification(`对比完成,发现 ${totalChanges} 处差异`, 'success');
// 如果有差异,自动定位到第一个差异
if (leftDiffRows.length > 0) {
navigateToDiff(leftDiffRows[0].index, 'left');
} else if (rightDiffRows.length > 0) {
navigateToDiff(rightDiffRows[0].index, 'right');
}
} else {
showNotification('内容完全相同,没有发现差异', 'info');
}
}, 300);
}
// 复制差异报告
function setupCopyDiff() {
const copyBtn = document.getElementById('copy-diff');
copyBtn.addEventListener('click', () => {
const deleteCount = document.getElementById('delete-count').textContent;
const addCount = document.getElementById('add-count').textContent;
const modifyCount = document.getElementById('modify-count').textContent;
const diffReport = `差异对比报告:
删除内容:${deleteCount} 处
新增内容:${addCount} 处
修改内容:${modifyCount} 处
对比级别:${appState.diffMode === 'line' ? '行级' : appState.diffMode === 'char' ? '字符级' : '双重'}
生成时间:${new Date().toLocaleString()}`;
navigator.clipboard.writeText(diffReport).then(() => {
showNotification('差异报告已复制到剪贴板', 'success');
}).catch(err => {
showNotification('复制失败,请手动复制', 'warning');
});
});
}
// 重置视图
function resetAll() {
// 重置输入框
document.getElementById('left-input').value = '';
document.getElementById('right-input').value = '';
// 重置对比区域
document.getElementById('comparison-area').style.display = 'none';
document.getElementById('sync-toggle').style.display = 'none';
// 重置差异导航表格
document.getElementById('left-diff-nav').style.display = 'none';
document.getElementById('right-diff-nav').style.display = 'none';
// 重置统计
document.getElementById('delete-count').textContent = '0';
document.getElementById('add-count').textContent = '0';
document.getElementById('modify-count').textContent = '0';
// 重置字数
updateWordCount();
// 重置字符图例
document.getElementById('char-legend').style.display = 'none';
// 重置模式选择
document.querySelectorAll('.level-btn').forEach(btn => {
if (btn.dataset.level === 'both') {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
// 重置应用状态
appState.diffMode = 'both';
appState.diffIndices = [];
appState.currentDiffIndex = 0;
appState.diffData = null;
appState.activeDiffItem = null;
showNotification('所有内容已重置', 'info');
}
// 导出差异
function setupExportDiff() {
const exportBtn = document.getElementById('export-diff');
exportBtn.addEventListener('click', () => {
const leftInput = document.getElementById('left-input').value;
const rightInput = document.getElementById('right-input').value;
if (!leftInput && !rightInput) {
showNotification('没有内容可以导出', 'warning');
return;
}
// 使用已计算的差异数据或重新计算
const diff = appState.diffData || findEnhancedDifferences(leftInput, rightInput, appState.diffMode);
let exportText = "=== 字符级差异对比报告 ===\n\n";
diff.forEach((item, index) => {
exportText += `行 ${index + 1}: `;
switch(item.type) {
case 'same':
exportText += `[相同] ${item.left}\n`;
break;
case 'deleted':
exportText += `[删除] ${item.left}\n`;
break;
case 'added':
exportText += `[新增] ${item.right}\n`;
break;
case 'modified':
exportText += `[修改] 原文: ${item.left}\n`;
exportText += ` 修改后: ${item.right}\n`;
break;
}
});
exportText += `\n=== 统计 ===\n`;
exportText += `删除内容: ${document.getElementById('delete-count').textContent} 处\n`;
exportText += `新增内容: ${document.getElementById('add-count').textContent} 处\n`;
exportText += `修改内容: ${document.getElementById('modify-count').textContent} 处\n`;
exportText += `对比级别: ${appState.diffMode === 'line' ? '行级' : appState.diffMode === 'char' ? '字符级' : '双重'}\n`;
exportText += `生成时间: ${new Date().toLocaleString()}\n`;
// 创建下载链接
const blob = new Blob([exportText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `差异报告_${new Date().toISOString().slice(0, 10)}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification('差异报告已导出', 'success');
});
}
// 初始化输入框事件
function setupInputEvents() {
const leftInput = document.getElementById('left-input');
const rightInput = document.getElementById('right-input');
// 字数统计
leftInput.addEventListener('input', updateWordCount);
rightInput.addEventListener('input', updateWordCount);
// 粘贴功能
document.getElementById('paste-left').addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
leftInput.value = text;
updateWordCount();
showNotification('内容已粘贴到原文', 'success');
} catch (err) {
showNotification('粘贴失败,请使用Ctrl+V', 'warning');
}
});
document.getElementById('paste-right').addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
rightInput.value = text;
updateWordCount();
showNotification('内容已粘贴到修改后', 'success');
} catch (err) {
showNotification('粘贴失败,请使用Ctrl+V', 'warning');
}
});
// 清空功能
document.getElementById('clear-left').addEventListener('click', () => {
leftInput.value = '';
updateWordCount();
showNotification('原文内容已清空', 'info');
});
document.getElementById('clear-right').addEventListener('click', () => {
rightInput.value = '';
updateWordCount();
showNotification('修改后内容已清空', 'info');
});
}
// 初始化模式切换
function setupModeToggle() {
const editModeBtn = document.getElementById('edit-mode-btn');
const viewModeBtn = document.getElementById('view-mode-btn');
editModeBtn.addEventListener('click', () => {
editModeBtn.classList.add('active');
viewModeBtn.classList.remove('active');
document.getElementById('input-panel').style.display = 'flex';
document.getElementById('comparison-area').style.display = 'none';
document.getElementById('sync-toggle').style.display = 'none';
});
viewModeBtn.addEventListener('click', () => {
if (!appState.diffData || appState.diffData.length === 0) {
showNotification('请先对比内容再切换到查看模式', 'warning');
return;
}
viewModeBtn.classList.add('active');
editModeBtn.classList.remove('active');
document.getElementById('input-panel').style.display = 'none';
document.getElementById('comparison-area').style.display = 'flex';
document.getElementById('sync-toggle').style.display = 'flex';
});
}
// 初始化对比级别选择
function setupDiffLevelSelector() {
document.querySelectorAll('.level-btn').forEach(btn => {
btn.addEventListener('click', () => {
// 更新按钮状态
document.querySelectorAll('.level-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 更新对比级别
appState.diffMode = btn.dataset.level;
// 更新字符图例显示
const charLegend = document.getElementById('char-legend');
if (appState.diffMode !== 'line') {
charLegend.style.display = 'flex';
} else {
charLegend.style.display = 'none';
}
// 如果已有对比内容,重新对比
const leftInput = document.getElementById('left-input');
const rightInput = document.getElementById('right-input');
if ((leftInput.value.trim() || rightInput.value.trim()) && appState.diffData) {
// 重新渲染内容
const { diffIndices, leftDiffRows, rightDiffRows } = renderContent(appState.diffData);
// 重新创建差异导航表格
createDiffNavigation(leftDiffRows, 'left');
createDiffNavigation(rightDiffRows, 'right');
showNotification(`已切换到${btn.textContent}模式`, 'info');
}
});
});
}
// 初始化应用
function initApp() {
// 初始字数统计
updateWordCount();
// 设置输入框事件
setupInputEvents();
// 设置模式切换
setupModeToggle();
// 设置对比级别选择
setupDiffLevelSelector();
// 设置同步滚动
setupSyncScroll();
// 设置功能按钮
setupCopyDiff();
setupExportDiff();
// 对比按钮
document.getElementById('compare-btn').addEventListener('click', compareContent);
// 重置按钮
document.getElementById('reset-view').addEventListener('click', resetAll);
// 初始提示
showNotification('欢迎使用字符级差异对比工具!输入内容后点击"对比内容"按钮开始对比。', 'info');
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initApp);
</script>
</body>
</html>
字符级内容差异对比工具
于 2026-01-23 17:34:12 首次发布

1903

被折叠的 条评论
为什么被折叠?



