feat: 0.10.2 大快照加载修复、快照保存防抖与关于弹框 - 新增snapshot:read专用读取IPC(128MB上限,修复保存32MB内容上限与加载10MB通用上限不对称导致大快照保存成功却永远无法加载的自相矛盾,顺带消除加载污染lastDir记忆)、快照保存中保存/加载按钮saving禁用与App守卫双保险(computing-overlay不拦截交互可并发触发两轮保存对话框)、同目录防呆路径归一化判定(复用normDir,Windows异写路径不再绕过)、关于弹框(header常驻ⓘ按钮,app:get-version取版本号,作者/双邮箱/组织/开源地址/许可证,点击复制走主进程剪贴板)、删除孤儿CSS类.diff-empty-pane与folderTree注释补unreadable,新增14单测与2条E2E(626单测+E2E 29全过覆盖率98.15/93.49/93.65/98.15),文档四件套同步
This commit is contained in:
@@ -12,7 +12,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
- **双栏并排对比**:左(原文件)右(对比文件)对照,行号清晰对齐
|
||||
- **行级 + 词级双重高亮**:整行增删一目了然,同一行内的细微改动也能被精确定位
|
||||
- **文件夹对比**:选择两个文件夹递归对比全部文件——相同 / 语义同 / 不同 / 仅左 / 仅右 / 无法读取六种状态、字节级内容判定(超大文件采样近似判定并标注)、大小一致条目并发比对(大目录扫描不逐文件等待)、**扫描进度反馈**(比对阶段实时显示"已比对 N / M")、**子目录树形展示**(目录聚合子树状态,点击折叠展开)、**文件名搜索**与**状态精确过滤**(点击统计徽章)与**扩展名多选过滤**、**忽略规则**(gitignore 简化子集含高级 glob:`node_modules/` 目录整树剪枝、`*.log` 通配、`lo?g` 单字符、`*.[ch]` 字符类(范围与取反)、`src/temp` 多段路径、`build/**/cache` 跨段、`!keep.log` 否定规则(最后匹配优先);无效规则实时提示不再静默丢弃;规则随偏好记忆)、**双击目录行聚合对比**(差异文件导航限定在该子树内批量审查)、单文件被锁/权限不足自动降级标记不中断整体扫描;双击任一文件进入单文件对比,**差异文件导航**(Alt+↓/Alt+↑),一键返回文件夹列表;**增量重扫**——重新扫描只对变更文件重判(size+mtime 指纹一致的条目零 IO 沿用判定,语义判等开关切换零 IO 纯内存组合,"重新扫描"按钮不重选目录直接刷新);**增量缓存跨会话持久化**(扫描缓存落盘 userData,重启后同目录对自动增量重扫)
|
||||
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
|
||||
- **快照基线与内容级对照**:把当前文件夹对比结果保存为快照(JSON 文件,**可选携带差异条目的双侧文件内容**,保存期间批量读取实时显示"已读取 N / M"进度,保存中防重复触发),日后加载与最新结果对照——已修复 / 新增差异 / 新文件 / 已移除 / 仍有差异 / 保持一致六类状态变迁统计与条目标注,点击徽章按变迁过滤,对照结果随重扫自动刷新;对照模式双击含基线内容的条目进入"**快照基线 vs 当前文件**"的单文件内容对比(完整复用比较选项/字符级/搜索/导出能力,一眼看穿"从什么变成了什么");内容体积双限防膨胀(单文件 2MB、总量 32MB 超限降级为纯状态快照,保存永不失败);**大快照专用读取通道**(加载上限 128MB,通用文件的 10MB 上限不拦截自家保存的大快照,且不污染文件对话框的目录记忆);对照模式导出的文件夹报告附**逐条变迁对照表**(变迁 / 当前状态 / 基线状态 / 大小,三格式);快照目录对匹配做路径归一化(Windows 同目录异写不误拦),截断的扫描结果禁止保存快照、当前结果截断时也禁止加载对照(双侧防对照误报),快照与当前语义判等口径不一致时加载提示甄别,v1 旧快照兼容加载(纯状态对照)
|
||||
- **语义判等**:换行符(CRLF/LF)、缩进、空行、排版重排差异的文本文件计为“语义同”,不计入差异——消除跨平台/编辑器换行符误报;可开关(默认开启),判定在后台 worker 解码完成
|
||||
- **文件夹报告导出**:对比结果导出为 HTML / 纯文本 / Markdown 三格式(全量条目 + 判定口径 + 统计概览)
|
||||
- **虚拟滚动**:仅渲染可见行,数万行大文件滚动依然流畅
|
||||
@@ -39,6 +39,7 @@ DiffLens 是一款跨平台桌面文本对比工具,帮助你快速定位两
|
||||
- **右键菜单键盘导航**:行级右键菜单支持 ↑/↓ 移动高亮(跳过禁用项、首尾循环)、Enter 触发、Esc 关闭,鼠标悬停与键盘高亮同步
|
||||
- **滚动联动**:左右两个面板滚动位置自动同步
|
||||
- **性能基准 E2E**:5 万行差异计算与 300KB GBK 解码带耗时上限断言(真实 Electron 链路),性能回归在发布前即可拦截
|
||||
- **关于弹框**:header 右侧 ⓘ 图标一键查看应用介绍、版本号、作者(thzxx)、联系邮箱、组织(MetonaTeam)、开源地址与许可证;邮箱/地址点击复制到剪贴板
|
||||
|
||||
---
|
||||
|
||||
@@ -116,7 +117,7 @@ e2e/ # Playwright E2E(对构建产物启动真实 Electron 验收主
|
||||
- 作者:thzxx
|
||||
- 组织:MetonaTeam
|
||||
- 许可证:MIT License(见 [LICENSE](./LICENSE))
|
||||
- 版本:0.10.1
|
||||
- 版本:0.10.2
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-1
@@ -20,7 +20,7 @@ z = 补丁版本号(Patch)
|
||||
> **`x` 永远是 `0`,永远不要提升到 `1.0.0`。**
|
||||
> 版本迭代**只允许修改 `y` 和 `z`**,`x` 保持 `0` 不变。
|
||||
|
||||
当前基线版本:**`0.10.1`**
|
||||
当前基线版本:**`0.10.2`**
|
||||
|
||||
---
|
||||
|
||||
@@ -130,6 +130,13 @@ z = 补丁版本号(Patch)
|
||||
- 快照保存读取进度:保存快照批量读取差异条目内容期间遮罩显示"已读取 N / M"(文件数口径:去重后双侧路径总数,按 200/批推进,与扫描进度反馈对称,大差异目录不再无反馈干等);保存结束/失败进度随遮罩复位
|
||||
- 差异文件导航游标修复:导航游标不在差异序列时导航组直接隐藏——此前显示"—"计数且"下一个"按钮可点无响应(goDiffFile 静默 return);触发路径为双击 same/语义同条目进入对比(0.7.0 遗留)与快照对照模式双击 fixed/regressed/new 等当前无差异条目进入内容对照(0.10.0 放大暴露面);双击差异条目即回序列、导航恢复
|
||||
- 测试与文档:新增 16 用例(ignoreRules 字符通配 12 / folderScan 字符通配集成 1 / App 导航隐藏 2 与读取进度 1)并修复 0.10.0 遗留的 snapshotDiff.test 版本号类型错误(makeSnapshot 传 3/0 违反 1|2 字面量联合,改展开构造),共 612 单测 + 27 E2E
|
||||
0.10.2 ← 大快照加载修复 · 快照保存防抖 · 关于弹框(已发布):
|
||||
- 大快照加载修复(保存/加载上限不对称):保存侧快照内容总量上限 32M 字符(中文 JSON 化可达 ~96MB),但加载走 file:open 通用通道被 10MB 上限拦截——大差异目录的快照"保存成功却永远无法加载";新增 snapshot:read 专用 IPC(JSON 过滤对话框 + 128MB 读取上限 + UTF-8 直读不做编码探测),顺带消除快照加载污染文件对话框 lastDir 记忆的副作用
|
||||
- 快照保存中防抖:保存遮罩为纯视觉反馈(computing-overlay 的 pointer-events:none 不拦截交互),此前保存期间可再点"保存/加载快照"并发触发两轮保存对话框——现 FolderView 保存/加载按钮随 saving 禁用(与扫描中禁用同语言),App.loadSnapshot 增加保存中守卫双保险
|
||||
- 同目录防呆归一化:两次选择同目录的判定从严格字符串比较改为路径归一化后比较(复用 snapshotDiff 导出的 normDir,与 scanCacheStore 同款逻辑)——Windows 异写路径(大小写/分隔符差异)不再绕过防呆
|
||||
- 关于弹框:header 右侧常驻 ⓘ 图标按钮(两种模式可见)→ 弹框展示应用 Logo/介绍/版本号(app:get-version IPC 取 app.getVersion(),package.json 为唯一来源,dev 与打包一致)/作者 thzxx/邮箱(stinanimz@gmail.com、1440196015@qq.com)/组织 MetonaTeam/开源地址/许可证 MIT;Esc/遮罩/关闭按钮关闭,邮箱与开源地址点击复制(复用主进程剪贴板管线)
|
||||
- 微清理:删除孤儿 CSS 类 .diff-empty-pane(0.9.2 清理孤儿变量的漏网);folderTree 聚合优先级注释补 unreadable
|
||||
- 测试与文档:新增 14 单测(AboutModal 9 / App 4:关于打开关闭·复制·文件夹模式可见·异写防呆 / FolderView 1:saving 禁用)与 2 条 E2E(关于弹框真实链路含版本号与复制 / 11MB 大快照加载成功固化上限修复),共 626 单测 + 29 E2E
|
||||
...
|
||||
0.y.z ← 长期停留,永不进入 1.x
|
||||
```
|
||||
|
||||
+12
-5
@@ -45,7 +45,7 @@ vitest.config.ts # Vitest 配置(jsdom + 测试范围含
|
||||
playwright.config.ts # Playwright E2E 配置(对 out/ 产物启动真实 Electron,发布前必跑)
|
||||
e2e/
|
||||
├── helpers.ts # E2E 辅助(启动应用/对话框 stub/临时 fixture/粘贴入口/合成拖放)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照/拖拽导入/偏好重启)
|
||||
└── app.spec.ts # E2E 用例(启动/粘贴/编码链路/选项/导航/导出写盘/文件夹对比/增量重扫/忽略规则含高级 glob/扫描进度/快照含内容级对照与大快照专用通道/关于弹框/拖拽导入/偏好重启)
|
||||
src/shared/
|
||||
├── ignoreRules.ts # 忽略规则匹配器纯逻辑(gitignore 简化子集含高级 glob 与段内字符通配——? 单字符/[abc] 字符类;主进程扫描剪枝与渲染端无效提示共用,段模式预编译 + DP 段序列匹配 + 否定仲裁)
|
||||
└── ignoreRules.test.ts # 忽略规则单元测试(名字/目录/通配回归 + 多段/锚定/跨段/段内通配/否定仲裁/无效清单/上限/IPC 校验 + ? 与字符类专项)
|
||||
@@ -85,13 +85,16 @@ src/renderer/src/
|
||||
│ ├── DiffView.test.tsx # 对比视图(渲染/滚动同步/右键/拖放/搜索命中行内高亮)
|
||||
│ ├── ErrorBoundary.tsx # 顶层渲染错误边界(错误面板 + 一键重载,防白屏死机)
|
||||
│ ├── ErrorBoundary.test.tsx # 渲染错误兜底测试(抛错子组件 fallback/正常子组件透传)
|
||||
│ ├── AboutModal.tsx # 「关于」弹框(应用介绍/版本/作者/邮箱/组织/开源地址/许可证;邮箱与地址点击复制)
|
||||
│ ├── AboutModal.test.tsx # 关于弹框测试(元信息渲染/版本占位/复制回调/三种关闭方式/卸载监听清理)
|
||||
│ ├── LogoIcon.tsx # 应用 Logo 图标(App 头部与关于弹框共用单一实现)
|
||||
│ ├── folderTree.ts # 文件夹条目树纯逻辑(建树/状态聚合/扁平化/过滤含扩展名/子树收集)
|
||||
│ ├── folderTree.test.ts # 条目树单元测试(聚合排序/折叠/过滤/搜索/扩展名过滤/relExt)
|
||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/双击/导出菜单/虚拟滚动/resize 重算)
|
||||
│ ├── FolderView.test.tsx # 文件夹视图(树形渲染/折叠/统计徽章点击过滤/搜索/扩展名下拉/忽略规则下拉含无效规则提示与进度遮罩/快照对照模式含变迁过滤归零复位与内容对照 title/目录双击/重新扫描与重新选择/快照保存中 saving 禁用/双击/导出菜单/虚拟滚动/resize 重算)
|
||||
│ ├── TextInputModal.test.tsx # 粘贴文本弹窗
|
||||
│ └── Toolbar.test.tsx # 工具栏(选项/导航/导出菜单向上弹出/计算中禁用/字符级开关与空白选项联动灰显勾选+已包含徽标+点击解释+已降级与部分降级标记/搜索框计数与 Enter·Shift+Enter·Esc 键盘交互/ref 转发)
|
||||
└── __tests__/
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃)
|
||||
├── App.test.tsx # 应用集成级测试(加载/拖拽/粘贴/导出/容错/toast 队列/清空确认/状态栏行数/大输入回退/单文件视图搜索/Ctrl+F/文件夹模式进出与双击(含同目录异写归一化防呆)/语义判等链路/差异文件导航(含游标不在序列时导航隐藏)/扫描竞态丢弃过期结果/增量重扫携带缓存/目录聚合导航/字符级部分降级/忽略规则链路与持久化/快照保存加载对照与拦截(加载走 snapshot:read 专用通道)/快照内容级对照(v2 保存含内容与读取进度·超限降级·双击基线vs当前·v1 兼容)/扫描进度显示与过期丢弃/关于弹框(打开·元信息·复制·双模式可见·Esc 关闭))
|
||||
└── main.test.ts # 入口挂载冒烟测试
|
||||
```
|
||||
|
||||
@@ -147,8 +150,9 @@ src/renderer/src/
|
||||
- 忽略所有空白:仅行内空白不同的两侧文本开启后判为完全一致;导出报告携带比较选项说明
|
||||
- 单文件视图搜索(0.9.3):输入关键字即时命中计数与首个命中行 active 定位 · Enter/Shift+Enter 命中行间推进回退(行号随动)· 首尾循环 · 大小写不敏感 · 无命中 0/0 且 Enter 不崩溃不推进 · Esc 清空复位 · Ctrl+F 聚焦搜索框 · 命中行被“仅看差异”折叠时计数仍统计全量行、跳转 toast 提示且不推进
|
||||
- 大输入(超快路径阈值)在无 Worker 环境去抖到期后回退同步计算,完成后无计算中遮罩
|
||||
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入 · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
|
||||
- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"(0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复)
|
||||
- 文件夹模式:依次选择两侧目录进入(路径卡片/统计徽章/状态栏切换/默认仅看差异过滤 same) · 任一次选择取消不进入 · 两次选择同一目录防呆提示且不进入(含 Windows 异写路径归一化判定,0.10.2) · 扫描失败 toast 且不进入 · 双击差异条目加载两侧进入单文件对比并可返回文件夹(返回清空两侧面板) · 双击单侧条目加载存在侧并清空另一侧 · 双击条目任一侧读取失败(error 返回 / IPC 异常)清空失败侧显示未选择(不残留旧对比内容与旧文件名) · 「返回文件对比」清空文件夹状态回单文件空态(先有文件对比再进文件夹的场景同样清空,不残留旧对比) · 扫描竞态:慢的旧扫描后完成被会话序号丢弃(不覆盖新扫描的目录与条目、不残留扫描遮罩);扫描进行中退出文件夹对比后迟到的结果不把界面拉回文件夹模式
|
||||
- 快照基线(0.9.0,0.9.1 收尾,0.9.2 对称防呆,0.10.0 内容级对照):保存快照落盘内容为合法快照 JSON(含当前结果与目录对) · 截断结果保存快照被拦截提示且不落盘(0.9.1,防对照误报) · 当前扫描结果截断时加载快照同样被拦截(0.9.2,不弹文件选择——截断缺失文件会被误报已移除) · 对照模式中重扫触发截断自动退出对照并提示(0.9.2) · 快照语义判等口径与当前开关不一致时加载提示含口径差异、一致时无提示(0.9.2) · 加载快照对照模式渲染变迁统计与条目标注 · 目录对不一致 / 快照文件无效 / 读取失败拦截提示 · 对照模式导出报告携带快照口径说明与变迁对照明细(0.9.1) · 退出对照恢复常规视图 · 对照随重扫自动刷新 · 快照内容级对照(0.10.0):保存时差异条目双侧内容入 contents(批量读取按路径返回、same 条目不存、单文件超限侧不读该侧 null)/ 内容总量超限整体降级为纯状态快照并提示(vi.mock 压低上限构造)/ 对照模式双击含基线内容条目进入"快照基线 vs 当前"内容对比(左面板快照基线标注、右面板当前文件、有差异徽章)/ v1 快照双击走常规当前文件对比(兼容回归锚) · 保存读取分批进度遮罩"已读取 N / M"(0.10.1,第二批挂起构造断言,结束复位) · 双击 same 条目 / 对照模式双击 fixed 条目进入对比后差异文件导航隐藏(0.10.1,游标不在差异序列;双击差异条目导航恢复) · 加载走 snapshot:read 专用读取通道(0.10.2,mock 随通道迁移;保存/加载上限不对称修复的通道基础)
|
||||
- 关于弹框(0.10.2):点击 header ⓘ 按钮打开(版本/作者/双邮箱/组织/开源地址/许可证全量断言,版本号来自 app:get-version) · Esc 关闭 · 点击邮箱触发剪贴板复制与 toast 反馈 · 文件夹模式下入口同样可见
|
||||
|
||||
### 5.6 diff 计算 worker 化(`useDiff.ts` / `createDiffWorker.ts`)
|
||||
- 快路径:两侧总行数不超过 2000 时同步计算,不创建 worker,结果与 computeDiff 一致
|
||||
@@ -191,6 +195,7 @@ src/renderer/src/
|
||||
- 扫描进度遮罩(0.10.0):progress props 非空时遮罩显示"已比对 N / M",null 时仅基础文案
|
||||
- 过滤:仅看差异隐藏 same 与 semantic-same;徽章点击状态精确过滤(激活态高亮、与仅看差异互斥联动)
|
||||
- 扫描中交互防抖(0.9.2):扫描进行中语义判等开关与忽略规则入口禁用(触发重扫的入口,点击不触发回调/不打开下拉);仅看差异开关与搜索仍可用(纯视图过滤不触发重扫);「重新选择」扫描中保持可用(0.9.3 固化:发起新会话作废在途扫描,与「返回文件对比」同类)
|
||||
- 快照保存中防抖(0.10.2):saving=true 时保存/加载快照按钮禁用(点击不触发回调);重新扫描/重新选择/导出/返回不受影响(仅快照链路防抖)
|
||||
- 目录行双击(0.8.0):触发 onOpenDir 携带目录相对路径;双击前折叠态自动展开(toggle 残留消除);重新扫描按钮触发 onRescan 且扫描中禁用
|
||||
- 双击文件回调携带原始条目;大小格式化(B/KB 与缺失占位);approximate 渲染 ≈ 标注;语义同条目双击提示;截断提示;空态提示(无文件 / 全部一致 / 过滤无结果)
|
||||
- 虚拟滚动:大列表仅渲染可见窗口、容器总高度按树形扁平化行数撑满
|
||||
@@ -214,6 +219,8 @@ src/renderer/src/
|
||||
- 增量缓存跨会话持久化(0.9.0):第一实例扫描后 userData/scan-cache.json 落盘(等待异步写盘,断言含条目与合法结构)→ 同 userData 第二实例重扫同目录结果正确(主进程注入盘上缓存)
|
||||
- 快照基线(0.9.0,0.9.1 增强,0.10.0 内容级对照):保存快照真实落盘(JSON 含版本与条目)→ 修复差异 + 新增文件重扫 → 加载快照对照(已修复/新文件变迁统计与条目标注)→ 对照模式导出报告真实写盘断言变迁明细(已修复/新文件/保持一致,0.9.1)→ 退出对照恢复;内容级对照——保存 v2 快照(JSON 断言含差异条目双侧内容)→ 修改左侧文件重扫 → 加载快照双击变更条目进入"快照基线 vs 当前"内容对比(左面板"快照 · 文件名"标注、基线旧词与当前新词词级高亮、有差异徽章)
|
||||
- 大文件性能基准(0.9.0):5 万行双文件 20 处差异——打开右侧到「有差异」出现耗时 < 15s;300KB GBK 15000 行——打开到状态栏行数渲染 < 10s(万级实测秒级,上限按慢机 4 倍余量防 flaky,断言失败即性能回归)
|
||||
- 关于弹框(0.10.2):点击 header ⓘ 打开弹框——版本号(app.getVersion 真实链路,0.x 形态正则)/作者/双邮箱/组织/开源地址/许可证断言;点击邮箱走主进程剪贴板并 toast;Esc 关闭
|
||||
- 大快照加载(0.10.2):手工构造 11MB+ 快照 JSON(contents 塞大字符串)→ snapshot:read 专用通道加载成功("已加载快照"出现且无 10MB 拦截提示)——固化保存/加载上限不对称修复
|
||||
- 偏好持久化:跨实例重启恢复比较选项与视图开关(同一 userData)
|
||||
- 隔离:每用例启动清空 localStorage + 重载,避免偏好跨用例污染(偏好用例验证实例显式跳过清理)
|
||||
|
||||
|
||||
@@ -768,6 +768,74 @@ test.describe('偏好持久化(跨实例重启)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('关于弹框(0.10.2)', () => {
|
||||
test('点击关于按钮展示应用信息,点击邮箱复制,Esc 关闭', async () => {
|
||||
const { app, window } = await launchApp()
|
||||
await window.getByTitle('关于 DiffLens').click()
|
||||
// 版本号来自主进程 app.getVersion()(package.json 为唯一来源,0.x 形态)
|
||||
await expect(window.locator('.about-version')).toHaveText(/^版本 0\.\d+\.\d+$/)
|
||||
await expect(window.locator('.about-modal')).toContainText('DiffLens')
|
||||
await expect(window.locator('.about-modal')).toContainText('thzxx')
|
||||
await expect(window.locator('.about-modal')).toContainText('stinanimz@gmail.com')
|
||||
await expect(window.locator('.about-modal')).toContainText('1440196015@qq.com')
|
||||
await expect(window.locator('.about-modal')).toContainText('MetonaTeam')
|
||||
await expect(window.locator('.about-modal')).toContainText('https://git.metona.cn/MetonaTeam/DiffLens')
|
||||
await expect(window.locator('.about-modal')).toContainText('MIT License')
|
||||
// 点击邮箱复制到剪贴板(主进程剪贴板链路)+ toast 反馈
|
||||
await window.getByText('stinanimz@gmail.com').click()
|
||||
await expect(window.getByText(/邮箱已复制到剪贴板/)).toBeVisible()
|
||||
// Esc 关闭弹框
|
||||
await window.keyboard.press('Escape')
|
||||
await expect(window.locator('.about-modal')).toHaveCount(0)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('大快照加载(0.10.2 专用读取通道)', () => {
|
||||
test('超过 10MB 的快照可正常加载对照(通用文件上限不再拦截自家快照)', async () => {
|
||||
const root = await makeTmpDir('difflens-e2e-bigsnap-')
|
||||
try {
|
||||
const leftDir = join(root, 'left')
|
||||
const rightDir = join(root, 'right')
|
||||
const { mkdir, writeFile: wf } = await import('fs/promises')
|
||||
await mkdir(leftDir, { recursive: true })
|
||||
await mkdir(rightDir, { recursive: true })
|
||||
await wf(join(leftDir, 'same.txt'), 'identical')
|
||||
await wf(join(rightDir, 'same.txt'), 'identical')
|
||||
|
||||
// 手工构造 11MB+ 快照(contents 塞大字符串):旧通道 file:open 的 10MB 上限会拦截
|
||||
const snap = {
|
||||
version: 2,
|
||||
leftDir,
|
||||
rightDir,
|
||||
semantic: true,
|
||||
truncated: false,
|
||||
total: 1,
|
||||
entries: [
|
||||
{ rel: 'same.txt', status: 'same', leftSize: 9, rightSize: 9 }
|
||||
],
|
||||
savedAt: Date.now(),
|
||||
contents: { 'same.txt': { left: 'x'.repeat(11 * 1024 * 1024), right: null } }
|
||||
}
|
||||
const snapPath = join(root, 'big-snapshot.json')
|
||||
await wf(snapPath, JSON.stringify(snap), 'utf-8')
|
||||
|
||||
const { app, window } = await launchApp()
|
||||
// 对话框调用序:folder:pick 左 → folder:pick 右 → snapshot:read 快照
|
||||
await stubOpenDialogSeq(app, [[leftDir], [rightDir], [snapPath]])
|
||||
await window.getByRole('button', { name: '对比文件夹' }).click()
|
||||
await expect(window.getByText('相同 1')).toBeVisible()
|
||||
await window.getByRole('button', { name: '加载快照' }).click()
|
||||
// 专用通道(128MB 上限)加载成功:出现对照 toast,而非"文件超过 10MB"拦截
|
||||
await expect(window.getByText(/已加载快照/)).toBeVisible()
|
||||
await expect(window.getByText(/文件超过 10MB/)).toHaveCount(0)
|
||||
await app.close()
|
||||
} finally {
|
||||
await removeTmpDir(root)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('大文件性能基准(耗时上限断言)', () => {
|
||||
// 口径说明:万级行 diff 在 worker 后台实测为秒级;上限按慢机约 4 倍余量放宽
|
||||
// 防 CI flaky(用例超时 60s 兜底)。断言失败即性能回归,需排查引擎/IO 退化。
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "DiffLens",
|
||||
"version": "0.10.1",
|
||||
"version": "0.10.2",
|
||||
"description": "DiffLens — 精美酷炫的文本对比桌面应用",
|
||||
"author": "thzxx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -370,6 +370,47 @@ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => {
|
||||
return readTextFile(filePath)
|
||||
})
|
||||
|
||||
/**
|
||||
* 快照文件的读取上限(128MB):保存侧内容总量上限 32M 字符(中文 JSON 化后可达 ~96MB),
|
||||
* 走通用读取管线的 10MB 上限会拦截自家保存的大快照(保存成功却无法加载的自相矛盾),
|
||||
* 故快照加载走本专用通道;上限仍防御异常超大文件拖垮渲染进程。
|
||||
*/
|
||||
const SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* IPC: 选择并读取快照文件(专用通道,与 file:open 分离)。
|
||||
* - 读取上限 128MB(快照为应用自有 JSON,可大于通用文件的 10MB);
|
||||
* - 快照始终以 UTF-8 写盘,直接按 UTF-8 解码,无需编码探测;
|
||||
* - 不写 lastDir(快照目录与常用文件目录语义不同,避免污染文件对话框记忆)。
|
||||
*/
|
||||
ipcMain.handle('snapshot:read', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: '选择快照文件',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '快照文件', extensions: ['json'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const filePath = result.filePaths[0]
|
||||
const name = filePath.split(/[\\/]/).pop() ?? filePath
|
||||
let buf: Buffer
|
||||
try {
|
||||
const st = await fs.promises.stat(filePath)
|
||||
if (st.size > SNAPSHOT_MAX_BYTES) {
|
||||
return { error: 'too-large' as const, name, size: st.size }
|
||||
}
|
||||
buf = await fs.promises.readFile(filePath)
|
||||
} catch {
|
||||
return { error: 'read-failed' as const, name }
|
||||
}
|
||||
return { path: filePath, name, text: buf.toString('utf-8'), encoding: 'UTF-8', binary: false }
|
||||
})
|
||||
|
||||
/** IPC: 应用版本号(package.json 的 version 为唯一来源,dev 与打包行为一致) */
|
||||
ipcMain.handle('app:get-version', () => app.getVersion())
|
||||
|
||||
/** 批量读取的路径数上限(渲染端快照保存按差异条目收集,超出说明异常输入,整体拒绝) */
|
||||
const READ_BATCH_MAX_PATHS = 256
|
||||
|
||||
|
||||
@@ -132,7 +132,15 @@ const api = {
|
||||
ipcRenderer.invoke('file:read-by-path', filePath),
|
||||
/** 批量读取文本文件(快照保存收集差异条目内容用;单文件失败为错误对象不整体失败) */
|
||||
readFilesByPaths: (paths: string[]): Promise<ReadBatchResult | { error: 'invalid' }> =>
|
||||
ipcRenderer.invoke('folder:read-batch', paths)
|
||||
ipcRenderer.invoke('folder:read-batch', paths),
|
||||
/**
|
||||
* 选择并读取快照文件(专用通道):读取上限 128MB(通用文件 10MB 上限会拦截
|
||||
* 自家保存的大快照),UTF-8 直读不做编码探测,不写 lastDir 记忆。
|
||||
* 返回结构与 openFile 一致(成功数据 / 失败原因 / 用户取消 null)。
|
||||
*/
|
||||
readSnapshot: (): Promise<FileOpenResult> => ipcRenderer.invoke('snapshot:read'),
|
||||
/** 应用版本号(主进程 app.getVersion(),package.json 的 version 为唯一来源) */
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version')
|
||||
}
|
||||
|
||||
export type DiffLensApi = typeof api
|
||||
|
||||
+57
-17
@@ -18,6 +18,7 @@ import {
|
||||
parseSnapshot,
|
||||
diffSnapshot,
|
||||
snapshotDirsMatch,
|
||||
normDir,
|
||||
isDiffStatus,
|
||||
SNAPSHOT_VERSION,
|
||||
SNAPSHOT_FILE_MAX_BYTES,
|
||||
@@ -32,6 +33,8 @@ import { useDiff } from './hooks/useDiff'
|
||||
import type { PaneMeta } from './components/DiffView'
|
||||
import type { SideCell } from './diff/diffEngine'
|
||||
import FolderView from './components/FolderView'
|
||||
import AboutModal from './components/AboutModal'
|
||||
import LogoIcon from './components/LogoIcon'
|
||||
import type { FolderEntry, FolderScanResult } from '../../preload/index'
|
||||
|
||||
/** 生成默认报告文件名所需的时间戳:YYYYMMDD-HHmmss */
|
||||
@@ -155,16 +158,6 @@ interface PaneState {
|
||||
text: string
|
||||
}
|
||||
|
||||
function LogoIcon(): ReactElement {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth="2.1" />
|
||||
<line x1="15.5" y1="15.5" x2="20.5" y2="20.5" stroke="#fff" strokeWidth="2.4" strokeLinecap="round" />
|
||||
<path d="M5 11h3.4M5 14h2" stroke="#030712" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyPane({
|
||||
side,
|
||||
onOpen,
|
||||
@@ -251,11 +244,28 @@ export default function App(): ReactElement {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const [pasteSide, setPasteSide] = useState<'left' | 'right' | null>(null)
|
||||
const [pasteOpen, setPasteOpen] = useState(false)
|
||||
// 「关于」弹框开关与应用版本号(挂载时经 IPC 取一次,package.json 为唯一来源)
|
||||
const [aboutOpen, setAboutOpen] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const pasteWrapRef = useRef<HTMLDivElement>(null)
|
||||
const closePaste = useCallback((): void => setPasteOpen(false), [])
|
||||
// 点击外部 / 按 Esc 关闭粘贴下拉
|
||||
useDismiss(pasteWrapRef, closePaste)
|
||||
|
||||
// 挂载时获取应用版本号(关于弹框展示;失败静默显示占位)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
window.api
|
||||
.getVersion()
|
||||
.then((v) => {
|
||||
if (alive && typeof v === 'string') setAppVersion(v)
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// toast 队列:最多同时展示 3 条,各自独立计时互不清除
|
||||
// (此前单条覆盖式会互顶:二进制乱码预警被行数预警立即顶掉,用户看不到前一条)
|
||||
const toastSeq = useRef(0)
|
||||
@@ -469,8 +479,9 @@ export default function App(): ReactElement {
|
||||
return
|
||||
}
|
||||
if (!rightDir) return
|
||||
// 同目录防呆:两侧相同只能得到"全部一致"的无意义结果
|
||||
if (leftDir === rightDir) {
|
||||
// 同目录防呆(路径归一化后比较,与 scanCacheStore/snapshotDiff 同款逻辑):
|
||||
// Windows 异写路径(大小写/分隔符差异)不得绕过;两侧相同只能得到"全部一致"的无意义结果
|
||||
if (normDir(leftDir) === normDir(rightDir)) {
|
||||
showToast('两次选择的是同一个文件夹,请选择两个不同的文件夹')
|
||||
return
|
||||
}
|
||||
@@ -654,22 +665,29 @@ export default function App(): ReactElement {
|
||||
})()
|
||||
}, [folderState, folderSemantic, showToast])
|
||||
|
||||
/** 加载快照文件并与当前结果对照:无当前结果 / 当前结果截断 / 目录对不一致 / 文件无效时拦截提示 */
|
||||
/** 加载快照文件并与当前结果对照:无当前结果 / 保存中 / 当前结果截断 / 目录对不一致 / 文件无效时拦截提示 */
|
||||
const loadSnapshot = useCallback((): void => {
|
||||
if (!folderState) {
|
||||
showToast('请先进行文件夹对比,再加载快照')
|
||||
return
|
||||
}
|
||||
// 保存中防抖:保存遮罩不拦截交互(pointer-events:none 仅视觉反馈),此处守卫
|
||||
// 防止保存与加载并发竞争(FolderView 侧按钮已同步禁用,此为双保险)
|
||||
if (snapshotSaving) {
|
||||
showToast('正在保存快照,请稍候')
|
||||
return
|
||||
}
|
||||
// 当前结果截断:条目不完整,快照有而当前"缺失"的条目会被误报为已移除(与保存侧拦截对称)
|
||||
if (folderState.truncated) {
|
||||
showToast('当前扫描结果已截断(文件过多),条目不完整无法对照,请先缩小对比范围')
|
||||
return
|
||||
}
|
||||
void (async (): Promise<void> => {
|
||||
let data: Awaited<ReturnType<typeof window.api.openFile>>
|
||||
let data: Awaited<ReturnType<typeof window.api.readSnapshot>>
|
||||
try {
|
||||
// 不指定侧别的文件选择(主进程 title 为通用"选择文本文件",仅读取内容)
|
||||
data = await window.api.openFile()
|
||||
// 快照专用读取通道:读取上限 128MB(通用文件 10MB 上限会拦截自家保存的大快照),
|
||||
// 且不写 lastDir(不污染文件对话框的目录记忆)
|
||||
data = await window.api.readSnapshot()
|
||||
} catch {
|
||||
showToast('快照读取失败,请重试')
|
||||
return
|
||||
@@ -698,7 +716,7 @@ export default function App(): ReactElement {
|
||||
: '已加载快照,列表按状态变迁标注'
|
||||
)
|
||||
})()
|
||||
}, [folderState, folderSemantic, showToast])
|
||||
}, [folderState, folderSemantic, snapshotSaving, showToast])
|
||||
|
||||
/** 退出快照对照(清空基线与对照结果) */
|
||||
const exitSnapshot = useCallback((): void => {
|
||||
@@ -1272,6 +1290,17 @@ export default function App(): ReactElement {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="btn ghost about-btn"
|
||||
title="关于 DiffLens"
|
||||
onClick={() => setAboutOpen(true)}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="7.6" r="1.35" fill="currentColor" />
|
||||
<line x1="12" y1="10.6" x2="12" y2="17" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{mode === 'folder' && folderState ? (
|
||||
@@ -1282,6 +1311,7 @@ export default function App(): ReactElement {
|
||||
truncated={folderState.truncated}
|
||||
total={folderState.total}
|
||||
scanning={folderScanning}
|
||||
saving={snapshotSaving}
|
||||
progress={scanProgress}
|
||||
onlyDiff={folderOnlyDiff}
|
||||
onOnlyDiffChange={setFolderOnlyDiff}
|
||||
@@ -1418,6 +1448,16 @@ export default function App(): ReactElement {
|
||||
)}
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => setMenu(null)} />}
|
||||
{aboutOpen && (
|
||||
<AboutModal
|
||||
version={appVersion}
|
||||
onClose={() => setAboutOpen(false)}
|
||||
onCopy={(text, label) => {
|
||||
void window.api.setClipboard(text)
|
||||
showToast(`${label}已复制到剪贴板`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pasteSide && (
|
||||
<TextInputModal
|
||||
title={`粘贴${pasteSide === 'left' ? '左侧' : '右侧'}文本`}
|
||||
|
||||
@@ -33,7 +33,11 @@ function mockApi(overrides?: Partial<typeof window.api>): typeof window.api {
|
||||
onScanProgress: (): (() => void) => () => {},
|
||||
readByPath: async () => null,
|
||||
// 批量读取默认空结果(快照保存的 contents 数据流由专项用例覆写)
|
||||
readFilesByPaths: async () => ({ files: [] })
|
||||
readFilesByPaths: async () => ({ files: [] }),
|
||||
// 快照加载专用通道:默认用户取消(null);快照内容由各用例覆写
|
||||
readSnapshot: async () => null,
|
||||
// 应用版本号(关于弹框展示;测试固定值避免与真实版本耦合)
|
||||
getVersion: async () => '9.9.9-test'
|
||||
}
|
||||
return { ...base, ...overrides } as typeof window.api
|
||||
}
|
||||
@@ -1989,19 +1993,19 @@ describe('App - 快照基线', () => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 进入文件夹模式(当前结果 curEntries);可覆盖 openFile 返回快照内容 */
|
||||
async function enterForSnapshot(openFileText?: string) {
|
||||
/** 进入文件夹模式(当前结果 curEntries);可覆盖 readSnapshot 返回快照内容 */
|
||||
async function enterForSnapshot(snapshotText?: string) {
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
|
||||
...(openFileText !== undefined
|
||||
...(snapshotText !== undefined
|
||||
? {
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: openFileText,
|
||||
text: snapshotText,
|
||||
encoding: 'UTF-8',
|
||||
binary: false
|
||||
})
|
||||
@@ -2081,7 +2085,7 @@ describe('App - 快照基线', () => {
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 3 }),
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: makeSnapJson(),
|
||||
@@ -2137,7 +2141,7 @@ describe('App - 快照基线', () => {
|
||||
it('快照读取失败(error 返回):拦截提示', async () => {
|
||||
await enterForSnapshot()
|
||||
window.api = mockApi({
|
||||
openFile: async () => ({ error: 'read-failed' as const, name: 'snap.json' })
|
||||
readSnapshot: async () => ({ error: 'read-failed' as const, name: 'snap.json' })
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
expect(await screen.findByText(/快照读取失败,请重试/)).toBeInTheDocument()
|
||||
@@ -2172,7 +2176,7 @@ describe('App - 快照基线', () => {
|
||||
]
|
||||
return { entries, truncated: false, total: entries.length }
|
||||
},
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: makeSnapJson({ entries: snapBase }),
|
||||
@@ -2194,13 +2198,13 @@ describe('App - 快照基线', () => {
|
||||
})
|
||||
|
||||
it('当前扫描结果截断时加载快照被拦截提示,不弹文件选择', async () => {
|
||||
const openFile = vi.fn()
|
||||
const readSnapshot = vi.fn()
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: true, total: 9999 }),
|
||||
openFile
|
||||
readSnapshot
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
@@ -2208,7 +2212,7 @@ describe('App - 快照基线', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
expect(await screen.findByText(/当前扫描结果已截断/)).toBeInTheDocument()
|
||||
// 拦截发生在文件选择之前(对称防呆:截断的当前结果会把缺失文件误报为已移除)
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
expect(readSnapshot).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText(/已加载快照/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2242,7 +2246,7 @@ describe('App - 快照基线', () => {
|
||||
? { entries: curEntries, truncated: false, total: 3 }
|
||||
: { entries: curEntries.slice(0, 2), truncated: true, total: 9999 }
|
||||
},
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: makeSnapJson(),
|
||||
@@ -2391,7 +2395,7 @@ describe('App - 快照内容级对照(v2)', () => {
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
@@ -2440,7 +2444,7 @@ describe('App - 快照内容级对照(v2)', () => {
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
@@ -2493,7 +2497,7 @@ describe('App - 快照内容级对照(v2)', () => {
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: curEntries, truncated: false, total: 2 }),
|
||||
openFile: async () => ({
|
||||
readSnapshot: async () => ({
|
||||
path: '/tmp/snap.json',
|
||||
name: 'snap.json',
|
||||
text: snapJson,
|
||||
@@ -2616,4 +2620,64 @@ describe('App - 扫描进度反馈', () => {
|
||||
)
|
||||
expect(screen.queryByText(/已比对/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - 关于弹框(0.10.2)', () => {
|
||||
it('点击 header 关于按钮打开弹框:版本/作者/邮箱/组织/开源地址可见,Esc 关闭', async () => {
|
||||
render(<App />)
|
||||
// 两种模式常驻入口
|
||||
fireEvent.click(screen.getByTitle('关于 DiffLens'))
|
||||
// 版本号来自 app:get-version(mock 固定 9.9.9-test)
|
||||
expect(await screen.findByText('版本 9.9.9-test')).toBeInTheDocument()
|
||||
expect(screen.getByText('thzxx')).toBeInTheDocument()
|
||||
expect(screen.getByText('stinanimz@gmail.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('1440196015@qq.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('MetonaTeam')).toBeInTheDocument()
|
||||
expect(screen.getByText('https://git.metona.cn/MetonaTeam/DiffLens')).toBeInTheDocument()
|
||||
expect(screen.getByText('MIT License')).toBeInTheDocument()
|
||||
// Esc 关闭
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('版本 9.9.9-test')).not.toBeInTheDocument()
|
||||
)
|
||||
})
|
||||
|
||||
it('点击邮箱复制到剪贴板并 toast 反馈', async () => {
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByTitle('关于 DiffLens'))
|
||||
fireEvent.click(await screen.findByText('stinanimz@gmail.com'))
|
||||
expect(clipboardSpy).toHaveBeenCalledWith('stinanimz@gmail.com')
|
||||
expect(await screen.findByText(/邮箱已复制到剪贴板/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('文件夹模式下关于按钮同样可见', async () => {
|
||||
const dirs = ['C:/left-dir', 'D:/right-dir']
|
||||
let call = 0
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder: async () => ({ entries: [], truncated: false, total: 0 })
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
await screen.findByText('文件夹对比 · 双击条目进入单文件对比')
|
||||
expect(screen.getByTitle('关于 DiffLens')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('App - 同目录防呆归一化(0.10.2)', () => {
|
||||
it('Windows 异写路径(盘符大小写/分隔符/尾斜杠差异)的同一目录被拦截,不进入扫描', async () => {
|
||||
// 注:normDir 有意保留路径段大小写(大小写敏感文件系统取舍),仅归一化盘符/分隔符/尾斜杠
|
||||
const dirs = ['C:\\Foo', 'c:/Foo/']
|
||||
let call = 0
|
||||
const scanFolder = vi.fn(async () => ({ entries: [], truncated: false, total: 0 }))
|
||||
window.api = mockApi({
|
||||
pickFolder: async () => dirs[call++] ?? null,
|
||||
scanFolder
|
||||
})
|
||||
render(<App />)
|
||||
fireEvent.click(screen.getByText('对比文件夹'))
|
||||
expect(await screen.findByText(/两次选择的是同一个文件夹/)).toBeInTheDocument()
|
||||
expect(scanFolder).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('返回文件对比')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,9 @@ describe('main 入口', () => {
|
||||
scanFolder: async () => ({ entries: [], truncated: false, total: 0 }),
|
||||
onScanProgress: (): (() => void) => () => {},
|
||||
readByPath: async () => null,
|
||||
readFilesByPaths: async () => ({ files: [] })
|
||||
readFilesByPaths: async () => ({ files: [] }),
|
||||
readSnapshot: async () => null,
|
||||
getVersion: async () => '0.0.0-test'
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import AboutModal, { ABOUT_INFO } from './AboutModal'
|
||||
|
||||
function setup(overrides?: { version?: string }): { onClose: ReturnType<typeof vi.fn>; onCopy: ReturnType<typeof vi.fn> } {
|
||||
const onClose = vi.fn()
|
||||
const onCopy = vi.fn()
|
||||
render(
|
||||
<AboutModal version={overrides?.version ?? '0.10.2'} onClose={onClose} onCopy={onCopy} />
|
||||
)
|
||||
return { onClose, onCopy }
|
||||
}
|
||||
|
||||
describe('AboutModal - 渲染', () => {
|
||||
it('展示应用名、版本与全部元信息(作者/邮箱/组织/开源地址/许可证)', () => {
|
||||
setup()
|
||||
expect(screen.getByText(ABOUT_INFO.name)).toBeInTheDocument()
|
||||
expect(screen.getByText('版本 0.10.2')).toBeInTheDocument()
|
||||
expect(screen.getByText(ABOUT_INFO.description)).toBeInTheDocument()
|
||||
expect(screen.getByText(ABOUT_INFO.author)).toBeInTheDocument()
|
||||
for (const m of ABOUT_INFO.emails) {
|
||||
expect(screen.getByText(m)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.getByText(ABOUT_INFO.organization)).toBeInTheDocument()
|
||||
expect(screen.getByText(ABOUT_INFO.repoUrl)).toBeInTheDocument()
|
||||
expect(screen.getByText(ABOUT_INFO.license)).toBeInTheDocument()
|
||||
expect(screen.getByText(/© 2026 MetonaTeam/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('版本号未取得时显示占位(版本 —)', () => {
|
||||
setup({ version: '' })
|
||||
expect(screen.getByText('版本 —')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AboutModal - 复制交互', () => {
|
||||
it('点击邮箱触发 onCopy(携带邮箱与标签)', () => {
|
||||
const { onCopy } = setup()
|
||||
fireEvent.click(screen.getByText('stinanimz@gmail.com'))
|
||||
expect(onCopy).toHaveBeenCalledWith('stinanimz@gmail.com', '邮箱')
|
||||
fireEvent.click(screen.getByText('1440196015@qq.com'))
|
||||
expect(onCopy).toHaveBeenCalledWith('1440196015@qq.com', '邮箱')
|
||||
})
|
||||
|
||||
it('点击开源地址/作者/组织触发 onCopy', () => {
|
||||
const { onCopy } = setup()
|
||||
fireEvent.click(screen.getByText(ABOUT_INFO.repoUrl))
|
||||
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.repoUrl, '开源地址')
|
||||
fireEvent.click(screen.getByText(ABOUT_INFO.author))
|
||||
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.author, '作者')
|
||||
fireEvent.click(screen.getByText(ABOUT_INFO.organization))
|
||||
expect(onCopy).toHaveBeenCalledWith(ABOUT_INFO.organization, '组织')
|
||||
})
|
||||
|
||||
it('许可证为纯展示不可点击(不触发 onCopy)', () => {
|
||||
const { onCopy } = setup()
|
||||
fireEvent.click(screen.getByText(ABOUT_INFO.license))
|
||||
expect(onCopy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AboutModal - 关闭交互', () => {
|
||||
it('Esc 触发 onClose', () => {
|
||||
const { onClose } = setup()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('点击遮罩触发 onClose;点击弹框内容区不关闭', () => {
|
||||
const { onClose } = setup()
|
||||
fireEvent.mouseDown(screen.getByText(ABOUT_INFO.name))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.mouseDown(document.querySelector('.modal-backdrop')!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('点击右上角关闭按钮触发 onClose', () => {
|
||||
const { onClose } = setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: '✕' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('卸载后 Esc 监听移除(不再触发 onClose)', () => {
|
||||
const onClose = vi.fn()
|
||||
const onCopy = vi.fn()
|
||||
const { unmount } = render(
|
||||
<AboutModal version="0.10.2" onClose={onClose} onCopy={onCopy} />
|
||||
)
|
||||
unmount()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, type ReactElement } from 'react'
|
||||
import LogoIcon from './LogoIcon'
|
||||
|
||||
interface AboutModalProps {
|
||||
/** 应用版本号(App 经 app:get-version IPC 获取;未取得时传空串显示占位) */
|
||||
version: string
|
||||
onClose: () => void
|
||||
/** 复制到剪贴板(App 实现 setClipboard + toast 反馈) */
|
||||
onCopy: (text: string, label: string) => void
|
||||
}
|
||||
|
||||
/** 关于弹框的展示信息(应用自有元信息,随版本演进同步维护) */
|
||||
export const ABOUT_INFO = {
|
||||
name: 'DiffLens',
|
||||
/** 应用介绍(与 README 首段口径一致) */
|
||||
description:
|
||||
'精美酷炫的开源文本对比桌面应用——行级 + 词级 + 字符级差异定位、文件夹递归对比、快照基线对照,帮助你快速看穿两份文本之间的所有差异。',
|
||||
author: 'thzxx',
|
||||
emails: ['stinanimz@gmail.com', '1440196015@qq.com'],
|
||||
organization: 'MetonaTeam',
|
||||
repoUrl: 'https://git.metona.cn/MetonaTeam/DiffLens',
|
||||
license: 'MIT License'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 「关于」弹框:应用介绍、版本、作者、邮箱、组织、开源地址与许可证。
|
||||
* 关闭方式与粘贴弹窗同语言:Esc / 点击遮罩 / 右上角关闭按钮;
|
||||
* 邮箱与开源地址点击复制到剪贴板(复用主进程剪贴板管线,不引入外部跳转)。
|
||||
*/
|
||||
export default function AboutModal({
|
||||
version,
|
||||
onClose,
|
||||
onCopy
|
||||
}: AboutModalProps): ReactElement {
|
||||
// Esc 关闭
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<div className="about-modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<button className="about-close" title="关闭" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
<div className="about-logo">
|
||||
<LogoIcon size={40} />
|
||||
</div>
|
||||
<div className="about-name">{ABOUT_INFO.name}</div>
|
||||
<div className="about-version">版本 {version !== '' ? version : '—'}</div>
|
||||
<p className="about-desc">{ABOUT_INFO.description}</p>
|
||||
<div className="about-rows">
|
||||
<div className="about-row">
|
||||
<span className="about-k">作者</span>
|
||||
<button
|
||||
className="about-v"
|
||||
title="点击复制"
|
||||
onClick={() => onCopy(ABOUT_INFO.author, '作者')}
|
||||
>
|
||||
{ABOUT_INFO.author}
|
||||
</button>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-k">邮箱</span>
|
||||
<span className="about-vs">
|
||||
{ABOUT_INFO.emails.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className="about-v"
|
||||
title="点击复制"
|
||||
onClick={() => onCopy(m, '邮箱')}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-k">组织</span>
|
||||
<button
|
||||
className="about-v"
|
||||
title="点击复制"
|
||||
onClick={() => onCopy(ABOUT_INFO.organization, '组织')}
|
||||
>
|
||||
{ABOUT_INFO.organization}
|
||||
</button>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-k">开源地址</span>
|
||||
<button
|
||||
className="about-v"
|
||||
title="点击复制"
|
||||
onClick={() => onCopy(ABOUT_INFO.repoUrl, '开源地址')}
|
||||
>
|
||||
{ABOUT_INFO.repoUrl}
|
||||
</button>
|
||||
</div>
|
||||
<div className="about-row">
|
||||
<span className="about-k">许可证</span>
|
||||
<span className="about-v plain">{ABOUT_INFO.license}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-foot">© 2026 MetonaTeam · thzxx · DiffLens</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -566,6 +566,27 @@ describe('FolderView - 其他', () => {
|
||||
expect(onLoadSnapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('快照保存中禁用保存/加载快照按钮,其余操作不受影响(0.10.2 保存遮罩不拦截交互的按钮级防抖)', () => {
|
||||
const onSaveSnapshot = vi.fn()
|
||||
const onLoadSnapshot = vi.fn()
|
||||
render(
|
||||
<FolderView
|
||||
{...fullProps(ENTRIES, { saving: true, onSaveSnapshot, onLoadSnapshot })}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '保存快照' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '加载快照' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存快照' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '加载快照' }))
|
||||
expect(onSaveSnapshot).not.toHaveBeenCalled()
|
||||
expect(onLoadSnapshot).not.toHaveBeenCalled()
|
||||
// 保存中仅是快照链路防抖:重新扫描/重新选择/导出/返回不受影响
|
||||
expect(screen.getByRole('button', { name: '重新扫描' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '重新选择' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '导出报告' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '返回文件对比' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('导出报告菜单选择格式触发 onExport 并关闭菜单', () => {
|
||||
const onExport = vi.fn()
|
||||
render(<FolderView {...fullProps(ENTRIES, { onExport })} />)
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface FolderViewProps {
|
||||
truncated: boolean
|
||||
total: number
|
||||
scanning: boolean
|
||||
/** 快照保存中(批量读取差异条目内容期间):保存/加载快照入口禁用(保存遮罩不拦截交互,按钮级防抖) */
|
||||
saving?: boolean
|
||||
/** 比对进度(主进程推送;null = 尚无进度态,仅扫描中遮罩展示) */
|
||||
progress?: { done: number; total: number } | null
|
||||
/** 仅看差异(隐藏两侧一致的文件);与状态过滤互斥(后者开启时本开关自动关闭) */
|
||||
@@ -112,6 +114,7 @@ export default function FolderView({
|
||||
truncated,
|
||||
total,
|
||||
scanning,
|
||||
saving = false,
|
||||
progress,
|
||||
onlyDiff,
|
||||
onOnlyDiffChange,
|
||||
@@ -342,16 +345,28 @@ export default function FolderView({
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
disabled={scanning}
|
||||
title={scanning ? '正在扫描文件夹' : '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'}
|
||||
disabled={scanning || saving}
|
||||
title={
|
||||
scanning
|
||||
? '正在扫描文件夹'
|
||||
: saving
|
||||
? '正在保存快照'
|
||||
: '保存当前对比结果为快照基线(JSON 文件),供日后加载对照'
|
||||
}
|
||||
onClick={onSaveSnapshot}
|
||||
>
|
||||
保存快照
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
disabled={scanning}
|
||||
title={scanning ? '正在扫描文件夹' : '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'}
|
||||
disabled={scanning || saving}
|
||||
title={
|
||||
scanning
|
||||
? '正在扫描文件夹'
|
||||
: saving
|
||||
? '正在保存快照'
|
||||
: '加载之前保存的快照,与当前结果对照(已修复 / 新增差异 / 新文件 / 已移除)'
|
||||
}
|
||||
onClick={onLoadSnapshot}
|
||||
>
|
||||
加载快照
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
/**
|
||||
* 应用 Logo(放大镜 + 差异短横线的暗色科幻风格图标)。
|
||||
* 尺寸由 size 控制(App 头部 18 / 关于弹框 40),描边随尺寸等比缩放。
|
||||
*/
|
||||
export default function LogoIcon({ size = 18 }: { size?: number }): ReactElement {
|
||||
const s = size / 18
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="6.2" stroke="#fff" strokeWidth={2.1 * Math.max(s, 0.7)} />
|
||||
<line
|
||||
x1="15.5"
|
||||
y1="15.5"
|
||||
x2="20.5"
|
||||
y2="20.5"
|
||||
stroke="#fff"
|
||||
strokeWidth={2.4 * Math.max(s, 0.7)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M5 11h3.4M5 14h2"
|
||||
stroke="#030712"
|
||||
strokeWidth={1.6 * Math.max(s, 0.7)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export interface TreeNode {
|
||||
entry?: FolderEntry
|
||||
/** 子节点(目录节点独有;同层按名称字典序) */
|
||||
children: TreeNode[]
|
||||
/** 目录节点:子树叶子的状态聚合(优先级 different > only > semantic-same > same) */
|
||||
/** 目录节点:子树叶子的状态聚合(优先级 different > unreadable > only > semantic-same > same) */
|
||||
agg?: FolderEntryStatus
|
||||
/** 目录节点:子树文件总数 */
|
||||
fileCount?: number
|
||||
@@ -29,7 +29,7 @@ export interface TreeRow {
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态;unreadable 需用户关注) */
|
||||
/** 状态聚合优先级:数值大者优先(目录徽章展示最值得关注的子树状态:different > unreadable > only > 语义同 > 相同) */
|
||||
const AGG_RANK: Record<FolderEntryStatus, number> = {
|
||||
different: 5,
|
||||
unreadable: 4,
|
||||
|
||||
@@ -180,8 +180,9 @@ export const SNAPSHOT_CHANGE_LABEL: Record<SnapshotChange, string> = {
|
||||
* 单一路径归一化:反斜杠→斜杠、去尾分隔符、Windows 盘符小写(其余保留原大小写)。
|
||||
* 与主进程 scanCacheStore.ts 的 normDir 保持完全一致(两进程无法共享模块,改动需双向同步):
|
||||
* Windows 大小写不敏感文件系统下 C:\Foo 与 c:/Foo 为同一目录,逐字符比较会误判目录对不一致。
|
||||
* 0.10.2 起导出供 App 的同目录防呆复用(startFolderCompare 的两侧目录判重)。
|
||||
*/
|
||||
function normDir(p: string): string {
|
||||
export function normDir(p: string): string {
|
||||
let s = p.replace(/\\/g, '/')
|
||||
while (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1)
|
||||
if (s.length >= 2 && s[1] === ':') s = s[0].toLowerCase() + s.slice(1)
|
||||
|
||||
@@ -263,14 +263,6 @@ button:disabled {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.diff-empty-pane {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pane-mid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -881,6 +873,126 @@ button:disabled {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ============ 关于弹框 ============ */
|
||||
/* header 右侧的关于图标按钮(紧凑正方形) */
|
||||
.about-btn {
|
||||
padding: 5px 7px;
|
||||
}
|
||||
|
||||
.about-modal {
|
||||
position: relative;
|
||||
width: min(480px, 90vw);
|
||||
background: rgba(16, 22, 34, 0.98);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 30px rgba(124, 108, 255, 0.2);
|
||||
overflow: hidden;
|
||||
z-index: 1001;
|
||||
padding: 26px 28px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.about-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 7px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.about-close:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.about-logo {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
box-shadow: 0 0 24px rgba(124, 108, 255, 0.55);
|
||||
}
|
||||
.about-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(90deg, #c9bfff, var(--accent-2));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.about-version {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.about-desc {
|
||||
margin: 14px 4px 16px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
.about-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
.about-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 24px;
|
||||
}
|
||||
.about-k {
|
||||
flex: 0 0 64px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.about-vs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.about-v {
|
||||
padding: 2px 8px;
|
||||
margin-left: -8px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--accent-2);
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
transition: background 0.13s ease;
|
||||
}
|
||||
button.about-v:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.about-v.plain {
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
}
|
||||
.about-foot {
|
||||
margin-top: 14px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* ============ 状态栏 ============ */
|
||||
.status-bar {
|
||||
flex: 0 0 auto;
|
||||
|
||||
Reference in New Issue
Block a user