fix: v0.4.2 导出菜单溢出修复与 HTML 报告可读性重设计 - 底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到)、HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS)、TXT 与 Markdown 报告头部补人话结论
This commit is contained in:
@@ -22,7 +22,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
- **大文件防护**:超过 10MB 的文件自动拦截并提示;行数过多时给出耗时预警;超长单行自动跳过词级高亮,避免卡顿
|
||||||
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
- **二进制文件预警**:疑似二进制内容给出乱码提示,避免误读为差异
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行),便于分享与归档
|
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式(始终包含全量行)。HTML 报告为"给人看"设计:一句话结论、文件卡片、统计概览、图例说明、差异分块目录导航,相同内容段自动折叠(点击展开零丢失),并支持打印与窄屏
|
||||||
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
- **拖拽与按钮双入口**:支持文件拖拽导入,也可通过界面按钮选择文件或粘贴文本
|
||||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.4.1
|
- 版本:0.4.2
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
|||||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||||
|
|
||||||
当前基线版本:**`0.4.1`**
|
当前基线版本:**`0.4.2`**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,7 +67,8 @@ z = 补丁版本号(Patch)
|
|||||||
0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
|
0.3.1 ← 稳健性与测试补强(已发布):超长单行词级 diff 防护 · 打开对话框补“所有文件”筛选 · 虚拟滚动 minWidth 测试覆盖 · 滚动同步去锁化重构
|
||||||
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
0.3.2 ← 报告对齐与性能收尾(已发布):纯文本报告行号右对齐 · 超长行列宽封顶防卡顿 · 对话框与拖拽扩展名清单对齐 · 右键菜单打开时导航快捷键守卫 · 清理导出死属性
|
||||||
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
0.4.0 ← 差异计算 worker 化(已发布):大文件 diff 移入 Worker 后台计算不阻塞界面 · 输入去抖与过期任务丢弃(终止重建实现取消) · 小输入同步快路径 · 无 Worker 环境回退 · 计算中遮罩与导出禁用
|
||||||
0.4.1 ← worker 创建开销优化与文档对齐(当前):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
0.4.1 ← worker 创建开销优化与文档对齐(已发布):worker 延迟至去抖到期才创建(去抖期内取消零创建开销,无 Worker 回退同样走去抖) · 质量门禁文档补 npm test · README 架构树补 hooks · Markdown 报告标题移出引用块 · 安装包排除开发期文件
|
||||||
|
0.4.2 ← 导出菜单溢出修复与报告可读性重设计(当前):底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到) · HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS) · TXT 与 Markdown 报告头部补人话结论
|
||||||
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
0.5.0 ← 新增功能(候选:主进程解码 worker 化 · Playwright E2E · 文件夹对比)
|
||||||
...
|
...
|
||||||
0.y.z ← 长期停留,永不进入 1.x
|
0.y.z ← 长期停留,永不进入 1.x
|
||||||
|
|||||||
+6
-4
@@ -60,7 +60,7 @@ src/renderer/src/
|
|||||||
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
│ ├── ContextMenu.test.tsx # 右键菜单(含边缘防溢出)
|
||||||
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放)
|
||||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单/计算中禁用)
|
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用)
|
||||||
└── __tests__/
|
└── __tests__/
|
||||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
|
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/大输入回退)
|
||||||
└── main.test.ts # 入口挂载冒烟测试
|
└── main.test.ts # 入口挂载冒烟测试
|
||||||
@@ -82,9 +82,11 @@ src/renderer/src/
|
|||||||
- 行切分:结尾换行不产生多余空行
|
- 行切分:结尾换行不产生多余空行
|
||||||
|
|
||||||
### 5.2 报告生成(`report.ts`)
|
### 5.2 报告生成(`report.ts`)
|
||||||
- HTML:输出合法文档、含文件名/统计/类型高亮语义
|
- HTML:输出合法文档;Hero 区含人话结论(verdict)、文件卡片、统计卡与图例;差异分块(hunk-head)与块内行渲染;多块时输出目录锚点(toc/href="#seg-N"),单块不输出;相同内容段折叠(details/summary)且折叠段内保留全量行数据;一致场景 verdict ok;两侧空内容提示
|
||||||
- 纯文本:含增删改标记(`+ -`)、行号按最大位数右对齐
|
- 纯文本:含增删改标记(`+ -`)、行号按最大位数右对齐、头部人话结论
|
||||||
- Markdown:表格化、管道符转义保持表格结构
|
- Markdown:表格化、管道符转义保持表格结构、引用块含人话结论
|
||||||
|
- 差异分块(`buildSegments`):空行集 / 无变更单段全展示 / 上下文 3 行与边界收敛 / 相邻区间合并 / 远距分两段省略数正确 / 自定义 context
|
||||||
|
- 人话总结(`plainSummary`):无差异一致结论 / 为 0 项省略 / 三类齐全完整罗列
|
||||||
- 格式分发与扩展名映射(`REPORT_EXT`)
|
- 格式分发与扩展名映射(`REPORT_EXT`)
|
||||||
|
|
||||||
### 5.3 文本工具(`textUtils.ts`)
|
### 5.3 文本工具(`textUtils.ts`)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.4.1",
|
"version": "0.4.2",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ describe('Toolbar', () => {
|
|||||||
expect(onExport).toHaveBeenCalledWith('html')
|
expect(onExport).toHaveBeenCalledWith('html')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('导出菜单向上弹出(工具栏贴窗口底部,防止溢出底边)', () => {
|
||||||
|
const { container } = setup()
|
||||||
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
expect(container.querySelector('.export-menu.up')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('点击外部区域关闭导出菜单', () => {
|
it('点击外部区域关闭导出菜单', () => {
|
||||||
setup()
|
setup()
|
||||||
fireEvent.click(screen.getByText('导出报告'))
|
fireEvent.click(screen.getByText('导出报告'))
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default function Toolbar({
|
|||||||
导出报告
|
导出报告
|
||||||
</button>
|
</button>
|
||||||
{exportOpen && (
|
{exportOpen && (
|
||||||
<div className="export-menu">
|
<div className="export-menu up">
|
||||||
{FORMATS.map((f) => (
|
{FORMATS.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.fmt}
|
key={f.fmt}
|
||||||
|
|||||||
@@ -1,9 +1,104 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { computeDiff } from './diffEngine'
|
import { computeDiff, type DiffRow } from './diffEngine'
|
||||||
import { buildHtmlReport, buildTxtReport, buildMarkdownReport, buildReport, REPORT_EXT } from './report'
|
import {
|
||||||
|
buildHtmlReport,
|
||||||
|
buildTxtReport,
|
||||||
|
buildMarkdownReport,
|
||||||
|
buildReport,
|
||||||
|
buildSegments,
|
||||||
|
plainSummary,
|
||||||
|
REPORT_EXT
|
||||||
|
} from './report'
|
||||||
|
|
||||||
const ctx = { leftName: 'a.txt', rightName: 'b.txt', leftEncoding: 'UTF-8', rightEncoding: 'UTF-8' }
|
const ctx = { leftName: 'a.txt', rightName: 'b.txt', leftEncoding: 'UTF-8', rightEncoding: 'UTF-8' }
|
||||||
|
|
||||||
|
/** 便捷构造行集:u=unchanged, a=added, r=removed, m=modified */
|
||||||
|
function rowsOf(kinds: string[]): DiffRow[] {
|
||||||
|
let n = 0
|
||||||
|
return kinds.map((k) => {
|
||||||
|
const id = `t${n++}`
|
||||||
|
const changed = k !== 'u'
|
||||||
|
const left = { lineNo: k === 'a' ? null : n, text: k === 'a' ? null : `L${n}`, segs: null }
|
||||||
|
const right = { lineNo: k === 'r' ? null : n, text: k === 'r' ? null : `R${n}`, segs: null }
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
rowKind: (k === 'a' ? 'added' : k === 'r' ? 'removed' : k === 'm' ? 'modified' : 'unchanged') as DiffRow['rowKind'],
|
||||||
|
isChanged: changed,
|
||||||
|
left,
|
||||||
|
right
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSegments - 差异分块', () => {
|
||||||
|
it('空行集返回空段', () => {
|
||||||
|
expect(buildSegments([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无任何变更时单段全展示且无省略', () => {
|
||||||
|
const segs = buildSegments(rowsOf(['u', 'u', 'u', 'u']))
|
||||||
|
expect(segs).toEqual([{ start: 0, end: 3, changes: 0, skippedBefore: 0, skippedAfter: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('单处居中变更保留前后 3 行上下文,其余省略', () => {
|
||||||
|
const rows = rowsOf(['u', 'u', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'u', 'u'])
|
||||||
|
const segs = buildSegments(rows)
|
||||||
|
expect(segs).toEqual([{ start: 2, end: 8, changes: 1, skippedBefore: 2, skippedAfter: 2 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('变更在开头/结尾时区间收敛到边界', () => {
|
||||||
|
const rows = rowsOf(['m', 'u', 'u', 'u', 'u', 'm'])
|
||||||
|
const segs = buildSegments(rows)
|
||||||
|
expect(segs).toEqual([{ start: 0, end: 5, changes: 2, skippedBefore: 0, skippedAfter: 0 }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('相近变更(间距不超过 2×context+1)合并为同段', () => {
|
||||||
|
const rows = rowsOf(['u', 'u', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'm', 'u', 'u', 'u', 'u'])
|
||||||
|
// 变更 5 与 9:区间 [2,8] 与 [6,12] 重叠 → 单段 [2,12]
|
||||||
|
const segs = buildSegments(rows)
|
||||||
|
expect(segs).toHaveLength(1)
|
||||||
|
expect(segs[0].start).toBe(2)
|
||||||
|
expect(segs[0].end).toBe(12)
|
||||||
|
expect(segs[0].changes).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('远距变更分为两段,段间省略数正确', () => {
|
||||||
|
const kinds = Array.from({ length: 30 }, () => 'u')
|
||||||
|
kinds[2] = 'm'
|
||||||
|
kinds[20] = 'm'
|
||||||
|
const segs = buildSegments(rowsOf(kinds))
|
||||||
|
expect(segs).toHaveLength(2)
|
||||||
|
expect(segs[0]).toMatchObject({ start: 0, end: 5, changes: 1, skippedBefore: 0 })
|
||||||
|
expect(segs[1]).toMatchObject({ start: 17, end: 23, changes: 1, skippedBefore: 11, skippedAfter: 6 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('自定义 context=0 时仅展示变更行本身', () => {
|
||||||
|
const rows = rowsOf(['u', 'u', 'u', 'm', 'u', 'u'])
|
||||||
|
const segs = buildSegments(rows, 0)
|
||||||
|
expect(segs).toEqual([{ start: 3, end: 3, changes: 1, skippedBefore: 3, skippedAfter: 2 }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('plainSummary - 人话总结', () => {
|
||||||
|
it('无差异时给出一致结论', () => {
|
||||||
|
expect(plainSummary({ changedLines: 0, inserted: 0, deleted: 0, modified: 0 })).toBe(
|
||||||
|
'两文件内容完全一致'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('为 0 的项被省略', () => {
|
||||||
|
expect(plainSummary({ changedLines: 2, inserted: 2, deleted: 0, modified: 0 })).toBe(
|
||||||
|
'共 2 处不同:新增 2 行'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('三类变更齐全时完整罗列', () => {
|
||||||
|
expect(plainSummary({ changedLines: 9, inserted: 4, deleted: 3, modified: 2 })).toBe(
|
||||||
|
'共 9 处不同:新增 4 行、删除 3 行、修改 2 行'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('buildHtmlReport', () => {
|
describe('buildHtmlReport', () => {
|
||||||
const { rows, summary } = computeDiff('line1\nline2', 'line1\nline3')
|
const { rows, summary } = computeDiff('line1\nline2', 'line1\nline3')
|
||||||
|
|
||||||
@@ -15,19 +110,67 @@ describe('buildHtmlReport', () => {
|
|||||||
expect(html).toContain('b.txt')
|
expect(html).toContain('b.txt')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Hero 区包含人话结论、文件卡与统计卡', () => {
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('共 1 处不同:修改 1 行')
|
||||||
|
expect(html).toContain('class="verdict"')
|
||||||
|
expect(html).toContain('file-card left')
|
||||||
|
expect(html).toContain('file-card right')
|
||||||
|
expect(html).toContain('stat ins')
|
||||||
|
expect(html).toContain('stat rate')
|
||||||
|
})
|
||||||
|
|
||||||
it('包含差异统计与类型高亮语义', () => {
|
it('包含差异统计与类型高亮语义', () => {
|
||||||
const html = buildHtmlReport(rows, ctx, summary)
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
expect(html).toContain('modified')
|
expect(html).toContain('modified')
|
||||||
expect(html).toContain('<b>')
|
expect(html).toContain('hunk-head')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('内容完全一致时显示绿色一致结论', () => {
|
||||||
|
const { rows, summary } = computeDiff('same', 'same')
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('两文件内容完全一致')
|
||||||
|
expect(html).toContain('verdict ok')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('多差异块时渲染目录锚点,单块时不渲染目录', () => {
|
||||||
|
const left = Array.from({ length: 20 }, (_, i) => `l${i}`).join('\n')
|
||||||
|
const right = left.replace('l2', 'X').replace('l16', 'Y')
|
||||||
|
const multi = computeDiff(left, right)
|
||||||
|
const html = buildHtmlReport(multi.rows, ctx, multi.summary)
|
||||||
|
expect(html).toContain('toc-list')
|
||||||
|
expect(html).toContain('href="#seg-2"')
|
||||||
|
|
||||||
|
const single = computeDiff('a', 'b')
|
||||||
|
const singleHtml = buildHtmlReport(single.rows, ctx, single.summary)
|
||||||
|
expect(singleHtml).not.toContain('href="#seg-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('大段相同内容以折叠行呈现且保留全量数据', () => {
|
||||||
|
const left = Array.from({ length: 40 }, (_, i) => `l${i}`).join('\n')
|
||||||
|
const right = left.replace('l20', 'X')
|
||||||
|
const { rows, summary } = computeDiff(left, right)
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('<details><summary>⋯ 相同内容')
|
||||||
|
// 折叠段内仍包含被省略行的数据(信息零丢失)
|
||||||
|
expect(html).toContain('l0')
|
||||||
|
expect(html).toContain('l39')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('两侧均为空时给出空内容提示', () => {
|
||||||
|
const { rows, summary } = computeDiff('', '')
|
||||||
|
const html = buildHtmlReport(rows, ctx, summary)
|
||||||
|
expect(html).toContain('没有可对比的行')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('buildTxtReport', () => {
|
describe('buildTxtReport', () => {
|
||||||
it('输出带增删改标记的行式文本', () => {
|
it('输出带增删改标记与人话结论的行式文本', () => {
|
||||||
const { rows, summary } = computeDiff('a', 'a\nb')
|
const { rows, summary } = computeDiff('a', 'a\nb')
|
||||||
const txt = buildTxtReport(rows, ctx, summary)
|
const txt = buildTxtReport(rows, ctx, summary)
|
||||||
expect(txt).toContain('DiffLens')
|
expect(txt).toContain('DiffLens')
|
||||||
expect(txt).toContain('+')
|
expect(txt).toContain('+')
|
||||||
|
expect(txt).toContain('结论:共 1 处不同:新增 1 行')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('行号按最大位数右对齐填充', () => {
|
it('行号按最大位数右对齐填充', () => {
|
||||||
@@ -57,6 +200,12 @@ describe('buildMarkdownReport', () => {
|
|||||||
expect(md).toContain('| 左行号 |')
|
expect(md).toContain('| 左行号 |')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('引用块包含人话结论', () => {
|
||||||
|
const { rows, summary } = computeDiff('a', 'b')
|
||||||
|
const md = buildMarkdownReport(rows, ctx, summary)
|
||||||
|
expect(md).toContain('> 结论:共 1 处不同:修改 1 行')
|
||||||
|
})
|
||||||
|
|
||||||
it('管道符被转义以保持表格结构', () => {
|
it('管道符被转义以保持表格结构', () => {
|
||||||
const { rows, summary } = computeDiff('a|b', 'a')
|
const { rows, summary } = computeDiff('a|b', 'a')
|
||||||
const md = buildMarkdownReport(rows, ctx, summary)
|
const md = buildMarkdownReport(rows, ctx, summary)
|
||||||
|
|||||||
+403
-50
@@ -43,6 +43,79 @@ function cellHtml(cell: SideCell): string {
|
|||||||
const typeTag = (k: DiffRow['rowKind']): string =>
|
const typeTag = (k: DiffRow['rowKind']): string =>
|
||||||
k === 'added' ? '+' : k === 'removed' ? '−' : k === 'modified' ? '~' : ' '
|
k === 'added' ? '+' : k === 'removed' ? '−' : k === 'modified' ? '~' : ' '
|
||||||
|
|
||||||
|
/** ============ 差异分块(Hunk) ============ */
|
||||||
|
|
||||||
|
/** 变更块前后保留的上下文行数(GitHub 风格惯例) */
|
||||||
|
export const HUNK_CONTEXT = 3
|
||||||
|
|
||||||
|
/** 一段直接展示的行区间(含上下文),与相邻段之间被省略的相同行数 */
|
||||||
|
export interface ReportSegment {
|
||||||
|
/** 段起始行索引(0 基,含上下文,含) */
|
||||||
|
start: number
|
||||||
|
/** 段结束行索引(0 基,含上下文,含) */
|
||||||
|
end: number
|
||||||
|
/** 段内变更行数 */
|
||||||
|
changes: number
|
||||||
|
/** 段前被省略的行数(首段可能非 0) */
|
||||||
|
skippedBefore: number
|
||||||
|
/** 段后被省略的行数(仅最后一段可能非 0) */
|
||||||
|
skippedAfter: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把全量 diff 行切分为"差异块":每处变更前后各保留 context 行上下文,
|
||||||
|
* 相邻区间重叠或紧邻时合并;两块之间的大段相同行交由渲染层折叠省略。
|
||||||
|
* 无任何变更时返回单段全展示(不折叠)。
|
||||||
|
*/
|
||||||
|
export function buildSegments(rows: DiffRow[], context: number = HUNK_CONTEXT): ReportSegment[] {
|
||||||
|
if (rows.length === 0) return []
|
||||||
|
const changedIdx: number[] = []
|
||||||
|
rows.forEach((r, i) => {
|
||||||
|
if (r.isChanged) changedIdx.push(i)
|
||||||
|
})
|
||||||
|
if (changedIdx.length === 0) {
|
||||||
|
return [{ start: 0, end: rows.length - 1, changes: 0, skippedBefore: 0, skippedAfter: 0 }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 变更 ±context 生成区间,重叠或紧邻(中间无省略行)时合并
|
||||||
|
const ranges: Array<[number, number]> = []
|
||||||
|
for (const idx of changedIdx) {
|
||||||
|
const s = Math.max(0, idx - context)
|
||||||
|
const e = Math.min(rows.length - 1, idx + context)
|
||||||
|
const last = ranges[ranges.length - 1]
|
||||||
|
if (last && s <= last[1] + 1) {
|
||||||
|
last[1] = Math.max(last[1], e)
|
||||||
|
} else {
|
||||||
|
ranges.push([s, e])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segs: ReportSegment[] = []
|
||||||
|
let cursor = 0
|
||||||
|
for (const [s, e] of ranges) {
|
||||||
|
let changes = 0
|
||||||
|
for (let i = s; i <= e; i++) {
|
||||||
|
if (rows[i].isChanged) changes++
|
||||||
|
}
|
||||||
|
segs.push({ start: s, end: e, changes, skippedBefore: s - cursor, skippedAfter: 0 })
|
||||||
|
cursor = e + 1
|
||||||
|
}
|
||||||
|
segs[segs.length - 1].skippedAfter = rows.length - cursor
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ============ 人话总结 ============ */
|
||||||
|
|
||||||
|
/** 一句话结论:给不熟悉 diff 符号的人看(无差异 / 增删改汇总,为 0 的项省略) */
|
||||||
|
export function plainSummary(summary: DiffSummary): string {
|
||||||
|
if (summary.changedLines === 0) return '两文件内容完全一致'
|
||||||
|
const parts: string[] = []
|
||||||
|
if (summary.inserted > 0) parts.push(`新增 ${summary.inserted} 行`)
|
||||||
|
if (summary.deleted > 0) parts.push(`删除 ${summary.deleted} 行`)
|
||||||
|
if (summary.modified > 0) parts.push(`修改 ${summary.modified} 行`)
|
||||||
|
return `共 ${summary.changedLines} 处不同:${parts.join('、')}`
|
||||||
|
}
|
||||||
|
|
||||||
const headLines = (
|
const headLines = (
|
||||||
ctx: ReportContext,
|
ctx: ReportContext,
|
||||||
summary: DiffSummary,
|
summary: DiffSummary,
|
||||||
@@ -55,40 +128,317 @@ const headLines = (
|
|||||||
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
|
||||||
}`,
|
}`,
|
||||||
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
`对比时间:${new Date().toLocaleString('zh-CN')}`,
|
||||||
|
`结论:${plainSummary(summary)}`,
|
||||||
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
`变更统计:新增 ${summary.inserted} | 删除 ${summary.deleted} | 修改 ${summary.modified}`
|
||||||
]
|
]
|
||||||
|
|
||||||
/** ============ HTML 报告 ============ */
|
/** ============ HTML 报告 ============ */
|
||||||
export function buildHtmlReport(
|
|
||||||
rows: DiffRow[],
|
/** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开) */
|
||||||
ctx: ReportContext,
|
const REPORT_CSS = `
|
||||||
summary: DiffSummary
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
html { scroll-behavior:smooth; }
|
||||||
|
body {
|
||||||
|
font-family:'Segoe UI','Microsoft YaHei',system-ui,sans-serif;
|
||||||
|
color:#dbe3f4;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 20% -10%, rgba(124,108,255,.16), transparent 60%),
|
||||||
|
radial-gradient(1000px 500px at 90% 110%, rgba(34,211,238,.12), transparent 55%),
|
||||||
|
#0a0e17;
|
||||||
|
padding:28px 18px 60px;
|
||||||
|
line-height:1.5;
|
||||||
|
}
|
||||||
|
.wrap { max-width:1180px; margin:0 auto; }
|
||||||
|
|
||||||
|
/* ---- Hero 概览 ---- */
|
||||||
|
.hero {
|
||||||
|
border:1px solid rgba(148,163,255,.32);
|
||||||
|
border-radius:16px;
|
||||||
|
background:linear-gradient(180deg, rgba(124,108,255,.12), rgba(14,20,32,.65));
|
||||||
|
box-shadow:0 20px 60px rgba(0,0,0,.45), 0 0 30px rgba(124,108,255,.12);
|
||||||
|
padding:22px 24px;
|
||||||
|
margin-bottom:18px;
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
font-size:22px; font-weight:800; letter-spacing:.5px;
|
||||||
|
background:linear-gradient(90deg,#c9bfff,#22d3ee);
|
||||||
|
-webkit-background-clip:text; background-clip:text; color:transparent;
|
||||||
|
}
|
||||||
|
.hero-time { color:#74809a; font-size:12px; margin:2px 0 10px; }
|
||||||
|
.verdict {
|
||||||
|
font-size:16px; font-weight:700; color:#f6bd87;
|
||||||
|
padding:8px 14px; border-radius:10px; margin-bottom:14px;
|
||||||
|
background:rgba(245,158,11,.10); border:1px solid rgba(245,158,11,.35);
|
||||||
|
}
|
||||||
|
.verdict.ok {
|
||||||
|
color:#7ee2a8; background:rgba(34,197,94,.10); border-color:rgba(34,197,94,.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.files { display:flex; align-items:stretch; gap:10px; margin-bottom:14px; }
|
||||||
|
.file-card {
|
||||||
|
flex:1; min-width:0; padding:10px 14px; border-radius:12px;
|
||||||
|
background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.18);
|
||||||
|
}
|
||||||
|
.file-card.left { border-left:3px solid #8b7cff; }
|
||||||
|
.file-card.right { border-left:3px solid #22d3ee; }
|
||||||
|
.file-side { font-size:11px; color:#74809a; letter-spacing:1px; }
|
||||||
|
.file-name {
|
||||||
|
font-family:Consolas,'JetBrains Mono',monospace; font-size:14px; font-weight:700;
|
||||||
|
color:#dbe3f4; margin:3px 0 2px; word-break:break-all;
|
||||||
|
}
|
||||||
|
.file-meta { font-size:12px; color:#74809a; }
|
||||||
|
.file-path {
|
||||||
|
font-size:11px; color:#4d5a74; margin-top:3px; word-break:break-all;
|
||||||
|
}
|
||||||
|
.files-arrow {
|
||||||
|
align-self:center; flex:none; color:#74809a; font-size:18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats { display:flex; flex-wrap:wrap; gap:10px; margin-bottom:14px; }
|
||||||
|
.stat {
|
||||||
|
flex:1; min-width:96px; text-align:center; padding:10px 8px; border-radius:12px;
|
||||||
|
background:rgba(148,163,255,.05); border:1px solid rgba(148,163,255,.18);
|
||||||
|
}
|
||||||
|
.stat-num { font-size:22px; font-weight:800; font-variant-numeric:tabular-nums; }
|
||||||
|
.stat-label { font-size:11px; color:#74809a; margin-top:2px; }
|
||||||
|
.stat.ins .stat-num { color:#7ee2a8; }
|
||||||
|
.stat.del .stat-num { color:#f7a9a9; }
|
||||||
|
.stat.mod .stat-num { color:#f6bd87; }
|
||||||
|
.stat.same .stat-num { color:#74809a; }
|
||||||
|
.stat.rate .stat-num { color:#22d3ee; }
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display:flex; flex-wrap:wrap; gap:8px 18px; font-size:12px; color:#9aa7c2;
|
||||||
|
padding:10px 14px; border-radius:10px; background:rgba(148,163,255,.04);
|
||||||
|
}
|
||||||
|
.legend .sw {
|
||||||
|
display:inline-block; width:12px; height:12px; border-radius:3px;
|
||||||
|
margin-right:5px; vertical-align:-1px;
|
||||||
|
}
|
||||||
|
.sw.add { background:rgba(34,197,94,.55); }
|
||||||
|
.sw.del { background:rgba(248,113,113,.5); }
|
||||||
|
.sw.mod { background:rgba(245,158,11,.5); }
|
||||||
|
.sw.word-del { background:rgba(248,113,113,.3); text-decoration:line-through; }
|
||||||
|
.sw.word-ins { background:rgba(34,197,94,.3); }
|
||||||
|
|
||||||
|
/* ---- 差异分布目录 ---- */
|
||||||
|
.toc {
|
||||||
|
display:flex; gap:12px; align-items:flex-start; margin-bottom:18px;
|
||||||
|
padding:10px 14px; border-radius:12px;
|
||||||
|
background:rgba(148,163,255,.04); border:1px solid rgba(148,163,255,.14);
|
||||||
|
}
|
||||||
|
.toc-title { flex:none; font-size:12px; color:#74809a; letter-spacing:1px; padding-top:2px; }
|
||||||
|
.toc-list {
|
||||||
|
display:flex; flex-wrap:wrap; gap:6px 8px;
|
||||||
|
max-height:120px; overflow:auto;
|
||||||
|
}
|
||||||
|
.toc-list a {
|
||||||
|
font-size:12px; color:#9aa7c2; text-decoration:none;
|
||||||
|
padding:2px 9px; border-radius:999px;
|
||||||
|
background:rgba(124,108,255,.08); border:1px solid rgba(148,163,255,.16);
|
||||||
|
}
|
||||||
|
.toc-list a:hover { color:#fff; border-color:rgba(148,163,255,.4); }
|
||||||
|
.toc-list b { color:#c9bfff; margin-right:6px; }
|
||||||
|
.toc-list i { font-style:normal; color:#74809a; margin-left:6px; }
|
||||||
|
|
||||||
|
/* ---- 明细表 ---- */
|
||||||
|
.diff-table {
|
||||||
|
border-collapse:collapse; width:100%; table-layout:fixed;
|
||||||
|
font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px;
|
||||||
|
border:1px solid rgba(148,163,255,.14); border-radius:12px; overflow:hidden;
|
||||||
|
}
|
||||||
|
.diff-table col.c-tag { width:26px; }
|
||||||
|
.diff-table col.c-ln { width:48px; }
|
||||||
|
.diff-table col.c-tx { width:calc((100% - 122px) / 2); }
|
||||||
|
.diff-table thead td {
|
||||||
|
font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:11px; color:#74809a;
|
||||||
|
background:rgba(124,108,255,.08); border-bottom:1px solid rgba(148,163,255,.18);
|
||||||
|
padding:6px 8px; text-align:left; letter-spacing:1px;
|
||||||
|
}
|
||||||
|
.diff-table td {
|
||||||
|
border-bottom:1px solid rgba(148,163,255,.08);
|
||||||
|
padding:2px 8px; line-height:21px; white-space:pre-wrap; word-break:break-all;
|
||||||
|
vertical-align:top;
|
||||||
|
}
|
||||||
|
.diff-table td.tag { text-align:right; color:#74809a; }
|
||||||
|
.diff-table td.lnl, .diff-table td.lnr { text-align:right; color:#46536e; }
|
||||||
|
.diff-table tr.added { background:rgba(34,197,94,.12); }
|
||||||
|
.diff-table tr.removed { background:rgba(248,113,113,.10); }
|
||||||
|
.diff-table tr.modified { background:rgba(245,158,11,.10); }
|
||||||
|
.diff-table tbody > tr:hover { background:rgba(34,211,238,.07); }
|
||||||
|
.cell.empty { color:transparent; }
|
||||||
|
.ins { border-radius:2px; background:rgba(34,197,94,.3); }
|
||||||
|
.del { border-radius:2px; background:rgba(248,113,113,.3); text-decoration:line-through; }
|
||||||
|
|
||||||
|
/* 块标题与锚点定位高亮 */
|
||||||
|
.hunk-head td {
|
||||||
|
font-family:'Segoe UI','Microsoft YaHei',sans-serif; font-size:12px; color:#9aa7c2;
|
||||||
|
background:rgba(124,108,255,.06); letter-spacing:.5px; padding:5px 10px;
|
||||||
|
}
|
||||||
|
.hunk-head b { color:#c9bfff; }
|
||||||
|
tbody.hunk:target .hunk-head td {
|
||||||
|
background:rgba(34,211,238,.14); color:#dbe3f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠的相同内容段:点开即得全量行 */
|
||||||
|
tr.skip td { padding:0; }
|
||||||
|
tr.skip details { border-bottom:1px solid rgba(148,163,255,.08); }
|
||||||
|
tr.skip summary {
|
||||||
|
cursor:pointer; list-style:none; user-select:none;
|
||||||
|
font-family:'Segoe UI','Microsoft YaHei',sans-serif;
|
||||||
|
font-size:12px; color:#5d6b88; font-style:italic;
|
||||||
|
padding:5px 10px; background:rgba(148,163,255,.03);
|
||||||
|
}
|
||||||
|
tr.skip summary:hover { color:#9aa7c2; background:rgba(148,163,255,.06); }
|
||||||
|
tr.skip .fold-table { border:none; }
|
||||||
|
tr.skip .fold-table td { color:#8d99b5; }
|
||||||
|
tr.skip .fold-table tr.added, tr.skip .fold-table tr.removed,
|
||||||
|
tr.skip .fold-table tr.modified { color:#dbe3f4; }
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
margin-top:16px; color:#4d5a74; font-size:11px; text-align:center; letter-spacing:1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 窄屏 ---- */
|
||||||
|
@media (max-width:760px) {
|
||||||
|
body { padding:14px 8px 40px; }
|
||||||
|
.files { flex-direction:column; }
|
||||||
|
.files-arrow { transform:rotate(90deg); align-self:center; }
|
||||||
|
.diff-table col.c-tx { width:calc((100% - 122px) / 2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 打印:自动切亮色 ---- */
|
||||||
|
@media print {
|
||||||
|
body { background:#fff; color:#111; padding:0; }
|
||||||
|
.hero { border-color:#ccc; background:#fafafa; box-shadow:none; }
|
||||||
|
.hero-title { color:#3a2f8f; -webkit-text-fill-color:#3a2f8f; }
|
||||||
|
.verdict { color:#8a5a00; background:#fff7e6; border-color:#e8c07a; }
|
||||||
|
.verdict.ok { color:#116639; background:#effaf3; border-color:#9fdcb6; }
|
||||||
|
.file-card, .stat, .toc, .legend { background:#fafafa; border-color:#ddd; }
|
||||||
|
.file-name, .stat-num, .toc-list b { color:#111; }
|
||||||
|
.stat.ins .stat-num { color:#15803d; }
|
||||||
|
.stat.del .stat-num { color:#b91c1c; }
|
||||||
|
.stat.mod .stat-num { color:#b45309; }
|
||||||
|
.stat.rate .stat-num { color:#0e7490; }
|
||||||
|
.diff-table { border-color:#ccc; }
|
||||||
|
.diff-table td { border-color:#eee; color:#111; }
|
||||||
|
.diff-table thead td { background:#f3f3f6; color:#666; }
|
||||||
|
.diff-table td.lnl, .diff-table td.lnr, td.tag { color:#888; }
|
||||||
|
.diff-table tr.added { background:#e9f9ef; }
|
||||||
|
.diff-table tr.removed { background:#fdeeee; }
|
||||||
|
.diff-table tr.modified { background:#fdf4e5; }
|
||||||
|
.cell.empty { color:transparent; }
|
||||||
|
.ins { background:#b7f0c9; }
|
||||||
|
.del { background:#f9c9c9; }
|
||||||
|
tr.skip summary { background:#fafafa; color:#777; }
|
||||||
|
.toc-list a { color:#333; background:#f3f3f6; border-color:#ddd; }
|
||||||
|
a { text-decoration:none; }
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
/** 统计卡 */
|
||||||
|
function statCard(cls: string, label: string, value: string | number): string {
|
||||||
|
return `<div class="stat ${cls}"><div class="stat-num">${value}</div><div class="stat-label">${label}</div></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件信息卡(名称/编码/行数/路径) */
|
||||||
|
function fileCard(
|
||||||
|
side: 'left' | 'right',
|
||||||
|
name: string,
|
||||||
|
encoding: string,
|
||||||
|
path: string | undefined,
|
||||||
|
lines: number
|
||||||
): string {
|
): string {
|
||||||
const head = headLines(ctx, summary, 'DiffLens 差异报告')
|
return `<div class="file-card ${side}">
|
||||||
const body = rows
|
<div class="file-side">${side === 'left' ? '左侧 · 原始文件' : '右侧 · 对比文件'}</div>
|
||||||
.map((row) => {
|
<div class="file-name">${esc(name || '(未选择)')}</div>
|
||||||
const tag = typeTag(row.rowKind)
|
<div class="file-meta">${esc(encoding || '未知编码')} · ${lines} 行</div>
|
||||||
const l = row.left
|
${path ? `<div class="file-path">${esc(path)}</div>` : ''}
|
||||||
const r = row.right
|
</div>`
|
||||||
const lCls = row.rowKind === 'added' ? 'empty' : row.rowKind
|
}
|
||||||
const rCls = row.rowKind === 'removed' ? 'empty' : row.rowKind
|
|
||||||
return `<tr class="${row.rowKind}">
|
/** 单行 HTML(主表与折叠内嵌表共用) */
|
||||||
|
function rowHtml(row: DiffRow): string {
|
||||||
|
const tag = typeTag(row.rowKind)
|
||||||
|
const l = row.left
|
||||||
|
const r = row.right
|
||||||
|
const lCls = row.rowKind === 'added' ? 'empty' : row.rowKind
|
||||||
|
const rCls = row.rowKind === 'removed' ? 'empty' : row.rowKind
|
||||||
|
return `<tr class="${row.rowKind}">
|
||||||
<td class="tag">${tag}</td>
|
<td class="tag">${tag}</td>
|
||||||
<td class="lnl">${padNo(l.lineNo)}</td>
|
<td class="lnl">${padNo(l.lineNo)}</td>
|
||||||
<td class="cell ${lCls}">${cellHtml(l)}</td>
|
<td class="cell ${lCls}">${cellHtml(l)}</td>
|
||||||
<td class="lnr">${padNo(r.lineNo)}</td>
|
<td class="lnr">${padNo(r.lineNo)}</td>
|
||||||
<td class="cell ${rCls}">${cellHtml(r)}</td>
|
<td class="cell ${rCls}">${cellHtml(r)}</td>
|
||||||
</tr>`
|
</tr>`
|
||||||
})
|
}
|
||||||
.join('\n')
|
|
||||||
|
|
||||||
const statsBadges = [
|
/** 折叠行:大段相同内容收进 details,点开即得全量行(信息零丢失) */
|
||||||
['ins', `+${summary.inserted}`],
|
function skipHtml(count: number, slice: DiffRow[]): string {
|
||||||
['del', `−${summary.deleted}`],
|
const inner = slice.map(rowHtml).join('\n')
|
||||||
['mod', `~${summary.modified}`]
|
return `<tr class="skip"><td colspan="5">
|
||||||
]
|
<details><summary>⋯ 相同内容 ${count} 行 · 点击展开 ⋯</summary>
|
||||||
.map(([c, t]) => `<span class="badge ${c}">${t}</span>`)
|
<table class="fold-table"><colgroup><col class="c-tag"><col class="c-ln"><col class="c-tx"><col class="c-ln"><col class="c-tx"></colgroup><tbody>
|
||||||
.join(' ')
|
${inner}
|
||||||
|
</tbody></table>
|
||||||
|
</details>
|
||||||
|
</td></tr>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 差异分布目录:多于一块时给出锚点导航(行号区间取段首/段尾行的行号,空槽取另一侧) */
|
||||||
|
function renderToc(rows: DiffRow[], segs: ReportSegment[]): string {
|
||||||
|
if (segs.length <= 1) return ''
|
||||||
|
const links = segs
|
||||||
|
.map((s, i) => {
|
||||||
|
const head = rows[s.start].left.lineNo ?? rows[s.start].right.lineNo ?? '·'
|
||||||
|
const tail = rows[s.end].left.lineNo ?? rows[s.end].right.lineNo ?? '·'
|
||||||
|
const range = head === tail ? `行 ${head}` : `行 ${head}–${tail}`
|
||||||
|
return `<a href="#seg-${i + 1}"><b>${i + 1}</b>${range}<i>${s.changes} 处变更</i></a>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
return `<div class="toc"><span class="toc-title">差异分布</span><div class="toc-list">${links}</div></div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHtmlReport(
|
||||||
|
rows: DiffRow[],
|
||||||
|
ctx: ReportContext,
|
||||||
|
summary: DiffSummary
|
||||||
|
): string {
|
||||||
|
const segs = buildSegments(rows)
|
||||||
|
const unchanged = rows.length - summary.changedLines
|
||||||
|
const rate = rows.length > 0 ? `${((summary.changedLines / rows.length) * 100).toFixed(1)}%` : '—'
|
||||||
|
const leftLines = rows.filter((r) => r.left.lineNo !== null).length
|
||||||
|
const rightLines = rows.filter((r) => r.right.lineNo !== null).length
|
||||||
|
const verdictOk = summary.changedLines === 0
|
||||||
|
|
||||||
|
// 明细:段前折叠省略行 + 段内行(含块标题行);最后补尾部折叠
|
||||||
|
const bodyParts: string[] = []
|
||||||
|
let cursor = 0
|
||||||
|
segs.forEach((s, i) => {
|
||||||
|
const headNo = rows[s.start].left.lineNo ?? rows[s.start].right.lineNo ?? '·'
|
||||||
|
const tailNo = rows[s.end].left.lineNo ?? rows[s.end].right.lineNo ?? '·'
|
||||||
|
const range = headNo === tailNo ? `行 ${headNo}` : `行 ${headNo} – ${tailNo}`
|
||||||
|
const inner: string[] = [
|
||||||
|
`<tr class="hunk-head"><td colspan="5">第 <b>${i + 1}</b> 块 · ${range} · ${s.changes} 处变更</td></tr>`
|
||||||
|
]
|
||||||
|
if (s.skippedBefore > 0) inner.push(skipHtml(s.skippedBefore, rows.slice(cursor, s.start)))
|
||||||
|
for (let j = s.start; j <= s.end; j++) inner.push(rowHtml(rows[j]))
|
||||||
|
cursor = s.end + 1
|
||||||
|
bodyParts.push(`<tbody class="hunk" id="seg-${i + 1}">\n${inner.join('\n')}\n</tbody>`)
|
||||||
|
})
|
||||||
|
if (segs.length > 0 && segs[segs.length - 1].skippedAfter > 0) {
|
||||||
|
const last = segs[segs.length - 1]
|
||||||
|
bodyParts.push(
|
||||||
|
`<tbody class="hunk">\n${skipHtml(last.skippedAfter, rows.slice(cursor))}\n</tbody>`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyBody =
|
||||||
|
rows.length === 0
|
||||||
|
? `<tbody><tr class="hunk-head"><td colspan="5">两侧均为空内容,没有可对比的行</td></tr></tbody>`
|
||||||
|
: bodyParts.join('\n')
|
||||||
|
|
||||||
|
const toc = rows.length === 0 ? '' : renderToc(rows, segs)
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
@@ -96,38 +446,41 @@ export function buildHtmlReport(
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DiffLens 差异报告</title>
|
<title>DiffLens 差异报告</title>
|
||||||
<style>
|
<style>${REPORT_CSS}</style>
|
||||||
* { margin:0; padding:0; box-sizing:border-box; }
|
|
||||||
body { font-family:'Segoe UI','Microsoft YaHei',system-ui,sans-serif; background:#0a0e17; color:#dbe3f4; padding:24px; }
|
|
||||||
.wrap { max-width:1100px; margin:0 auto; }
|
|
||||||
h1 { font-size:20px; margin-bottom:16px; color:#c9bfff; }
|
|
||||||
.meta { color:#74809a; font-size:13px; line-height:1.8; margin-bottom:14px; }
|
|
||||||
.badge { display:inline-block; padding:2px 10px; border-radius:999px; font-size:12px; font-weight:700; margin-right:6px; }
|
|
||||||
.badge.ins { color:#7ee2a8; background:rgba(34,197,94,.14); }
|
|
||||||
.badge.del { color:#f7a9a9; background:rgba(248,113,113,.12); }
|
|
||||||
.badge.mod { color:#f6bd87; background:rgba(245,158,11,.12); }
|
|
||||||
table { border-collapse:collapse; width:100%; font-family:Consolas,'JetBrains Mono',monospace; font-size:12.5px; }
|
|
||||||
td { border-bottom:1px solid rgba(148,163,255,.1); padding:3px 8px; line-height:21px; white-space:pre; vertical-align:top; }
|
|
||||||
td.tag { width:22px; text-align:right; color:#74809a; }
|
|
||||||
td.lnl, td.lnr { width:48px; text-align:right; color:#46536e; }
|
|
||||||
tr.added { background:rgba(34,197,94,.12); }
|
|
||||||
tr.removed { background:rgba(248,113,113,.1); }
|
|
||||||
tr.modified { background:rgba(245,158,11,.1); }
|
|
||||||
.cell.empty { color:transparent; }
|
|
||||||
.ins { border-radius:2px; background:rgba(34,197,94,.3); }
|
|
||||||
.del { border-radius:2px; background:rgba(248,113,113,.3); text-decoration:line-through; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>🔍 DiffLens 差异报告</h1>
|
<header class="hero">
|
||||||
<div class="meta">${head.map((h) => esc(h)).join('<br>')}<br><b>${statsBadges}</b></div>
|
<div class="hero-title">🔍 DiffLens 差异报告</div>
|
||||||
<table>
|
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
|
||||||
<thead><tr><td class="tag"></td><td class="lnl">#</td><td>左文件</td><td class="lnr">#</td><td>右文件</td></tr></thead>
|
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
|
||||||
<tbody>
|
<div class="files">
|
||||||
${body}
|
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
|
||||||
</tbody>
|
<div class="files-arrow">➜</div>
|
||||||
|
${fileCard('right', ctx.rightName, ctx.rightEncoding, ctx.rightPath, rightLines)}
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
${statCard('ins', '新增行', `+${summary.inserted}`)}
|
||||||
|
${statCard('del', '删除行', `−${summary.deleted}`)}
|
||||||
|
${statCard('mod', '修改行', `~${summary.modified}`)}
|
||||||
|
${statCard('same', '未更改行', unchanged)}
|
||||||
|
${statCard('rate', '行变更率', rate)}
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="sw add"></i>+ 新增行</span>
|
||||||
|
<span><i class="sw del"></i>− 删除行</span>
|
||||||
|
<span><i class="sw mod"></i>~ 修改行</span>
|
||||||
|
<span><i class="sw word-ins"></i>绿底高亮 = 修改行里新增的文字</span>
|
||||||
|
<span><i class="sw word-del"></i>红底删除线 = 修改行里被移除的文字</span>
|
||||||
|
</div>
|
||||||
|
${toc}
|
||||||
|
</header>
|
||||||
|
<table class="diff-table">
|
||||||
|
<colgroup><col class="c-tag"><col class="c-ln"><col class="c-tx"><col class="c-ln"><col class="c-tx"></colgroup>
|
||||||
|
<thead><tr><td class="tag"></td><td class="lnl">#</td><td>左文件(原始)</td><td class="lnr">#</td><td>右文件(对比)</td></tr></thead>
|
||||||
|
${emptyBody}
|
||||||
</table>
|
</table>
|
||||||
|
<div class="foot">由 DiffLens 生成 · 相同内容段已折叠,点击可展开查看全部</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -593,6 +593,12 @@ button:disabled {
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 底部工具栏的下拉改向上弹出,避免溢出窗口底边(导出菜单专用修饰类) */
|
||||||
|
.export-menu.up {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
|
|||||||
Reference in New Issue
Block a user