diff --git a/README.md b/README.md index e39dbbb..ede706d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两 - **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码 - **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景 - **差异统计徽章**:实时统计新增 / 删除 / 修改行数 +- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档 - **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开 - **滚动联动**:左右两个面板滚动位置自动同步 @@ -83,7 +84,7 @@ src/ - 作者:thzxx - 组织:MetonaTeam - 开源协议:MIT License(见 [LICENSE](./LICENSE)) -- 版本:0.1.0 +- 版本:0.2.0 --- diff --git a/docs/应用开发与版本迭代规范.md b/docs/应用开发与版本迭代规范.md index 9e71e84..a34bcf6 100644 --- a/docs/应用开发与版本迭代规范.md +++ b/docs/应用开发与版本迭代规范.md @@ -55,8 +55,8 @@ z = 补丁版本号(Patch) ``` 0.1.0 ← 初始版本(已发布) -0.1.1 ← 体验打磨补丁(当前):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正 -0.2.0 ← 新增功能 +0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正 +0.2.0 ← 导出差异报告(当前):HTML / 纯文本 / Markdown 三种格式 0.2.1 ← 修复该版本 Bug 0.3.0 ← 新增功能 ... diff --git a/package-lock.json b/package-lock.json index 16cc372..b72787e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "DiffLens", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "DiffLens", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "@electron-toolkit/preload": "^3.0.1", @@ -4762,7 +4762,7 @@ "license": "MIT" }, "node_modules/is-unicode-supported": { - "version": "0.1.1", + "version": "0.2.0", "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, @@ -7009,7 +7009,7 @@ } }, "node_modules/yocto-queue": { - "version": "0.1.1", + "version": "0.2.0", "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, diff --git a/package.json b/package.json index c8f642a..43e6081 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DiffLens", - "version": "0.1.1", + "version": "0.2.0", "description": "DiffLens — 精美酷炫的文本对比桌面应用", "author": "thzxx", "license": "MIT", diff --git a/src/main/index.ts b/src/main/index.ts index 451f290..c59e126 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/preload/index.ts b/src/preload/index.ts index 1eda44d..57d192b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('file:show-in-folder', filePath), setClipboard: (text: string): Promise => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5a538b8..02a3819 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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(null) const [navIndex, setNavIndex] = useState(0) const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null) + const [toast, setToast] = useState(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 { {menu && setMenu(null)} />} + {toast &&
{toast}
} ) } \ No newline at end of file diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index a9ddd20..4293e4e 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -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): void => onOptionsChange({ ...options, ...patch }) + const [exportOpen, setExportOpen] = useState(false) const canNav = navCount > 0 @@ -71,6 +83,32 @@ export default function Toolbar({ +
+ + {exportOpen && ( +
+ {FORMATS.map((f) => ( + + ))} +
+ )} +
+
diff --git a/src/renderer/src/diff/report.ts b/src/renderer/src/diff/report.ts new file mode 100644 index 0000000..c9db4de --- /dev/null +++ b/src/renderer/src/diff/report.ts @@ -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, '"') + +/** 格式化为固定宽行号(右对齐) */ +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 `${esc(s.text)}` + }) + .join('') + } + return `${esc(cell.text ?? '')}` +} + +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 ` + ${tag} + ${padNo(l.lineNo)} + ${cellHtml(l)} + ${padNo(r.lineNo)} + ${cellHtml(r)} +` + }) + .join('\n') + + const statsBadges = [ + ['ins', `+${summary.inserted}`], + ['del', `−${summary.deleted}`], + ['mod', `~${summary.modified}`] + ] + .map(([c, t]) => `${t}`) + .join(' ') + + return ` + + + + +DiffLens 差异报告 + + + +
+

🔍 DiffLens 差异报告

+
${head.map((h) => esc(h)).join('
')}
${statsBadges}
+ + + +${body} + +
#左文件#右文件
+
+ +` +} + +/** ============ 纯文本报告 ============ */ +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 = { + html: 'html', + txt: 'txt', + md: 'md' +} \ No newline at end of file diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index b126e18..79daa36 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -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;