@@ -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'
}
}