feat: v0.2.0 导出差异报告 - HTML/纯文本/Markdown 三种格式
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
/** 格式化为固定宽行号(右对齐) */
|
||||
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'
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user