feat: v0.2.0 导出差异报告 - HTML/纯文本/Markdown 三种格式

This commit is contained in:
2026-08-17 17:40:09 +08:00
parent fb9a202d97
commit ff0018e6d3
10 changed files with 351 additions and 10 deletions
+16
View File
@@ -88,6 +88,22 @@ ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
})
/** IPC: 保存差异报告(系统保存对话框 + 写入内容) */
ipcMain.handle('file:save-report', async (_event, content: string, defaultName: string) => {
const ext = defaultName.split('.').pop() ?? 'txt'
const result = await dialog.showSaveDialog({
title: '保存差异报告',
defaultPath: join(app.getPath('documents'), defaultName),
filters: [
{ name: '差异报告', extensions: [ext] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (result.canceled || !result.filePath) return { ok: false, path: null }
fs.writeFileSync(result.filePath, content, 'utf-8')
return { ok: true, path: result.filePath }
})
/** IPC: 打开系统文件管理器定位文件 */
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
shell.showItemInFolder(filePath)
+2
View File
@@ -14,6 +14,8 @@ const api = {
ipcRenderer.invoke('file:open', side ?? null),
decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> =>
ipcRenderer.invoke('file:decode-buffer', buffer),
saveReport: (content: string, defaultName: string): Promise<{ ok: boolean; path: string | null }> =>
ipcRenderer.invoke('file:save-report', content, defaultName),
showInFolder: (filePath: string): Promise<boolean> =>
ipcRenderer.invoke('file:show-in-folder', filePath),
setClipboard: (text: string): Promise<boolean> =>
+35
View File
@@ -11,9 +11,19 @@ import type { DiffResult, DiffSummary } from './diff/diffEngine'
import DiffView from './components/DiffView'
import Toolbar from './components/Toolbar'
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
import { buildReport, REPORT_EXT, type ReportFormat, type ReportContext } from './diff/report'
import type { PaneMeta } from './components/DiffView'
import type { SideCell } from './diff/diffEngine'
/** 生成默认报告文件名所需的时间戳:YYYYMMDD-HHmmss */
function stamp(): string {
const d = new Date()
const p = (n: number): string => String(n).padStart(2, '0')
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(
d.getMinutes()
)}${p(d.getSeconds())}`
}
interface PaneState {
meta: PaneMeta
path: string
@@ -80,6 +90,12 @@ export default function App(): ReactElement {
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [navIndex, setNavIndex] = useState(0)
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
const [toast, setToast] = useState<string | null>(null)
const showToast = useCallback((text: string) => {
setToast(text)
window.setTimeout(() => setToast(null), 3600)
}, [])
const openPane = useCallback(async (side: 'left' | 'right') => {
const data = await window.api.openFile(side)
@@ -159,6 +175,22 @@ export default function App(): ReactElement {
[paneL, paneR]
)
const exportReport = useCallback(
async (fmt: ReportFormat) => {
const ctx: ReportContext = {
leftName: paneL?.meta.name ?? '',
rightName: paneR?.meta.name ?? '',
leftEncoding: paneL?.meta.encoding ?? '',
rightEncoding: paneR?.meta.encoding ?? ''
}
const content = buildReport(diff.rows, ctx, summary, fmt)
const defaultName = `DiffLens-report-${stamp()}.${REPORT_EXT[fmt]}`
const res = await window.api.saveReport(content, defaultName)
if (res.ok && res.path) showToast(`报告已保存:${res.path}`)
},
[diff, paneL, paneR, summary, showToast]
)
const anyPane = paneL !== null || paneR !== null
const leftMeta = paneL?.meta ?? null
@@ -211,6 +243,8 @@ export default function App(): ReactElement {
navIndex={navIndex}
navCount={changedRows.length}
onNav={go}
onExport={(fmt) => void exportReport(fmt)}
canExport={anyPane}
/>
</>
) : (
@@ -243,6 +277,7 @@ export default function App(): ReactElement {
</footer>
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
{toast && <div className="toast">{toast}</div>}
</div>
)
}
+40 -2
View File
@@ -1,11 +1,18 @@
import { type ReactElement } from 'react'
import { useState, type ReactElement } from 'react'
import { DiffSummary } from '../diff/diffEngine'
import { type ReportFormat } from '../diff/report'
export interface DiffOptions {
trimWhitespace: boolean
ignoreCase: boolean
}
const FORMATS: { fmt: ReportFormat; label: string }[] = [
{ fmt: 'html', label: 'HTML 报告' },
{ fmt: 'txt', label: '纯文本' },
{ fmt: 'md', label: 'Markdown' }
]
interface ToolbarProps {
options: DiffOptions
onOptionsChange: (options: DiffOptions) => void
@@ -13,6 +20,8 @@ interface ToolbarProps {
navIndex: number
navCount: number
onNav: (dir: 1 | -1) => void
onExport: (fmt: ReportFormat) => void
canExport: boolean
}
export default function Toolbar({
@@ -21,9 +30,12 @@ export default function Toolbar({
summary,
navIndex,
navCount,
onNav
onNav,
onExport,
canExport
}: ToolbarProps): ReactElement {
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
const [exportOpen, setExportOpen] = useState(false)
const canNav = navCount > 0
@@ -71,6 +83,32 @@ export default function Toolbar({
</button>
</div>
<div className="tool-group export-wrap">
<button
className="btn primary"
disabled={!canExport}
onClick={() => setExportOpen((o) => !o)}
>
</button>
{exportOpen && (
<div className="export-menu">
{FORMATS.map((f) => (
<button
key={f.fmt}
className="ctx-item"
onClick={() => {
setExportOpen(false)
onExport(f.fmt)
}}
>
{f.label}
</button>
))}
</div>
)}
</div>
<div className="header-spacer" style={{ margin: 0 }} />
<div className="stats">
+198
View File
@@ -0,0 +1,198 @@
import { DiffRow, DiffSummary, SideCell } from './diffEngine'
export type ReportFormat = 'html' | 'txt' | 'md'
export interface ReportContext {
/** 左侧文件显示名;未选择为空串 */
leftName: string
/** 右侧文件显示名 */
rightName: string
/** 左侧编码标注 */
leftEncoding: string
/** 右侧编码标注 */
rightEncoding: string
}
const esc = (s: string): string =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
/** 格式化为固定宽行号(右对齐) */
function padNo(n: number | null): string {
return n === null ? '' : String(n)
}
/** 单元格文本(词级高亮段在 HTML 里渲染为 span) */
function cellHtml(cell: SideCell): string {
if (cell.segs) {
return cell.segs
.map((s) => {
const cls = s.kind === 'insert' ? 'ins' : s.kind === 'delete' ? 'del' : 'ct'
return `<span class="${cls}">${esc(s.text)}</span>`
})
.join('')
}
return `<span>${esc(cell.text ?? '')}</span>`
}
const typeTag = (k: DiffRow['rowKind']): string =>
k === 'added' ? '+' : k === 'removed' ? '' : k === 'modified' ? '~' : ' '
const headLines = (
ctx: ReportContext,
summary: DiffSummary,
title: string
): string[] => [
title,
`对比:${ctx.leftName || '(未选择)'}${ctx.leftEncoding ? ` (${ctx.leftEncoding})` : ''}${ctx.rightName || '(未选择)'}${ctx.rightEncoding ? ` (${ctx.rightEncoding})` : ''}`,
`生成时间:${new Date().toLocaleString('zh-CN')}`,
`变更统计:新增 ${summary.inserted} 删除 ${summary.deleted} 修改 ${summary.modified}`
]
/** ============ HTML 报告 ============ */
export function buildHtmlReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
): 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}">
<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(' ')
return `<!doctype html>
<html lang="zh-CN">
<head>
<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>
</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>
</table>
</div>
</body>
</html>`
}
/** ============ 纯文本报告 ============ */
export function buildTxtReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
): string {
const head = headLines(ctx, summary, '====== DiffLens 差异报告 ======')
const lines = [...head, '----------------------------------']
for (const row of rows) {
const tag = typeTag(row.rowKind)
if (row.rowKind === 'modified') {
// 修改行:左/右并置
const l = padNo(row.left.lineNo) || ' '
const r = padNo(row.right.lineNo) || ' '
lines.push(`~ ${l} | ${row.left.text ?? ''} ==> ${r} | ${row.right.text ?? ''}`)
} else {
const no = padNo(
row.rowKind === 'added' ? row.right.lineNo : row.left.lineNo
)
const text = row.rowKind === 'added' ? row.right.text : row.left.text
lines.push(`${tag} ${no} | ${text ?? ''}`)
}
}
lines.push('----------------------------------')
lines.push('生成于 DiffLens')
return lines.join('\n')
}
/** ============ Markdown 报告 ============ */
export function buildMarkdownReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary
): string {
const head = headLines(ctx, summary, '# DiffLens 差异报告')
const out: string[] = [...head.map((h) => `> ${h}`), '']
out.push('| 类型 | 左行号 | 左文件 | 右行号 | 右文件 |')
out.push('| --- | --- | --- | --- | --- |')
for (const row of rows) {
const tag = typeTag(row.rowKind)
const lno = padNo(row.left.lineNo)
const rno = padNo(row.right.lineNo)
const ltext = (row.left.text ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ')
const rtext = (row.right.text ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ')
// 为空的行用占位符避免表格塌陷
const lc = lno ? ltext : '>'
const rc = rno ? rtext : '>'
out.push(`| ${tag} | ${lno} | \`${lc}\` | ${rno} | \`${rc}\` |`)
}
out.push('')
out.push('> 生成于 DiffLens')
return out.join('\n')
}
/** 按格式生成报告字符串 */
export function buildReport(
rows: DiffRow[],
ctx: ReportContext,
summary: DiffSummary,
format: ReportFormat
): string {
if (format === 'html') return buildHtmlReport(rows, ctx, summary)
if (format === 'txt') return buildTxtReport(rows, ctx, summary)
return buildMarkdownReport(rows, ctx, summary)
}
export const REPORT_EXT: Record<ReportFormat, string> = {
html: 'html',
txt: 'txt',
md: 'md'
}
+51
View File
@@ -546,6 +546,57 @@ button:disabled {
opacity: 0.4;
}
/* ============ 导出菜单与提示 ============ */
.export-wrap {
position: relative;
}
.export-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 148px;
padding: 5px;
border-radius: 10px;
background: rgba(16, 22, 34, 0.96);
border: 1px solid var(--border-strong);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6), 0 0 24px rgba(124, 108, 255, 0.18);
backdrop-filter: blur(8px);
z-index: 900;
display: flex;
flex-direction: column;
gap: 2px;
}
.toast {
position: fixed;
left: 50%;
bottom: 46px;
transform: translateX(-50%);
z-index: 1100;
max-width: 72vw;
padding: 9px 16px;
border-radius: 10px;
background: rgba(16, 22, 34, 0.96);
border: 1px solid var(--border-strong);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 20px rgba(34, 211, 238, 0.18);
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
animation: toast-in 0.2s ease;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 8px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* ============ 状态栏 ============ */
.status-bar {
flex: 0 0 auto;