feat: v0.2.0 导出差异报告 - HTML/纯文本/Markdown 三种格式
This commit is contained in:
@@ -15,6 +15,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
|||||||
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
- **智能编码识别**:自动探测 UTF-8 / UTF-16 / GBK(含 BOM),中文文档不乱码
|
||||||
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景
|
- **忽略选项**:支持“忽略行首尾空白”“忽略大小写”,适配弱差异场景
|
||||||
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
- **差异统计徽章**:实时统计新增 / 删除 / 修改行数
|
||||||
|
- **导出一键报告**:将对比结果导出为 HTML / 纯文本 / Markdown 三种格式,便于分享与归档
|
||||||
- **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开
|
- **拖拽与菜单双入口**:支持文件拖拽导入,也支持顶部菜单与快捷键打开
|
||||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ src/
|
|||||||
- 作者:thzxx
|
- 作者:thzxx
|
||||||
- 组织:MetonaTeam
|
- 组织:MetonaTeam
|
||||||
- 开源协议:MIT License(见 [LICENSE](./LICENSE))
|
- 开源协议:MIT License(见 [LICENSE](./LICENSE))
|
||||||
- 版本:0.1.0
|
- 版本:0.2.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -55,8 +55,8 @@ z = 补丁版本号(Patch)
|
|||||||
|
|
||||||
```
|
```
|
||||||
0.1.0 ← 初始版本(已发布)
|
0.1.0 ← 初始版本(已发布)
|
||||||
0.1.1 ← 体验打磨补丁(当前):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
0.1.1 ← 体验打磨补丁(已发布):拖拽编码识别 · 行级右键菜单 · 尾部换行显示修正
|
||||||
0.2.0 ← 新增功能
|
0.2.0 ← 导出差异报告(当前):HTML / 纯文本 / Markdown 三种格式
|
||||||
0.2.1 ← 修复该版本 Bug
|
0.2.1 ← 修复该版本 Bug
|
||||||
0.3.0 ← 新增功能
|
0.3.0 ← 新增功能
|
||||||
...
|
...
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
@@ -4762,7 +4762,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-unicode-supported": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
|
||||||
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
|
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -7009,7 +7009,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yocto-queue": {
|
"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",
|
"resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DiffLens",
|
"name": "DiffLens",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||||
"author": "thzxx",
|
"author": "thzxx",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ ipcMain.handle('file:decode-buffer', (_event, buffer: ArrayBuffer) => {
|
|||||||
return decodeText(Buffer.from(buffer))
|
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: 打开系统文件管理器定位文件 */
|
/** IPC: 打开系统文件管理器定位文件 */
|
||||||
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
|
ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
|
||||||
shell.showItemInFolder(filePath)
|
shell.showItemInFolder(filePath)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const api = {
|
|||||||
ipcRenderer.invoke('file:open', side ?? null),
|
ipcRenderer.invoke('file:open', side ?? null),
|
||||||
decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> =>
|
decodeBuffer: (buffer: ArrayBuffer): Promise<{ text: string; encoding: string }> =>
|
||||||
ipcRenderer.invoke('file:decode-buffer', buffer),
|
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> =>
|
showInFolder: (filePath: string): Promise<boolean> =>
|
||||||
ipcRenderer.invoke('file:show-in-folder', filePath),
|
ipcRenderer.invoke('file:show-in-folder', filePath),
|
||||||
setClipboard: (text: string): Promise<boolean> =>
|
setClipboard: (text: string): Promise<boolean> =>
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import type { DiffResult, DiffSummary } from './diff/diffEngine'
|
|||||||
import DiffView from './components/DiffView'
|
import DiffView from './components/DiffView'
|
||||||
import Toolbar from './components/Toolbar'
|
import Toolbar from './components/Toolbar'
|
||||||
import ContextMenu, { type ContextMenuItem } from './components/ContextMenu'
|
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 { PaneMeta } from './components/DiffView'
|
||||||
import type { SideCell } from './diff/diffEngine'
|
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 {
|
interface PaneState {
|
||||||
meta: PaneMeta
|
meta: PaneMeta
|
||||||
path: string
|
path: string
|
||||||
@@ -80,6 +90,12 @@ export default function App(): ReactElement {
|
|||||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||||
const [navIndex, setNavIndex] = useState(0)
|
const [navIndex, setNavIndex] = useState(0)
|
||||||
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null)
|
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 openPane = useCallback(async (side: 'left' | 'right') => {
|
||||||
const data = await window.api.openFile(side)
|
const data = await window.api.openFile(side)
|
||||||
@@ -159,6 +175,22 @@ export default function App(): ReactElement {
|
|||||||
[paneL, paneR]
|
[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 anyPane = paneL !== null || paneR !== null
|
||||||
|
|
||||||
const leftMeta = paneL?.meta ?? null
|
const leftMeta = paneL?.meta ?? null
|
||||||
@@ -211,6 +243,8 @@ export default function App(): ReactElement {
|
|||||||
navIndex={navIndex}
|
navIndex={navIndex}
|
||||||
navCount={changedRows.length}
|
navCount={changedRows.length}
|
||||||
onNav={go}
|
onNav={go}
|
||||||
|
onExport={(fmt) => void exportReport(fmt)}
|
||||||
|
canExport={anyPane}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -243,6 +277,7 @@ export default function App(): ReactElement {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||||
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { type ReactElement } from 'react'
|
import { useState, type ReactElement } from 'react'
|
||||||
import { DiffSummary } from '../diff/diffEngine'
|
import { DiffSummary } from '../diff/diffEngine'
|
||||||
|
import { type ReportFormat } from '../diff/report'
|
||||||
|
|
||||||
export interface DiffOptions {
|
export interface DiffOptions {
|
||||||
trimWhitespace: boolean
|
trimWhitespace: boolean
|
||||||
ignoreCase: boolean
|
ignoreCase: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORMATS: { fmt: ReportFormat; label: string }[] = [
|
||||||
|
{ fmt: 'html', label: 'HTML 报告' },
|
||||||
|
{ fmt: 'txt', label: '纯文本' },
|
||||||
|
{ fmt: 'md', label: 'Markdown' }
|
||||||
|
]
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
options: DiffOptions
|
options: DiffOptions
|
||||||
onOptionsChange: (options: DiffOptions) => void
|
onOptionsChange: (options: DiffOptions) => void
|
||||||
@@ -13,6 +20,8 @@ interface ToolbarProps {
|
|||||||
navIndex: number
|
navIndex: number
|
||||||
navCount: number
|
navCount: number
|
||||||
onNav: (dir: 1 | -1) => void
|
onNav: (dir: 1 | -1) => void
|
||||||
|
onExport: (fmt: ReportFormat) => void
|
||||||
|
canExport: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbar({
|
export default function Toolbar({
|
||||||
@@ -21,9 +30,12 @@ export default function Toolbar({
|
|||||||
summary,
|
summary,
|
||||||
navIndex,
|
navIndex,
|
||||||
navCount,
|
navCount,
|
||||||
onNav
|
onNav,
|
||||||
|
onExport,
|
||||||
|
canExport
|
||||||
}: ToolbarProps): ReactElement {
|
}: ToolbarProps): ReactElement {
|
||||||
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
const set = (patch: Partial<DiffOptions>): void => onOptionsChange({ ...options, ...patch })
|
||||||
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
|
||||||
const canNav = navCount > 0
|
const canNav = navCount > 0
|
||||||
|
|
||||||
@@ -71,6 +83,32 @@ export default function Toolbar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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="header-spacer" style={{ margin: 0 }} />
|
||||||
|
|
||||||
<div className="stats">
|
<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;
|
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 {
|
.status-bar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user