fix: v0.4.2 导出菜单溢出修复与 HTML 报告可读性重设计 - 底部工具栏导出菜单改向上弹出(修复超出窗口底边选不到)、HTML 报告全新设计(Hero 概览/人话结论/文件卡片/统计卡/图例/差异分块目录/相同段折叠可展开/打印亮色/窄屏适配,零 JS)、TXT 与 Markdown 报告头部补人话结论

This commit is contained in:
2026-08-18 09:37:07 +08:00
parent ffa47c7f1c
commit fe445598c9
9 changed files with 582 additions and 65 deletions
@@ -86,6 +86,12 @@ describe('Toolbar', () => {
expect(onExport).toHaveBeenCalledWith('html')
})
it('导出菜单向上弹出(工具栏贴窗口底部,防止溢出底边)', () => {
const { container } = setup()
fireEvent.click(screen.getByText('导出报告'))
expect(container.querySelector('.export-menu.up')).not.toBeNull()
})
it('点击外部区域关闭导出菜单', () => {
setup()
fireEvent.click(screen.getByText('导出报告'))
+1 -1
View File
@@ -125,7 +125,7 @@ export default function Toolbar({
</button>
{exportOpen && (
<div className="export-menu">
<div className="export-menu up">
{FORMATS.map((f) => (
<button
key={f.fmt}
+153 -4
View File
@@ -1,9 +1,104 @@
import { describe, it, expect } from 'vitest'
import { computeDiff } from './diffEngine'
import { buildHtmlReport, buildTxtReport, buildMarkdownReport, buildReport, REPORT_EXT } from './report'
import { computeDiff, type DiffRow } from './diffEngine'
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' }
/** 便捷构造行集: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', () => {
const { rows, summary } = computeDiff('line1\nline2', 'line1\nline3')
@@ -15,19 +110,67 @@ describe('buildHtmlReport', () => {
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('包含差异统计与类型高亮语义', () => {
const html = buildHtmlReport(rows, ctx, summary)
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', () => {
it('输出带增删改标记的行式文本', () => {
it('输出带增删改标记与人话结论的行式文本', () => {
const { rows, summary } = computeDiff('a', 'a\nb')
const txt = buildTxtReport(rows, ctx, summary)
expect(txt).toContain('DiffLens')
expect(txt).toContain('+')
expect(txt).toContain('结论:共 1 处不同:新增 1 行')
})
it('行号按最大位数右对齐填充', () => {
@@ -57,6 +200,12 @@ describe('buildMarkdownReport', () => {
expect(md).toContain('| 左行号 |')
})
it('引用块包含人话结论', () => {
const { rows, summary } = computeDiff('a', 'b')
const md = buildMarkdownReport(rows, ctx, summary)
expect(md).toContain('> 结论:共 1 处不同:修改 1 行')
})
it('管道符被转义以保持表格结构', () => {
const { rows, summary } = computeDiff('a|b', 'a')
const md = buildMarkdownReport(rows, ctx, summary)
+404 -51
View File
@@ -43,6 +43,79 @@ function cellHtml(cell: SideCell): string {
const typeTag = (k: DiffRow['rowKind']): string =>
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 = (
ctx: ReportContext,
summary: DiffSummary,
@@ -55,40 +128,317 @@ const headLines = (
ctx.rightPath ? ` [${ctx.rightPath}]` : ''
}`,
`对比时间:${new Date().toLocaleString('zh-CN')}`,
`结论:${plainSummary(summary)}`,
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}`
]
/** ============ HTML 报告 ============ */
export function buildHtmlReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
/** 报告内嵌样式:延续应用暗色科幻主题,附带打印亮色与窄屏适配(零 JS,任意浏览器可开) */
const REPORT_CSS = `
* { 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 {
const head = headLines(ctx, summary, 'DiffLens 差异报告')
const body = rows
.map((row) => {
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}">
return `<div class="file-card ${side}">
<div class="file-side">${side === 'left' ? '左侧 · 原始文件' : '右侧 · 对比文件'}</div>
<div class="file-name">${esc(name || '(未选择)')}</div>
<div class="file-meta">${esc(encoding || '未知编码')} · ${lines} 行</div>
${path ? `<div class="file-path">${esc(path)}</div>` : ''}
</div>`
}
/** 单行 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="lnl">${padNo(l.lineNo)}</td>
<td class="cell ${lCls}">${cellHtml(l)}</td>
<td class="lnr">${padNo(r.lineNo)}</td>
<td class="cell ${rCls}">${cellHtml(r)}</td>
</tr>`
})
.join('\n')
}
const statsBadges = [
['ins', `+${summary.inserted}`],
['del', `${summary.deleted}`],
['mod', `~${summary.modified}`]
]
.map(([c, t]) => `<span class="badge ${c}">${t}</span>`)
.join(' ')
/** 折叠行:大段相同内容收进 details,点开即得全量行(信息零丢失) */
function skipHtml(count: number, slice: DiffRow[]): string {
const inner = slice.map(rowHtml).join('\n')
return `<tr class="skip"><td colspan="5">
<details><summary>⋯ 相同内容 ${count} 行 · 点击展开 ⋯</summary>
<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>
${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>
<html lang="zh-CN">
@@ -96,38 +446,41 @@ export function buildHtmlReport(
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DiffLens 差异报告</title>
<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>
<style>${REPORT_CSS}</style>
</head>
<body>
<div class="wrap">
<h1>🔍 DiffLens 差异报告</h1>
<div class="meta">${head.map((h) => esc(h)).join('<br>')}<br><b>${statsBadges}</b></div>
<table>
<thead><tr><td class="tag"></td><td class="lnl">#</td><td>左文件</td><td class="lnr">#</td><td>右文件</td></tr></thead>
<tbody>
${body}
</tbody>
<header class="hero">
<div class="hero-title">🔍 DiffLens 差异报告</div>
<div class="hero-time">生成于 ${new Date().toLocaleString('zh-CN')}</div>
<div class="verdict${verdictOk ? ' ok' : ''}">${esc(plainSummary(summary))}</div>
<div class="files">
${fileCard('left', ctx.leftName, ctx.leftEncoding, ctx.leftPath, leftLines)}
<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>
<div class="foot">由 DiffLens 生成 · 相同内容段已折叠,点击可展开查看全部</div>
</div>
</body>
</html>`
@@ -216,4 +569,4 @@ export const REPORT_EXT: Record<ReportFormat, string> = {
html: 'html',
txt: 'txt',
md: 'md'
}
}
+6
View File
@@ -593,6 +593,12 @@ button:disabled {
gap: 2px;
}
/* 底部工具栏的下拉改向上弹出,避免溢出窗口底边(导出菜单专用修饰类) */
.export-menu.up {
top: auto;
bottom: calc(100% + 6px);
}
.toast {
position: fixed;
left: 50%;